@waica/behaviors 0.16.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,6 +69,10 @@ 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. */
@@ -80,6 +92,12 @@ export declare class Health extends Component {
80
92
  * destroy or scene unload leaves this present but inactive instead.
81
93
  */
82
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;
83
101
  /** Whether the node is being flashed: exactly while a window is open. */
84
102
  get blinking(): boolean;
85
103
  /** Whether the signalled death must be checked on this component's next update. */
@@ -113,6 +131,22 @@ export declare class Health extends Component {
113
131
  heal(amount: number): void;
114
132
  /** Mirrors current into the named stat, when one is named. */
115
133
  private publish;
134
+ /**
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;
116
150
  /**
117
151
  * Opens a fresh invulnerability window on `game.time`, starting visible:
118
152
  * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
package/dist/health.js CHANGED
@@ -1,4 +1,5 @@
1
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, 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,6 +51,8 @@ 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',
@@ -56,6 +61,7 @@ export class Health extends Component {
56
61
  'lastDamageSource',
57
62
  'windowHandle',
58
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,6 +73,10 @@ 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. */
@@ -86,6 +96,12 @@ export class Health extends Component {
86
96
  * destroy or scene unload leaves this present but inactive instead.
87
97
  */
88
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;
89
105
  /** Whether the node is being flashed: exactly while a window is open. */
90
106
  get blinking() {
91
107
  return this.windowHandle?.active ?? false;
@@ -164,6 +180,7 @@ export class Health extends Component {
164
180
  this.current = 0;
165
181
  this.lastDamageSource = source;
166
182
  this.publish();
183
+ this.syncHealthBar();
167
184
  this.game.events.emit('damage', {
168
185
  entity: this.entity,
169
186
  amount,
@@ -172,6 +189,7 @@ export class Health extends Component {
172
189
  });
173
190
  if (this.hurtSound)
174
191
  this.game.audio.play(this.hurtSound, { at: this.entity });
192
+ this.showDamageNumber(amount);
175
193
  this.openWindow();
176
194
  if (this.current === 0) {
177
195
  this.die();
@@ -187,12 +205,57 @@ export class Health extends Component {
187
205
  return;
188
206
  this.current = Math.min(this.max, this.current + amount);
189
207
  this.publish();
208
+ this.syncHealthBar();
190
209
  }
191
210
  /** Mirrors current into the named stat, when one is named. */
192
211
  publish() {
193
212
  if (this.stat)
194
213
  this.game.stats.set(this.stat, this.current);
195
214
  }
215
+ /**
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.
221
+ */
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();
242
+ return;
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
+ }
196
259
  /**
197
260
  * Opens a fresh invulnerability window on `game.time`, starting visible:
198
261
  * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
@@ -241,6 +304,9 @@ export class Health extends Component {
241
304
  this.blinkHandle?.cancel();
242
305
  this.blinkHandle = null;
243
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();
244
310
  this.game.events.emit('death', { entity: this.entity });
245
311
  const machine = this.entity.get(StateMachine);
246
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.16.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.16.0"
22
+ "@waica/engine": "^0.17.0"
23
23
  }
24
24
  }