feedback3 WD: shots leave the hands and aim at the chest in screen terms; a shown Spine model no longer jolts toward its target (GitHub #61)

This commit is contained in:
sganggs committed 2026-10-04 17:27:56 +08:00
1 parent 8e17a575cc
commit 41739beaaf
4 files changed
+155 -19

No files matched your search

+43 -17
View File
@@ -120,8 +120,24 @@ const ANCHOR_SNAP = 0.75;
const SKILL_GOLD = 0xffd45a;
const NO_OPTS = Object.freeze({});
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** World height of a unit's chest (shots start / aim there) and just above its feet (where shells land). */
const chestZ = (v) => (v.z || 0) + (v.hover || 0) + (v._headTiles || 1.2) * 0.45;
/**
* Shot heights (GitHub #61): where a shot or beam leaves a unit (`launch`, ≈ its hands) and where shots, beams and lock
* marks meet a unit (`aim`, ≈ its chest), as shares of the drawn model's height above the feet (`_headTiles`). The
* models are upright screen billboards, so these are heights on screen, solved into world heights through the camera
* (projection.js liftFor, `bodyZ`): 0.45 × the head height taken as a WORLD height drew at ≈ 18 % of the model under the
* 30° pitch — shots left the hips and aimed at the targets' hips. [ASSUMED] the shares (a battle chibi's hands / chest;
* the data has no official muzzle points).
*/
export const SHOT_HEIGHT = Object.freeze({ launch: 0.45, aim: 0.5 });
/** World height of the point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT), seen through `cam`. */
export function bodyZ(cam, v, frac) {
const z0 = (v.z || 0) + (v.hover || 0);
const h = (v._headTiles || 1.2) * frac;
if (!cam || typeof cam.liftFor !== 'function') return z0 + h;
const dz = cam.liftFor(v.x, v.y, z0, h * cam.scaleAt(v.x, v.y, z0));
return Number.isFinite(dz) ? z0 + dz : z0 + h;
}
/** World height just above a unit's feet (where shells land). */
const feetZ = (v) => (v.z || 0) + (v.hover || 0) + 0.2;
/** Cheap fingerprint of a camera's framing (the damage-number layout cache is reused only while it is unchanged). */
const camKey = (c) => (c ? c.tx + c.ty * 1e3 + c.tz * 1e6 + c.tilt * 7.13 + c.dist * 1e4 + c.scale * 3.7e-2 + c.cx * 1.1e-5 + c.cy * 1.3e-8 : 0);
@@ -458,6 +474,13 @@ export class FxSystem {
return this._proj(view.x, view.y, z, out);
}
/** Screen point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT: beams, a mortar's muzzle). */
_bodyPt(view, frac, out = this._p) {
const p = this._proj(view.x, view.y, (view.z || 0) + (view.hover || 0), out);
p.y -= (view._headTiles || 1.2) * frac * p.s;
return p;
}
/** `n` sparks flying out of a screen point (halved at quality 'low'); o: speed, up, g, life, size, tex. */
burst(x, y, s, n, tint, o = NO_OPTS) {
const q = this.quality === 'low' ? Math.ceil(n / 2) : n;
@@ -477,7 +500,7 @@ export class FxSystem {
attack(src, tgt, kind) {
if (!src) return;
// chain: the source is the previous target of the bounce (sim ai.js), so the arc hops unit to unit
if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff); return; }
if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff, 0.22, 1, true); return; }
if (kind === 'beam') { if (tgt && tgt !== src) this._beam(src, tgt, src.isEnemy ? 0xff7a5a : 0xffe6a8, 0.18, 0.15); return; }
const spec = PROJ[kind];
if (!spec || !tgt) {
@@ -485,14 +508,15 @@ export class FxSystem {
return;
}
const pr = this._takeProj();
const cam = this.ctx.cam();
const dx = tgt.x - src.x, dy = tgt.y - src.y;
const dist = Math.hypot(dx, dy);
const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0;
const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body
const look = spec.look;
pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0;
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = chestZ(src);
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : chestZ(tgt);
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = bodyZ(cam, src, SHOT_HEIGHT.launch);
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : bodyZ(cam, tgt, SHOT_HEIGHT.aim);
pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up)
pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5);
pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0;
@@ -587,7 +611,7 @@ export class FxSystem {
const spec = pr.spec, look = spec.look;
pr.t += dt;
const tg = pr.tgt;
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : chestZ(tg); }
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : bodyZ(cam, tg, SHOT_HEIGHT.aim); }
const k = Math.min(1, pr.t / pr.dur);
if (k >= 1 && !pr.hit) { pr.hit = true; pr.fade = 0; this._impact(pr, cam); }
let fk = 0;
@@ -654,12 +678,12 @@ export class FxSystem {
let gx, gy, gz;
if (pr.phase === 0) {
const tg = pr.tgt;
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = chestZ(tg); }
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = bodyZ(cam, tg, SHOT_HEIGHT.aim); }
gx = pr.tx; gy = pr.ty; gz = pr.tz;
} else {
const sv = pr.src;
if (!sv || sv.destroyed || sv.alive === false) return false;
gx = sv.x; gy = sv.y; gz = chestZ(sv);
gx = sv.x; gy = sv.y; gz = bodyZ(cam, sv, SHOT_HEIGHT.launch);
}
const dx = gx - pr.bx, dy = gy - pr.by, dz = gz - pr.bz;
const d = Math.hypot(dx, dy, dz);
@@ -864,7 +888,7 @@ export class FxSystem {
const pr = this._takeProj();
const gz = this._groundZ(x, y);
pr.kind = 'bombardShell'; pr.spec = BOMBARD_SHELL; pr.src = src; pr.tgt = null;
pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? chestZ(src) : gz + 0.5;
pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? bodyZ(this.ctx.cam(), src, SHOT_HEIGHT.launch) : gz + 0.5;
pr.tx = x; pr.ty = y; pr.tz = gz;
pr.t = 0; pr.dur = clamp(flight, 0.1, 4); pr.fade = 0; pr.hit = false; pr.emit = 0; pr.arc = 0; pr.ang = Math.PI / 2;
pr.rise = pr.dur >= 0.45 ? SHELL_RISE : 0;
@@ -879,7 +903,7 @@ export class FxSystem {
this.ring(x, y, gz, rr * 0.96, rr, 0xff7a4a, pr.dur, 'ring', 'pulse');
if (src && this.rich) {
// the shot leaves her upwards: a muzzle flash and a streak climbing out of sight
const p = this._chest(src, this._g);
const p = this._bodyPt(src, SHOT_HEIGHT.launch, this._g);
const s = p.s;
this.particle('muzzle', p.x, p.y, { tint: 0xffc27a, life: 0.1, s0: (s / 64) * 0.6, s1: (s / 64) * 0.8, a0: 1, a1: 0, rot: -Math.PI / 2, anchorX: 0.19 });
this.particle('glow', p.x, p.y, { tint: 0xffb35c, life: 0.14, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.9, a1: 0 });
@@ -907,10 +931,11 @@ export class FxSystem {
if (best && bd <= r + 1.5) this._releaseLock(best);
}
_beam(a, b, color, dur = 0.22, jitter = 1) {
this.beamList.push({ a, b, color, t: 0, dur, jitter, seed: Math.random() * 1000 });
/** A beam from view `a` to view `b`; `chain`: a bounce, `a` is the previous target (it leaves from its chest). */
_beam(a, b, color, dur = 0.22, jitter = 1, chain = false) {
this.beamList.push({ a, b, color, t: 0, dur, jitter, chain, seed: Math.random() * 1000 });
if (this.beamList.length > 40) this.beamList.shift();
const q = this._chest(b, this._g);
const q = this._bodyPt(b, SHOT_HEIGHT.aim, this._g);
this.particle('flare', q.x, q.y, { tint: color, life: 0.16, s0: (q.s / 128) * 0.7, s1: (q.s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() });
}
@@ -922,8 +947,9 @@ export class FxSystem {
for (const bm of this.beamList) {
bm.t += dt;
if (bm.t >= bm.dur || !bm.a || !bm.b) continue;
this._chest(bm.a, p); const px = p.x, py = p.y, s = p.s;
this._chest(bm.b, q);
// a chain bounce leaves the previous target where the shot met it (its chest), a beam leaves the shooter's hands
this._bodyPt(bm.a, bm.chain ? SHOT_HEIGHT.aim : SHOT_HEIGHT.launch, p); const px = p.x, py = p.y, s = p.s;
this._bodyPt(bm.b, SHOT_HEIGHT.aim, q);
const k = 1 - bm.t / bm.dur;
const segs = 7;
// soft glow, coloured body, white-hot core
@@ -1136,7 +1162,7 @@ export class FxSystem {
}
if (L.out >= 0) { L.out += dt; if (L.out >= LOCK_FADE) { this._freeLock(L); continue; } }
const v = L.view;
if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = chestZ(v); }
if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = bodyZ(cam, v, SHOT_HEIGHT.aim); }
const p = cam.project(L.x, L.y, L.z, this._p);
const s = p.s;
const out = L.out >= 0 ? L.out / LOCK_FADE : 0;
@@ -1803,7 +1829,7 @@ export class FxSystem {
// `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot
const lv = at.v || this._viewOf(ex.id);
this._touchLocks(ex.src ?? null);
this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? chestZ(lv) : at.z + 0.55);
this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55);
break;
}
const v = at.v;
+18
View File
@@ -199,6 +199,24 @@ export class Camera {
/** CSS px per world unit at a world point. */
scaleAt(x, y, z = 0) { return this._k / this.depthOf(x, y, z); }
/**
* World height above (x, y, z) whose projection lies `px` CSS px above the projection of (x, y, z) — exact for this
* camera. A unit's model is an upright screen billboard, so a point at a given drawn height on it (render/fx.js
* SHOT_HEIGHT, GitHub #61) is this lift, not that many tiles: under the 30° pitch a vertical world height shows at
* about half its length.
*/
liftFor(x, y, z, px) {
const vy = y - this._py, vz = z - this._pz;
let depth = vy * this._s - vz * this._c;
if (!(depth > MIN_DEPTH)) depth = MIN_DEPTH;
const up = vy * this._c + vz * this._s;
// screen y = cy − k·up/depth; raising z by h: up += h·sin, depth −= h·cos — solve for the wanted up/depth ratio
const ratio = up / depth + px / this._k;
const den = this._s + ratio * this._c;
if (!(den > 1e-6)) return px / Math.max(1e-6, (this._k / depth) * Math.max(this._s, 0.1));
return (ratio * depth - up) / den;
}
/**
* Inverse projection onto the horizontal plane at height `z`. Returns `{ x, y }` (written into `out`) or
* null when the view ray does not hit the plane in front of the camera.
+3 -2
View File
@@ -876,8 +876,9 @@ export class UnitView {
if (this.dimmed) alpha *= 0.35;
this.alpha = alpha;
// body placement
const lungeK = this.lunge > 0 ? Math.sin(this.lunge * Math.PI) * 0.12 : 0;
// body placement — the 0.12-tile jolt toward the target on an attack stands in for the avatar diamond's missing attack
// clip; a Spine model plays its own and keeps its place (GitHub #61: every ranged attack shoved the model aside)
const lungeK = this.lunge > 0 && !(this.actor && this.spineReady) ? Math.sin(this.lunge * Math.PI) * 0.12 : 0;
this.lunge = Math.max(0, this.lunge - dt * 5);
const lx = this.lungeDir.x * lungeK, ly = this.lungeDir.y * lungeK;
let bx = p.x, by = p.y;
+91
View File
@@ -0,0 +1,91 @@
// test/render/feedback3-shot-height.test.js — GitHub #61 (0.1.3): shots left an operator at its hips and aimed at the
// target's hips, and every attack shoved the whole model 0.12 tiles toward its target. render/fx.js took 0.45 × the
// head height as a WORLD height, which the 30° camera pitch draws at ≈ 18 % of the (upright, screen-space) model; the
// attack jolt of render/units.js was meant for the avatar diamond only. Now launch / aim points are heights on screen
// (fx.js SHOT_HEIGHT, solved through projection.js Camera.liftFor) and a shown Spine model keeps its place.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX, UnitView;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = presetCamera('normal', { width: 1600, height: 900 });
const close = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
test('Camera.liftFor: the world height that draws `px` above a point — exact on every preset camera', () => {
const fields = { normal: [[2, 9, 0], [10, 11, 0.4], [9, 12, 0]], prep: [[2, 7, 0.16], [8, 12, 0]], boss: [[3, 0, 0], [10, 3, 0], [18, 5, 0.4]], unite: [[2, 9, 0], [18, 12, 0]] };
for (const [kind, pts] of Object.entries(fields)) {
const c = presetCamera(kind, { width: 1280, height: 720 });
for (const [x, y, z] of pts) {
for (const px of [0, 20, 64, 140]) {
const h = c.liftFor(x, y, z, px);
close(c.project(x, y, z).y - c.project(x, y, z + h).y, px, 1e-6, `${kind} (${x},${y},${z}) +${px}px`);
}
}
}
const s = cam.scaleAt(5, 10, 0);
assert.ok(cam.liftFor(5, 10, 0, s) > 1.8, 'one tile of screen height is ≈ 2 world tiles under the 30° pitch');
});
/** A unit view as the FX system reads it. */
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.18, alive: true, destroyed: false, isEnemy: false, info: { defId: 'char_x' }, onHit() {}, ...o });
function makeFx() {
const ctx = fakeViewCtx(fake.P);
return new FX.FxSystem({
P: fake.P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: () => null, screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
}
/** Share of a unit's drawn model height (above its feet, on screen) of world point (x, y, z). */
const share = (v, x, y, z) => { const f = cam.project(x, y, v.z); return (f.y - cam.project(x, y, z).y) / (v._headTiles * f.s); };
test('an arrow leaves the shooter at its hands and flies at the target\'s chest (SHOT_HEIGHT), not at the hips', () => {
assert.deepEqual({ ...FX.SHOT_HEIGHT }, { launch: 0.45, aim: 0.5 });
const fx = makeFx();
const src = unit(1, 3, 10), tgt = unit(2, 7, 11, { isEnemy: true, _headTiles: 1.6 });
fx.attack(src, tgt, 'arrow');
const pr = fx.projs[fx.projs.length - 1];
close(share(src, pr.x0, pr.y0, pr.z0), FX.SHOT_HEIGHT.launch, 0.03, 'launch share of the model height');
close(share(tgt, pr.tx, pr.ty, pr.tz), FX.SHOT_HEIGHT.aim, 1e-6, 'aim share of the target\'s model');
// the old rule: 0.45 × head height as a world height → ≈ 18–23 % of the model
assert.ok(share(src, src.x, src.y, 0.45 * src._headTiles) < 0.25, 'the hip height it used to start from');
// a raised / flying unit: measured from where it is drawn
const fly = unit(3, 6, 9, { z: 0.4, hover: 0.32 });
assert.ok(Math.abs(FX.bodyZ(cam, fly, 0.5) - 0.72 - cam.liftFor(6, 9, 0.72, 0.5 * 1.18 * cam.scaleAt(6, 9, 0.72))) < 1e-9);
});
test('the attack jolt only moves the avatar diamond: a shown Spine model keeps its place on an attack', async () => {
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle', attack: { begin: null, loop: 'Attack', end: null } }, animations: { Idle: 1, Attack: 1 } };
let ready = false;
const assets = { picture: () => null, image: async () => null, spineEntry: () => entry,
spine: { acquire: () => (ready ? Promise.resolve({ animations: [{ name: 'Idle' }, { name: 'Attack' }] }) : new Promise(() => {})), release() {} } };
const make = () => new UnitView(fakeViewCtx(fake.P, { assets, cam: () => cam }), { id: 1, side: 'ally', kind: 'op', defId: 'c', spine: 'c', tier: 3, x: 4, y: 10, maxHp: 1000, dir: 'RIGHT' });
const target = { x: 8, y: 12 };
const feet = cam.project(4, 10, 0);
// no model (still loading): the diamond jolts toward the target
const d = make();
d.update(1 / 60, cam, 0);
d.onAttack(target, 1, 'arrow');
d.update(0.1, cam, 0.1); d.update(0.01, cam, 0.11); // (the jolt peaks a tenth of a second in)
assert.ok(Math.hypot(d.root.position.x - feet.x, d.root.position.y - feet.y) > 1, 'the avatar diamond lunges');
// a Spine model shown: no jolt
ready = true;
const v = make();
await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r));
assert.ok(v.actor && v.spineReady);
v.update(1 / 60, cam, 0);
v.onAttack(target, 1, 'arrow');
for (let i = 0; i < 4; i++) {
v.update(0.05, cam, 0.05 * i);
close(v.root.position.x, feet.x, 1e-6, 'x'); close(v.root.position.y, feet.y, 1e-6, 'y');
}
assert.equal(v.actor.current, 'Attack', 'the model shows the attack with its own clip');
});