feedback2 WK: an unblocked ranged enemy stands for each attack's clip (its wind-up and the rest of the clip), then walks on; build-data copies each enemy's attack clip into enemies.json attackAnim (GitHub #58)
This commit is contained in:
1 parent
50c9b5b1b6
commit
0623336f30
11 files changed
+254
-22
No files matched your search
@@ -815,7 +815,8 @@ export class Battle {
|
||||
e.deploySeq = ++this._deploySeq;
|
||||
e.deployedAt = this.time;
|
||||
e.atkCd = 0;
|
||||
e.pauseUntil = -Infinity;
|
||||
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
|
||||
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
|
||||
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
|
||||
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack)
|
||||
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null };
|
||||
|
||||
+61
-17
@@ -16,10 +16,11 @@
|
||||
// always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets,
|
||||
// Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while
|
||||
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位").
|
||||
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
|
||||
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
|
||||
// (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content arms it (`e.profile.dmgType`:
|
||||
// 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
|
||||
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
|
||||
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
|
||||
// blocker → taunt → latest deployed (targeting.js sortAllyTargets). An enemy's damage type is its data's unless content
|
||||
// arms it (`e.profile.dmgType`: 转译基底's forms, whose data never attacks). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
|
||||
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
|
||||
// both re-plan the route when released (恐惧 outranks 诱导).
|
||||
@@ -449,9 +450,11 @@ export function updateEnemy(b, e, dt) {
|
||||
// hidden (teleporting) enemies only advance wait legs
|
||||
const stunned = e.s.flags.stun;
|
||||
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
|
||||
if (!e.hidden && !stunned) enemyAttack(b, e);
|
||||
// true: an unblocked ranged enemy in the wind-up of its next attack with a target in range (it stands)
|
||||
const winding = !e.hidden && !stunned && enemyAttack(b, e);
|
||||
if (!e.alive) return;
|
||||
if (stunned && !e.hidden) return;
|
||||
// a stun / freeze / sleep cuts the attack clip short: no stand left once it ends [ASSUMED]
|
||||
if (stunned && !e.hidden) { e.atkStandUntil = -Infinity; return; }
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
// (unblockable/levitate/fear may also arrive through a plain addBuff, which does not unblock by itself; a
|
||||
@@ -462,6 +465,9 @@ export function updateEnemy(b, e, dt) {
|
||||
}
|
||||
if (!e.hidden && b._checkBlock(e)) return;
|
||||
if (b.time < e.pauseUntil) return;
|
||||
// standing for an attack clip (attackStand, GitHub #58): drawn idle — the client plays the clip, then Move again; a
|
||||
// 恐惧 runs at once (it cannot attack)
|
||||
if (winding || (b.time < e.atkStandUntil && !e.s.flags.fear)) { e.moving = false; return; }
|
||||
if (e.s.flags.noMove) { e.moving = false; return; } // standing (a 重生, a form change): drawn idle, not walking
|
||||
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
|
||||
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
|
||||
@@ -573,15 +579,45 @@ function advanceRoute(b, e, dt, R) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an unblocked ranged enemy stands for one attack (GitHub #58 — the owner's decision of 2026-10-04 from
|
||||
* first-hand memory of the official game: a ranged enemy stops for each attack's animation and walks on between
|
||||
* attacks; the handbook names attacking on the move as a special ability, “十字路口”量产型's 「不停止移动的四向攻击」).
|
||||
* [ASSUMED] the stand lasts exactly its attack clip — data/enemies.json `attackAnim` { dur, hit }: the clip the client
|
||||
* plays for its attacks and its strike frame (tools/build-data.mjs, from the asset manifest) —, `hit` of it before
|
||||
* the strike (the wind-up, while a target is in range) and the rest after it, both shortened when the attacks come
|
||||
* quicker than the clip (it then plays faster: render/spine.js, the same rule) — no source gives a length. An enemy in
|
||||
* another form (掠海漂移体's crawl, 转译基底's forms, 杰斯顿's second form) stands for its base clip [ASSUMED].
|
||||
* Returns the wind-up and rest (s) into `out`: an enemy with no attack clip known stands ATTACK_PAUSE after the strike
|
||||
* (the old rule), a 「不停止移动」 one (`attackMoves`: data or content) never stops.
|
||||
*/
|
||||
export function attackStand(e, out = { wind: 0, rest: 0 }) {
|
||||
out.wind = 0; out.rest = 0;
|
||||
if (e.profile?.attackMoves ?? e.def?.attackMoves) return out;
|
||||
const a = e.def?.attackAnim;
|
||||
if (!a || !(a.dur > 0)) { out.rest = ATTACK_PAUSE; return out; }
|
||||
const iv = e.s.interval;
|
||||
const speed = iv > 0 && iv < a.dur ? a.dur / iv : 1;
|
||||
const hit = Number.isFinite(a.hit) ? Math.min(a.dur, Math.max(0, a.hit)) : a.dur / 2;
|
||||
out.wind = hit / speed;
|
||||
out.rest = (a.dur - hit) / speed;
|
||||
return out;
|
||||
}
|
||||
const STAND = { wind: 0, rest: 0 };
|
||||
|
||||
/**
|
||||
* One tick of an enemy's attack: attacks when its cooldown is over and a target is in reach. Returns true while an
|
||||
* unblocked ranged enemy is in the wind-up of its next attack (cooldown ≤ its clip's wind-up, attackStand) with a
|
||||
* target in range: it stands (updateEnemy).
|
||||
*/
|
||||
function enemyAttack(b, e) {
|
||||
const def = e.def;
|
||||
if (e.profile && e.profile.noAttack) return;
|
||||
if (e.profile && e.profile.noAttack) return false;
|
||||
const dmgType = (e.profile && e.profile.dmgType) || def.dmgType;
|
||||
if (dmgType === 'none' || e.s.atk <= 0) return;
|
||||
if (e.s.flags.fear || e.s.flags.disarm) return;
|
||||
if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击
|
||||
if (e.atkCd > 0) return;
|
||||
if (dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
|
||||
if (dmgType === 'none' || e.s.atk <= 0) return false;
|
||||
if (e.s.flags.fear || e.s.flags.disarm) return false;
|
||||
if (e.s.flags.tremble && e.blockedBy) return false; // 战栗: 被阻挡后无法进行普通攻击
|
||||
if (dmgType === 'heal') { if (e.atkCd <= 0) enemyHeal(b, e, e.base.rangeRadius); return false; }
|
||||
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
|
||||
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
|
||||
// Content may flip it with `e.profile.melee = false`.
|
||||
@@ -592,6 +628,11 @@ function enemyAttack(b, e) {
|
||||
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
|
||||
// applied to the candidates before the priority sort and the target count
|
||||
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
|
||||
if (e.atkCd > 0) {
|
||||
// the wind-up of the next attack (GitHub #58): an unblocked ranged enemy stands once a target is in range
|
||||
if (e.blockedBy || radius <= 0 || !(e.atkCd <= attackStand(e, STAND).wind + 1e-9)) return false;
|
||||
return b.alliesInRadius(e.x, e.y, reach, null).some((a) => canTargetAlly(e, a, true) && (!own || own(a)));
|
||||
}
|
||||
let targets = [];
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
@@ -604,15 +645,16 @@ function enemyAttack(b, e) {
|
||||
}
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
if (!targets.length) return;
|
||||
if (!targets.length) return false;
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack
|
||||
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
|
||||
if (palsy) {
|
||||
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
|
||||
e.atkCd = e.s.interval;
|
||||
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
|
||||
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
|
||||
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
|
||||
b.fx('palsy', { x: e.x, y: e.y, id: e.id });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const n = Math.max(1, Math.floor(e.profile?.maxTargets ?? 1) + Math.floor(e.s.maxTargets));
|
||||
if (targets.length > n) targets = targets.slice(0, n);
|
||||
@@ -620,7 +662,7 @@ function enemyAttack(b, e) {
|
||||
const ctx = { attacker: e, targets, isSkill: false, profile: e.profile };
|
||||
b.emit('beforeAttack', ctx);
|
||||
targets = (ctx.targets || []).filter((t) => t && t.alive);
|
||||
if (!targets.length || !e.alive) return;
|
||||
if (!targets.length || !e.alive) return false;
|
||||
}
|
||||
e.lastAttackAt = b.time;
|
||||
e.stats.attacks++;
|
||||
@@ -644,7 +686,9 @@ function enemyAttack(b, e) {
|
||||
}
|
||||
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, isSkill: false });
|
||||
e.atkCd = e.s.interval;
|
||||
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
|
||||
// stands for the rest of its attack clip (attackStand; the wind-up was stood before the strike)
|
||||
if (!e.blockedBy && radius > 0) e.atkStandUntil = b.time + attackStand(e, STAND).rest;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Enemy healers (dmgType 'heal'): heal the lowest-HP% other enemy within their radius. */
|
||||
|
||||
@@ -13,7 +13,11 @@ export const COLS = GEO.COLS;
|
||||
|
||||
/** tilesPerSecond = moveSpeed × MOVE_SCALE (DESIGN §3). */
|
||||
export const MOVE_SCALE = 0.5;
|
||||
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
|
||||
/**
|
||||
* An unblocked ranged enemy stands for each attack's clip (ai.js attackStand, GitHub #58); one whose model has no attack
|
||||
* clip known (no `attackAnim` in data/enemies.json) stands this long after each attack instead, as do all after an
|
||||
* attack 麻痹 interrupts (DESIGN §5.5).
|
||||
*/
|
||||
export const ATTACK_PAUSE = 0.35;
|
||||
/**
|
||||
* Collider radius of an allied unit (PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形" —
|
||||
|
||||
@@ -1743,7 +1743,7 @@ function kitRoar(ab) {
|
||||
* `_immuneStunWhenAffecting` 0, like the other enemy channels here): nothing leaves the drone, it keeps its bomb and casts
|
||||
* again after the skill's cooldown (1 s, counted from the interrupt) once it is free and an operator is in range.
|
||||
* [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (the
|
||||
* engine pauses an enemy for its attack clip, ATTACK_PAUSE; the drop is the drone's only attack-like cast); the
|
||||
* engine stands a ranged enemy for its attack clip, ai.js attackStand; the drop is the drone's only attack-like cast); the
|
||||
* bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick
|
||||
* before the clip ends — the client would draw the bomb back for a frame); an interrupted cast does not use up the one
|
||||
* trigger (`_maxTriggerTime` 1 — the bomb is still on the drone); a stun after the release does not stop the cast end.
|
||||
|
||||
@@ -225,6 +225,14 @@ export function immuneSet(v, extra = null) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** data/enemies.json `attackAnim` → { dur, hit } (hit null = none: half the clip), or null when malformed / absent. */
|
||||
function normAttackAnim(a) {
|
||||
const dur = Number(a?.dur);
|
||||
if (!(Number.isFinite(dur) && dur > 0)) return null;
|
||||
const hit = Number(a.hit);
|
||||
return { dur, hit: a.hit != null && Number.isFinite(hit) ? Math.min(dur, Math.max(0, hit)) : null };
|
||||
}
|
||||
|
||||
/** Normalise an enemy record (research 05 shape or data/enemies.json shape) into an EnemyDef. */
|
||||
export function normalizeEnemy(key, e) {
|
||||
const st = e.stats ?? e;
|
||||
@@ -262,6 +270,8 @@ export function normalizeEnemy(key, e) {
|
||||
notCountInTotal: !!(e.notCountInTotal),
|
||||
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
|
||||
staticBody: !!e.staticBody, // 静态刚体: pushes / pulls never move it (Battle._displaceable)
|
||||
attackAnim: normAttackAnim(e.attackAnim), // its attack clip { dur, hit } (ai.js attackStand); null = none known
|
||||
attackMoves: !!e.attackMoves, // 「不停止移动」: never stops to attack (ai.js attackStand)
|
||||
tags: e.tags ?? [],
|
||||
abilities: e.abilities ?? [],
|
||||
skills: e.skills ?? [],
|
||||
|
||||
Reference in new issue
Block a user