Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
+27
-2
@@ -13,7 +13,7 @@
|
||||
// ATTACK_PAUSE seconds after each attack. Reaching the final leg's end = leak. An `attract` (诱导) status suspends the
|
||||
// route: the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
|
||||
|
||||
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, COLS } from './constants.js';
|
||||
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
|
||||
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
|
||||
import { reduceElement } from './damage.js';
|
||||
import { OB_CRATE } from './grid.js';
|
||||
@@ -140,7 +140,9 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
b._ev(['atk', u.id, t.id, vis]);
|
||||
if (isHeal) { doHeal(b, u, prof, t); continue; }
|
||||
const info = { isSkill, index: i, attackId };
|
||||
if (ranged && t.side === 'enemy') {
|
||||
if (ranged && t.side === 'enemy' && prof.projectile === 'boomerang') {
|
||||
throwBoomerang(b, u, prof, t, info);
|
||||
} else if (ranged && t.side === 'enemy') {
|
||||
const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED;
|
||||
// projectiles land even if the shooter died meanwhile (damage is credited to it)
|
||||
b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0,
|
||||
@@ -154,6 +156,29 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
if (prof.afterAttack) b._safe(() => prof.afterAttack(b, u, targets), 'profile.afterAttack', u);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回环射手 boomerang (projectile 'boomerang', professions.js loopshooter): it flies out to the target at
|
||||
* PROJECTILE_SPEEDS.boomerang and the attack hits on arrival; a target that died / vanished meanwhile is not hit (the
|
||||
* boomerang still flies to its last position — a splash profile would burst there), then it flies back to the thrower's
|
||||
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
|
||||
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
|
||||
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
|
||||
*/
|
||||
function throwBoomerang(b, u, prof, t, info) {
|
||||
const seq = u.deploySeq;
|
||||
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
|
||||
const home = () => u.alive && u.deployed && u.deploySeq === seq;
|
||||
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
|
||||
onHit: (c) => {
|
||||
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
|
||||
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
|
||||
if (!home()) return;
|
||||
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
|
||||
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
|
||||
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
|
||||
} });
|
||||
}
|
||||
|
||||
/** Apply one attack hit (called on impact for projectiles). `target` may be null (splash on a dead target's spot). */
|
||||
export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
const atk = u.s.atk;
|
||||
|
||||
@@ -17,8 +17,14 @@ export const MOVE_SCALE = 0.5;
|
||||
export const ATTACK_PAUSE = 0.35;
|
||||
/** Default projectile speed in tiles/s for ranged operators/enemies. */
|
||||
export const PROJECTILE_SPEED = 12;
|
||||
/** Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. */
|
||||
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10 });
|
||||
/**
|
||||
* Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. `boomerang` (回环射手 跃跃) is the OUTBOUND
|
||||
* flight to the target — PRTS 跃跃 特性 note "投射物飞行速度15,返回时飞行速度3.75"; the way back is
|
||||
* BOOMERANG_RETURN_SPEED (ai.js throwBoomerang).
|
||||
*/
|
||||
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15 });
|
||||
/** 回环射手: speed (tiles/s) of a boomerang flying back from its hit point to its thrower (PRTS "返回时飞行速度3.75"). */
|
||||
export const BOOMERANG_RETURN_SPEED = 3.75;
|
||||
|
||||
/** Minimum damage ratio after mitigation (5 % of the pre-mitigation amount). */
|
||||
export const MIN_DAMAGE_RATIO = 0.05;
|
||||
|
||||
@@ -593,7 +593,10 @@ export default {
|
||||
}),
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_09 跃跃 乐趣加倍: ATK +atk, each attack throws `cnt` boomerangs (cnt hits). 戏耍随心: prob per hit for
|
||||
// 1_09 跃跃 (回环射手: every attack is a boomerang out to the target and back — she attacks again only once it is
|
||||
// caught, professions.js loopshooter / ai.js throwBoomerang). 乐趣加倍: ATK +atk, each attack throws `cnt`
|
||||
// boomerangs at the target (cnt hits); they fly the same path, so the flight carries cnt hits and all of them are
|
||||
// back together ("必须回收全部回旋投掷物才可以进行下一次攻击"). 戏耍随心: prob per hit (each boomerang on its own) for
|
||||
// ATK ×atk_scale. Elite module (LPS-X, trait atk_scale): vs enemies on the 8 surrounding tiles ATK ×atk_scale.
|
||||
// Alternate S1 强力击·β型 (AUTO, attack SP): the next attack at atk_scale × ATK.
|
||||
chess_char_1_09_a: (bb, chess) => {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// Simplifications (one line per id; see also the report of the content phase):
|
||||
// 1_15 盟约·辅助干员 element pick = neural → burn → apoptosis, skipping the elements already bursting on the target.
|
||||
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
|
||||
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
|
||||
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
|
||||
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
|
||||
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
|
||||
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
|
||||
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
|
||||
@@ -98,6 +101,7 @@
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
import { COLS, ROWS } from '../../constants.js';
|
||||
import { rotateOffset } from '../../dir.js';
|
||||
import { summonToken, TOKEN_IDS } from '../tokens.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
@@ -345,6 +349,15 @@ function pithst(bb, chess, def) {
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续
|
||||
|
||||
/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */
|
||||
const LEMUEN_SHELL_INTERVAL = 0.3;
|
||||
const LEMUEN_SHELL_WINDOW = 10;
|
||||
/**
|
||||
* Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the
|
||||
* PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell).
|
||||
*/
|
||||
const LEMUEN_SHELL_FLIGHT = 0.3;
|
||||
|
||||
function ensureWanted(battle) {
|
||||
const S = bstate(battle);
|
||||
if (S.wanted) return S.wanted;
|
||||
@@ -394,18 +407,43 @@ function lemuen(bb, chess, def) {
|
||||
for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e;
|
||||
return best;
|
||||
};
|
||||
const bombard = (battle, unit, locks) => {
|
||||
for (const L of locks) {
|
||||
const x = L.e.alive && !L.e.hidden ? L.e.x : L.x;
|
||||
const y = L.e.alive && !L.e.hidden ? L.e.y : L.y;
|
||||
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
|
||||
for (const e of battle.enemiesInRadius(x, y, d2)) {
|
||||
if (e.s.flags.untargetable) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
|
||||
}
|
||||
// S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every
|
||||
// LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4",
|
||||
// emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot
|
||||
// it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once
|
||||
// (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the
|
||||
// ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸").
|
||||
const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2));
|
||||
const markOf = (L) => {
|
||||
const e = L.e;
|
||||
if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; }
|
||||
return L;
|
||||
};
|
||||
const blast = (battle, unit, atk, x, y) => {
|
||||
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
|
||||
for (const e of battle.enemiesInRadius(x, y, d2)) {
|
||||
if (e.s.flags.untargetable) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
battle.dealDamage(unit, e, { amount: atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
|
||||
}
|
||||
};
|
||||
const bombard = (battle, unit, locks) => {
|
||||
const atk = unit.s.atk;
|
||||
const seq = unit.deploySeq;
|
||||
const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9));
|
||||
const fire = (i) => {
|
||||
// knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land)
|
||||
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
|
||||
const L = markOf(locks[i]);
|
||||
// the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike
|
||||
const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir);
|
||||
const x = L.x + ox, y = L.y + oy;
|
||||
battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i });
|
||||
battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit });
|
||||
if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit });
|
||||
};
|
||||
if (n > 0) fire(0);
|
||||
};
|
||||
// S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt
|
||||
// steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim
|
||||
const aimS = {
|
||||
@@ -463,7 +501,7 @@ function lemuen(bb, chess, def) {
|
||||
onTick({ battle, unit, skill, dt }) {
|
||||
const m = unit.mem;
|
||||
if (!m.lemLocks) return;
|
||||
for (const L of m.lemLocks) if (L.e.alive && !L.e.hidden) { L.x = L.e.x; L.y = L.e.y; }
|
||||
for (const L of m.lemLocks) markOf(L);
|
||||
if (!unit.canAct) return;
|
||||
m.lemAcc += dt;
|
||||
if (m.lemAcc + 1e-9 < aim) return;
|
||||
@@ -477,6 +515,7 @@ function lemuen(bb, chess, def) {
|
||||
if (skill.active && skill.ammoLeft <= 0) skill.end('ammo');
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
// the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none
|
||||
const locks = unit.mem.lemLocks || [];
|
||||
unit.mem.lemLocks = null;
|
||||
if (reason !== 'death' && unit.alive) bombard(battle, unit, locks);
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
// PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait-text / trait-blackboard tunables (TUNE[sub]) →
|
||||
// data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides.
|
||||
// Profile fields:
|
||||
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none' projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'
|
||||
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none'
|
||||
// projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('boomerang': out to the target and back to
|
||||
// the thrower, ai.js throwBoomerang) boomerang bool (回环射手: keeps 'boomerang')
|
||||
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
|
||||
// allInRange bool splashRadius tiles splashScale (× damage for splash victims)
|
||||
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
|
||||
@@ -175,6 +177,18 @@ const installHunter = (battle, unit) => {
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.ammo = max; }, { owner: unit });
|
||||
};
|
||||
|
||||
/**
|
||||
* 回环射手 (loopshooter) trait "持有回旋投射物时才能够攻击(投射物需要时间回收)": its projectile is a boomerang that
|
||||
* flies to the target, hits on arrival and flies back to the thrower (ai.js throwBoomerang, speeds in constants.js);
|
||||
* the thrower attacks only while it holds its boomerang — every one it threw must be caught first (PRTS 跃跃 S2 note
|
||||
* "必须回收全部回旋投掷物才可以进行下一次攻击") — and once its attack cooldown is ready, so the real interval is the longer
|
||||
* of the two ("实际攻击间隔会受投掷物的实际飞行时间影响产生浮动"). A (re)deployed thrower holds a fresh one.
|
||||
*/
|
||||
const installLoopshooter = (battle, unit) => {
|
||||
unit.trait.boomerangsOut = 0;
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.boomerangsOut = 0; }, { owner: unit });
|
||||
};
|
||||
|
||||
const installTactician = (battle, unit) => {
|
||||
const spawn = () => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
@@ -266,13 +280,8 @@ export const SUB = Object.freeze({
|
||||
canAttack: (battle, unit) => (unit.trait.ammo ?? 8) > 0,
|
||||
dmgMul: (b, u) => u.profile.ammoScale ?? 1.2,
|
||||
afterAttack: (battle, unit) => { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 8) - 1); } }),
|
||||
loopshooter: P({ projectile: 'lob',
|
||||
afterAttack: (battle, unit, targets) => {
|
||||
const t = targets[0];
|
||||
if (!t) return;
|
||||
const d = Math.hypot(t.x - unit.x, t.y - unit.y);
|
||||
unit.atkCd = Math.max(unit.atkCd, (2 * d) / 10);
|
||||
} }),
|
||||
loopshooter: P({ projectile: 'boomerang', boomerang: true, install: installLoopshooter,
|
||||
canAttack: (battle, unit) => !(unit.trait.boomerangsOut > 0) }),
|
||||
reaperrange: P({ allInRange: true,
|
||||
dmgMul: (battle, unit, target) => {
|
||||
const grid = unit.profile.frontGrid;
|
||||
@@ -456,6 +465,9 @@ export function resolveProfile(def, kitTrait = null) {
|
||||
}
|
||||
if (def.targetPriority) p.priority = PRIORITY_ALIASES[def.targetPriority] ?? def.targetPriority;
|
||||
if (def.splashRadius != null && def.splashRadius > 0) p.splashRadius = def.splashRadius;
|
||||
// a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a
|
||||
// build-data default) would turn the out-and-back flight into a plain shot
|
||||
if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang';
|
||||
if (def.type === 'token') {
|
||||
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
|
||||
if (def.stats.atk <= 0) p.noAttack = true;
|
||||
|
||||
Reference in new issue
Block a user