Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
This commit is contained in:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
@@ -0,0 +1,235 @@
|
||||
// server/sim/fear.js — 恐惧 movement of enemies (DESIGN §5.5; user playtest #6 item 13).
|
||||
//
|
||||
// Official rule (gamedata_const termDescriptionDict ba.fear "无法被阻挡并四散逃跑"; PRTS 术语释义 恐惧 "受影响的目标大体上会
|
||||
// 尝试尽可能远离效果来源"; PRTS 诱发移动 §恐惧 — the fear controller):
|
||||
// * 恐惧可达地块: when a fear is applied the controller records the tiles whose CENTRE lies within 10 tiles of the hit
|
||||
// position (the feared unit's position then), inside the 90° fan pointing away from the effect source (±45°), that
|
||||
// can reach the unit's goal, are passable terrain (obstacles or not) and are not the protection objective (no
|
||||
// 地穴 in this mode). Without a source, with the unit itself as the source (自惧 / "对自身施加…恐惧", e.g. the
|
||||
// “萨科塔” enemies' SelfFear) or with the source exactly on the hit position there is NO reachable tile.
|
||||
// * 恐惧移动: the unit picks a random reachable tile as a temporary checkpoint, offset at random inside a square of
|
||||
// side 0.5 around the tile centre, and moves there; on arrival it picks the next one, until the fear ends. A picked
|
||||
// tile farther than 5 path steps is dropped from the list for good and the unit's own tile is used instead; with
|
||||
// no reachable tile the checkpoint is always its own tile (same offset) — so a self-feared unit flutters around
|
||||
// inside its tile ("小范围乱飞"). A new fear refreshes the list from the current position (Battle.applyStatus
|
||||
// stamps `buff.data.fear` on every application). Waiting checkpoints keep counting down meanwhile; when the fear
|
||||
// ends the unit re-plans its route from where it stands ("恐惧效果结束时立刻清除【恐惧移动】" — so does a running
|
||||
// 诱导, ai.js moveAttracted). 恐惧 outranks 诱导 (PRTS 诱移优先级).
|
||||
// Path steps (the 5-step limit): 4-neighbour steps over the unit's passable, obstacle-free tiles, the checkpoint tile
|
||||
// included (ground: walkable, blocks and crates excluded — a crate's move cost 1000 is beyond any 5-step budget and a
|
||||
// block has no path; flyers: fly-passable), like the official SPFA's unit edges.
|
||||
// The temporary path: ground units walk the grid's smoothed flow-field waypoints to the checkpoint's tile (stepping
|
||||
// back to their tile centre first when the straight line from an off-centre position would clip a tile they cannot
|
||||
// walk, as ai.js planLeg), flyers fly straight. Like every enemy path it re-plans from where the unit stands when
|
||||
// something else moved it (a push / pull — Battle.displace) or an obstacle changed (grid.version): to the same
|
||||
// checkpoint while it stays reachable, else to a new pick. Speed = the unit's own (move-speed buffs such as the
|
||||
// SelfFear ×1.5 included). Deterministic: every draw is battle.rng().
|
||||
|
||||
import { MOVE_SCALE, COLS } from './constants.js';
|
||||
import { straightClear } from './grid.js';
|
||||
|
||||
/** 半径10格 — the fan's radius (tiles). */
|
||||
export const FEAR_RADIUS = 10;
|
||||
/** 左右各45° — cos of the fan's half angle. */
|
||||
export const FEAR_HALF_COS = Math.SQRT1_2;
|
||||
/** 寻路距离5格内 — the farthest checkpoint in path steps. */
|
||||
export const FEAR_REACH = 5;
|
||||
/** 0.5边长正方形的随机偏移 — side of the random offset square around a checkpoint tile's centre. */
|
||||
export const FEAR_JITTER = 0.5;
|
||||
|
||||
/**
|
||||
* Stamp a fear application on its buff (Battle.applyStatus): the hit position, the source's position and whether
|
||||
* the unit feared itself. Every application gets a new per-battle `seq`, which makes the mover rebuild its tiles.
|
||||
*/
|
||||
export function stampFear(b, target, buff, source) {
|
||||
const self = !source || source === target;
|
||||
b._fearSeq = (b._fearSeq || 0) + 1;
|
||||
buff.data = {
|
||||
...buff.data,
|
||||
fear: {
|
||||
seq: b._fearSeq, hx: target.x, hy: target.y, self,
|
||||
sx: self ? target.x : Number(source.x), sy: self ? target.y : Number(source.y),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest fear stamp among the unit's fear buffs (null when none carries one — e.g. a raw addBuff). */
|
||||
function stampOf(e) {
|
||||
let best = null;
|
||||
for (const b of e.buffs) {
|
||||
const f = (b.status === 'fear' || b.key === 'fear') && b.data ? b.data.fear : null;
|
||||
if (f && (!best || f.seq > best.seq)) best = f;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The goal tile of the enemy's route (its final leg), or null. */
|
||||
function goalOf(e) {
|
||||
const legs = e.route && e.route.legs;
|
||||
if (!legs) return null;
|
||||
for (let i = legs.length - 1; i >= 0; i--) if (legs[i].final && legs[i].r != null) return legs[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Passable terrain for this unit, obstacles or not ("不论该格是否有障碍物"). */
|
||||
const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c, true));
|
||||
|
||||
/**
|
||||
* 恐惧可达地块 of a stamp: tile keys in row-major order (deterministic).
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function reachableTiles(b, e, st) {
|
||||
if (!st || st.self) return [];
|
||||
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = Math.hypot(dx, dy);
|
||||
if (!(d > 1e-9)) return [];
|
||||
const ux = dx / d, uy = dy / d;
|
||||
const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
|
||||
const goal = goalOf(e);
|
||||
let field = null, field2 = null;
|
||||
if (!fly && goal) { field = g.flowField(goal.r, goal.c); field2 = g.flowField(goal.r, goal.c, { ignoreObstacles: true }); }
|
||||
const out = [];
|
||||
for (let r = R.r0; r <= R.r1; r++) {
|
||||
for (let c = R.c0; c <= R.c1; c++) {
|
||||
const vx = c - st.hx, vy = r - st.hy, L = Math.hypot(vx, vy);
|
||||
if (L > FEAR_RADIUS + 1e-9) continue;
|
||||
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue;
|
||||
if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue;
|
||||
const k = r * COLS + c;
|
||||
if (field && field.dist[k] < 0 && field2.dist[k] < 0) continue; // 该格可抵达其终点
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the unit can stand on / step through tile key `k` (its passable terrain; ground: no block, no crate). */
|
||||
function openTile(g, fly, k) {
|
||||
const r = (k / COLS) | 0, c = k - r * COLS;
|
||||
return passable(g, fly, r, c) && (fly || !g.isObstacle(r, c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Path steps (4-neighbour, the unit's open tiles, `to` included) from tile key `from` to `to`, or Infinity beyond
|
||||
* `limit` — an obstacle tile has no path ("寻路距离"), so a pick on one is dropped like a far one.
|
||||
*/
|
||||
function stepsBetween(b, e, from, to, limit) {
|
||||
if (from === to) return 0;
|
||||
const g = b.grid, fly = e.motion === 'FLY';
|
||||
const open = (k) => openTile(g, fly, k);
|
||||
if (!open(to)) return Infinity;
|
||||
let frontier = [from];
|
||||
const seen = new Set(frontier);
|
||||
for (let d = 1; d <= limit && frontier.length; d++) {
|
||||
const next = [];
|
||||
for (const k of frontier) {
|
||||
const r = (k / COLS) | 0, c = k - r * COLS;
|
||||
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
|
||||
const rr = r + dr, cc = c + dc;
|
||||
if (!g.inRect(rr, cc)) continue;
|
||||
const kk = rr * COLS + cc;
|
||||
if (seen.has(kk)) continue;
|
||||
if (kk === to) return d;
|
||||
seen.add(kk);
|
||||
if (open(kk)) next.push(kk);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/** The unit's tile (row, col, key), clamped to the rect. */
|
||||
function hereOf(g, e) {
|
||||
const r = Math.max(g.rect.r0, Math.min(g.rect.r1, Math.round(e.y)));
|
||||
const c = Math.max(g.rect.c0, Math.min(g.rect.c1, Math.round(e.x)));
|
||||
return { r, c, k: r * COLS + c };
|
||||
}
|
||||
|
||||
/**
|
||||
* The temporary path from where the unit stands to its checkpoint (`m.k` tile, `m.goal` point): ground units follow
|
||||
* the smoothed flow-field waypoints (null when an obstacle now cuts the tile off), flyers fly straight. Records the
|
||||
* grid version; moveFeared records where each step left the unit and re-plans when either changes behind its back.
|
||||
* @returns {boolean} false when a ground unit can no longer reach the checkpoint tile
|
||||
*/
|
||||
function planPath(b, e, m) {
|
||||
const g = b.grid, h = hereOf(g, e);
|
||||
const pts = [];
|
||||
if (e.motion !== 'FLY' && m.k !== h.k) {
|
||||
if (!openTile(g, false, m.k)) return false;
|
||||
const r = (m.k / COLS) | 0, c = m.k - r * COLS;
|
||||
const wp = g.waypoints(h.r, h.c, r, c);
|
||||
if (!wp) return false;
|
||||
for (let j = 1; j < wp.length - 1; j++) pts.push({ x: wp[j][1], y: wp[j][0] });
|
||||
// the smoothed chain is line-of-sight clear from the tile CENTRE only (as ai.js planLeg)
|
||||
const first = pts.length ? pts[0] : { x: c, y: r };
|
||||
if ((e.x !== h.c || e.y !== h.r) && !straightClear(g, e.x, e.y, first)) pts.unshift({ x: h.c, y: h.r });
|
||||
}
|
||||
pts.push(m.goal);
|
||||
m.pts = pts;
|
||||
m.i = 0;
|
||||
m.ver = g.version;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Choose the next temporary checkpoint (a random reachable tile within 5 path steps, else the own tile) and plan to it. */
|
||||
function pickCheckpoint(b, e, m) {
|
||||
const here = hereOf(b.grid, e).k;
|
||||
let k = here;
|
||||
if (m.tiles.length) {
|
||||
const i = Math.floor(b.rng() * m.tiles.length);
|
||||
const cand = m.tiles[i];
|
||||
if (stepsBetween(b, e, here, cand, FEAR_REACH) <= FEAR_REACH) k = cand;
|
||||
else m.tiles.splice(i, 1); // "…格子会永久从当次恐惧的恐惧可达地块中剔除"
|
||||
}
|
||||
const ox = (b.rng() - 0.5) * FEAR_JITTER, oy = (b.rng() - 0.5) * FEAR_JITTER;
|
||||
const aim = (key) => {
|
||||
const r = (key / COLS) | 0, c = key - r * COLS;
|
||||
m.k = key;
|
||||
m.goal = { x: c + ox, y: r + oy };
|
||||
return planPath(b, e, m);
|
||||
};
|
||||
// (a pick within 5 open steps has a path unless the unit itself stands on a block; the own tile needs none)
|
||||
if (!aim(k)) aim(here);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tick of fear movement for enemy `e` (ai.js updateEnemy, while `e.s.flags.fear`). Sets `e.moving`.
|
||||
*/
|
||||
export function moveFeared(b, e, dt) {
|
||||
const st = stampOf(e);
|
||||
let m = e.mem.fearMove;
|
||||
if (!m || (st && m.seq !== st.seq)) {
|
||||
m = e.mem.fearMove = { seq: st ? st.seq : -1, tiles: reachableTiles(b, e, st), pts: null, i: 0 };
|
||||
}
|
||||
// pushed / pulled (Battle.displace) or an obstacle changed since the last tick: re-plan the walk from here — to the
|
||||
// same checkpoint while it stays reachable, else a new pick (a stale path would walk through walls)
|
||||
if (m.pts && (m.ver !== b.grid.version || e.x !== m.px || e.y !== m.py) && !planPath(b, e, m)) m.pts = null;
|
||||
// a waiting checkpoint of the route keeps counting down ("停驻时间会相应扣除恐惧生效时间")
|
||||
const R = e.route, leg = R && R.legs ? R.legs[R.legIdx] : null;
|
||||
if (leg && leg.t === 'wait') {
|
||||
if (R.waitLeft == null) R.waitLeft = leg.time;
|
||||
R.waitLeft -= dt;
|
||||
if (R.waitLeft <= 1e-9) { R.waitLeft = null; R.legIdx++; }
|
||||
}
|
||||
if (R) R.pts = null; // the route re-plans from wherever the fear leaves the unit
|
||||
const speed = e.s.moveSpeed * MOVE_SCALE;
|
||||
let moved = false;
|
||||
if (speed > 0) {
|
||||
let dist = speed * dt;
|
||||
for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
|
||||
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
|
||||
const p = m.pts[m.i];
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
|
||||
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
|
||||
moved = moved || d > 1e-9;
|
||||
}
|
||||
}
|
||||
m.px = e.x; m.py = e.y;
|
||||
e.moving = moved;
|
||||
}
|
||||
|
||||
/** The fear ended: forget the controller; the route re-plans from here (route.pts was cleared while feared). */
|
||||
export function endFear(e) {
|
||||
e.mem.fearMove = null;
|
||||
if (e.route) e.route.pts = null;
|
||||
}
|
||||
Reference in new issue
Block a user