@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.
- 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 +34 -0
- package/dist/health.js +66 -0
- 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,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';
|
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
|
}
|