@waica/behaviors 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ import { type Entity } from '@waica/engine';
2
+ /**
3
+ * How far above its entity a behavior's Anchored Piece is attached
4
+ * (issue #72, CA-14): the top edge of the entity's first Sprite or
5
+ * AnimatedSprite box — the box the pointer picks against, so
6
+ * `offsetY + height × (1 − anchorY)` — plus a small margin, or one world
7
+ * unit when it has neither. Read at attach time; no behavior carries a
8
+ * height param of its own.
9
+ */
10
+ export declare function anchorHeight(entity: Entity): number;
@@ -0,0 +1,21 @@
1
+ import { AnimatedSprite, Sprite } from '@waica/engine';
2
+ /** World units left between the top of the sprite box and an Anchored Piece. */
3
+ const ANCHOR_MARGIN = 0.2;
4
+ /** Where an Anchored Piece sits on an entity that draws no sprite. */
5
+ const SPRITELESS_ANCHOR_HEIGHT = 1;
6
+ /**
7
+ * How far above its entity a behavior's Anchored Piece is attached
8
+ * (issue #72, CA-14): the top edge of the entity's first Sprite or
9
+ * AnimatedSprite box — the box the pointer picks against, so
10
+ * `offsetY + height × (1 − anchorY)` — plus a small margin, or one world
11
+ * unit when it has neither. Read at attach time; no behavior carries a
12
+ * height param of its own.
13
+ */
14
+ export function anchorHeight(entity) {
15
+ for (const component of entity.components) {
16
+ if (component instanceof Sprite || component instanceof AnimatedSprite) {
17
+ return component.offsetY + component.height * (1 - component.anchorY) + ANCHOR_MARGIN;
18
+ }
19
+ }
20
+ return SPRITELESS_ANCHOR_HEIGHT;
21
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The names of the stock Anchored Pieces (issue #72, ADR 0018): every piece
3
+ * these behaviors ship except npc-line, the screen-space fallback. Their
4
+ * `{{bindings}}` are per-instance values, not Game stats, so tooling such as
5
+ * validate_project reads this list to tell them apart.
6
+ */
7
+ export declare const ANCHORED_UI_PIECES: readonly string[];
@@ -0,0 +1,12 @@
1
+ import { HEALTH_UI } from './health-ui.js';
2
+ import { INTERACTABLE_UI, INTERACTABLE_UI_PIECE } from './interactable.js';
3
+ /**
4
+ * The names of the stock Anchored Pieces (issue #72, ADR 0018): every piece
5
+ * these behaviors ship except npc-line, the screen-space fallback. Their
6
+ * `{{bindings}}` are per-instance values, not Game stats, so tooling such as
7
+ * validate_project reads this list to tell them apart.
8
+ */
9
+ export const ANCHORED_UI_PIECES = Object.freeze([
10
+ ...Object.keys(INTERACTABLE_UI).filter((name) => name !== INTERACTABLE_UI_PIECE),
11
+ ...Object.keys(HEALTH_UI),
12
+ ]);
@@ -1,5 +1,5 @@
1
1
  import { Component, Sprite, screenInputToLogical, } from '@waica/engine';
2
- import { fireInteract, INTERACTABLE_UI_PIECE, Interactable } from './interactable.js';
2
+ import { Interactable, interactWith } from './interactable.js';
3
3
  import { Health } from './health.js';
4
4
  import { MeleeAttack } from './melee-attack.js';
5
5
  import { buildNavigationGrid } from './navigation-grid.js';
@@ -198,10 +198,8 @@ function driveNpcOrder(clickToMove, entity, game, order) {
198
198
  return null;
199
199
  }
200
200
  if (distance(pointOf(entity), pointOf(target)) <= interactable.radius) {
201
- game.stats.set('npcLine', interactable.line);
202
- // Scene-scoped, same reason as interactUpdate's prompt (grill decision 8).
203
- game.ui.show(INTERACTABLE_UI_PIECE, { scope: 'scene' });
204
- fireInteract(target, entity);
201
+ // The interact key's own path: bubble or npc-line, npcLine, onInteract (grill S8).
202
+ interactWith(game, entity, target, interactable);
205
203
  clickToMove.cancel(); // CA-5: one trigger per arrival, no key simulated
206
204
  return null;
207
205
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The stock Anchored Pieces (issue #72, ADR 0018) a Health can name
3
+ * through its damageNumber and healthBar params. Registering them only
4
+ * makes them available: nothing shows until a Health names one. Each draws
5
+ * itself above its zero-size anchor box, centred on it.
6
+ *
7
+ * damage-number reads the hit's `{{amount}}` and rises and fades over the
8
+ * 0.8 s the instance lives. health-bar has no text at all: it is driven by
9
+ * the `--current` / `--max` custom properties its instance publishes, and
10
+ * is one world unit wide (`--waica-unit`) at any zoom.
11
+ */
12
+ export declare const HEALTH_UI: Readonly<Record<string, string>>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The stock Anchored Pieces (issue #72, ADR 0018) a Health can name
3
+ * through its damageNumber and healthBar params. Registering them only
4
+ * makes them available: nothing shows until a Health names one. Each draws
5
+ * itself above its zero-size anchor box, centred on it.
6
+ *
7
+ * damage-number reads the hit's `{{amount}}` and rises and fades over the
8
+ * 0.8 s the instance lives. health-bar has no text at all: it is driven by
9
+ * the `--current` / `--max` custom properties its instance publishes, and
10
+ * is one world unit wide (`--waica-unit`) at any zoom.
11
+ */
12
+ export const HEALTH_UI = {
13
+ 'damage-number': `<style>
14
+ .damage-number {
15
+ position: absolute;
16
+ left: 0;
17
+ bottom: 0;
18
+ transform: translate(-50%, 0);
19
+ width: max-content;
20
+ font: 800 20px/1 system-ui, sans-serif;
21
+ color: #ff5d73;
22
+ white-space: nowrap;
23
+ text-shadow: 0 2px 0 #000, 0 0 4px #000c;
24
+ pointer-events: none;
25
+ user-select: none;
26
+ animation: damage-number-rise 0.8s ease-out forwards;
27
+ }
28
+ @keyframes damage-number-rise {
29
+ from {
30
+ transform: translate(-50%, 0) scale(1.25);
31
+ opacity: 1;
32
+ }
33
+ 60% {
34
+ opacity: 1;
35
+ }
36
+ to {
37
+ transform: translate(-50%, calc(-0.8 * var(--waica-unit, 40px))) scale(1);
38
+ opacity: 0;
39
+ }
40
+ }
41
+ </style>
42
+ <div class="damage-number">-{{amount}}</div>
43
+ `,
44
+ 'health-bar': `<style>
45
+ .health-bar {
46
+ position: absolute;
47
+ left: 0;
48
+ bottom: 0;
49
+ transform: translate(-50%, 0);
50
+ box-sizing: border-box;
51
+ width: var(--waica-unit, 40px);
52
+ height: 7px;
53
+ padding: 1px;
54
+ border-radius: 4px;
55
+ background: #1a1a2ecc;
56
+ border: 1px solid #ffffff2e;
57
+ pointer-events: none;
58
+ user-select: none;
59
+ }
60
+ .health-bar-fill {
61
+ width: calc(var(--current) / var(--max) * 100%);
62
+ height: 100%;
63
+ border-radius: 2px;
64
+ background: #ef476f;
65
+ transition: width 0.15s ease-out;
66
+ }
67
+ </style>
68
+ <div class="health-bar"><div class="health-bar-fill"></div></div>
69
+ `,
70
+ };
package/dist/health.d.ts CHANGED
@@ -49,6 +49,14 @@ export declare class Health extends Component {
49
49
  label: string;
50
50
  ref: 'sound';
51
51
  };
52
+ damageNumber: {
53
+ label: string;
54
+ ref: 'ui';
55
+ };
56
+ healthBar: {
57
+ label: string;
58
+ ref: 'ui';
59
+ };
52
60
  };
53
61
  static transient: string[];
54
62
  max: number;
@@ -61,14 +69,35 @@ export declare class Health extends Component {
61
69
  stat: string;
62
70
  /** Sound played at this entity's position on every accepted hit. Empty plays nothing (CA-12). */
63
71
  hurtSound: string;
72
+ /** UI piece shown over this entity on every accepted hit, with `{{amount}}`. Empty shows nothing. */
73
+ damageNumber: string;
74
+ /** UI piece shown over this entity below max health, with `{{current}}`/`{{max}}`. Empty shows nothing. */
75
+ healthBar: string;
64
76
  /** Health left; 0 is dead. Filled in from max on ready. */
65
77
  current: number;
66
78
  /** Whoever dealt the last accepted hit, for the state that reacts to it. */
67
79
  lastDamageSource: Entity | undefined;
68
- /** Seconds left in the invulnerability window. */
69
- private invulnerable;
70
- /** Seconds into the current blink, for the 10 Hz toggle. */
71
- private blinkClock;
80
+ /**
81
+ * The open invulnerability window, on `game.time`. Null once
82
+ * `closeWindow()` has run. A window ended by owner destroy or scene
83
+ * unload instead of a natural close never runs `closeWindow()` — cancellation
84
+ * runs no callback — so this can also be present but inactive; `blinking`
85
+ * and `inspectState().invulnerable` both read through `.active`/`.remaining`,
86
+ * so that case still reports correctly.
87
+ */
88
+ private windowHandle;
89
+ /**
90
+ * The 10 Hz blink toggle, on `game.time`. Null once `closeWindow()` has
91
+ * run, same asymmetry as `windowHandle`: a window cancelled by owner
92
+ * destroy or scene unload leaves this present but inactive instead.
93
+ */
94
+ private blinkHandle;
95
+ /**
96
+ * The healthBar instance while the entity is hurt but alive (issue #72,
97
+ * CA-15); null at full health, at 0 and before the first hit. One the
98
+ * engine removed with its entity or its scene reads `alive: false`.
99
+ */
100
+ private healthBarHandle;
72
101
  /** Whether the node is being flashed: exactly while a window is open. */
73
102
  get blinking(): boolean;
74
103
  /** Whether the signalled death must be checked on this component's next update. */
@@ -82,7 +111,7 @@ export declare class Health extends Component {
82
111
  */
83
112
  inspectState(): Record<string, unknown>;
84
113
  onReady(): void;
85
- onUpdate(dt: number): void;
114
+ onUpdate(): void;
86
115
  /**
87
116
  * Makes good on a death the graph said it would handle. Signalling is
88
117
  * fire-and-forget, so "it declared an edge" is a promise, not a receipt:
@@ -103,11 +132,41 @@ export declare class Health extends Component {
103
132
  /** Mirrors current into the named stat, when one is named. */
104
133
  private publish;
105
134
  /**
106
- * Flashes the node at 10 Hz for as long as the window stays open, and
107
- * leaves it visible the frame the window closes — the flash is feedback,
108
- * never a state a hit can leave the entity stuck in.
135
+ * Floats the damageNumber piece over the entity for an accepted hit
136
+ * (issue #72, CA-13). Called before die(), so a killing blow's number is
137
+ * already attached when the entity goes, and lingers where it stood for
138
+ * its own 0.8 s. A non-finite amount (OutOfBounds' Infinity) has no number
139
+ * worth showing.
140
+ */
141
+ private showDamageNumber;
142
+ /**
143
+ * Keeps the healthBar piece in step with current (issue #72, CA-15): none
144
+ * at full health; attached by the first hit that leaves the entity below
145
+ * max, then updated in place through `set` by every later hit or heal;
146
+ * removed once current is back at max, or at 0 (die() removes it too).
147
+ */
148
+ private syncHealthBar;
149
+ private removeHealthBar;
150
+ /**
151
+ * Opens a fresh invulnerability window on `game.time`, starting visible:
152
+ * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
153
+ * at 10 Hz for as long as it stays open (CA-11). `invulnerability <= 0`
154
+ * schedules nothing and touches no `game.time` at all — the window never
155
+ * opens. Both are owned by this entity, so `Entity.destroy()` cancels
156
+ * them immediately mid-window.
157
+ *
158
+ * The `invulnerability <= 0` check below runs once, here, before either
159
+ * handle is touched — it is not re-checked later. So lowering
160
+ * `invulnerability` to 0 while a window is already open does not close it
161
+ * early: that window keeps blinking and rejecting damage until it runs
162
+ * its normal course. Deliberate, not a bug: nothing re-reads
163
+ * `invulnerability` once a window is open.
109
164
  */
110
- private blink;
165
+ private openWindow;
166
+ /** Cancels the blink and restores visibility the moment the window closes. */
167
+ private closeWindow;
168
+ /** The 10 Hz flash while a window is open: openWindow() starts it visible, so a plain toggle lands on the right phase every time. */
169
+ private toggleBlink;
111
170
  /**
112
171
  * Announces the death, then lets the role decide — but only if it says it
113
172
  * can: a graph with no death edge would swallow the signal silently, so
package/dist/health.js CHANGED
@@ -1,4 +1,5 @@
1
- import { Component, SIMULATION_TIME_EPSILON, StateMachine, } from '@waica/engine';
1
+ import { Component, StateMachine, } from '@waica/engine';
2
+ import { anchorHeight } from './anchor-height.js';
2
3
  /**
3
4
  * Below this, current is treated as exactly zero. Repeated fractional
4
5
  * damage (e.g. ten hits of 0.1 against max: 1) can leave a floating-point
@@ -9,6 +10,8 @@ import { Component, SIMULATION_TIME_EPSILON, StateMachine, } from '@waica/engine
9
10
  const DEATH_EPSILON = 1e-9;
10
11
  /** Seconds each blink phase lasts while invulnerable: 10 Hz on/off. */
11
12
  const BLINK_PERIOD = 0.1;
13
+ /** Seconds of Game Time a damage number lives (issue #72, CA-13). */
14
+ const DAMAGE_NUMBER_SECONDS = 0.8;
12
15
  /**
13
16
  * Where a 'signal:death' from `current` could land: the targets of every
14
17
  * death edge on the current state or on '*'. Mirrors the merge
@@ -48,14 +51,17 @@ export class Health extends Component {
48
51
  invulnerability: { label: 'Invulnerability', min: 0, max: 5, step: 0.1 },
49
52
  stat: { label: 'Stat', ref: 'stat' },
50
53
  hurtSound: { label: 'Hurt sound', ref: 'sound' },
54
+ damageNumber: { label: 'Damage number', ref: 'ui' },
55
+ healthBar: { label: 'Health bar', ref: 'ui' },
51
56
  };
52
57
  static transient = [
53
58
  'current',
54
- 'invulnerable',
55
59
  'deathPending',
56
60
  'expectedStates',
57
61
  'lastDamageSource',
58
- 'blinkClock',
62
+ 'windowHandle',
63
+ 'blinkHandle',
64
+ 'healthBarHandle',
59
65
  ];
60
66
  max = 3;
61
67
  /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
@@ -67,17 +73,38 @@ export class Health extends Component {
67
73
  stat = '';
68
74
  /** Sound played at this entity's position on every accepted hit. Empty plays nothing (CA-12). */
69
75
  hurtSound = '';
76
+ /** UI piece shown over this entity on every accepted hit, with `{{amount}}`. Empty shows nothing. */
77
+ damageNumber = '';
78
+ /** UI piece shown over this entity below max health, with `{{current}}`/`{{max}}`. Empty shows nothing. */
79
+ healthBar = '';
70
80
  /** Health left; 0 is dead. Filled in from max on ready. */
71
81
  current = 0;
72
82
  /** Whoever dealt the last accepted hit, for the state that reacts to it. */
73
83
  lastDamageSource;
74
- /** Seconds left in the invulnerability window. */
75
- invulnerable = 0;
76
- /** Seconds into the current blink, for the 10 Hz toggle. */
77
- blinkClock = 0;
84
+ /**
85
+ * The open invulnerability window, on `game.time`. Null once
86
+ * `closeWindow()` has run. A window ended by owner destroy or scene
87
+ * unload instead of a natural close never runs `closeWindow()` — cancellation
88
+ * runs no callback — so this can also be present but inactive; `blinking`
89
+ * and `inspectState().invulnerable` both read through `.active`/`.remaining`,
90
+ * so that case still reports correctly.
91
+ */
92
+ windowHandle = null;
93
+ /**
94
+ * The 10 Hz blink toggle, on `game.time`. Null once `closeWindow()` has
95
+ * run, same asymmetry as `windowHandle`: a window cancelled by owner
96
+ * destroy or scene unload leaves this present but inactive instead.
97
+ */
98
+ blinkHandle = null;
99
+ /**
100
+ * The healthBar instance while the entity is hurt but alive (issue #72,
101
+ * CA-15); null at full health, at 0 and before the first hit. One the
102
+ * engine removed with its entity or its scene reads `alive: false`.
103
+ */
104
+ healthBarHandle = null;
78
105
  /** Whether the node is being flashed: exactly while a window is open. */
79
106
  get blinking() {
80
- return this.invulnerable > 0;
107
+ return this.windowHandle?.active ?? false;
81
108
  }
82
109
  /** Whether the signalled death must be checked on this component's next update. */
83
110
  deathPending = false;
@@ -94,7 +121,7 @@ export class Health extends Component {
94
121
  invulnerability: this.invulnerability,
95
122
  stat: this.stat,
96
123
  current: this.current,
97
- invulnerable: this.invulnerable,
124
+ invulnerable: this.windowHandle?.remaining ?? 0,
98
125
  blinking: this.blinking,
99
126
  lastDamageSource: this.lastDamageSource?.name ?? null,
100
127
  };
@@ -110,17 +137,9 @@ export class Health extends Component {
110
137
  if (this.current === 0)
111
138
  this.die();
112
139
  }
113
- onUpdate(dt) {
114
- if (this.invulnerable > 0) {
115
- this.invulnerable = Math.max(0, this.invulnerable - dt);
116
- // this.invulnerable is a countdown of SIMULATION_STEP-sized dts, which
117
- // float error can leave as a tiny positive residual instead of
118
- // exactly 0 on the step that should close the window; without this,
119
- // `> 0` above stays true for one whole extra step.
120
- if (this.invulnerable <= SIMULATION_TIME_EPSILON)
121
- this.invulnerable = 0;
122
- this.blink(dt);
123
- }
140
+ onUpdate() {
141
+ // The invulnerability window and its blink now run entirely on
142
+ // game.time (ADR 0017, CA-11): this no longer drives either.
124
143
  if (this.deathPending) {
125
144
  this.deathPending = false;
126
145
  this.settleDeath();
@@ -154,13 +173,14 @@ export class Health extends Component {
154
173
  // too — every NaN comparison is false, so amount <= 0 lets it through
155
174
  // and poisons current (NaN - anything is NaN, and every guard against
156
175
  // it is false forever after).
157
- if (!(amount > 0) || this.current <= 0 || this.invulnerable > 0)
176
+ if (!(amount > 0) || this.current <= 0 || this.blinking)
158
177
  return;
159
178
  this.current = Math.max(0, this.current - amount);
160
179
  if (this.current < DEATH_EPSILON)
161
180
  this.current = 0;
162
181
  this.lastDamageSource = source;
163
182
  this.publish();
183
+ this.syncHealthBar();
164
184
  this.game.events.emit('damage', {
165
185
  entity: this.entity,
166
186
  amount,
@@ -169,8 +189,8 @@ export class Health extends Component {
169
189
  });
170
190
  if (this.hurtSound)
171
191
  this.game.audio.play(this.hurtSound, { at: this.entity });
172
- this.invulnerable = this.invulnerability;
173
- this.blinkClock = 0;
192
+ this.showDamageNumber(amount);
193
+ this.openWindow();
174
194
  if (this.current === 0) {
175
195
  this.die();
176
196
  return;
@@ -185,6 +205,7 @@ export class Health extends Component {
185
205
  return;
186
206
  this.current = Math.min(this.max, this.current + amount);
187
207
  this.publish();
208
+ this.syncHealthBar();
188
209
  }
189
210
  /** Mirrors current into the named stat, when one is named. */
190
211
  publish() {
@@ -192,31 +213,100 @@ export class Health extends Component {
192
213
  this.game.stats.set(this.stat, this.current);
193
214
  }
194
215
  /**
195
- * Flashes the node at 10 Hz for as long as the window stays open, and
196
- * leaves it visible the frame the window closes — the flash is feedback,
197
- * never a state a hit can leave the entity stuck in.
216
+ * Floats the damageNumber piece over the entity for an accepted hit
217
+ * (issue #72, CA-13). Called before die(), so a killing blow's number is
218
+ * already attached when the entity goes, and lingers where it stood for
219
+ * its own 0.8 s. A non-finite amount (OutOfBounds' Infinity) has no number
220
+ * worth showing.
198
221
  */
199
- blink(dt) {
200
- if (this.invulnerable > 0) {
201
- this.blinkClock += dt;
202
- this.entity.node.visible = Math.floor(this.blinkClock / BLINK_PERIOD) % 2 === 0;
222
+ showDamageNumber(amount) {
223
+ if (!this.damageNumber || !Number.isFinite(amount))
224
+ return;
225
+ this.game.ui.attach(this.damageNumber, this.entity, {
226
+ offset: [0, anchorHeight(this.entity)],
227
+ seconds: DAMAGE_NUMBER_SECONDS,
228
+ values: { amount },
229
+ });
230
+ }
231
+ /**
232
+ * Keeps the healthBar piece in step with current (issue #72, CA-15): none
233
+ * at full health; attached by the first hit that leaves the entity below
234
+ * max, then updated in place through `set` by every later hit or heal;
235
+ * removed once current is back at max, or at 0 (die() removes it too).
236
+ */
237
+ syncHealthBar() {
238
+ if (!this.healthBar)
239
+ return;
240
+ if (this.current <= 0 || this.current >= this.max) {
241
+ this.removeHealthBar();
203
242
  return;
204
243
  }
244
+ const bar = this.healthBarHandle;
245
+ if (bar?.alive) {
246
+ bar.set('current', this.current);
247
+ bar.set('max', this.max);
248
+ return;
249
+ }
250
+ this.healthBarHandle = this.game.ui.attach(this.healthBar, this.entity, {
251
+ offset: [0, anchorHeight(this.entity)],
252
+ values: { current: this.current, max: this.max },
253
+ });
254
+ }
255
+ removeHealthBar() {
256
+ this.healthBarHandle?.remove();
257
+ this.healthBarHandle = null;
258
+ }
259
+ /**
260
+ * Opens a fresh invulnerability window on `game.time`, starting visible:
261
+ * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
262
+ * at 10 Hz for as long as it stays open (CA-11). `invulnerability <= 0`
263
+ * schedules nothing and touches no `game.time` at all — the window never
264
+ * opens. Both are owned by this entity, so `Entity.destroy()` cancels
265
+ * them immediately mid-window.
266
+ *
267
+ * The `invulnerability <= 0` check below runs once, here, before either
268
+ * handle is touched — it is not re-checked later. So lowering
269
+ * `invulnerability` to 0 while a window is already open does not close it
270
+ * early: that window keeps blinking and rejecting damage until it runs
271
+ * its normal course. Deliberate, not a bug: nothing re-reads
272
+ * `invulnerability` once a window is open.
273
+ */
274
+ openWindow() {
275
+ if (this.invulnerability <= 0)
276
+ return;
277
+ this.entity.node.visible = true;
278
+ this.windowHandle = this.game.time.after(this.invulnerability, () => this.closeWindow(), { owner: this.entity });
279
+ this.blinkHandle = this.game.time.every(BLINK_PERIOD, () => this.toggleBlink(), { owner: this.entity });
280
+ }
281
+ /** Cancels the blink and restores visibility the moment the window closes. */
282
+ closeWindow() {
283
+ this.blinkHandle?.cancel();
284
+ this.blinkHandle = null;
285
+ this.windowHandle = null;
205
286
  this.entity.node.visible = true;
206
287
  }
288
+ /** The 10 Hz flash while a window is open: openWindow() starts it visible, so a plain toggle lands on the right phase every time. */
289
+ toggleBlink() {
290
+ this.entity.node.visible = !this.entity.node.visible;
291
+ }
207
292
  /**
208
293
  * Announces the death, then lets the role decide — but only if it says it
209
294
  * can: a graph with no death edge would swallow the signal silently, so
210
295
  * destroying is the fallback rather than the exception.
211
296
  */
212
297
  die() {
213
- // The killing blow opened a window like any other hit; close it. The
214
- // death pose must hold steady, and a revived entity starts without
215
- // leftover immunity. The node is visible here by construction: blink()
216
- // restores it the frame a window closes, and a lethal hit only lands
217
- // while no window is open.
218
- this.invulnerable = 0;
219
- this.blinkClock = 0;
298
+ // The killing blow opened a window like any other hit; cancel it here
299
+ // (running no callback, per game.time's cancel contract) rather than
300
+ // let it run its course. The death pose must hold steady, and a
301
+ // revived entity starts without leftover immunity.
302
+ this.windowHandle?.cancel();
303
+ this.windowHandle = null;
304
+ this.blinkHandle?.cancel();
305
+ this.blinkHandle = null;
306
+ this.entity.node.visible = true;
307
+ // Before 'death' goes out, even when a death state keeps the entity
308
+ // alive: a dead entity shows no bar (CA-15).
309
+ this.removeHealthBar();
220
310
  this.game.events.emit('death', { entity: this.entity });
221
311
  const machine = this.entity.get(StateMachine);
222
312
  const targets = machine ? deathTargets(machine.states, machine.current) : [];
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
13
13
  export { Hazard, resolveHazardTouch, type HazardTouch } from './hazard.js';
14
14
  export { MeleeAttack } from './melee-attack.js';
15
15
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
16
+ export { HEALTH_UI } from './health-ui.js';
17
+ export { ANCHORED_UI_PIECES } from './anchored-ui-pieces.js';
16
18
  export { Respawnable } from './respawnable.js';
17
19
  export { OutOfBounds } from './out-of-bounds.js';
18
20
  export { Lifetime } from './lifetime.js';
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
13
13
  export { Hazard, resolveHazardTouch } from './hazard.js';
14
14
  export { MeleeAttack } from './melee-attack.js';
15
15
  export { Health, declaresDeathHandling, deathTargets } from './health.js';
16
+ export { HEALTH_UI } from './health-ui.js';
17
+ export { ANCHORED_UI_PIECES } from './anchored-ui-pieces.js';
16
18
  export { Respawnable } from './respawnable.js';
17
19
  export { OutOfBounds } from './out-of-bounds.js';
18
20
  export { Lifetime } from './lifetime.js';
@@ -1,4 +1,4 @@
1
- import { Component, type Entity, type StateContext } from '@waica/engine';
1
+ import { Component, type Entity, type Game, type StateContext } from '@waica/engine';
2
2
  /**
3
3
  * Something the player can talk to or examine: a dialogue line and the
4
4
  * radius it can be triggered from. The component is pure data — the
@@ -23,7 +23,11 @@ export declare class Interactable extends Component {
23
23
  radius: number;
24
24
  }
25
25
  export declare const INTERACTABLE_UI_PIECE = "npc-line";
26
- /** The UI fragment every archetype using Interactable must register. */
26
+ /**
27
+ * The UI fragments every archetype using Interactable must register:
28
+ * npc-line is the screen-space fallback; npc-bubble and interact-prompt are
29
+ * Anchored Pieces (ADR 0018), each drawn above its zero-size anchor box.
30
+ */
27
31
  export declare const INTERACTABLE_UI: Readonly<Record<string, string>>;
28
32
  /**
29
33
  * Runs onInteract on every component of `target` — the winner of the
@@ -33,10 +37,23 @@ export declare const INTERACTABLE_UI: Readonly<Record<string, string>>;
33
37
  * fires the same way from either input scheme.
34
38
  */
35
39
  export declare function fireInteract(target: Entity, initiator: Entity): void;
40
+ /**
41
+ * `player` interacts with `target`, the NPC its Interactable belongs to.
42
+ * The one interaction both paths run — the interact key (interactUpdate)
43
+ * and a ClickToMove arrival (grill S8): publishes the line through the
44
+ * npcLine stat; shows it in an npc-bubble anchored to the NPC when the
45
+ * project defines that piece, replacing any bubble already open and
46
+ * taking the prompt's place (CA-11, CA-12), else in the npc-line screen
47
+ * piece as before; then fires onInteract.
48
+ */
49
+ export declare function interactWith(game: Game, player: Entity, target: Entity, interactable: Interactable): void;
36
50
  /**
37
51
  * The player role's interact lookup, run by its '*' hook in every state:
38
- * pressing interact near an Interactable publishes its line through the
39
- * npcLine stat and shows the npc-line UI piece; walking out of every
40
- * radius hides it again. Nearest one wins when several are in range.
52
+ * pressing interact near an Interactable interacts with it (interactWith),
53
+ * and the nearest one in range carries the interact prompt. The bubble
54
+ * belongs to the nearest Interactable in range too: it closes when another
55
+ * one becomes the nearest, and walking out of every radius closes it,
56
+ * removes the prompt and hides npc-line. Nearest one wins when several are
57
+ * in range.
41
58
  */
42
59
  export declare function interactUpdate({ entity, game }: StateContext): void;
@@ -1,4 +1,5 @@
1
- import { Component } from '@waica/engine';
1
+ import { Component, } from '@waica/engine';
2
+ import { anchorHeight } from './anchor-height.js';
2
3
  /**
3
4
  * Something the player can talk to or examine: a dialogue line and the
4
5
  * radius it can be triggered from. The component is pure data — the
@@ -16,7 +17,15 @@ export class Interactable extends Component {
16
17
  radius = 1.5;
17
18
  }
18
19
  export const INTERACTABLE_UI_PIECE = 'npc-line';
19
- /** The UI fragment every archetype using Interactable must register. */
20
+ /** The speech bubble anchored to the NPC, used instead of npc-line when defined (issue #72). */
21
+ const NPC_BUBBLE_PIECE = 'npc-bubble';
22
+ /** The "Press E" prompt anchored to the nearest NPC in range, when defined (issue #72). */
23
+ const INTERACT_PROMPT_PIECE = 'interact-prompt';
24
+ /**
25
+ * The UI fragments every archetype using Interactable must register:
26
+ * npc-line is the screen-space fallback; npc-bubble and interact-prompt are
27
+ * Anchored Pieces (ADR 0018), each drawn above its zero-size anchor box.
28
+ */
20
29
  export const INTERACTABLE_UI = {
21
30
  [INTERACTABLE_UI_PIECE]: `<style>
22
31
  .npc-line {
@@ -36,6 +45,73 @@ export const INTERACTABLE_UI = {
36
45
  }
37
46
  </style>
38
47
  <div class="npc-line">{{npcLine}}</div>
48
+ `,
49
+ [NPC_BUBBLE_PIECE]: `<style>
50
+ .npc-bubble {
51
+ position: absolute;
52
+ left: 0;
53
+ bottom: 0;
54
+ transform: translate(-50%, -10px);
55
+ width: max-content;
56
+ max-width: 260px;
57
+ padding: 8px 14px;
58
+ border-radius: 10px;
59
+ background: #1a1a2ee6;
60
+ border: 1px solid #ffffff2e;
61
+ font: 500 16px/1.35 system-ui, sans-serif;
62
+ color: #f5f5f5;
63
+ text-align: center;
64
+ white-space: normal;
65
+ overflow-wrap: break-word;
66
+ text-shadow: 0 1px 2px #000a;
67
+ pointer-events: none;
68
+ user-select: none;
69
+ }
70
+ .npc-bubble::after {
71
+ content: '';
72
+ position: absolute;
73
+ left: 50%;
74
+ top: 100%;
75
+ transform: translateX(-50%);
76
+ border: 8px solid transparent;
77
+ border-top-color: #1a1a2ee6;
78
+ }
79
+ </style>
80
+ <div class="npc-bubble">{{line}}</div>
81
+ `,
82
+ [INTERACT_PROMPT_PIECE]: `<style>
83
+ .interact-prompt {
84
+ position: absolute;
85
+ left: 0;
86
+ bottom: 0;
87
+ transform: translate(-50%, -6px);
88
+ width: max-content;
89
+ padding: 3px 8px;
90
+ border-radius: 8px;
91
+ background: #1a1a2ecc;
92
+ border: 1px solid #ffffff2e;
93
+ font: 500 13px/1.4 system-ui, sans-serif;
94
+ color: #f5f5f5;
95
+ white-space: nowrap;
96
+ text-shadow: 0 1px 2px #000a;
97
+ pointer-events: none;
98
+ user-select: none;
99
+ }
100
+ .interact-prompt kbd {
101
+ display: inline-block;
102
+ min-width: 1.4em;
103
+ margin-left: 2px;
104
+ padding: 0 5px;
105
+ border-radius: 4px;
106
+ background: #f5f5f5;
107
+ box-shadow: 0 2px 0 #9a9ab0;
108
+ font: 700 13px/1.4 system-ui, sans-serif;
109
+ color: #1a1a2e;
110
+ text-align: center;
111
+ text-shadow: none;
112
+ }
113
+ </style>
114
+ <div class="interact-prompt">Press <kbd>{{key}}</kbd></div>
39
115
  `,
40
116
  };
41
117
  /**
@@ -49,11 +125,97 @@ export function fireInteract(target, initiator) {
49
125
  for (const component of [...target.components])
50
126
  component.onInteract?.(initiator);
51
127
  }
128
+ /**
129
+ * Keyed by the player entity, so the interact key and a ClickToMove arrival
130
+ * share one bubble, and nothing outlives the player or its Game.
131
+ */
132
+ const piecesByPlayer = new WeakMap();
133
+ function piecesOf(player) {
134
+ let pieces = piecesByPlayer.get(player);
135
+ if (!pieces) {
136
+ pieces = { bubble: null, prompt: null };
137
+ piecesByPlayer.set(player, pieces);
138
+ }
139
+ return pieces;
140
+ }
141
+ function closeBubble(pieces) {
142
+ pieces.bubble?.handle.remove();
143
+ pieces.bubble = null;
144
+ }
145
+ function closePrompt(pieces) {
146
+ pieces.prompt?.handle.remove();
147
+ pieces.prompt = null;
148
+ }
149
+ /** A key code as the prompt shows it: 'KeyE' → 'E', 'Digit1' → '1', 'Space' and 'ArrowUp' as they are. */
150
+ function keyLabel(code) {
151
+ return code.replace(/^(?:Key|Digit)/, '');
152
+ }
153
+ /**
154
+ * Keeps the interact prompt (CA-12) on `nearest`, the nearest Interactable
155
+ * in range: exactly one interact-prompt with values { key }, the first key
156
+ * bound to interact — when the project defines the piece and binds the
157
+ * action, and never while a bubble is open. Otherwise none, and nothing
158
+ * logged: the piece's presence is checked before any attach.
159
+ */
160
+ function syncPrompt(game, pieces, nearest) {
161
+ const code = game.ui.has(INTERACT_PROMPT_PIECE)
162
+ ? game.input.bindingsFor('interact')[0]
163
+ : undefined;
164
+ if (code === undefined || pieces.bubble?.handle.alive) {
165
+ closePrompt(pieces);
166
+ return;
167
+ }
168
+ if (pieces.prompt?.handle.alive && pieces.prompt.npc === nearest)
169
+ return;
170
+ closePrompt(pieces);
171
+ pieces.prompt = {
172
+ npc: nearest,
173
+ handle: game.ui.attach(INTERACT_PROMPT_PIECE, nearest, {
174
+ offset: [0, anchorHeight(nearest)],
175
+ values: { key: keyLabel(code) },
176
+ }),
177
+ };
178
+ }
179
+ /**
180
+ * `player` interacts with `target`, the NPC its Interactable belongs to.
181
+ * The one interaction both paths run — the interact key (interactUpdate)
182
+ * and a ClickToMove arrival (grill S8): publishes the line through the
183
+ * npcLine stat; shows it in an npc-bubble anchored to the NPC when the
184
+ * project defines that piece, replacing any bubble already open and
185
+ * taking the prompt's place (CA-11, CA-12), else in the npc-line screen
186
+ * piece as before; then fires onInteract.
187
+ */
188
+ export function interactWith(game, player, target, interactable) {
189
+ game.stats.set('npcLine', interactable.line);
190
+ if (game.ui.has(NPC_BUBBLE_PIECE)) {
191
+ const pieces = piecesOf(player);
192
+ closeBubble(pieces);
193
+ pieces.bubble = {
194
+ npc: target,
195
+ handle: game.ui.attach(NPC_BUBBLE_PIECE, target, {
196
+ offset: [0, anchorHeight(target)],
197
+ values: { line: interactable.line },
198
+ }),
199
+ };
200
+ // The bubble answers the prompt: it goes while the bubble is open (CA-12).
201
+ closePrompt(pieces);
202
+ }
203
+ else {
204
+ // Scene-scoped: the scan that hides npc-line dies with the scene, so
205
+ // without a scope the line would survive a swap into a map where
206
+ // nothing knows to hide it — stale text and all (grill decision 8).
207
+ game.ui.show(INTERACTABLE_UI_PIECE, { scope: 'scene' });
208
+ }
209
+ fireInteract(target, player);
210
+ }
52
211
  /**
53
212
  * The player role's interact lookup, run by its '*' hook in every state:
54
- * pressing interact near an Interactable publishes its line through the
55
- * npcLine stat and shows the npc-line UI piece; walking out of every
56
- * radius hides it again. Nearest one wins when several are in range.
213
+ * pressing interact near an Interactable interacts with it (interactWith),
214
+ * and the nearest one in range carries the interact prompt. The bubble
215
+ * belongs to the nearest Interactable in range too: it closes when another
216
+ * one becomes the nearest, and walking out of every radius closes it,
217
+ * removes the prompt and hides npc-line. Nearest one wins when several are
218
+ * in range.
57
219
  */
58
220
  export function interactUpdate({ entity, game }) {
59
221
  const nearestEntity = game.query.nearest(entity.position.x, entity.position.y, {
@@ -61,19 +223,19 @@ export function interactUpdate({ entity, game }) {
61
223
  exclude: entity,
62
224
  where: (candidate, { distance }) => distance <= candidate.get(Interactable).radius,
63
225
  });
226
+ const pieces = piecesOf(entity);
64
227
  if (!nearestEntity) {
65
228
  game.ui.hide(INTERACTABLE_UI_PIECE);
229
+ closeBubble(pieces);
230
+ closePrompt(pieces);
66
231
  return;
67
232
  }
68
- const nearest = nearestEntity.get(Interactable);
233
+ if (pieces.bubble && pieces.bubble.npc !== nearestEntity)
234
+ closeBubble(pieces);
69
235
  if (game.input.justPressed('interact') && !game.input.consumed('interact')) {
70
236
  // The press is spent: an input:interact edge needs a NEW press.
71
237
  game.input.consume('interact');
72
- game.stats.set('npcLine', nearest.line);
73
- // Scene-scoped: the scan that hides this prompt dies with the scene, so
74
- // without a scope the prompt would survive a swap into a map where
75
- // nothing knows to hide it — stale line and all (grill decision 8).
76
- game.ui.show(INTERACTABLE_UI_PIECE, { scope: 'scene' });
77
- fireInteract(nearestEntity, entity);
238
+ interactWith(game, entity, nearestEntity, nearestEntity.get(Interactable));
78
239
  }
240
+ syncPrompt(game, pieces, nearestEntity);
79
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Waica built-in behaviors — the curated game-feel library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,6 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@waica/engine": "^0.15.0"
22
+ "@waica/engine": "^0.17.0"
23
23
  }
24
24
  }