@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.
- package/dist/anchor-height.d.ts +10 -0
- package/dist/anchor-height.js +21 -0
- package/dist/anchored-ui-pieces.d.ts +7 -0
- package/dist/anchored-ui-pieces.js +12 -0
- package/dist/click-to-move.js +3 -5
- package/dist/health-ui.d.ts +12 -0
- package/dist/health-ui.js +70 -0
- package/dist/health.d.ts +68 -9
- package/dist/health.js +127 -37
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/interactable.d.ts +22 -5
- package/dist/interactable.js +174 -12
- package/package.json +2 -2
|
@@ -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
|
+
]);
|
package/dist/click-to-move.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Component, Sprite, screenInputToLogical, } from '@waica/engine';
|
|
2
|
-
import {
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
'
|
|
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
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
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.
|
|
173
|
-
this.
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
|
|
200
|
-
if (this.
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
this.
|
|
219
|
-
this.
|
|
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';
|
package/dist/interactable.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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;
|
package/dist/interactable.js
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
22
|
+
"@waica/engine": "^0.17.0"
|
|
23
23
|
}
|
|
24
24
|
}
|