feedback1d WN: 暴鸰 drops its bomb as a projectile and flies on without it (D4)
The drone triggered its one bomb, but the damage and the blast landed in the trigger tick
while the model kept the bomb hanging under it for good. Official (PRTS 暴鸰; the client's
battle prefab enemy_1040_bombd and projectile_bombd): the cast plays the Attack clip, the bomb
leaves on its OnAttack event (0.267 s), flies to the target at 5 tiles/s and explodes on it
and the 8 tiles around; the drone switches to its bomb-less clips and speeds up.
Sim (kitBombd): release after BOMBD_RELEASE with an 'atk' event of kind 'droneBomb' and fx
'phase' {kind: 'bombed'}, a homing projectile (PROJECTILE_SPEEDS.droneBomb 5) whose arrival
deals the damage, then the speed buff. Render: FORMS.enemy_1040_bombd.bombed (*_2 clips),
PROJ.droneBomb (a falling shell), a one-off cast plays the attack clip once at its own
speed instead of the drone's 5 s attack rhythm, no puff for the silent mode change.
This commit is contained in:
1 parent
46ccfd55f6
commit
901d5b9613
13 files changed
+339
-45
No files matched your search
@@ -1349,7 +1349,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e[0] !== 'atk' || woundUp.has(e) || CHAIN_KINDS.has(e[3])) return;
|
||||
const v = views.get(e[1]);
|
||||
if (!v || !v.windUp) return;
|
||||
if (v.windUp(t - upcomingT)) woundUp.add(e);
|
||||
if (v.windUp(t - upcomingT, e[3])) woundUp.add(e);
|
||||
}
|
||||
|
||||
const EVS = [];
|
||||
@@ -1381,7 +1381,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const src = views.get(e[1]) || battleView(e[1]);
|
||||
const tgt = views.get(e[2]) || battleView(e[2]);
|
||||
// chain / chainHeal bounces: the "source" is the previous target of the bounce, not an attacker
|
||||
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now);
|
||||
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now, e[3]);
|
||||
if (e[3] === 'none' || !e[3]) { if (tgt && src) meleePending.set(tgt.id, { src, t: now }); }
|
||||
fx.attack(src, tgt, e[3]);
|
||||
break;
|
||||
|
||||
@@ -237,9 +237,16 @@ const FX_GUESS = [
|
||||
[/stealth|hide|vanish|cloak/i, 'vanish'], [/pull|push|knock|dash|leap/i, 'move'], [/pulse|wave|ring|sonic/i, 'wave'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 'phase' (an enemy's mode change, render/units.js FORMS) kinds the model shows on its own: 暴鸰 'bombed' — its bomb is
|
||||
* the 'droneBomb' projectile of the same moment, a puff on the drone would read as something else (feedback D4).
|
||||
*/
|
||||
const SILENT_PHASES = new Set(['bombed']);
|
||||
|
||||
/** Visual spec of an fx kind (see FX_KINDS); `extra.kind` / `extra.element` may pick a better colour. */
|
||||
export function fxSpec(kind, extra = {}) {
|
||||
const k = typeof kind === 'string' ? kind : '';
|
||||
if (k === 'phase' && SILENT_PHASES.has(extra && extra.kind)) return { a: 'none', c: 0xffffff };
|
||||
let spec = FX_KINDS[k];
|
||||
if (!spec) {
|
||||
const g = FX_GUESS.find(([re]) => re.test(k));
|
||||
|
||||
+24
-14
@@ -6,15 +6,18 @@
|
||||
//
|
||||
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
|
||||
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
|
||||
// attack(interval) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
|
||||
// so its OnAttack frame lands now, timeScale = loopDuration / interval
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
|
||||
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
|
||||
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
|
||||
// clip plays once at its own speed, then base
|
||||
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards)
|
||||
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
|
||||
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast: to the end of its clip), then the
|
||||
// end clip (if any) and base.
|
||||
|
||||
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
@@ -211,42 +214,49 @@ export class SpineActor {
|
||||
* clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started;
|
||||
* false when it is still too early (call again next frame) or there is nothing to wind up.
|
||||
*/
|
||||
windUp(interval, lead) {
|
||||
windUp(interval, lead, once = false) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
|
||||
const iv = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
const loopDur = this.dur(clip.loop);
|
||||
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
|
||||
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
const hit = this._hitTime(clip.loop, loopDur);
|
||||
const plan = windUpPlan(loopDur, hit, iv, lead);
|
||||
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
|
||||
this.interval = iv;
|
||||
if (!once) this.interval = iv;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + lead + Math.max(0.45, iv * 1.4);
|
||||
this._play(clip.loop, true, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.attackUntil = this.clock + lead + (once ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
|
||||
this._play(clip.loop, !once, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.windTs = plan.ts;
|
||||
this.windUntil = this.clock + lead;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
|
||||
attack(interval) {
|
||||
/**
|
||||
* An attack happened now. `interval` = seconds between attacks (game time already scaled to real); `once` = a one-off
|
||||
* cast (no rhythm): the clip plays once at its own speed from its strike frame, then the resting state.
|
||||
*/
|
||||
attack(interval, once = false) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
|
||||
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
if (!once) this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
const ts = clampN(loopDur / this.interval, 0.35, 4);
|
||||
const ts = once ? 1 : clampN(loopDur / this.interval, 0.35, 4);
|
||||
const hit = this._hitTime(clip.loop, loopDur);
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + Math.max(0.45, this.interval * 1.4);
|
||||
this.attackUntil = this.clock + (once ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
|
||||
this.windUntil = null;
|
||||
if (!wasAttacking) {
|
||||
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
|
||||
// the strike frame now
|
||||
this._play(clip.loop, true, { timeScale: ts, start: hit, mix: 0.06 });
|
||||
this._play(clip.loop, !once, { timeScale: ts, start: hit, mix: 0.06 });
|
||||
} else if (once) {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) e.timeScale = ts;
|
||||
} else {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) {
|
||||
|
||||
@@ -89,7 +89,7 @@ export function dmgStyleKey(type) {
|
||||
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
|
||||
|
||||
/**
|
||||
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
|
||||
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang | droneBomb;
|
||||
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
|
||||
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
|
||||
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
|
||||
@@ -101,6 +101,8 @@ export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0x
|
||||
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
|
||||
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
|
||||
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
|
||||
* once a one-off cast, not an attack rhythm: the shooter's attack clip plays once at its own speed (render/spine.js
|
||||
* SpineActor.attack / windUp) — 暴鸰's bomb drop
|
||||
*/
|
||||
export const PROJ = Object.freeze({
|
||||
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
|
||||
@@ -111,6 +113,9 @@ export const PROJ = Object.freeze({
|
||||
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
|
||||
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
|
||||
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
|
||||
// 暴鸰's bomb (sim content/enemies.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls
|
||||
// onto its target with a low arc and bursts where the sim's 'explode' blast goes off
|
||||
droneBomb: { look: 'shell', speed: 5, once: true, tint: 0xffe2c8, glow: 0xff5a3a, len: 0.6, width: 0.3, head: 0.5, trail: 0xff7a4a, arc: 0.35, smoke: 0x2e2824, hit: 'boom' },
|
||||
});
|
||||
|
||||
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
|
||||
|
||||
+29
-13
@@ -25,7 +25,8 @@
|
||||
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
|
||||
// goes down with `die(true)`: straight to the held end of the clip, no fall.
|
||||
// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
|
||||
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
|
||||
// plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its bomb (*_2) after the drop
|
||||
// (feedback D4) — FORMS; a view built later keeps the mode.
|
||||
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
|
||||
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
|
||||
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
|
||||
@@ -53,7 +54,7 @@
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@@ -101,10 +102,20 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
|
||||
|
||||
/**
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
|
||||
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
|
||||
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
|
||||
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'; 暴鸰 flies on
|
||||
* its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2, Die→Die_2;
|
||||
* user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its Attack clip).
|
||||
* The mode's roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
|
||||
*/
|
||||
export const FORMS = Object.freeze({
|
||||
enemy_1040_bombd: Object.freeze({
|
||||
bombed: Object.freeze({
|
||||
roles: Object.freeze({
|
||||
idle: 'Idle_2', deploy: 'Idle_2', die: 'Die_2',
|
||||
move: Object.freeze({ begin: 'Move_Begin_2', loop: 'Move_Loop_2', end: 'Move_End_2' }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
enemy_2025_syufo: Object.freeze({
|
||||
crawl: Object.freeze({
|
||||
change: 'Change',
|
||||
@@ -514,13 +525,17 @@ export class UnitView {
|
||||
}
|
||||
|
||||
/** An attack was made (b.ev 'atk'). `target` = view or null. */
|
||||
onAttack(target, now) {
|
||||
onAttack(target, now, kind) {
|
||||
if (!this.alive) return;
|
||||
if (this.lastAtk >= 0) {
|
||||
const d = now - this.lastAtk;
|
||||
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
|
||||
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
|
||||
const once = !!PROJ[kind]?.once;
|
||||
if (!once) {
|
||||
if (this.lastAtk >= 0) {
|
||||
const d = now - this.lastAtk;
|
||||
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
|
||||
}
|
||||
this.lastAtk = now;
|
||||
}
|
||||
this.lastAtk = now;
|
||||
if (target && !this.isEnemy && this.info.kind !== 'device') {
|
||||
// operators keep their deploy direction (research 09 §1.2); enemies may turn towards their target
|
||||
} else if (target && this.isEnemy) {
|
||||
@@ -532,17 +547,18 @@ export class UnitView {
|
||||
this.lungeDir.x = dx / len; this.lungeDir.y = dy / len;
|
||||
}
|
||||
this.lunge = 1;
|
||||
if (this.actor) this.actor.attack(this.atkInterval); // game seconds: the actor's clock runs in game time
|
||||
if (this.actor) this.actor.attack(this.atkInterval, once); // game seconds: the actor's clock runs in game time
|
||||
if (this.imp) this.imp.dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* An attack by this unit is `lead` game seconds ahead in the snapshot buffer: start the Spine attack wind-up now
|
||||
* so the strike frame lines up with the attack. True once started (then stop calling for that attack).
|
||||
* so the strike frame lines up with the attack. True once started (then stop calling for that attack). `kind` = the
|
||||
* 'atk' projKind (a one-off cast winds up at the clip's own speed).
|
||||
*/
|
||||
windUp(lead) {
|
||||
windUp(lead, kind) {
|
||||
if (!this.alive || !this.actor || !this.spineReady) return false;
|
||||
const ok = this.actor.windUp(this.atkInterval, lead);
|
||||
const ok = this.actor.windUp(this.atkInterval, lead, !!PROJ[kind]?.once);
|
||||
if (ok && this.imp) this.imp.dirty = true;
|
||||
return ok;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user