- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
// 克莱门莎 (char_4231_clemnt), 本源近卫. Official CN tables 2026-10-08 (client 2.7.81),
|
||
// PRTS 克莱门莎, 2026-10-09: all skills at ranks 4/7 and both potential-sensitive talents.
|
||
// https://prts.wiki/w/克莱门莎 . No module was released with her.
|
||
// PRTS still marks several launch-day details unknown: [ASSUMED] carriage's unresolved 放逐 combination =
|
||
// cannot walk/attack/be blocked, but remains damageable;
|
||
// its boundary clearance 0.4, carriage updates once per sim tick, vortex ends with the skill;
|
||
// S3 first bombardment at 0.5 s, then twice at 1 s intervals, launched shots survive skill end.
|
||
// These pending confirmations are listed in DESIGN §28.1; keep each assumption local to this kit.
|
||
|
||
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
|
||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||
import { bodyInKeys, hitRect } from '../../../body.js';
|
||
import { dirVec } from '../../../dir.js';
|
||
import { COLS } from '../../../constants.js';
|
||
import { hasHp } from '../../../damage.js';
|
||
|
||
const S1 = 'skchr_clemnt_1', S2 = 'skchr_clemnt_2', S3 = 'skchr_clemnt_3';
|
||
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
|
||
const AIR = Object.freeze({ canHitFly: true });
|
||
const SPEED = 2, DISTANCE = 3, CATCH_HALF = 0.55, VORTEX_RADIUS = 1.5;
|
||
const SPLASH_RADIUS = 1.1; // PRTS 2026-10-10: collision test; four secondary targets take 100% ATK.
|
||
const FIRST_BOMB = 0.5; // [ASSUMED] Neither PRTS nor the edited official demo settles the first landing offset.
|
||
const CAST = 'clemnt:cast', CARRY = 'clemnt:carry:';
|
||
const skillBb = (raw, id) => skillRec(raw, id)?.bb ?? {};
|
||
const tile = (u) => [Math.round(u.y), Math.round(u.x)];
|
||
const immunity = (e) => e.def?.raw?.stats?.otherImmunities ?? [];
|
||
// No current mode enemy has the three named teleport immunities. Also honour explicit normalized flags for custom data.
|
||
const cannotCarry = (e) => e.s.flags.selfBound || e.def?.immune?.has('exile') || e.def?.immune?.has('teleport')
|
||
|| immunity(e).some((s) => /teleport|transmit|exile/i.test(String(s)));
|
||
|
||
/** Cabin's 1.1 × 1.1 collision square; huge bodies use the engine hit rectangle. */
|
||
function touches(e, x, y) {
|
||
const r = hitRect(e);
|
||
return r ? r.x1 >= x - CATCH_HALF && r.x0 <= x + CATCH_HALF && r.y1 >= y - CATCH_HALF && r.y0 <= y + CATCH_HALF
|
||
: Math.abs(e.x - x) <= CATCH_HALF + 1e-9 && Math.abs(e.y - y) <= CATCH_HALF + 1e-9;
|
||
}
|
||
|
||
export default {
|
||
char_4231_clemnt: (bb, raw) => {
|
||
const t0 = talentBb(raw, 0), t1 = talentBb(raw, 1);
|
||
const b1 = skillBb(raw, S1), b2 = skillBb(raw, S2), b3 = skillBb(raw, S3);
|
||
const rider = ({ battle, unit, target, dealt }, ratio) => {
|
||
if (dealt > 0 && hasHp(target)) battle.dealDamage(unit, target, {
|
||
amount: dealt * ratio, type: 'element', element: 'erosion', isSkill: true, tags: ['skill', 'clemnt:rider'],
|
||
});
|
||
};
|
||
const freePassengers = (battle, unit) => {
|
||
const c = unit.mem.clemntCabin;
|
||
if (c) for (const e of c.passengers) battle.removeBuff(e, CARRY + unit.id);
|
||
battle.removeBuff(unit, CAST);
|
||
};
|
||
const finishCabin = (battle, unit, c) => {
|
||
if (c.stopped) return;
|
||
c.stopped = true;
|
||
freePassengers(battle, unit);
|
||
// PRTS: vortex at the cabin's actual stopping position, radius 1.5, can hit air.
|
||
c.tick = 0;
|
||
battle.fx('tide', { x: c.x, y: c.y, r: VORTEX_RADIUS, dur: unit.skill.timeLeft, id: unit.id });
|
||
};
|
||
const startCabin = (battle, unit) => {
|
||
const [dr, dc] = dirVec(unit.dir), [r, col] = tile(unit);
|
||
let distance = DISTANCE;
|
||
for (let d = 1; d <= DISTANCE + 1; d++) {
|
||
const rr = r + dr * d, cc = col + dc * d, t = battle.grid.tile(rr, cc);
|
||
if (!battle.grid.groundPassable(rr, cc) || t.height === 'HIGH' || t.key === 'tile_hole') {
|
||
// [ASSUMED] PRTS's provisional rule: stop 0.4 tiles before the impassable edge.
|
||
distance = Math.min(distance, Math.max(0, d - 0.5 - 0.4)); break;
|
||
}
|
||
}
|
||
const c = unit.mem.clemntCabin = { x: col, y: r, ox: col, oy: r, dr, dc, distance, moved: 0,
|
||
mass: num(b2['attack@max_passenger_mass'], 8), passengers: [], stopped: false, tick: 0 };
|
||
battle.addBuff(unit, { key: CAST, duration: 10, flags: { disarm: true }, tags: ['skill'] });
|
||
battle.fx('anchor', { x: col + dc * distance, y: r + dr * distance, fromX: col, fromY: r, id: unit.id, dur: distance / SPEED });
|
||
if (distance <= 1e-9) finishCabin(battle, unit, c);
|
||
};
|
||
const tickCabin = (battle, unit, dt) => {
|
||
const c = unit.mem.clemntCabin;
|
||
if (!c || !up(unit)) return;
|
||
if (!c.stopped) {
|
||
c.moved = Math.min(c.distance, c.moved + SPEED * dt);
|
||
c.x = c.ox + c.dc * c.moved; c.y = c.oy + c.dr * c.moved;
|
||
// Release before either boarding limit is checked: invalid riders occupy neither a slot
|
||
// nor weight. Return their current weight, including modifiers applied during the ride.
|
||
for (let i = c.passengers.length - 1; i >= 0; i--) {
|
||
const e = c.passengers[i], [er, ec] = tile(e);
|
||
if (!e.alive || e.hidden || cannotCarry(e)
|
||
|| !battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) {
|
||
battle.removeBuff(e, CARRY + unit.id);
|
||
c.passengers.splice(i, 1);
|
||
c.mass += e.weight;
|
||
}
|
||
}
|
||
const cap = num(b2['attack@max_passenger_cnt'], 15);
|
||
for (const e of battle.enemies) {
|
||
if (c.passengers.length >= cap) break;
|
||
if (!e.alive || !e.deployed || e.hidden || e.isFlying || cannotCarry(e) || c.passengers.includes(e)
|
||
|| e.weight > c.mass + 1e-9 || !touches(e, c.x, c.y)) continue;
|
||
const [er, ec] = tile(e);
|
||
if (!battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) continue;
|
||
c.mass -= e.weight;
|
||
c.passengers.push(e);
|
||
battle._unblock(e);
|
||
// [ASSUMED] PRTS has not yet resolved every status in 放逐; no invulnerability or hide is invented.
|
||
battle.addBuff(e, { key: CARRY + unit.id, flags: { noMove: true, disarm: true, unblockable: true }, source: unit, tags: ['skill'] });
|
||
}
|
||
for (const e of c.passengers) {
|
||
if (!e.findBuff(CARRY + unit.id)) continue;
|
||
const fromX = e.x, fromY = e.y;
|
||
e.x = c.x; e.y = c.y;
|
||
if (e.route) e.route.pts = null;
|
||
battle.fx('teleport', { x: e.x, y: e.y, fromX, fromY, id: e.id, src: unit.id });
|
||
}
|
||
if (c.moved >= c.distance - 1e-9) finishCabin(battle, unit, c);
|
||
return;
|
||
}
|
||
const foes = battle.enemiesInRadius(c.x, c.y, VORTEX_RADIUS).filter((e) => canTargetEnemy(unit, e, AIR));
|
||
for (const e of foes) battle.addBuff(e, { key: `clemnt:vortex:${unit.id}`, duration: 0.1,
|
||
mods: { moveMul: 1 + num(b2['attack@move_speed'], -0.7) }, source: unit, tags: ['skill'] });
|
||
c.tick += dt;
|
||
if (c.tick < 1 - 1e-9) return;
|
||
c.tick -= 1;
|
||
for (const e of foes) {
|
||
battle.dealDamage(unit, e, { amount: num(b2['attack@water_element'], 200), type: 'element',
|
||
element: 'erosion', isSkill: true, canDodge: false, tags: ['skill', 'dot', 'clemnt:vortex:erosion'] });
|
||
if (hasHp(e)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2['attack@physical_atk_scale']),
|
||
type: 'phys', canDodge: false, isSkill: true, tags: ['skill', 'dot', 'clemnt:vortex'] });
|
||
}
|
||
};
|
||
const bombard = (battle, unit, e, m, skill) => {
|
||
const [r, c] = tile(e), key = r * COLS + c;
|
||
if ((m.tiles.get(key) ?? -1) > battle.time) return false;
|
||
m.tiles.set(key, battle.time + num(b3.projectile_life_time, 5));
|
||
const keys = new Set([[r, c], [r + 1, c], [r - 1, c], [r, c + 1], [r, c - 1]].map(([rr, cc]) => rr * COLS + cc));
|
||
const atk = unit.s.atk;
|
||
battle.fx('lock', { x: c, y: r, id: e.id, src: unit.id, dur: FIRST_BOMB });
|
||
for (let n = 0; n < 3; n++) battle.after(FIRST_BOMB + n, () => {
|
||
battle.fx('blast', { x: c, y: r, r: 1.5, id: unit.id });
|
||
for (const t of battle.enemies) {
|
||
if (!canTargetEnemy(unit, t, GROUND) || !bodyInKeys(t, keys)) continue;
|
||
battle.dealDamage(unit, t, { amount: atk * num(b3.s3_atk_scale), type: 'phys', canDodge: false,
|
||
isSkill: true, tags: ['skill', 'clemnt:bomb'] });
|
||
if (hasHp(t)) battle.dealDamage(unit, t, { amount: atk * num(b3.ep_damage_scale), type: 'elemental', element: 'erosion',
|
||
canDodge: false, isSkill: true, tags: ['skill', 'clemnt:bomb'] });
|
||
}
|
||
}, { owner: unit, holdsBattle: true });
|
||
skill.ammoLeft--;
|
||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||
return true;
|
||
};
|
||
return {
|
||
skills: {
|
||
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1),
|
||
onHit(c) { rider(c, num(b1.ep_damage_ratio)); } } },
|
||
[S2]: {
|
||
kind: 'duration', mods: { aspd: num(b2.attack_speed) },
|
||
targeting: { rangeGrid: skillRec(raw, S2)?.rangeGrid },
|
||
attack: { atkScale: num(b2['attack@aoe_atk_scale'], 1), groundOnly: true,
|
||
onEachHit({ battle, unit, target, kind, attackId }) {
|
||
if (kind !== 'main' || !target) return;
|
||
// Main victim plus at most four neighbours, never every unit in the radius.
|
||
// [ASSUMED] Ties among splash victims follow the engine's stable spawn order.
|
||
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS)
|
||
.filter((e) => e !== target && canTargetEnemy(unit, e, GROUND))
|
||
.slice(0, Math.max(0, num(b2['attack@max_target'], 5) - 1));
|
||
for (const e of near) battle.dealDamage(unit, e, {
|
||
amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys',
|
||
isAttack: true, isSplash: true, isSkill: true, attackId, tags: ['skill', 'clemnt:splash'],
|
||
});
|
||
} },
|
||
onStart({ battle, unit }) { startCabin(battle, unit); },
|
||
onTick({ battle, unit, dt }) { tickCabin(battle, unit, dt); },
|
||
onEnd({ battle, unit }) { freePassengers(battle, unit); unit.mem.clemntCabin = null; },
|
||
},
|
||
[S3]: {
|
||
kind: 'ammo', ammo: num(b3.trigger_time, 10),
|
||
targeting: { rangeGrid: skillRec(raw, S3)?.rangeGrid, maxTargets: num(b3['attack@max_target'], 3) },
|
||
attack: { atkScale: num(b3['attack@atk_scale'], 1), onHit(c) { rider(c, num(b3['attack@ep_damage_ratio'])); } },
|
||
onAttack(c) { c.noAmmo = true; },
|
||
onStart({ unit }) { unit.mem.clemntBombs = { marks: new Map(), tiles: new Map() }; },
|
||
onTick({ battle, unit, skill }) {
|
||
const m = unit.mem.clemntBombs;
|
||
if (!m || !unit.canAct) return;
|
||
const choices = [];
|
||
for (const [e, until] of m.marks) {
|
||
if (until < battle.time - 1e-9 || !e.alive || e.hidden) { m.marks.delete(e); continue; }
|
||
if (canTargetEnemy(unit, e, GROUND)) choices.push(e);
|
||
}
|
||
const dist2 = (e) => { const dx = e.x - unit.x, dy = e.y - unit.y; return dx * dx + dy * dy; };
|
||
choices.sort((a, b) => dist2(a) - dist2(b) || a.spawnSeq - b.spawnSeq);
|
||
for (const e of choices) if (bombard(battle, unit, e, m, skill)) { m.marks.delete(e); break; }
|
||
if (skill.ammoLeft <= 0) skill.end('ammo');
|
||
},
|
||
onEnd({ unit }) { unit.mem.clemntBombs = null; },
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 生死定夺: ATK scale BEFORE DEF, per physical damage instance.
|
||
battle.on('hit', ({ source, target, dmg }) => {
|
||
if (source !== unit || dmg.type !== 'phys' || !target || target.side !== 'enemy') return;
|
||
const chance = target.s.def < target.base.def ? num(t0.prob_special) : num(t0.prob_normal);
|
||
if (battle.rng() < chance) dmg.amount *= num(t0.t1_atk_scale, 1);
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 习得性防御: source tile in forward half-plane, plus every enemy she blocks.
|
||
battle.on('hit', ({ source, target, dmg }) => {
|
||
if (target !== unit || !source || (dmg.type !== 'phys' && dmg.type !== 'arts')) return;
|
||
const [dr, dc] = dirVec(unit.dir), [r, c] = tile(source);
|
||
if (source.blockedBy !== unit && (r - unit.tileR) * dr + (c - unit.tileC) * dc < 0) return;
|
||
const sea = source.def?.tags?.includes('seamonster');
|
||
dmg.mul *= 1 - num(sea ? t1.damage_resistance_seamonster : t1.damage_resistance_normal);
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
if (unit.skill?.id === S2) {
|
||
// Maintainer 2026-10-10 (#467): follow Chen S3 automation. The vortex hits air while normal attacks do not.
|
||
// [ASSUMED] No mode footage establishes a separate auto-cast shape for this new operator; use the expanded
|
||
// skill attack grid with permanent extensions, retaining shared operation cooldown/control checks.
|
||
unit.skill.addTriggerRange(() => [{
|
||
keys: absoluteRangeKeys(skillRec(raw, S2)?.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
|
||
profile: AIR,
|
||
}]);
|
||
}
|
||
battle.on('elementBurst', ({ target, element }) => {
|
||
if (element !== 'erosion' || target?.side !== 'enemy' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|
||
|| !canTargetEnemy(unit, target, GROUND)) return;
|
||
unit.mem.clemntBombs?.marks.set(target, battle.time + num(b3.ep_break_duration, 1));
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
};
|