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:
sganggs committed 2026-10-09 17:10:30 +08:00
1 parent 62eb113419
commit 2e122873f0
21 files changed
+2940 -2312

No files matched your search

+1
View File
@@ -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) });
},
};
+215
View File
@@ -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 });
},
};
},
};
+13 -2
View File
@@ -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) {