0.2.3: add Clementia and Shu/Ulpianus modules (#437)
Regenerate roster-019, roster-038 and diy-059 through diy-140 for added and regrouped loadouts; other golden families unchanged.
This commit is contained in:
1 parent
62eb113419
commit
2e122873f0
21 files changed
+2940
-2312
No files matched your search
@@ -150,6 +150,7 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-necras.js',
|
||||
'op-kalts2.js',
|
||||
'op-monstr.js',
|
||||
'op-clemnt.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,8 +143,14 @@ export default {
|
||||
} },
|
||||
{ install(battle, unit) { // 血脉的哺养
|
||||
battle.on('kill', (c) => {
|
||||
if (c.killer !== unit || c.victim.side !== 'enemy' || !on(unit)) return;
|
||||
// CRU-Y stages 2/3: kills credited to any 深海猎人 count while Ulpianus is on the field.
|
||||
const hunterKill = num(t1.add_block_cnt_stack_cnt) > 0 && isOp(c.killer) && isAbyssal(c.killer);
|
||||
if ((c.killer !== unit && !hunterKill) || c.victim.side !== 'enemy' || !on(unit)) return;
|
||||
battle.addBuff(unit, { key: 'ulpia:blood', refresh: 'stack', maxStacks: Math.max(1, num(t1.max_stack_cnt, 9)), mods: mods({ hpFlat: num(t1.max_hp), atkFlat: num(t1.atk) }) });
|
||||
const threshold = num(t1.add_block_cnt_stack_cnt);
|
||||
if (threshold > 0 && unit.findBuff('ulpia:blood')?.stacks >= threshold) {
|
||||
battle.addBuff(unit, { key: 'ulpia:bloodBlock', mods: { blockCnt: num(t1.block_cnt) }, tags: ['talent'] });
|
||||
}
|
||||
const share = mods({ hpFlat: num(t1['ulpia_t_1[abyssal].max_hp']), atkFlat: num(t1['ulpia_t_1[abyssal].atk']) });
|
||||
if (!Object.keys(share).length) return;
|
||||
for (const a of battle.allies()) {
|
||||
@@ -154,6 +160,11 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// CRU-Y: all his damage against a blocked enemy is scaled, including anchor hits.
|
||||
const blockedScale = num(tb.damage_scale, 1);
|
||||
if (blockedScale !== 1) battle.on('hit', (c) => {
|
||||
if (c.source === unit && c.target?.side === 'enemy' && c.target.blockedBy) c.dmg.mul *= blockedScale;
|
||||
}, { owner: unit });
|
||||
if (num(tb.heal_scale) > 0) permBuff(battle, unit, 'ulpia:module', { healingTakenMul: num(tb.heal_scale) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// 克莱门莎 (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] S2's ordinary splash radius 1.5;
|
||||
// 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 { 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.5; // [ASSUMED] PRTS has not published S2's normal attack splash radius.
|
||||
const FIRST_BOMB = 0.5; // [ASSUMED] PRTS lists the landing delay as unknown.
|
||||
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;
|
||||
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.alive || e.hidden || cannotCarry(e)) { battle.removeBuff(e, CARRY + unit.id); continue; }
|
||||
if (!e.findBuff(CARRY + unit.id)) continue;
|
||||
const [er, ec] = tile(e);
|
||||
if (!battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) { battle.removeBuff(e, 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, true)
|
||||
.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 * num(b2['attack@aoe_atk_scale'], 1), 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) {
|
||||
battle.on('elementBurst', ({ target, element }) => {
|
||||
if (element !== 'erosion' || !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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -99,6 +99,10 @@ export default {
|
||||
const born = RANGE_IDS[r0?.bbStr?.born_range_id] ?? null;
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1);
|
||||
// GUA-Y stages 2/3: the extra ATK sits on the module's hidden talent (index -1), not 天有四时.
|
||||
const seasonAtk = num(talentRec(chess, -1)?.bb?.atk);
|
||||
const squadOf = (battle, unit) => battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId);
|
||||
const suiReady = (battle, unit) => squadOf(battle, unit).filter((a) => SUI.has(a.def?.charId)).length >= SUI_NEEDED;
|
||||
const g1 = gridOf(chess, S1, X4), g2 = gridOf(chess, S2, X1), g3 = gridOf(chess, S3, X2);
|
||||
|
||||
/** The sown tiles of her current deployment (a Set of tile keys). */
|
||||
@@ -244,13 +248,15 @@ export default {
|
||||
const per = new Map();
|
||||
for (const a of ops) { const p = a.def?.profession ?? '?'; per.set(p, (per.get(p) ?? 0) + 1); }
|
||||
const many = per.size >= PROF_NEEDED, same = Math.max(0, ...per.values()) >= PROF_NEEDED;
|
||||
const bonus = seasonAtk > 0 && Number(many) + Number(same) + Number(suiReady(battle, unit)) >= 2;
|
||||
for (const a of ops) {
|
||||
if (many && hpUp) battle.addBuff(a, { key: 'talent:shu:seasons:hp', duration: FARM_HOLD, mods: { hpPct: hpUp }, source: unit, tags: ['talent'] });
|
||||
if (same && asUp) battle.addBuff(a, { key: 'talent:shu:seasons:aspd', duration: FARM_HOLD, mods: { aspd: asUp }, source: unit, tags: ['talent'] });
|
||||
if (bonus) battle.addBuff(a, { key: 'talent:shu:seasons:atk', duration: FARM_HOLD, mods: { atkPct: seasonAtk }, source: unit, tags: ['talent'] });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
// 编入队伍且编队中有四名【岁】干员: her squad's operators ATK + for the battle, +sp SP every `interval` s deployed
|
||||
const squadOf = () => battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId);
|
||||
const ownSquad = () => squadOf(battle, unit);
|
||||
const atk = num(t1.atk), sp = num(t1.sp), iv = num(t1.interval);
|
||||
const spBuff = (a) => {
|
||||
if (sp > 0 && iv > 0) battle.addBuff(a, { key: 'talent:shu:suiSp', interval: iv, onTick: ({ unit: x }) => giveSp(x, sp), source: unit, tags: ['talent'] });
|
||||
@@ -258,7 +264,7 @@ export default {
|
||||
const apply = () => {
|
||||
if (unit.mem.shuSuiDone) return;
|
||||
unit.mem.shuSuiDone = true;
|
||||
const squad = squadOf();
|
||||
const squad = ownSquad();
|
||||
if (squad.filter((a) => SUI.has(a.def?.charId)).length < SUI_NEEDED) return;
|
||||
const members = new Set(squad);
|
||||
for (const a of squad) {
|
||||
@@ -272,6 +278,11 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// GUA-Y: damage reduction, independent of the sown tile's 庇护 (official trait damage_resistance).
|
||||
const dr = num(tb.damage_resistance);
|
||||
if (dr > 0) battle.on('deploy', ({ unit: u }) => {
|
||||
if (u === unit) battle.addBuff(unit, { key: 'shu:gua-y', mods: { dmgTakenMul: 1 - dr }, source: unit, tags: ['module'] });
|
||||
}, { owner: unit });
|
||||
// GUA-X: 治疗生命值低于50%的友方单位时治疗量提升15% (heal_scale_up[hpratio][LE]: at or below hp_ratio)
|
||||
const hs = num(tb.heal_scale, 1), hr = num(tb.hp_ratio);
|
||||
if (hs !== 1 && hr > 0) {
|
||||
|
||||
Reference in new issue
Block a user