feedback7 R22-RENDER: a unit killed on the step of its push lies where the sim put it — a dying view's sample anchors the slide too (Grok's review of fb7-render, HIGH); golden: unchanged
A push and a kill on the same sim step bring the displace fx and the 'die' together: the view died before its slide could anchor, and render/app.js syncBattle gives a dying view only its position (v.x = s.x) — never sync(), the only place that set slide.live — so update() held the slide's start until its 0.5 s timeout, past the 0.8 s DIE window, and dropped it: the body stayed on its pre-push tile (8.852 instead of the sim's 10.5; a summoned enemy and a flyer alike). The per-view step of syncBattle is now units.js syncView (unchanged for living, device and revived views); its dying branch calls UnitView.followSample, the position part of sync(): the sample anchors a slide from `at` on, and is the position only when no slide owns it. A slide whose destination snapshot never shows (the unit left the snapshots) now goes on to the fx's destination after the 0.5 s hold instead of being dropped where it started. The frame-order test fed the view with sync() after its death and stepped the battle through the harness (whose step() drained the events away from the test's own queue): it now steps the battle itself and goes through syncView. Tests: test/render/unitview.test.js 38/38 — the app.js frame order over real battles: a 1.7-tile push; a kill on the push's step for a route enemy, a summoned one and a flyer (the three failed before: bodies at 8.852 / 7.262 / 8.852) and a kill 6 ticks later — the body ends where the sim's lies, every frame step < 0.35 tiles, no step back for the same-step kills; a never-shown slide goes to the destination. Render interp, attack recovery, champagne, eyemask, enemy attack clips, sim displace-fx: 94/94 with unitview.
This commit is contained in:
1 parent
6326dd4d0b
commit
7a33bc7e19
3 files changed
+94
-44
No files matched your search
@@ -117,7 +117,7 @@ import { fxForm } from '../../../shared/protocol.js';
|
|||||||
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
|
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
|
||||||
import { SnapshotBuffer, frameTime } from './interp.js';
|
import { SnapshotBuffer, frameTime } from './interp.js';
|
||||||
import { TileField } from './tiles.js';
|
import { TileField } from './tiles.js';
|
||||||
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
|
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
|
||||||
import { FxSystem, ensureDamageFonts } from './fx.js';
|
import { FxSystem, ensureDamageFonts } from './fx.js';
|
||||||
import { createDragController, pieceTile } from './drag.js';
|
import { createDragController, pieceTile } from './drag.js';
|
||||||
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||||
@@ -1500,9 +1500,7 @@ export async function createFieldView(host, options = {}) {
|
|||||||
if (!v) v = battleView(id) || createUnknown(id, s);
|
if (!v) v = battleView(id) || createUnknown(id, s);
|
||||||
if (!v) continue;
|
if (!v) continue;
|
||||||
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
|
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
|
||||||
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
|
syncView(v, s, renderT);
|
||||||
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
|
|
||||||
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
|
|
||||||
}
|
}
|
||||||
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
|
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
|
||||||
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
|
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export function dieClipDur(entry) {
|
|||||||
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
|
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
|
||||||
return Number.isFinite(d) && d > 0 ? d : 0;
|
return Number.isFinite(d) && d > 0 ? d : 0;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a
|
||||||
|
* device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive
|
||||||
|
* again (redeployed without an event) stands up first.
|
||||||
|
*/
|
||||||
|
export function syncView(v, s, t) {
|
||||||
|
if (v.alive || v.info?.kind === 'device') v.sync(s, t);
|
||||||
|
else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } }
|
||||||
|
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); }
|
||||||
|
}
|
||||||
/** World step (x = col, y = row) of a direction. */
|
/** World step (x = col, y = row) of a direction. */
|
||||||
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
|
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
|
||||||
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||||
@@ -763,11 +773,7 @@ export class UnitView {
|
|||||||
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
|
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
|
||||||
this.el = typeof s.el === 'string' ? s.el : null;
|
this.el = typeof s.el === 'string' ? s.el : null;
|
||||||
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
|
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
|
||||||
// a push / pull slide owns the drawn position (slideTo): the sample only anchors where it eases to, from the
|
this.followSample(s, t);
|
||||||
// snapshot that carries the destination on (`at`) — before that the sample is still sliding or snapping there
|
|
||||||
const sl = this.slide;
|
|
||||||
if (!sl) { this.x = s.x; this.y = s.y; }
|
|
||||||
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
|
|
||||||
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
|
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
|
||||||
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
|
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
|
||||||
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
|
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
|
||||||
@@ -797,6 +803,18 @@ export class UnitView {
|
|||||||
|
|
||||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases
|
||||||
|
* to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or
|
||||||
|
* snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots),
|
||||||
|
* so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render).
|
||||||
|
*/
|
||||||
|
followSample(s, t) {
|
||||||
|
const sl = this.slide;
|
||||||
|
if (!sl) { this.x = s.x; this.y = s.y; }
|
||||||
|
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there (DISPLACE_SLIDE).
|
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there (DISPLACE_SLIDE).
|
||||||
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
|
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
|
||||||
@@ -1051,8 +1069,9 @@ export class UnitView {
|
|||||||
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
|
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
|
||||||
else if (!sl.live) {
|
else if (!sl.live) {
|
||||||
this.x = sl.sx; this.y = sl.sy;
|
this.x = sl.sx; this.y = sl.sy;
|
||||||
// never shown (the unit left the snapshots, a stalled stream): the snapshot takes the view back
|
// the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the
|
||||||
if ((sl.wait += dt) > 0.5) this.slide = null;
|
// destination the fx named
|
||||||
|
if ((sl.wait += dt) > 0.5) sl.live = true;
|
||||||
} else {
|
} else {
|
||||||
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
|
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
|
||||||
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
|
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
|
||||||
|
|||||||
@@ -595,49 +595,59 @@ describe('a push / pull slide (推拉: the official impulse under friction)', ()
|
|||||||
assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`);
|
assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a slide that is never shown (the unit left the snapshots) hands the view back', async () => {
|
test('a slide whose destination snapshot never shows (the unit left the snapshots) goes to the fx\'s destination', async () => {
|
||||||
const v = await view(13);
|
const v = await view(13);
|
||||||
v.slideTo(7.7, 12, { at: 3 });
|
v.slideTo(7.7, 12, { at: 3 });
|
||||||
for (let i = 0; i < 40; i++) v.update(1 / 60, cam(), 0);
|
for (let i = 0; i < 24; i++) v.update(1 / 60, cam(), 0);
|
||||||
|
assert.equal(v.x, 5, 'held while the snapshot may still come');
|
||||||
|
for (let i = 0; i < 30; i++) v.update(1 / 60, cam(), 0);
|
||||||
assert.equal(v.slide, null);
|
assert.equal(v.slide, null);
|
||||||
|
assert.equal(v.x, 7.7, 'then slid to where the push put it');
|
||||||
v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4);
|
v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4);
|
||||||
assert.equal(v.x, 6.1);
|
assert.equal(v.x, 6.1, 'snapshots drive it again');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT, then syncBattle's look-ahead starts a
|
// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT — a 'die' kills the view —, then
|
||||||
// slide for a `displace` fx still queued up to the next snapshot, then sample → sync, then update) run over a real
|
// syncBattle's look-ahead starts a slide for a `displace` fx still queued up to the next snapshot, then each sampled unit
|
||||||
// battle: a pushed enemy is drawn sliding, not snapped
|
// goes through syncView — a dying view takes only the position —, then update) run over a real battle: a pushed enemy is
|
||||||
|
// drawn sliding, not snapped, and one killed by the push lies where the sim put it
|
||||||
describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => {
|
describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => {
|
||||||
async function pushed({ kill = false } = {}) {
|
/**
|
||||||
|
* A real battle: an enemy (`kind` 'route': on its route; 'summon': spawned mid-battle; 'flyer': a flying one) pushed
|
||||||
|
* 1.7 tiles at 3 s, killed `killDelay` ticks later when `kill`. Returns the drawn x of every frame from renderT 2.8 to
|
||||||
|
* 4.6, the sim's position right after the push and where the body lies (the sim's position at the kill).
|
||||||
|
*/
|
||||||
|
async function pushed({ kill = false, killDelay = 0, kind = 'route' } = {}) {
|
||||||
const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js');
|
const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js');
|
||||||
const { SnapshotBuffer } = await import('../../public/js/render/interp.js');
|
const { SnapshotBuffer } = await import('../../public/js/render/interp.js');
|
||||||
const { snapFrame } = await import('../../server/match/fields.js');
|
const { snapFrame } = await import('../../server/match/fields.js');
|
||||||
const h = makeBattle({ defs: { enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8 }) } },
|
const { syncView } = await import('../../public/js/render/units.js');
|
||||||
enemies: [{ key: 'enemy_walker', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60 });
|
const rec = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8, ...(kind === 'flyer' ? { motion: 'FLY' } : {}) });
|
||||||
|
const h = makeBattle({ defs: { enemies: { enemy_walker: rec } }, enemies: kind === 'summon' ? [] : [{ key: 'enemy_walker', time: 0, route: 0 }],
|
||||||
|
content: 'none', autoFinish: false, timeLimit: 60 });
|
||||||
const frames = [];
|
const frames = [];
|
||||||
let e = null, info = null, moved = 0, dest = null;
|
let e = null, info = null, moved = 0, dest = null, since = -1, body = null;
|
||||||
for (let i = 0; i < 30 * 6; i++) {
|
for (let i = 0; i < 30 * 7; i++) {
|
||||||
h.step();
|
h.b.step(); // the battle's own step: h.step() would drain the events into h.events
|
||||||
|
if (kind === 'summon' && !e && h.b.time >= 1) e = h.b.spawnEnemy('enemy_walker', { pos: [10, 8] }); // (h.spawn drains too)
|
||||||
e = e || h.enemy('enemy_walker');
|
e = e || h.enemy('enemy_walker');
|
||||||
if (!moved && h.b.time >= 3) {
|
if (!moved && e && h.b.time >= 3) { moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); dest = e.x; since = 0; }
|
||||||
moved = h.b.displace(e, { x: 1, y: 0 }, 1.7);
|
else if (since >= 0) since++;
|
||||||
dest = e.x;
|
if (kill && since === killDelay) { h.b.kill(e, null); body = e.x; }
|
||||||
if (kill) h.b.kill(e, null);
|
|
||||||
}
|
|
||||||
if (i % 3 === 2) {
|
if (i % 3 === 2) {
|
||||||
const ev = h.b.drainEvents();
|
const ev = h.b.drainEvents();
|
||||||
info = info || ev.find((x) => x[0] === 'spawn')?.[1];
|
info = info || ev.find((x) => x[0] === 'spawn' && e && x[1].id === e.id)?.[1];
|
||||||
const s = h.b.snapshot();
|
const s = h.b.snapshot();
|
||||||
frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t });
|
frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.ok(moved > 1, `the sim pushed it ${moved} tiles`);
|
assert.ok(moved > 1, `the sim pushed it ${moved} tiles`);
|
||||||
const ctx = fakeViewCtx(fake.P, { cam });
|
assert.equal(e.alive, !kill);
|
||||||
const v = new UnitView(ctx, { ...info, id: e.id }, {});
|
const v = new UnitView(fakeViewCtx(fake.P, { cam }), info, {});
|
||||||
const buf = new SnapshotBuffer(), sample = new Map(), xs = [];
|
const buf = new SnapshotBuffer(), sample = new Map(), xs = [];
|
||||||
const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); };
|
const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); };
|
||||||
for (let f = 0, fi = 0; f < 60 * 3; f++) {
|
for (let f = 0, fi = 0; f < 60 * 4; f++) {
|
||||||
const now = f / 60;
|
const now = f / 60;
|
||||||
for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); }
|
for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); }
|
||||||
const renderT = buf.update(now);
|
const renderT = buf.update(now);
|
||||||
@@ -646,28 +656,51 @@ describe('a push through SnapshotBuffer and UnitView in the app.js frame order (
|
|||||||
const at = buf.nextSnapT(renderT);
|
const at = buf.nextSnapT(renderT);
|
||||||
if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at));
|
if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at));
|
||||||
buf.sample(renderT, sample);
|
buf.sample(renderT, sample);
|
||||||
if (sample.get(e.id)) v.sync(sample.get(e.id), renderT);
|
if (sample.get(e.id)) syncView(v, sample.get(e.id), renderT);
|
||||||
v.update(1 / 60, cam(), now);
|
v.update(1 / 60, cam(), now);
|
||||||
if (renderT > 2.8 && renderT < 3.8) xs.push(v.x);
|
if (renderT > 2.8 && renderT < 4.6) xs.push(v.x);
|
||||||
}
|
}
|
||||||
return { xs, dest };
|
return { xs, dest, body };
|
||||||
}
|
}
|
||||||
|
/** The largest frame-to-frame step, the frames moving, the slide's first frame and any step back after it. */
|
||||||
|
const steps = (xs) => {
|
||||||
|
let big = 0, moving = 0, back = false;
|
||||||
|
const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before)
|
||||||
|
for (let i = 1; i < xs.length; i++) {
|
||||||
|
const d = xs[i] - xs[i - 1];
|
||||||
|
big = Math.max(big, Math.abs(d)); if (Math.abs(d) > 0.02) moving++;
|
||||||
|
if (from > 0 && i > from && d < -1e-9) back = true;
|
||||||
|
}
|
||||||
|
return { big, moving, from, back };
|
||||||
|
};
|
||||||
|
|
||||||
test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => {
|
test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => {
|
||||||
const { xs } = await pushed();
|
const { xs } = await pushed();
|
||||||
let big = 0, moving = 0;
|
const { big, moving } = steps(xs);
|
||||||
for (let i = 1; i < xs.length; i++) { const d = Math.abs(xs[i] - xs[i - 1]); big = Math.max(big, d); if (d > 0.02) moving++; }
|
|
||||||
assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`);
|
assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`);
|
||||||
assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`);
|
assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('killed right after the push, the body slides on to where the sim left it and stays there', async () => {
|
// a unit killed on the very step of its push: its 'die' comes due with the displace fx, so its view is dying before the
|
||||||
const { xs, dest } = await pushed({ kill: true });
|
// slide can anchor — and syncBattle gives a dying view only its position (syncView). The slide used to stay on hold,
|
||||||
assert.ok(Math.abs(xs[xs.length - 1] - dest) < 0.011, `${xs[xs.length - 1]} vs ${dest}`);
|
// be dropped after 0.5 s, past the 0.8 s DIE window, and leave the body on its pre-push tile (Grok's review of fb7-render)
|
||||||
const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before)
|
for (const [name, o] of [
|
||||||
assert.ok(from > 0);
|
['killed on the step of the push', { kind: 'route', killDelay: 0 }],
|
||||||
for (let i = from; i < xs.length; i++) assert.ok(xs[i] >= xs[i - 1] - 1e-9, `never back towards where it stood (frame ${i})`);
|
['a summoned enemy killed on the step of the push', { kind: 'summon', killDelay: 0 }],
|
||||||
});
|
['a flyer killed on the step of the push', { kind: 'flyer', killDelay: 0 }],
|
||||||
|
['killed 6 ticks after the push', { kind: 'route', killDelay: 6 }],
|
||||||
|
]) {
|
||||||
|
test(`${name}: the body slides on to where the sim put it and stays there, no frame stepping 0.35 tiles`, async () => {
|
||||||
|
const { xs, dest, body } = await pushed({ kill: true, ...o });
|
||||||
|
const { big, moving, from, back } = steps(xs);
|
||||||
|
if (!o.killDelay) assert.equal(body, dest, 'killed where the push put it');
|
||||||
|
assert.ok(Math.abs(xs[xs.length - 1] - body) < 0.011, `lies at ${xs[xs.length - 1].toFixed(3)}, the sim's body at ${body.toFixed(3)}`);
|
||||||
|
assert.ok(from > 0 && moving >= 8, `${moving} frames show it sliding`);
|
||||||
|
assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles`);
|
||||||
|
// (killed later, it walked back down its route a little first: the body eases onto that)
|
||||||
|
if (!o.killDelay) assert.ok(!back, 'never back towards where it stood');
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in
|
// PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in
|
||||||
|
|||||||
Reference in new issue
Block a user