@waica/behaviors 0.15.0 → 0.16.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/health.d.ts CHANGED
@@ -65,10 +65,21 @@ export declare class Health extends Component {
65
65
  current: number;
66
66
  /** Whoever dealt the last accepted hit, for the state that reacts to it. */
67
67
  lastDamageSource: Entity | undefined;
68
- /** Seconds left in the invulnerability window. */
69
- private invulnerable;
70
- /** Seconds into the current blink, for the 10 Hz toggle. */
71
- private blinkClock;
68
+ /**
69
+ * The open invulnerability window, on `game.time`. Null once
70
+ * `closeWindow()` has run. A window ended by owner destroy or scene
71
+ * unload instead of a natural close never runs `closeWindow()` — cancellation
72
+ * runs no callback — so this can also be present but inactive; `blinking`
73
+ * and `inspectState().invulnerable` both read through `.active`/`.remaining`,
74
+ * so that case still reports correctly.
75
+ */
76
+ private windowHandle;
77
+ /**
78
+ * The 10 Hz blink toggle, on `game.time`. Null once `closeWindow()` has
79
+ * run, same asymmetry as `windowHandle`: a window cancelled by owner
80
+ * destroy or scene unload leaves this present but inactive instead.
81
+ */
82
+ private blinkHandle;
72
83
  /** Whether the node is being flashed: exactly while a window is open. */
73
84
  get blinking(): boolean;
74
85
  /** Whether the signalled death must be checked on this component's next update. */
@@ -82,7 +93,7 @@ export declare class Health extends Component {
82
93
  */
83
94
  inspectState(): Record<string, unknown>;
84
95
  onReady(): void;
85
- onUpdate(dt: number): void;
96
+ onUpdate(): void;
86
97
  /**
87
98
  * Makes good on a death the graph said it would handle. Signalling is
88
99
  * fire-and-forget, so "it declared an edge" is a promise, not a receipt:
@@ -103,11 +114,25 @@ export declare class Health extends Component {
103
114
  /** Mirrors current into the named stat, when one is named. */
104
115
  private publish;
105
116
  /**
106
- * Flashes the node at 10 Hz for as long as the window stays open, and
107
- * leaves it visible the frame the window closes — the flash is feedback,
108
- * never a state a hit can leave the entity stuck in.
117
+ * Opens a fresh invulnerability window on `game.time`, starting visible:
118
+ * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
119
+ * at 10 Hz for as long as it stays open (CA-11). `invulnerability <= 0`
120
+ * schedules nothing and touches no `game.time` at all — the window never
121
+ * opens. Both are owned by this entity, so `Entity.destroy()` cancels
122
+ * them immediately mid-window.
123
+ *
124
+ * The `invulnerability <= 0` check below runs once, here, before either
125
+ * handle is touched — it is not re-checked later. So lowering
126
+ * `invulnerability` to 0 while a window is already open does not close it
127
+ * early: that window keeps blinking and rejecting damage until it runs
128
+ * its normal course. Deliberate, not a bug: nothing re-reads
129
+ * `invulnerability` once a window is open.
109
130
  */
110
- private blink;
131
+ private openWindow;
132
+ /** Cancels the blink and restores visibility the moment the window closes. */
133
+ private closeWindow;
134
+ /** The 10 Hz flash while a window is open: openWindow() starts it visible, so a plain toggle lands on the right phase every time. */
135
+ private toggleBlink;
111
136
  /**
112
137
  * Announces the death, then lets the role decide — but only if it says it
113
138
  * can: a graph with no death edge would swallow the signal silently, so
package/dist/health.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Component, SIMULATION_TIME_EPSILON, StateMachine, } from '@waica/engine';
1
+ import { Component, StateMachine, } from '@waica/engine';
2
2
  /**
3
3
  * Below this, current is treated as exactly zero. Repeated fractional
4
4
  * damage (e.g. ten hits of 0.1 against max: 1) can leave a floating-point
@@ -51,11 +51,11 @@ export class Health extends Component {
51
51
  };
52
52
  static transient = [
53
53
  'current',
54
- 'invulnerable',
55
54
  'deathPending',
56
55
  'expectedStates',
57
56
  'lastDamageSource',
58
- 'blinkClock',
57
+ 'windowHandle',
58
+ 'blinkHandle',
59
59
  ];
60
60
  max = 3;
61
61
  /** Seconds of immunity granted by taking a hit. 0 disables i-frames. */
@@ -71,13 +71,24 @@ export class Health extends Component {
71
71
  current = 0;
72
72
  /** Whoever dealt the last accepted hit, for the state that reacts to it. */
73
73
  lastDamageSource;
74
- /** Seconds left in the invulnerability window. */
75
- invulnerable = 0;
76
- /** Seconds into the current blink, for the 10 Hz toggle. */
77
- blinkClock = 0;
74
+ /**
75
+ * The open invulnerability window, on `game.time`. Null once
76
+ * `closeWindow()` has run. A window ended by owner destroy or scene
77
+ * unload instead of a natural close never runs `closeWindow()` — cancellation
78
+ * runs no callback — so this can also be present but inactive; `blinking`
79
+ * and `inspectState().invulnerable` both read through `.active`/`.remaining`,
80
+ * so that case still reports correctly.
81
+ */
82
+ windowHandle = null;
83
+ /**
84
+ * The 10 Hz blink toggle, on `game.time`. Null once `closeWindow()` has
85
+ * run, same asymmetry as `windowHandle`: a window cancelled by owner
86
+ * destroy or scene unload leaves this present but inactive instead.
87
+ */
88
+ blinkHandle = null;
78
89
  /** Whether the node is being flashed: exactly while a window is open. */
79
90
  get blinking() {
80
- return this.invulnerable > 0;
91
+ return this.windowHandle?.active ?? false;
81
92
  }
82
93
  /** Whether the signalled death must be checked on this component's next update. */
83
94
  deathPending = false;
@@ -94,7 +105,7 @@ export class Health extends Component {
94
105
  invulnerability: this.invulnerability,
95
106
  stat: this.stat,
96
107
  current: this.current,
97
- invulnerable: this.invulnerable,
108
+ invulnerable: this.windowHandle?.remaining ?? 0,
98
109
  blinking: this.blinking,
99
110
  lastDamageSource: this.lastDamageSource?.name ?? null,
100
111
  };
@@ -110,17 +121,9 @@ export class Health extends Component {
110
121
  if (this.current === 0)
111
122
  this.die();
112
123
  }
113
- onUpdate(dt) {
114
- if (this.invulnerable > 0) {
115
- this.invulnerable = Math.max(0, this.invulnerable - dt);
116
- // this.invulnerable is a countdown of SIMULATION_STEP-sized dts, which
117
- // float error can leave as a tiny positive residual instead of
118
- // exactly 0 on the step that should close the window; without this,
119
- // `> 0` above stays true for one whole extra step.
120
- if (this.invulnerable <= SIMULATION_TIME_EPSILON)
121
- this.invulnerable = 0;
122
- this.blink(dt);
123
- }
124
+ onUpdate() {
125
+ // The invulnerability window and its blink now run entirely on
126
+ // game.time (ADR 0017, CA-11): this no longer drives either.
124
127
  if (this.deathPending) {
125
128
  this.deathPending = false;
126
129
  this.settleDeath();
@@ -154,7 +157,7 @@ export class Health extends Component {
154
157
  // too — every NaN comparison is false, so amount <= 0 lets it through
155
158
  // and poisons current (NaN - anything is NaN, and every guard against
156
159
  // it is false forever after).
157
- if (!(amount > 0) || this.current <= 0 || this.invulnerable > 0)
160
+ if (!(amount > 0) || this.current <= 0 || this.blinking)
158
161
  return;
159
162
  this.current = Math.max(0, this.current - amount);
160
163
  if (this.current < DEATH_EPSILON)
@@ -169,8 +172,7 @@ export class Health extends Component {
169
172
  });
170
173
  if (this.hurtSound)
171
174
  this.game.audio.play(this.hurtSound, { at: this.entity });
172
- this.invulnerable = this.invulnerability;
173
- this.blinkClock = 0;
175
+ this.openWindow();
174
176
  if (this.current === 0) {
175
177
  this.die();
176
178
  return;
@@ -192,31 +194,53 @@ export class Health extends Component {
192
194
  this.game.stats.set(this.stat, this.current);
193
195
  }
194
196
  /**
195
- * Flashes the node at 10 Hz for as long as the window stays open, and
196
- * leaves it visible the frame the window closes — the flash is feedback,
197
- * never a state a hit can leave the entity stuck in.
197
+ * Opens a fresh invulnerability window on `game.time`, starting visible:
198
+ * `after(invulnerability)` closes it, and `every(0.1)` flashes the node
199
+ * at 10 Hz for as long as it stays open (CA-11). `invulnerability <= 0`
200
+ * schedules nothing and touches no `game.time` at all — the window never
201
+ * opens. Both are owned by this entity, so `Entity.destroy()` cancels
202
+ * them immediately mid-window.
203
+ *
204
+ * The `invulnerability <= 0` check below runs once, here, before either
205
+ * handle is touched — it is not re-checked later. So lowering
206
+ * `invulnerability` to 0 while a window is already open does not close it
207
+ * early: that window keeps blinking and rejecting damage until it runs
208
+ * its normal course. Deliberate, not a bug: nothing re-reads
209
+ * `invulnerability` once a window is open.
198
210
  */
199
- blink(dt) {
200
- if (this.invulnerable > 0) {
201
- this.blinkClock += dt;
202
- this.entity.node.visible = Math.floor(this.blinkClock / BLINK_PERIOD) % 2 === 0;
211
+ openWindow() {
212
+ if (this.invulnerability <= 0)
203
213
  return;
204
- }
214
+ this.entity.node.visible = true;
215
+ this.windowHandle = this.game.time.after(this.invulnerability, () => this.closeWindow(), { owner: this.entity });
216
+ this.blinkHandle = this.game.time.every(BLINK_PERIOD, () => this.toggleBlink(), { owner: this.entity });
217
+ }
218
+ /** Cancels the blink and restores visibility the moment the window closes. */
219
+ closeWindow() {
220
+ this.blinkHandle?.cancel();
221
+ this.blinkHandle = null;
222
+ this.windowHandle = null;
205
223
  this.entity.node.visible = true;
206
224
  }
225
+ /** The 10 Hz flash while a window is open: openWindow() starts it visible, so a plain toggle lands on the right phase every time. */
226
+ toggleBlink() {
227
+ this.entity.node.visible = !this.entity.node.visible;
228
+ }
207
229
  /**
208
230
  * Announces the death, then lets the role decide — but only if it says it
209
231
  * can: a graph with no death edge would swallow the signal silently, so
210
232
  * destroying is the fallback rather than the exception.
211
233
  */
212
234
  die() {
213
- // The killing blow opened a window like any other hit; close it. The
214
- // death pose must hold steady, and a revived entity starts without
215
- // leftover immunity. The node is visible here by construction: blink()
216
- // restores it the frame a window closes, and a lethal hit only lands
217
- // while no window is open.
218
- this.invulnerable = 0;
219
- this.blinkClock = 0;
235
+ // The killing blow opened a window like any other hit; cancel it here
236
+ // (running no callback, per game.time's cancel contract) rather than
237
+ // let it run its course. The death pose must hold steady, and a
238
+ // revived entity starts without leftover immunity.
239
+ this.windowHandle?.cancel();
240
+ this.windowHandle = null;
241
+ this.blinkHandle?.cancel();
242
+ this.blinkHandle = null;
243
+ this.entity.node.visible = true;
220
244
  this.game.events.emit('death', { entity: this.entity });
221
245
  const machine = this.entity.get(StateMachine);
222
246
  const targets = machine ? deathTargets(machine.states, machine.current) : [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/behaviors",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Waica built-in behaviors — the curated game-feel library",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,6 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@waica/engine": "^0.15.0"
22
+ "@waica/engine": "^0.16.0"
23
23
  }
24
24
  }