incanto 0.69.0 → 0.71.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/bin/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +5 -4
- package/bin/incanto-check.mjs +58 -31
- package/bin/incanto-editor.mjs +128 -5
- package/bin/incanto-env.mjs +3 -2
- package/bin/incanto-feel.mjs +24 -13
- package/bin/incanto-frame.mjs +8 -1
- package/bin/incanto-multiplay.mjs +11 -9
- package/bin/incanto-new.mjs +105 -10
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +121 -102
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +296 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
- package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -3
- package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
- package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
- package/dist/editor.js +1088 -882
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
- package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
- package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
- package/dist/gameplay.d.ts +254 -10
- package/dist/gameplay.js +2 -2
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +179 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
- package/dist/net.d.ts +27 -3
- package/dist/net.js +2 -2
- package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
- package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
- package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
- package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
- package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
- package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
- package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
- package/dist/test-CmN54kWv.js +4264 -0
- package/dist/test.d.ts +639 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +20 -37
- package/dist/vite.js +177 -7
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-yLIEYHbd.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
- package/editor/assets/index-DK9xMGpW.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +34 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +9 -1
- package/skills/incanto-audio.md +8 -2
- package/skills/incanto-behaviors-and-scripts.md +105 -7
- package/skills/incanto-building-2d-games.md +97 -10
- package/skills/incanto-building-3d-games.md +58 -4
- package/skills/incanto-editor.md +46 -7
- package/skills/incanto-environment.md +19 -1
- package/skills/incanto-game-feel.md +70 -0
- package/skills/incanto-gameplay-behaviors.md +172 -20
- package/skills/incanto-hud.md +142 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +239 -59
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +112 -2
- package/skills/incanto-save-slots.md +217 -7
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +207 -10
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +3 -1
- package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
- package/templates-app/beacon-isle-3d/package.json +2 -2
- package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
- package/templates-app/beacon-isle-3d/src/main.ts +11 -1
- package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
- package/templates-app/beacon-isle-3d/verify.ts +3 -1
- package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
- package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
- package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
- package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
- package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
- package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
- package/templates-app/molehill-2d/index.html +73 -0
- package/templates-app/molehill-2d/package.json +23 -0
- package/templates-app/molehill-2d/src/behaviors.ts +198 -0
- package/templates-app/molehill-2d/src/game.scene.json +1205 -0
- package/templates-app/molehill-2d/src/main.ts +41 -0
- package/templates-app/molehill-2d/tsconfig.json +13 -0
- package/templates-app/molehill-2d/verify.ts +247 -0
- package/templates-app/molehill-2d/vite.config.ts +12 -0
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/game.scene.json +178 -986
- package/templates-app/platformer-2d/src/main.ts +11 -1
- package/templates-app/platformer-2d/tsconfig.json +1 -1
- package/templates-app/star-survivor/package.json +2 -2
- package/templates-app/star-survivor/src/game.scene.json +43 -216
- package/templates-app/star-survivor/src/main.ts +11 -1
- package/templates-app/star-survivor/tsconfig.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/package.json +2 -2
- package/templates-app/tps-3d/src/behaviors.ts +19 -1
- package/templates-app/tps-3d/src/game.scene.json +66 -263
- package/templates-app/tps-3d/src/main.ts +13 -2
- package/templates-app/tps-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
- package/templates-app/village-quest-3d/package.json +2 -2
- package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
- package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +202 -863
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-Dj4L-5AA.js +0 -22
- package/dist/json-CwwhxQgb.js +0 -36
- package/dist/registry-CF70EArN.js +0 -212
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/test-D6ISmuFE.js +0 -2383
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BDvaZMQJ.js +0 -1
- package/editor/assets/index-CZIRr5EL.js +0 -10958
|
@@ -1,8 +1,146 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as jsonClone } from "./json-CfTjpvW8.js";
|
|
2
|
+
import { B as HudLayer, J as Node, K as requireNumber, P as effectiveOrder, Y as diagnose, d as rebuildOptions, et as Behavior, f as rememberAuthoredPath, h as loadScene, n as SaveSlots, ot as registerBehavior, s as duplicateNode } from "./save-slots-Bvuh2p_r.js";
|
|
2
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
3
|
-
import { t as
|
|
4
|
-
import {
|
|
4
|
+
import { t as Rng } from "./rng-CDOMybym.js";
|
|
5
|
+
import { h as didYouMean } from "./touch-BnCyPA0G.js";
|
|
5
6
|
import { Color, CubeCamera, DepthTexture, DoubleSide, Euler, FloatType, Frustum, HalfFloatType, LinearMipmapLinearFilter, MathUtils, Matrix4, Mesh, MeshDepthMaterial, Object3D, PerspectiveCamera, Plane, PlaneGeometry, Quaternion, ShaderMaterial, Sphere, Vector2, Vector3, Vector4, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
|
|
7
|
+
//#region src/gameplay/spatial.ts
|
|
8
|
+
/** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
|
|
9
|
+
function hasPosition$1(node) {
|
|
10
|
+
return Array.isArray(node.position);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Movement behaviors are dimension-agnostic — they read/write `this.node.position`
|
|
14
|
+
* directly. Attaching one to a non-spatial node (no `position` array) would be a
|
|
15
|
+
* silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
|
|
16
|
+
*/
|
|
17
|
+
function requirePosition(behavior) {
|
|
18
|
+
const node = behavior.node;
|
|
19
|
+
if (!hasPosition$1(node)) {
|
|
20
|
+
const behaviorName = behavior.constructor.name;
|
|
21
|
+
throw new IncantoError("PROP_TYPE_MISMATCH", `${behaviorName} on '${node.getPath()}' needs a spatial node with a numeric 'position' array (a Node2D/Node3D subtype). '${node.name}' has none.`, { path: node.getPath() });
|
|
22
|
+
}
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
/** Euclidean distance between two position arrays (works for 2D and 3D). */
|
|
26
|
+
function distance$1(a, b) {
|
|
27
|
+
let sum = 0;
|
|
28
|
+
const n = Math.max(a.length, b.length);
|
|
29
|
+
for (let i = 0; i < n; i++) {
|
|
30
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
31
|
+
sum += d * d;
|
|
32
|
+
}
|
|
33
|
+
return Math.sqrt(sum);
|
|
34
|
+
}
|
|
35
|
+
/** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
|
|
36
|
+
function add(a, b) {
|
|
37
|
+
const n = Math.max(a.length, b.length);
|
|
38
|
+
const out = new Array(n);
|
|
39
|
+
for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** Vector length. */
|
|
43
|
+
function length(v) {
|
|
44
|
+
let sum = 0;
|
|
45
|
+
for (const c of v) sum += c * c;
|
|
46
|
+
return Math.sqrt(sum);
|
|
47
|
+
}
|
|
48
|
+
/** Unit vector (zero-length stays zero). */
|
|
49
|
+
function normalize(v) {
|
|
50
|
+
const len = length(v);
|
|
51
|
+
if (len === 0) return v.map(() => 0);
|
|
52
|
+
return v.map((c) => c / len);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Step `from` toward `to` by at most `maxStep`. Returns the new position and
|
|
56
|
+
* whether the destination was reached this step (clamped — no overshoot).
|
|
57
|
+
*/
|
|
58
|
+
function moveToward(from, to, maxStep) {
|
|
59
|
+
const n = Math.max(from.length, to.length);
|
|
60
|
+
let sum = 0;
|
|
61
|
+
for (let i = 0; i < n; i++) {
|
|
62
|
+
const d = (to[i] ?? 0) - (from[i] ?? 0);
|
|
63
|
+
sum += d * d;
|
|
64
|
+
}
|
|
65
|
+
const dist = Math.sqrt(sum);
|
|
66
|
+
const position = new Array(n);
|
|
67
|
+
if (dist <= maxStep || dist === 0) {
|
|
68
|
+
for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
|
|
69
|
+
return {
|
|
70
|
+
position,
|
|
71
|
+
reached: true
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const f = maxStep / dist;
|
|
75
|
+
for (let i = 0; i < n; i++) {
|
|
76
|
+
const c = from[i] ?? 0;
|
|
77
|
+
position[i] = c + ((to[i] ?? 0) - c) * f;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
position,
|
|
81
|
+
reached: false
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
|
|
86
|
+
* 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
|
|
87
|
+
* (60fps reference, matching Camera2D's `follow`.)
|
|
88
|
+
*/
|
|
89
|
+
function smoothingFactor(smoothing, dt) {
|
|
90
|
+
const s = Math.min(Math.max(smoothing, 0), .99);
|
|
91
|
+
return s <= 0 ? 1 : 1 - s ** (dt * 60);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A node's position with every spatial ancestor's added in.
|
|
95
|
+
*
|
|
96
|
+
* The tree composes transforms for DRAWING — three does it for free — and every
|
|
97
|
+
* piece of engine logic that reads `node.position` gets the LOCAL number
|
|
98
|
+
* instead. Physics already knew (`worldPosition2D`/`worldPosition3D` in the
|
|
99
|
+
* adapters); the camera did not, and neither did anything that chases a target.
|
|
100
|
+
*
|
|
101
|
+
* A camera parented to the player is the Godot and Phaser idiom, and the
|
|
102
|
+
* authoring skill explicitly permits it — *"EVERY node type can hold children …
|
|
103
|
+
* any node works as a grouping container"*. Measured on exactly that scene:
|
|
104
|
+
*
|
|
105
|
+
* ```
|
|
106
|
+
* player world position : [1400, 900]
|
|
107
|
+
* renderer view centre : {"x":0,"y":0}
|
|
108
|
+
* framing : camera /Level/Player/Cam centred [1400, 900]
|
|
109
|
+
* 1 in view, 0 outside it
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* The renderer drew empty world 1600 px from the player while `framing` — the
|
|
113
|
+
* instrument the skills tell you to trust before handing a game back —
|
|
114
|
+
* certified the player on screen.
|
|
115
|
+
*
|
|
116
|
+
* Arity comes from the node itself: a 2D node has two components, a 3D node
|
|
117
|
+
* three, and an ancestor that is neither contributes nothing.
|
|
118
|
+
*/
|
|
119
|
+
function worldPosition(node) {
|
|
120
|
+
const own = node.position;
|
|
121
|
+
if (!Array.isArray(own)) return [];
|
|
122
|
+
const out = own.slice();
|
|
123
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
124
|
+
const p = at.position;
|
|
125
|
+
if (!Array.isArray(p)) continue;
|
|
126
|
+
for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) + (p[i] ?? 0);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Turn a world position into the local one `node.position` has to hold for the
|
|
132
|
+
* node to END UP there — the inverse of `worldPosition` for its parent chain.
|
|
133
|
+
*/
|
|
134
|
+
function localFromWorld(node, world) {
|
|
135
|
+
const out = world.slice();
|
|
136
|
+
for (let at = node.parent; at; at = at.parent) {
|
|
137
|
+
const p = at.position;
|
|
138
|
+
if (!Array.isArray(p)) continue;
|
|
139
|
+
for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) - (p[i] ?? 0);
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
6
144
|
//#region src/3d/frustum.ts
|
|
7
145
|
const scratchFrustum = new Frustum();
|
|
8
146
|
const scratchMatrix = new Matrix4();
|
|
@@ -2204,12 +2342,29 @@ function simpleWaveHeight(x, z, time, waveHeight) {
|
|
|
2204
2342
|
}
|
|
2205
2343
|
//#endregion
|
|
2206
2344
|
//#region src/3d/physics/collider-validate.ts
|
|
2345
|
+
/** Every key a 3D collider may carry — see the 2D twin for what this closes. */
|
|
2346
|
+
const COLLIDER_KEYS_3D = [
|
|
2347
|
+
"shape",
|
|
2348
|
+
"size",
|
|
2349
|
+
"radius",
|
|
2350
|
+
"height",
|
|
2351
|
+
"offset",
|
|
2352
|
+
"vertices",
|
|
2353
|
+
"indices"
|
|
2354
|
+
];
|
|
2207
2355
|
/**
|
|
2208
2356
|
* Hard-validate a 3D collider prop at LOAD time (see the 2D twin for rationale).
|
|
2209
2357
|
* An EMPTY collider is allowed — a body without a collider is work in progress.
|
|
2210
2358
|
*/
|
|
2211
2359
|
function validateCollider3D(collider, nodeName) {
|
|
2212
2360
|
const shape = collider.shape;
|
|
2361
|
+
for (const key of Object.keys(collider)) {
|
|
2362
|
+
if (COLLIDER_KEYS_3D.includes(key)) continue;
|
|
2363
|
+
throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}' has an unknown key "${key}".` + didYouMean(key, COLLIDER_KEYS_3D) + ` A collider is [${COLLIDER_KEYS_3D.join(", ")}] — friction, restitution and mass are props of the BODY, not of its collider.`, {
|
|
2364
|
+
prop: key,
|
|
2365
|
+
validOptions: [...COLLIDER_KEYS_3D]
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2213
2368
|
if (shape === void 0) return;
|
|
2214
2369
|
if (shape === "box") {
|
|
2215
2370
|
const size = collider.size;
|
|
@@ -2414,6 +2569,22 @@ var Node3D = class Node3D extends Node {
|
|
|
2414
2569
|
* error rather than a silent per-node decode; overrides that do not need it
|
|
2415
2570
|
* simply keep the one-argument signature. Null in a headless test or a
|
|
2416
2571
|
* detached probe, where the node falls back to loading uncached. */
|
|
2572
|
+
/**
|
|
2573
|
+
* Is there anything left to wait for before this node can be FROZEN?
|
|
2574
|
+
*
|
|
2575
|
+
* The static latch fired unconditionally, while `Renderer3D.render()` calls
|
|
2576
|
+
* `assets.load()` and `syncTree()` in the SAME frame — so a
|
|
2577
|
+
* `ModelInstance3D` frozen by its own or an ancestor group's `static` never
|
|
2578
|
+
* mounted its GLB at all, permanently and silently: the asset loaded fine, so
|
|
2579
|
+
* `assetErrors()` is empty and `stats().errors` is 0, and `framing` reads
|
|
2580
|
+
* props rather than pixels and still calls the node on-screen.
|
|
2581
|
+
*
|
|
2582
|
+
* The 2D adapter already had exactly this gate (`Node2D._staticReady`); this
|
|
2583
|
+
* is its twin. A node with nothing to wait for is ready by definition.
|
|
2584
|
+
*/
|
|
2585
|
+
_staticReady(_assets) {
|
|
2586
|
+
return true;
|
|
2587
|
+
}
|
|
2417
2588
|
_syncObject3D(alpha = 1, _assets = null) {
|
|
2418
2589
|
const o = this._ensureObject3D();
|
|
2419
2590
|
const prev = this._interpPrev;
|
|
@@ -2560,7 +2731,7 @@ var Area3D = class extends PhysicsBody3D {
|
|
|
2560
2731
|
*
|
|
2561
2732
|
* Empty in a scene with no physics world.
|
|
2562
2733
|
*/
|
|
2563
|
-
overlapping(group) {
|
|
2734
|
+
overlapping(group = void 0) {
|
|
2564
2735
|
const all = this._physics?.overlapping(this) ?? [];
|
|
2565
2736
|
return group === void 0 ? all : all.filter((n) => n.isInGroup(group));
|
|
2566
2737
|
}
|
|
@@ -2608,6 +2779,17 @@ var RigidBody3D = class extends PhysicsBody3D {
|
|
|
2608
2779
|
0,
|
|
2609
2780
|
0
|
|
2610
2781
|
];
|
|
2782
|
+
/**
|
|
2783
|
+
* The other name for `linearVelocity` — see `RigidBody2D.velocity`. On a
|
|
2784
|
+
* plain TS class `body.velocity = [...]` succeeds silently and does nothing,
|
|
2785
|
+
* and that is the spelling the physics skill taught.
|
|
2786
|
+
*/
|
|
2787
|
+
get velocity() {
|
|
2788
|
+
return this.linearVelocity;
|
|
2789
|
+
}
|
|
2790
|
+
set velocity(v) {
|
|
2791
|
+
this.linearVelocity = v;
|
|
2792
|
+
}
|
|
2611
2793
|
/** @internal set by Physics3D */
|
|
2612
2794
|
_physics3d = null;
|
|
2613
2795
|
/** World-space impulse (kg·m/s) — the floating-capsule controller's verb. */
|
|
@@ -2693,6 +2875,37 @@ var CharacterBody3D = class extends PhysicsBody3D {
|
|
|
2693
2875
|
if (!this._physics) throw new IncantoError("TREE_VIOLATION", this.collider.shape === void 0 ? `CharacterBody3D '${this.name}' has no collider — set a collider prop (physics skips collider-less bodies).` : `CharacterBody3D '${this.name}' has no physics world — await enablePhysics3D(engine) first.`);
|
|
2694
2876
|
this._physics.moveAndSlide(this);
|
|
2695
2877
|
}
|
|
2878
|
+
/** @internal Set by the physics step from the KCC's resolved collisions. */
|
|
2879
|
+
_onWall = null;
|
|
2880
|
+
/** @internal */
|
|
2881
|
+
_onCeiling = false;
|
|
2882
|
+
/**
|
|
2883
|
+
* Touching a wall.
|
|
2884
|
+
*
|
|
2885
|
+
* `CharacterBody2D` has had this since wall jump and wall slide turned out to
|
|
2886
|
+
* be not merely awkward to write in 2D but IMPOSSIBLE — `isOnFloor()` was the
|
|
2887
|
+
* only surface a character could report, so even a hand-written behavior had
|
|
2888
|
+
* nothing to ask. 3D was left with exactly that problem: a 3D platformer
|
|
2889
|
+
* could not wall-jump, and nothing said why.
|
|
2890
|
+
*/
|
|
2891
|
+
isOnWall() {
|
|
2892
|
+
return this._onWall !== null;
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* The wall's normal, pointing AWAY from it (toward the character) — the
|
|
2896
|
+
* vector a wall jump pushes along. `null` when not touching one.
|
|
2897
|
+
*
|
|
2898
|
+
* 2D answers this as `wallSide()`, a -1/+1 scalar, because a 2D character
|
|
2899
|
+
* has only two horizontal directions. In three dimensions the honest answer
|
|
2900
|
+
* is the normal itself.
|
|
2901
|
+
*/
|
|
2902
|
+
wallNormal() {
|
|
2903
|
+
return this._onWall === null ? null : [...this._onWall];
|
|
2904
|
+
}
|
|
2905
|
+
/** Head hit something — cut the jump short, the way every platformer does. */
|
|
2906
|
+
isOnCeiling() {
|
|
2907
|
+
return this._onCeiling;
|
|
2908
|
+
}
|
|
2696
2909
|
isOnFloor() {
|
|
2697
2910
|
return this._grounded;
|
|
2698
2911
|
}
|
|
@@ -3087,7 +3300,13 @@ var Water3D = class Water3D extends Node3D {
|
|
|
3087
3300
|
static props = {
|
|
3088
3301
|
size: { default: [40, 40] },
|
|
3089
3302
|
color: { default: "#2a6fbe" },
|
|
3090
|
-
opacity: {
|
|
3303
|
+
opacity: {
|
|
3304
|
+
default: .8,
|
|
3305
|
+
range: {
|
|
3306
|
+
min: 0,
|
|
3307
|
+
max: 1
|
|
3308
|
+
}
|
|
3309
|
+
},
|
|
3091
3310
|
renderOrder: { default: 1 },
|
|
3092
3311
|
waveHeight: { default: .04 },
|
|
3093
3312
|
waveSpeed: { default: 1 },
|
|
@@ -3111,11 +3330,20 @@ var Water3D = class Water3D extends Node3D {
|
|
|
3111
3330
|
.3
|
|
3112
3331
|
] },
|
|
3113
3332
|
sunColor: { default: "#fff5d6" },
|
|
3114
|
-
sunIntensity: {
|
|
3115
|
-
|
|
3333
|
+
sunIntensity: {
|
|
3334
|
+
default: 1,
|
|
3335
|
+
range: { min: 0 }
|
|
3336
|
+
},
|
|
3337
|
+
detailStrength: {
|
|
3338
|
+
default: .26,
|
|
3339
|
+
range: { min: 0 }
|
|
3340
|
+
},
|
|
3116
3341
|
absorption: { default: .15 },
|
|
3117
3342
|
refraction: { default: true },
|
|
3118
|
-
underwater: {
|
|
3343
|
+
underwater: {
|
|
3344
|
+
default: true,
|
|
3345
|
+
kinds: ["boolean", "object"]
|
|
3346
|
+
},
|
|
3119
3347
|
swell: { default: 0 },
|
|
3120
3348
|
swellDirectionDeg: { default: 0 },
|
|
3121
3349
|
swellWavelength: { default: 30 },
|
|
@@ -4255,143 +4483,6 @@ const cutoutMaskMaterial = new ShaderMaterial({
|
|
|
4255
4483
|
side: DoubleSide
|
|
4256
4484
|
});
|
|
4257
4485
|
//#endregion
|
|
4258
|
-
//#region src/gameplay/spatial.ts
|
|
4259
|
-
/** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
|
|
4260
|
-
function hasPosition$1(node) {
|
|
4261
|
-
return Array.isArray(node.position);
|
|
4262
|
-
}
|
|
4263
|
-
/**
|
|
4264
|
-
* Movement behaviors are dimension-agnostic — they read/write `this.node.position`
|
|
4265
|
-
* directly. Attaching one to a non-spatial node (no `position` array) would be a
|
|
4266
|
-
* silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
|
|
4267
|
-
*/
|
|
4268
|
-
function requirePosition(behavior) {
|
|
4269
|
-
const node = behavior.node;
|
|
4270
|
-
if (!hasPosition$1(node)) {
|
|
4271
|
-
const behaviorName = behavior.constructor.name;
|
|
4272
|
-
throw new IncantoError("PROP_TYPE_MISMATCH", `${behaviorName} on '${node.getPath()}' needs a spatial node with a numeric 'position' array (a Node2D/Node3D subtype). '${node.name}' has none.`, { path: node.getPath() });
|
|
4273
|
-
}
|
|
4274
|
-
return node;
|
|
4275
|
-
}
|
|
4276
|
-
/** Euclidean distance between two position arrays (works for 2D and 3D). */
|
|
4277
|
-
function distance$1(a, b) {
|
|
4278
|
-
let sum = 0;
|
|
4279
|
-
const n = Math.max(a.length, b.length);
|
|
4280
|
-
for (let i = 0; i < n; i++) {
|
|
4281
|
-
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
4282
|
-
sum += d * d;
|
|
4283
|
-
}
|
|
4284
|
-
return Math.sqrt(sum);
|
|
4285
|
-
}
|
|
4286
|
-
/** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
|
|
4287
|
-
function add(a, b) {
|
|
4288
|
-
const n = Math.max(a.length, b.length);
|
|
4289
|
-
const out = new Array(n);
|
|
4290
|
-
for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
|
|
4291
|
-
return out;
|
|
4292
|
-
}
|
|
4293
|
-
/** Vector length. */
|
|
4294
|
-
function length(v) {
|
|
4295
|
-
let sum = 0;
|
|
4296
|
-
for (const c of v) sum += c * c;
|
|
4297
|
-
return Math.sqrt(sum);
|
|
4298
|
-
}
|
|
4299
|
-
/** Unit vector (zero-length stays zero). */
|
|
4300
|
-
function normalize(v) {
|
|
4301
|
-
const len = length(v);
|
|
4302
|
-
if (len === 0) return v.map(() => 0);
|
|
4303
|
-
return v.map((c) => c / len);
|
|
4304
|
-
}
|
|
4305
|
-
/**
|
|
4306
|
-
* Step `from` toward `to` by at most `maxStep`. Returns the new position and
|
|
4307
|
-
* whether the destination was reached this step (clamped — no overshoot).
|
|
4308
|
-
*/
|
|
4309
|
-
function moveToward(from, to, maxStep) {
|
|
4310
|
-
const n = Math.max(from.length, to.length);
|
|
4311
|
-
let sum = 0;
|
|
4312
|
-
for (let i = 0; i < n; i++) {
|
|
4313
|
-
const d = (to[i] ?? 0) - (from[i] ?? 0);
|
|
4314
|
-
sum += d * d;
|
|
4315
|
-
}
|
|
4316
|
-
const dist = Math.sqrt(sum);
|
|
4317
|
-
const position = new Array(n);
|
|
4318
|
-
if (dist <= maxStep || dist === 0) {
|
|
4319
|
-
for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
|
|
4320
|
-
return {
|
|
4321
|
-
position,
|
|
4322
|
-
reached: true
|
|
4323
|
-
};
|
|
4324
|
-
}
|
|
4325
|
-
const f = maxStep / dist;
|
|
4326
|
-
for (let i = 0; i < n; i++) {
|
|
4327
|
-
const c = from[i] ?? 0;
|
|
4328
|
-
position[i] = c + ((to[i] ?? 0) - c) * f;
|
|
4329
|
-
}
|
|
4330
|
-
return {
|
|
4331
|
-
position,
|
|
4332
|
-
reached: false
|
|
4333
|
-
};
|
|
4334
|
-
}
|
|
4335
|
-
/**
|
|
4336
|
-
* Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
|
|
4337
|
-
* 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
|
|
4338
|
-
* (60fps reference, matching Camera2D's `follow`.)
|
|
4339
|
-
*/
|
|
4340
|
-
function smoothingFactor(smoothing, dt) {
|
|
4341
|
-
const s = Math.min(Math.max(smoothing, 0), .99);
|
|
4342
|
-
return s <= 0 ? 1 : 1 - s ** (dt * 60);
|
|
4343
|
-
}
|
|
4344
|
-
/**
|
|
4345
|
-
* A node's position with every spatial ancestor's added in.
|
|
4346
|
-
*
|
|
4347
|
-
* The tree composes transforms for DRAWING — three does it for free — and every
|
|
4348
|
-
* piece of engine logic that reads `node.position` gets the LOCAL number
|
|
4349
|
-
* instead. Physics already knew (`worldPosition2D`/`worldPosition3D` in the
|
|
4350
|
-
* adapters); the camera did not, and neither did anything that chases a target.
|
|
4351
|
-
*
|
|
4352
|
-
* A camera parented to the player is the Godot and Phaser idiom, and the
|
|
4353
|
-
* authoring skill explicitly permits it — *"EVERY node type can hold children …
|
|
4354
|
-
* any node works as a grouping container"*. Measured on exactly that scene:
|
|
4355
|
-
*
|
|
4356
|
-
* ```
|
|
4357
|
-
* player world position : [1400, 900]
|
|
4358
|
-
* renderer view centre : {"x":0,"y":0}
|
|
4359
|
-
* framing : camera /Level/Player/Cam centred [1400, 900]
|
|
4360
|
-
* 1 in view, 0 outside it
|
|
4361
|
-
* ```
|
|
4362
|
-
*
|
|
4363
|
-
* The renderer drew empty world 1600 px from the player while `framing` — the
|
|
4364
|
-
* instrument the skills tell you to trust before handing a game back —
|
|
4365
|
-
* certified the player on screen.
|
|
4366
|
-
*
|
|
4367
|
-
* Arity comes from the node itself: a 2D node has two components, a 3D node
|
|
4368
|
-
* three, and an ancestor that is neither contributes nothing.
|
|
4369
|
-
*/
|
|
4370
|
-
function worldPosition(node) {
|
|
4371
|
-
const own = node.position;
|
|
4372
|
-
if (!Array.isArray(own)) return [];
|
|
4373
|
-
const out = own.slice();
|
|
4374
|
-
for (let at = node.parent; at; at = at.parent) {
|
|
4375
|
-
const p = at.position;
|
|
4376
|
-
if (!Array.isArray(p)) continue;
|
|
4377
|
-
for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) + (p[i] ?? 0);
|
|
4378
|
-
}
|
|
4379
|
-
return out;
|
|
4380
|
-
}
|
|
4381
|
-
/**
|
|
4382
|
-
* Turn a world position into the local one `node.position` has to hold for the
|
|
4383
|
-
* node to END UP there — the inverse of `worldPosition` for its parent chain.
|
|
4384
|
-
*/
|
|
4385
|
-
function localFromWorld(node, world) {
|
|
4386
|
-
const out = world.slice();
|
|
4387
|
-
for (let at = node.parent; at; at = at.parent) {
|
|
4388
|
-
const p = at.position;
|
|
4389
|
-
if (!Array.isArray(p)) continue;
|
|
4390
|
-
for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) - (p[i] ?? 0);
|
|
4391
|
-
}
|
|
4392
|
-
return out;
|
|
4393
|
-
}
|
|
4394
|
-
//#endregion
|
|
4395
4486
|
//#region src/gameplay/buoyancy.ts
|
|
4396
4487
|
/** Gravity the float is sized against (m/s²) — the cap is expressed in g. */
|
|
4397
4488
|
const G = 9.81;
|
|
@@ -4458,10 +4549,18 @@ var Buoyancy = class extends Behavior {
|
|
|
4458
4549
|
wet = false;
|
|
4459
4550
|
/** Said once: a body that cannot be pushed will never start being able to. */
|
|
4460
4551
|
reportedNoImpulse = false;
|
|
4552
|
+
/** Said once: there was no water to float on when this first looked. */
|
|
4553
|
+
reportedNoWater = false;
|
|
4461
4554
|
update(dt) {
|
|
4462
4555
|
if (dt <= 0) return;
|
|
4463
4556
|
const water = this.resolveWater();
|
|
4464
|
-
if (!water)
|
|
4557
|
+
if (!water) {
|
|
4558
|
+
if (!this.reportedNoWater) {
|
|
4559
|
+
this.reportedNoWater = true;
|
|
4560
|
+
diagnose(this.node.tree?.engine ?? null, "warn", `[incanto] ${this.node.getPath()}: Buoyancy found no Water3D in the tree, so this body is not floating. It keeps looking, so a water added later is fine — otherwise add one, or name it with "water".`);
|
|
4561
|
+
}
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4465
4564
|
const node = this.node;
|
|
4466
4565
|
if (!node.applyImpulse) {
|
|
4467
4566
|
if (!this.reportedNoImpulse) {
|
|
@@ -4736,12 +4835,23 @@ var Chase = class extends Behavior {
|
|
|
4736
4835
|
* elsewhere is not a click. `hovered`/`unhovered` bracket the cursor being over
|
|
4737
4836
|
* it.
|
|
4738
4837
|
*/
|
|
4838
|
+
/**
|
|
4839
|
+
* Frames of no pointer before this is a mistake rather than a still cursor.
|
|
4840
|
+
*
|
|
4841
|
+
* Two seconds at 60 Hz: long enough that a player who has not moved the mouse
|
|
4842
|
+
* yet is not told their game is broken, short enough to be on screen before
|
|
4843
|
+
* anyone starts reading the source.
|
|
4844
|
+
*/
|
|
4845
|
+
const QUIET_BEFORE_COMPLAINING = 120;
|
|
4739
4846
|
var Clickable = class extends Behavior {
|
|
4740
4847
|
static props = {
|
|
4741
4848
|
/** Mouse button: 0 left, 1 middle, 2 right. */
|
|
4742
4849
|
button: { default: 0 },
|
|
4743
|
-
/** Ignore clicks further
|
|
4744
|
-
maxDistance: {
|
|
4850
|
+
/** Ignore clicks further than this from the CURRENT CAMERA (0 = any). */
|
|
4851
|
+
maxDistance: {
|
|
4852
|
+
default: 0,
|
|
4853
|
+
range: { min: 0 }
|
|
4854
|
+
}
|
|
4745
4855
|
};
|
|
4746
4856
|
static signals = [
|
|
4747
4857
|
"clicked",
|
|
@@ -4753,17 +4863,24 @@ var Clickable = class extends Behavior {
|
|
|
4753
4863
|
/** True while the cursor is over this node — read it to tint a hover state. */
|
|
4754
4864
|
hovering = false;
|
|
4755
4865
|
pressedHere = false;
|
|
4866
|
+
/** Frames in a row with nothing to click WITH — see the complaint below. */
|
|
4867
|
+
silentFrames = 0;
|
|
4756
4868
|
update() {
|
|
4757
4869
|
const engine = this.node.tree?.engine;
|
|
4758
4870
|
if (!engine) return;
|
|
4759
4871
|
const picker = engine.picker;
|
|
4760
4872
|
const pointer = engine.input.pointerPosition();
|
|
4761
4873
|
if (!picker || !pointer) {
|
|
4874
|
+
if (picker) {
|
|
4875
|
+
this.silentFrames += 1;
|
|
4876
|
+
if (this.silentFrames === QUIET_BEFORE_COMPLAINING) diagnose(engine, "error", `[incanto] Clickable on '${this.node.getPath()}' has had no pointer for ${QUIET_BEFORE_COMPLAINING} frames, so it can never fire. Nothing is feeding the cursor: pass \`pointer: true\` to createGame2D/3D, or call \`engine.input.attachPointer(canvas)\` on a manual boot.`);
|
|
4877
|
+
}
|
|
4762
4878
|
if (this.hovering) this.setHover(false);
|
|
4763
4879
|
return;
|
|
4764
4880
|
}
|
|
4881
|
+
this.silentFrames = 0;
|
|
4765
4882
|
const hit = engine.pickAt(pointer.x, pointer.y);
|
|
4766
|
-
const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange(
|
|
4883
|
+
const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange();
|
|
4767
4884
|
if (over !== this.hovering) this.setHover(over);
|
|
4768
4885
|
if (engine.input.mouseJustPressed(this.button)) this.pressedHere = over;
|
|
4769
4886
|
if (engine.input.mouseJustReleased(this.button)) {
|
|
@@ -4780,19 +4897,65 @@ var Clickable = class extends Behavior {
|
|
|
4780
4897
|
for (let n = hit; n; n = n.parent) if (n === this.node) return true;
|
|
4781
4898
|
return false;
|
|
4782
4899
|
}
|
|
4783
|
-
|
|
4900
|
+
/**
|
|
4901
|
+
* Is this node close enough to the camera to be worth clicking?
|
|
4902
|
+
*
|
|
4903
|
+
* It used to measure `distance(this.node, hit)` — and `hit` is always this
|
|
4904
|
+
* node or a DESCENDANT of it, since `isSelfOrDescendant` runs first. So the
|
|
4905
|
+
* prop documented as "ignore clicks further than this" gated on WHICH PART of
|
|
4906
|
+
* the node you clicked: a chest 500 px away measured 0 and was allowed, while
|
|
4907
|
+
* its own lid 200 px up measured 200 and was refused. The unused local named
|
|
4908
|
+
* `camera`, assigned the scene ROOT, is where the intent got lost.
|
|
4909
|
+
*/
|
|
4910
|
+
withinRange() {
|
|
4784
4911
|
if (this.maxDistance <= 0) return true;
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4912
|
+
const camera = this.currentCamera();
|
|
4913
|
+
if (!camera) {
|
|
4914
|
+
if (!this.warnedNoCamera) {
|
|
4915
|
+
this.warnedNoCamera = true;
|
|
4916
|
+
diagnose(this.node.tree?.engine ?? null, "error", `[incanto] Clickable on '${this.node.getPath()}' has maxDistance ${this.maxDistance}, which is a range FROM THE CAMERA — and this scene has no camera to measure from, so every click is accepted. Add one, or drop the prop.`);
|
|
4917
|
+
}
|
|
4918
|
+
return true;
|
|
4919
|
+
}
|
|
4920
|
+
const from = worldPosition(camera);
|
|
4921
|
+
const to = worldPosition(this.node);
|
|
4922
|
+
if (from.length === 0 || to.length === 0) return true;
|
|
4789
4923
|
let sum = 0;
|
|
4790
|
-
for (let i = 0; i < Math.max(
|
|
4791
|
-
const d = (
|
|
4924
|
+
for (let i = 0; i < Math.max(from.length, to.length); i++) {
|
|
4925
|
+
const d = (from[i] ?? 0) - (to[i] ?? 0);
|
|
4792
4926
|
sum += d * d;
|
|
4793
4927
|
}
|
|
4794
4928
|
return Math.sqrt(sum) <= this.maxDistance;
|
|
4795
4929
|
}
|
|
4930
|
+
warnedNoCamera = false;
|
|
4931
|
+
camera = null;
|
|
4932
|
+
/**
|
|
4933
|
+
* The camera this scene is drawn from — `current` first, then the first one.
|
|
4934
|
+
*
|
|
4935
|
+
* The same order `describeFraming` and both renderers use. Re-resolved when
|
|
4936
|
+
* the cached one leaves the tree, so a scene swap does not measure from a
|
|
4937
|
+
* camera that is gone.
|
|
4938
|
+
*/
|
|
4939
|
+
currentCamera() {
|
|
4940
|
+
if (this.camera?.tree && this.camera.current === true) return this.camera;
|
|
4941
|
+
const root = this.node.tree?.root;
|
|
4942
|
+
if (!root) return null;
|
|
4943
|
+
let first = null;
|
|
4944
|
+
const walk = (node) => {
|
|
4945
|
+
const type = node.constructor.typeName ?? "";
|
|
4946
|
+
if (type === "Camera2D" || type === "Camera3D") {
|
|
4947
|
+
if (node.current === true) return node;
|
|
4948
|
+
first ??= node;
|
|
4949
|
+
}
|
|
4950
|
+
for (const child of node.children) {
|
|
4951
|
+
const found = walk(child);
|
|
4952
|
+
if (found) return found;
|
|
4953
|
+
}
|
|
4954
|
+
return null;
|
|
4955
|
+
};
|
|
4956
|
+
this.camera = walk(root) ?? first;
|
|
4957
|
+
return this.camera;
|
|
4958
|
+
}
|
|
4796
4959
|
};
|
|
4797
4960
|
//#endregion
|
|
4798
4961
|
//#region src/gameplay/collector.ts
|
|
@@ -4830,6 +4993,10 @@ group: { default: "player" } };
|
|
|
4830
4993
|
const d = data;
|
|
4831
4994
|
if (typeof d.total === "number") this.total = d.total;
|
|
4832
4995
|
}
|
|
4996
|
+
/** The counter on screen. `collect(0)` used to be the only way to repaint it. */
|
|
4997
|
+
announce() {
|
|
4998
|
+
this.emit("totalChanged", this.total);
|
|
4999
|
+
}
|
|
4833
5000
|
};
|
|
4834
5001
|
//#endregion
|
|
4835
5002
|
//#region src/gameplay/currency.ts
|
|
@@ -4863,10 +5030,15 @@ var Currency = class extends Behavior {
|
|
|
4863
5030
|
max = 0;
|
|
4864
5031
|
/** Can this be paid right now? */
|
|
4865
5032
|
canAfford(cost) {
|
|
5033
|
+
if (cost <= 0) return true;
|
|
4866
5034
|
return cost <= this.amount;
|
|
4867
5035
|
}
|
|
4868
5036
|
/** Pay `cost` if it is there. Returns whether it went through. */
|
|
4869
5037
|
spend(cost) {
|
|
5038
|
+
if (requireNumber(cost, this.node, "Currency.spend") <= 0) {
|
|
5039
|
+
this.refuseNonsense("spend", cost);
|
|
5040
|
+
return true;
|
|
5041
|
+
}
|
|
4870
5042
|
if (!this.canAfford(cost)) {
|
|
4871
5043
|
this.emit("refused", cost);
|
|
4872
5044
|
return false;
|
|
@@ -4878,13 +5050,31 @@ var Currency = class extends Behavior {
|
|
|
4878
5050
|
}
|
|
4879
5051
|
/** Take payment in. */
|
|
4880
5052
|
earn(gain) {
|
|
5053
|
+
if (requireNumber(gain, this.node, "Currency.earn") <= 0) {
|
|
5054
|
+
this.refuseNonsense("earn", gain);
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
4881
5057
|
this.amount = this.max > 0 ? Math.min(this.max, this.amount + gain) : this.amount + gain;
|
|
4882
5058
|
this.emit("earned", gain);
|
|
4883
5059
|
this.emit("changed", this.amount);
|
|
4884
5060
|
}
|
|
5061
|
+
/** What has already been complained about, so a loop says it once. */
|
|
5062
|
+
complained = /* @__PURE__ */ new Set();
|
|
5063
|
+
/**
|
|
5064
|
+
* A zero is ordinary; a NEGATIVE is a caller that flipped a sign.
|
|
5065
|
+
*
|
|
5066
|
+
* Reported rather than thrown: this is called from game code inside a frame,
|
|
5067
|
+
* and a throw here quarantines the node holding the player's money.
|
|
5068
|
+
*/
|
|
5069
|
+
refuseNonsense(what, n) {
|
|
5070
|
+
if (n === 0 || this.complained.has(what)) return;
|
|
5071
|
+
this.complained.add(what);
|
|
5072
|
+
diagnose(this.node.tree?.engine ?? null, "error", `[incanto] Currency.${what}(${n}) on '${this.node.getPath()}' — a negative amount is a sign flip in the caller, never an offer. Ignored: ${what === "spend" ? "spending a negative used to PAY you" : "earning a negative used to TAKE from you"}.`);
|
|
5073
|
+
}
|
|
4885
5074
|
/** Set it outright (a shop that grants, a cheat, a restore). */
|
|
4886
5075
|
setAmount(next) {
|
|
4887
|
-
|
|
5076
|
+
const n = requireNumber(next, this.node, "Currency.setAmount");
|
|
5077
|
+
this.amount = this.max > 0 ? Math.min(this.max, n) : n;
|
|
4888
5078
|
this.emit("changed", this.amount);
|
|
4889
5079
|
}
|
|
4890
5080
|
serialize() {
|
|
@@ -4894,6 +5084,10 @@ var Currency = class extends Behavior {
|
|
|
4894
5084
|
const d = data;
|
|
4895
5085
|
if (typeof d.amount === "number") this.amount = d.amount;
|
|
4896
5086
|
}
|
|
5087
|
+
/** The wallet on screen — `setAmount`'s own doc comment already named a restore. */
|
|
5088
|
+
announce() {
|
|
5089
|
+
this.emit("changed", this.amount);
|
|
5090
|
+
}
|
|
4897
5091
|
};
|
|
4898
5092
|
//#endregion
|
|
4899
5093
|
//#region src/gameplay/free-target.ts
|
|
@@ -4957,7 +5151,15 @@ var Health = class extends Behavior {
|
|
|
4957
5151
|
/** Seconds of immunity after taking a hit (0 = no i-frames). */
|
|
4958
5152
|
invulnerableFor: { default: 0 },
|
|
4959
5153
|
/** queueFree() this node when it dies (clone-safe spawned-entity cleanup). */
|
|
4960
|
-
freeOnDeath: { default: false }
|
|
5154
|
+
freeOnDeath: { default: false },
|
|
5155
|
+
/**
|
|
5156
|
+
* …the PARENT, for the composition every other despawning behaviour
|
|
5157
|
+
* supports. A node carries ONE script, so an enemy that walks and can be
|
|
5158
|
+
* hurt splits `Patrol` and `Health` across children — and then the Health
|
|
5159
|
+
* child froze itself and left an IMMORTAL body walking, which is the exact
|
|
5160
|
+
* failure `Lifetime` and `DamageOnContact` got this prop for.
|
|
5161
|
+
*/
|
|
5162
|
+
...FREE_PARENT_PROP
|
|
4961
5163
|
};
|
|
4962
5164
|
static signals = [
|
|
4963
5165
|
"damaged",
|
|
@@ -4970,6 +5172,7 @@ var Health = class extends Behavior {
|
|
|
4970
5172
|
regenPerSec = 0;
|
|
4971
5173
|
invulnerableFor = 0;
|
|
4972
5174
|
freeOnDeath = false;
|
|
5175
|
+
freeParent = false;
|
|
4973
5176
|
/** Current hit points (set to `max` on ready). */
|
|
4974
5177
|
current = 0;
|
|
4975
5178
|
invulnTimer = 0;
|
|
@@ -4994,7 +5197,7 @@ var Health = class extends Behavior {
|
|
|
4994
5197
|
}
|
|
4995
5198
|
/** Apply `n` damage. No-op while invulnerable or already dead. Emits `damaged`. */
|
|
4996
5199
|
damage(n) {
|
|
4997
|
-
if (this.dead || n <= 0) return;
|
|
5200
|
+
if (this.dead || requireNumber(n, this.node, "Health.damage") <= 0) return;
|
|
4998
5201
|
if (this.invulnTimer > 0) return;
|
|
4999
5202
|
this.setCurrent(this.current - n);
|
|
5000
5203
|
this.emit("damaged", n, this.current);
|
|
@@ -5003,7 +5206,7 @@ var Health = class extends Behavior {
|
|
|
5003
5206
|
}
|
|
5004
5207
|
/** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
|
|
5005
5208
|
heal(n) {
|
|
5006
|
-
if (this.dead || n <= 0) return;
|
|
5209
|
+
if (this.dead || requireNumber(n, this.node, "Health.heal") <= 0) return;
|
|
5007
5210
|
this.setCurrent(this.current + n);
|
|
5008
5211
|
this.emit("healed", n, this.current);
|
|
5009
5212
|
}
|
|
@@ -5037,11 +5240,11 @@ var Health = class extends Behavior {
|
|
|
5037
5240
|
* A no-op on the living — reviving someone who never died would silently
|
|
5038
5241
|
* refill their health, which is a different feature and not this one.
|
|
5039
5242
|
*/
|
|
5040
|
-
revive(hp) {
|
|
5243
|
+
revive(hp = this.max) {
|
|
5041
5244
|
if (!this.dead) return;
|
|
5042
5245
|
this.dead = false;
|
|
5043
5246
|
this.current = 0;
|
|
5044
|
-
this.setCurrent(hp
|
|
5247
|
+
this.setCurrent(hp);
|
|
5045
5248
|
this.invulnTimer = this.invulnerableFor;
|
|
5046
5249
|
this.emit("revived", this.current);
|
|
5047
5250
|
}
|
|
@@ -5090,7 +5293,7 @@ var Health = class extends Behavior {
|
|
|
5090
5293
|
this.dead = true;
|
|
5091
5294
|
this.setCurrent(0);
|
|
5092
5295
|
this.emit("died");
|
|
5093
|
-
if (this.freeOnDeath) this.
|
|
5296
|
+
if (this.freeOnDeath) freeOwner(this, this.freeParent, "Health");
|
|
5094
5297
|
}
|
|
5095
5298
|
/** Current HP and whether death already fired — `max` comes back from JSON. */
|
|
5096
5299
|
serialize() {
|
|
@@ -5104,6 +5307,16 @@ var Health = class extends Behavior {
|
|
|
5104
5307
|
if (typeof d.current === "number") this.current = Math.min(this.max, Math.max(0, d.current));
|
|
5105
5308
|
if (typeof d.dead === "boolean") this.dead = d.dead;
|
|
5106
5309
|
}
|
|
5310
|
+
/**
|
|
5311
|
+
* Repaint the bar. Not `died` — a save is being read, nobody just died.
|
|
5312
|
+
*
|
|
5313
|
+
* This one needed the hook most: `setCurrent` is private AND early-returns
|
|
5314
|
+
* when the value is unchanged, so no call a game could make would repaint a
|
|
5315
|
+
* restored bar. It read 100 at 38 HP until the next hit teleported it to 37.
|
|
5316
|
+
*/
|
|
5317
|
+
announce() {
|
|
5318
|
+
this.emit("healthChanged", this.current, this.max);
|
|
5319
|
+
}
|
|
5107
5320
|
};
|
|
5108
5321
|
//#endregion
|
|
5109
5322
|
//#region src/gameplay/trigger.ts
|
|
@@ -5278,6 +5491,27 @@ var DayNight = class extends Behavior {
|
|
|
5278
5491
|
baseAmbient = .3;
|
|
5279
5492
|
baseExposure = 1;
|
|
5280
5493
|
captured = false;
|
|
5494
|
+
/** Said once: there was no sky to move when this first looked. */
|
|
5495
|
+
reportedNoSky = false;
|
|
5496
|
+
/**
|
|
5497
|
+
* What time it is. A save that forgets it returns you at dawn.
|
|
5498
|
+
*
|
|
5499
|
+
* `startHour` is the scene's opening, not the run's clock: a fresh `onReady`
|
|
5500
|
+
* cannot know that this player has been out all night. `paused` goes with it
|
|
5501
|
+
* because a game that stops the clock for a cutscene must not resume ticking
|
|
5502
|
+
* on load.
|
|
5503
|
+
*/
|
|
5504
|
+
serialize() {
|
|
5505
|
+
return {
|
|
5506
|
+
hour: this.hour,
|
|
5507
|
+
paused: this.paused
|
|
5508
|
+
};
|
|
5509
|
+
}
|
|
5510
|
+
deserialize(data) {
|
|
5511
|
+
const d = data ?? {};
|
|
5512
|
+
if (typeof d.hour === "number" && Number.isFinite(d.hour)) this.hour = (d.hour % 24 + 24) % 24;
|
|
5513
|
+
if (typeof d.paused === "boolean") this.paused = d.paused;
|
|
5514
|
+
}
|
|
5281
5515
|
onReady() {
|
|
5282
5516
|
this.hour = this.startHour;
|
|
5283
5517
|
}
|
|
@@ -5296,7 +5530,13 @@ var DayNight = class extends Behavior {
|
|
|
5296
5530
|
applyHour() {
|
|
5297
5531
|
const engine = this.node.tree?.engine;
|
|
5298
5532
|
const env = engine?.scene?.environment;
|
|
5299
|
-
if (!engine || !env?.sky)
|
|
5533
|
+
if (!engine || !env?.sky) {
|
|
5534
|
+
if (engine && !this.reportedNoSky) {
|
|
5535
|
+
this.reportedNoSky = true;
|
|
5536
|
+
diagnose(engine, "warn", `[incanto] ${this.node.getPath()}: DayNight drives environment.sky, and this scene has no \`sky\` — the clock runs and nothing on screen changes. It keeps looking, so a sky added later is fine; otherwise add \`"sky": { "type": "atmosphere" }\` to the scene environment.`);
|
|
5537
|
+
}
|
|
5538
|
+
return;
|
|
5539
|
+
}
|
|
5300
5540
|
if (!this.captured) {
|
|
5301
5541
|
this.captured = true;
|
|
5302
5542
|
this.baseAmbient = env.ambient?.intensity ?? .3;
|
|
@@ -5484,7 +5724,8 @@ var FloatAway = class extends Behavior {
|
|
|
5484
5724
|
const t = Math.min(1, this.elapsed / life);
|
|
5485
5725
|
const next = [...this.start];
|
|
5486
5726
|
const eased = 1 - (1 - t) * (1 - t);
|
|
5487
|
-
|
|
5727
|
+
const up = this.start.length === 2 ? -1 : 1;
|
|
5728
|
+
next[1] = (this.start[1] ?? 0) + up * this.rise * eased;
|
|
5488
5729
|
if (this.drift !== 0) next[0] = (this.start[0] ?? 0) + this.drift * eased;
|
|
5489
5730
|
node.position = next;
|
|
5490
5731
|
if (node.opacity !== void 0) {
|
|
@@ -5498,6 +5739,46 @@ var FloatAway = class extends Behavior {
|
|
|
5498
5739
|
}
|
|
5499
5740
|
};
|
|
5500
5741
|
//#endregion
|
|
5742
|
+
//#region src/core/effect-rng.ts
|
|
5743
|
+
/**
|
|
5744
|
+
* A random stream for something DECORATIVE, derived from the engine's seed.
|
|
5745
|
+
*
|
|
5746
|
+
* Particle emitters drew from `engine.rng` — the stream game logic reads — and
|
|
5747
|
+
* they spawn a number of particles that depends on `dt`. Measured on one scene
|
|
5748
|
+
* and one seed over the same simulated two seconds, comparing the world's own
|
|
5749
|
+
* draws by update index:
|
|
5750
|
+
*
|
|
5751
|
+
* ```
|
|
5752
|
+
* particles off: 60fps and 30fps identical
|
|
5753
|
+
* particles ON : they diverge at update 2
|
|
5754
|
+
* ```
|
|
5755
|
+
*
|
|
5756
|
+
* A purely visual effect changed the game's random decisions, and the PLAYER'S
|
|
5757
|
+
* FRAME RATE decided them: the same seed gives a different game on a slower
|
|
5758
|
+
* machine, and a replay recorded at 60 fps does not reproduce on a laptop that
|
|
5759
|
+
* dropped to 30.
|
|
5760
|
+
*
|
|
5761
|
+
* Derived from `engine.seed` and the node's PATH, so it is:
|
|
5762
|
+
*
|
|
5763
|
+
* - **stable** — the same emitter in the same scene gets the same stream every
|
|
5764
|
+
* run, which is what a screenshot diff and a replay need;
|
|
5765
|
+
* - **distinct** — two emitters do not march in lockstep;
|
|
5766
|
+
* - **separate** — nothing it draws is a value the game will not draw.
|
|
5767
|
+
*
|
|
5768
|
+
* Falls back to a fixed constant with no engine, which is what the emitters
|
|
5769
|
+
* already did when they had no tree.
|
|
5770
|
+
*/
|
|
5771
|
+
function effectRng(node, salt = 0) {
|
|
5772
|
+
const engine = node.tree?.engine;
|
|
5773
|
+
const path = node.getPath();
|
|
5774
|
+
let hash = (engine?.seed ?? 4660) >>> 0;
|
|
5775
|
+
for (let i = 0; i < path.length; i++) {
|
|
5776
|
+
hash ^= path.charCodeAt(i);
|
|
5777
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
5778
|
+
}
|
|
5779
|
+
return new Rng((hash ^ Math.imul(salt + 1, 2654435769)) >>> 0);
|
|
5780
|
+
}
|
|
5781
|
+
//#endregion
|
|
5501
5782
|
//#region src/gameplay/follow-camera.ts
|
|
5502
5783
|
/**
|
|
5503
5784
|
* Make the node it sits on chase a target's position — THE camera-follow
|
|
@@ -5541,6 +5822,7 @@ var FollowCamera = class extends Behavior {
|
|
|
5541
5822
|
offset = [];
|
|
5542
5823
|
smoothing = 0;
|
|
5543
5824
|
deadzone = 0;
|
|
5825
|
+
shakeRng = null;
|
|
5544
5826
|
onReady() {
|
|
5545
5827
|
if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
|
|
5546
5828
|
if (this.deadzone < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "deadzone" must be >= 0, got ${this.deadzone}.`, { prop: "deadzone" });
|
|
@@ -5568,13 +5850,14 @@ var FollowCamera = class extends Behavior {
|
|
|
5568
5850
|
else cam.position = localFromWorld(cam, here.map((c, i) => c + ((desired[i] ?? c) - c) * f));
|
|
5569
5851
|
}
|
|
5570
5852
|
if (this.shakeT > 0) {
|
|
5571
|
-
this.shakeT -= dt;
|
|
5853
|
+
this.shakeT -= this.engine?.unscaledDelta || dt;
|
|
5572
5854
|
const k = this.shakeMag * Math.max(0, this.shakeT) / this.shakeFalloff;
|
|
5573
5855
|
if (this.shakeT <= 0) this.shakeMag = 0;
|
|
5574
5856
|
const jolted = cam.position.slice();
|
|
5857
|
+
this.shakeRng ??= effectRng(this.node);
|
|
5575
5858
|
for (let i = 0; i < jolted.length; i++) {
|
|
5576
5859
|
const scale = jolted.length >= 3 && i === 1 ? .5 : 1;
|
|
5577
|
-
jolted[i] = (jolted[i] ?? 0) + (this.
|
|
5860
|
+
jolted[i] = (jolted[i] ?? 0) + (this.shakeRng.next() * 2 - 1) * k * scale;
|
|
5578
5861
|
}
|
|
5579
5862
|
cam.position = jolted;
|
|
5580
5863
|
}
|
|
@@ -5586,8 +5869,14 @@ var FollowCamera = class extends Behavior {
|
|
|
5586
5869
|
* Read an input action that a game is ALLOWED not to declare — and say so once
|
|
5587
5870
|
* when it is missing.
|
|
5588
5871
|
*
|
|
5589
|
-
* `CharacterController3D` and `GameFlow`
|
|
5590
|
-
* `catch {}
|
|
5872
|
+
* `CharacterController3D` and `GameFlow` wrapped every input read in a bare
|
|
5873
|
+
* `catch {}` — and they were not the only readers, which is a mistake this
|
|
5874
|
+
* comment made for a year by naming them. `CharacterController2D` and
|
|
5875
|
+
* `Interactable` read bare, so a top-down game with no `jump` and a
|
|
5876
|
+
* point-and-click with no `interact` had the whole NODE quarantined: the
|
|
5877
|
+
* character could not move, the NPC stopped answering.
|
|
5878
|
+
* `every-action-a-game-may-not-declare.test.ts` reads the sources and fails on
|
|
5879
|
+
* the next one. The tolerance is right: a game with no sprint key should not
|
|
5591
5880
|
* crash, and `pause()` stays an API even with no pause action. What was wrong is
|
|
5592
5881
|
* that the tolerance is indistinguishable from a TYPO. Write
|
|
5593
5882
|
* `"moveAction": "movement"` and the character stands still, forever, while
|
|
@@ -5612,29 +5901,80 @@ function tolerateUnknownAction(ctx, action, read, fallback) {
|
|
|
5612
5901
|
//#endregion
|
|
5613
5902
|
//#region src/gameplay/game-flow.ts
|
|
5614
5903
|
/**
|
|
5904
|
+
* Build a scene the way the game BOOTED it — resolver included.
|
|
5905
|
+
*
|
|
5906
|
+
* Both reload paths called `loadScene(clone, { engine })` and nothing else, so
|
|
5907
|
+
* the `resolveScene` `createGame2D/3D` was given at boot was dropped on every
|
|
5908
|
+
* restart and every scene change. A game using the documented `instance:`
|
|
5909
|
+
* sub-scenes therefore booted fine and threw `UNRESOLVED_INSTANCE` on its FIRST
|
|
5910
|
+
* restart — inside `GameFlow.update`, which took the restart key with it for the
|
|
5911
|
+
* rest of the run. The engine remembers it now; this is where it comes back.
|
|
5912
|
+
*/
|
|
5913
|
+
function reload(engine, source) {
|
|
5914
|
+
return loadScene(jsonClone(source), {
|
|
5915
|
+
engine,
|
|
5916
|
+
...engine.resolveScene ? { resolveScene: engine.resolveScene } : {}
|
|
5917
|
+
});
|
|
5918
|
+
}
|
|
5919
|
+
/**
|
|
5615
5920
|
* Reload the CURRENT scene from its source JSON — fresh nodes, reset physics,
|
|
5616
5921
|
* rewired input. The restart primitive every game-over screen wants.
|
|
5617
5922
|
*/
|
|
5618
5923
|
function restartScene(engine) {
|
|
5619
5924
|
const source = engine.scene?.source;
|
|
5620
5925
|
if (!source) return;
|
|
5621
|
-
engine.setScene(
|
|
5926
|
+
engine.setScene(reload(engine, source));
|
|
5622
5927
|
engine.ensurePhysics();
|
|
5623
5928
|
}
|
|
5624
5929
|
/**
|
|
5930
|
+
* Engines with a travel in flight. One door at a time, and the scene you are
|
|
5931
|
+
* LEAVING stops simulating while the screen is black.
|
|
5932
|
+
*
|
|
5933
|
+
* A `WeakSet` rather than a field, so this stays a free function and an engine
|
|
5934
|
+
* that is garbage collected mid-fade takes its entry with it.
|
|
5935
|
+
*/
|
|
5936
|
+
const travelling = /* @__PURE__ */ new WeakSet();
|
|
5937
|
+
/**
|
|
5625
5938
|
* Swap to another scene behind a black fade (title→game→next level).
|
|
5626
5939
|
* Headless (no DOM) the swap is immediate. Restores timeScale to 1.
|
|
5940
|
+
*
|
|
5941
|
+
* ONE AT A TIME. A door is usually a trigger, and a player who walks along a
|
|
5942
|
+
* wall of them fires several inside one fade. Measured on three doorways passed
|
|
5943
|
+
* in about a second:
|
|
5944
|
+
*
|
|
5945
|
+
* ```
|
|
5946
|
+
* setScene calls: 3 | order: ["crypt","garden","forge"]
|
|
5947
|
+
* final scene : forge
|
|
5948
|
+
* ```
|
|
5949
|
+
*
|
|
5950
|
+
* Three complete scenes loaded and the player landed in the third — a level
|
|
5951
|
+
* they never walked to, from a door they never reached. Further calls while a
|
|
5952
|
+
* travel is in flight are IGNORED rather than queued: you already went through
|
|
5953
|
+
* a door, and the second one is the same input arriving twice.
|
|
5954
|
+
*
|
|
5955
|
+
* The outgoing scene also stops while the screen is black. It kept simulating
|
|
5956
|
+
* through the fade, so a player who triggered the door mid-fall died in the
|
|
5957
|
+
* level they were leaving — and the checkpoint that death wrote was for the
|
|
5958
|
+
* level they had left.
|
|
5627
5959
|
*/
|
|
5628
5960
|
function goToScene(engine, sceneJson, opts) {
|
|
5961
|
+
if (travelling.has(engine)) return;
|
|
5629
5962
|
const fade = opts?.fadeSeconds ?? .4;
|
|
5630
5963
|
const swap = () => {
|
|
5631
|
-
engine.setScene(
|
|
5964
|
+
engine.setScene(reload(engine, sceneJson));
|
|
5632
5965
|
engine.ensurePhysics();
|
|
5633
5966
|
};
|
|
5634
5967
|
if (typeof document === "undefined" || fade <= 0) {
|
|
5635
5968
|
swap();
|
|
5636
5969
|
return;
|
|
5637
5970
|
}
|
|
5971
|
+
travelling.add(engine);
|
|
5972
|
+
const wasTimeScale = engine.timeScale;
|
|
5973
|
+
engine.timeScale = 0;
|
|
5974
|
+
const arrive = () => {
|
|
5975
|
+
travelling.delete(engine);
|
|
5976
|
+
if (wasTimeScale !== 0) engine.timeScale = wasTimeScale;
|
|
5977
|
+
};
|
|
5638
5978
|
const id = "incanto-scene-fade";
|
|
5639
5979
|
let el = document.getElementById(id);
|
|
5640
5980
|
if (!el) {
|
|
@@ -5646,11 +5986,17 @@ function goToScene(engine, sceneJson, opts) {
|
|
|
5646
5986
|
el.style.transition = `opacity ${fade / 2}s`;
|
|
5647
5987
|
el.style.opacity = "1";
|
|
5648
5988
|
setTimeout(() => {
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5989
|
+
try {
|
|
5990
|
+
swap();
|
|
5991
|
+
} catch (e) {
|
|
5992
|
+
diagnose(engine, "error", `[incanto] goToScene: the destination scene did not load — staying in '${engine.scene?.name ?? "?"}'.`, e);
|
|
5993
|
+
} finally {
|
|
5994
|
+
arrive();
|
|
5995
|
+
const out = document.getElementById(id);
|
|
5996
|
+
if (out) {
|
|
5997
|
+
out.style.opacity = "0";
|
|
5998
|
+
setTimeout(() => out.remove(), fade * 500 + 60);
|
|
5999
|
+
}
|
|
5654
6000
|
}
|
|
5655
6001
|
}, fade * 500 + 30);
|
|
5656
6002
|
}
|
|
@@ -5731,12 +6077,40 @@ var GameFlow = class GameFlow extends Behavior {
|
|
|
5731
6077
|
onReady() {
|
|
5732
6078
|
this.syncPausePanel();
|
|
5733
6079
|
}
|
|
5734
|
-
/**
|
|
6080
|
+
/**
|
|
6081
|
+
* The panel follows the state; nothing else may own its visibility.
|
|
6082
|
+
*
|
|
6083
|
+
* …and while it is up, the layer it lives on takes ARROW KEYS AND THE PAD.
|
|
6084
|
+
* `HudLayer.focusNavigation` is off by default and rightly so — a game whose
|
|
6085
|
+
* HUD happens to contain a button must not lose its arrow keys the moment one
|
|
6086
|
+
* exists — and `incanto-hud.md` says "turn it on for the screens that ARE
|
|
6087
|
+
* menus and off again when play resumes". A pause panel IS that screen, and
|
|
6088
|
+
* this is the one place in the engine that knows when it opens and closes, so
|
|
6089
|
+
* every game with a pause menu was writing the same two lines or shipping a
|
|
6090
|
+
* menu no controller could reach.
|
|
6091
|
+
*
|
|
6092
|
+
* The layer's own value is remembered and put back: a game that turned
|
|
6093
|
+
* navigation on for its own reasons keeps it after a pause.
|
|
6094
|
+
*/
|
|
5735
6095
|
syncPausePanel() {
|
|
5736
6096
|
if (this.pausePanelPath === "") return;
|
|
5737
6097
|
const panel = this.node.getNodeOrNull(this.pausePanelPath);
|
|
5738
|
-
if (panel
|
|
6098
|
+
if (!panel || !("visible" in panel)) return;
|
|
6099
|
+
const paused = this.state === "paused";
|
|
6100
|
+
panel.visible = paused;
|
|
6101
|
+
const layer = layerAbove(panel);
|
|
6102
|
+
if (!layer) return;
|
|
6103
|
+
if (paused) {
|
|
6104
|
+
if (this.navigationWas === null) this.navigationWas = layer.focusNavigation;
|
|
6105
|
+
layer.focusNavigation = true;
|
|
6106
|
+
layer.focus(firstFocusable(layer));
|
|
6107
|
+
} else if (this.navigationWas !== null) {
|
|
6108
|
+
if (layer.focusNavigation) layer.focusNavigation = this.navigationWas;
|
|
6109
|
+
this.navigationWas = null;
|
|
6110
|
+
}
|
|
5739
6111
|
}
|
|
6112
|
+
/** The layer's own `focusNavigation`, while this flow is borrowing it. */
|
|
6113
|
+
navigationWas = null;
|
|
5740
6114
|
gameOver(text = "GAME OVER", color = "#ef4444") {
|
|
5741
6115
|
this.transition("gameover", text, color);
|
|
5742
6116
|
}
|
|
@@ -5762,7 +6136,7 @@ var GameFlow = class GameFlow extends Behavior {
|
|
|
5762
6136
|
restartScene(this.engine);
|
|
5763
6137
|
}
|
|
5764
6138
|
/** Fade to another scene (next level, back to title). */
|
|
5765
|
-
goToScene(sceneJson, opts) {
|
|
6139
|
+
goToScene(sceneJson, opts = void 0) {
|
|
5766
6140
|
this.thaw();
|
|
5767
6141
|
goToScene(this.engine, sceneJson, opts);
|
|
5768
6142
|
}
|
|
@@ -5796,6 +6170,15 @@ var GameFlow = class GameFlow extends Behavior {
|
|
|
5796
6170
|
if (this.readAction("restartAction", "The restart key will do nothing.")) this.restart();
|
|
5797
6171
|
}
|
|
5798
6172
|
};
|
|
6173
|
+
/** The `HudLayer` a widget lives on, or null. */
|
|
6174
|
+
function layerAbove(node) {
|
|
6175
|
+
for (let at = node; at; at = at.parent) if (at instanceof HudLayer) return at;
|
|
6176
|
+
return null;
|
|
6177
|
+
}
|
|
6178
|
+
/** Where the ring starts: opening a menu should land somewhere, not nowhere. */
|
|
6179
|
+
function firstFocusable(layer) {
|
|
6180
|
+
return layer.focusables()[0] ?? null;
|
|
6181
|
+
}
|
|
5799
6182
|
//#endregion
|
|
5800
6183
|
//#region src/gameplay/interactable.ts
|
|
5801
6184
|
function hasPosition(node) {
|
|
@@ -5832,10 +6215,18 @@ var Interactable = class extends Behavior {
|
|
|
5832
6215
|
action = "interact";
|
|
5833
6216
|
range = 2;
|
|
5834
6217
|
actorGroup = "player";
|
|
6218
|
+
/** Actions already reported missing, so a per-frame read says it once. */
|
|
6219
|
+
reportedActions = /* @__PURE__ */ new Set();
|
|
5835
6220
|
update() {
|
|
5836
6221
|
const engine = this.node.tree?.engine;
|
|
5837
6222
|
if (!engine?.scene) return;
|
|
5838
|
-
if (!
|
|
6223
|
+
if (!tolerateUnknownAction({
|
|
6224
|
+
engine,
|
|
6225
|
+
who: this.node.getPath(),
|
|
6226
|
+
reported: this.reportedActions,
|
|
6227
|
+
consequence: "nothing here can be interacted with",
|
|
6228
|
+
authored: this.action !== "interact"
|
|
6229
|
+
}, this.action, () => engine.input.justPressed(this.action), false)) return;
|
|
5839
6230
|
if (!hasPosition(this.node)) return;
|
|
5840
6231
|
const self = worldPosition(this.node);
|
|
5841
6232
|
let nearest = null;
|
|
@@ -5907,13 +6298,14 @@ var Cooldown = class {
|
|
|
5907
6298
|
};
|
|
5908
6299
|
const SHAKE_FALLOFF_SECONDS = .28;
|
|
5909
6300
|
/** File the effect with the engine, if this one is attached to a running game. */
|
|
5910
|
-
function recordEffect(engine, kind, name, from, amount) {
|
|
6301
|
+
function recordEffect(engine, kind, name, from, amount, wiredAs) {
|
|
5911
6302
|
engine?.effects.record({
|
|
5912
6303
|
kind,
|
|
5913
6304
|
name,
|
|
5914
6305
|
from,
|
|
5915
6306
|
amount,
|
|
5916
|
-
at: engine.time
|
|
6307
|
+
at: engine.time,
|
|
6308
|
+
wiredAs
|
|
5917
6309
|
});
|
|
5918
6310
|
}
|
|
5919
6311
|
/**
|
|
@@ -5932,6 +6324,19 @@ var CameraShake = class extends Behavior {
|
|
|
5932
6324
|
t = 0;
|
|
5933
6325
|
prev = [];
|
|
5934
6326
|
/**
|
|
6327
|
+
* Decoration draws from its OWN stream — see `effectRng`.
|
|
6328
|
+
*
|
|
6329
|
+
* A shake drew from `engine.rng`, the stream game logic reads and
|
|
6330
|
+
* `captureState` saves, once per axis per RENDERED frame. So the player's
|
|
6331
|
+
* frame rate decided the game's random numbers, and toggling the
|
|
6332
|
+
* `reduceMotion` accessibility switch changed the rolls: a replay recorded at
|
|
6333
|
+
* 60 fps does not reproduce on a laptop that dropped to 30, and two players
|
|
6334
|
+
* with the same seed diverge because one of them was hit. The particle
|
|
6335
|
+
* siblings were moved off `engine.rng` for exactly this; both camera shakes
|
|
6336
|
+
* were left behind.
|
|
6337
|
+
*/
|
|
6338
|
+
shakeRng = null;
|
|
6339
|
+
/**
|
|
5935
6340
|
* Start a shake — unless the player asked for no motion.
|
|
5936
6341
|
*
|
|
5937
6342
|
* `settings.reduceMotion` is an accessibility switch, and camera movement the
|
|
@@ -5943,11 +6348,11 @@ var CameraShake = class extends Behavior {
|
|
|
5943
6348
|
* for 80 ms is feedback, not motion, and removing it would change what the
|
|
5944
6349
|
* game is rather than how it moves.
|
|
5945
6350
|
*/
|
|
5946
|
-
shake(magnitude, seconds) {
|
|
6351
|
+
shake(magnitude, seconds = this.falloff) {
|
|
5947
6352
|
if (this.engine?.settings.get("reduceMotion") === true) return;
|
|
5948
6353
|
this.magnitude = Math.max(this.magnitude, magnitude);
|
|
5949
|
-
this.t = seconds
|
|
5950
|
-
recordEffect(this.engine, "shake", "", this.node?.getPath() ?? "engine", magnitude);
|
|
6354
|
+
this.t = seconds;
|
|
6355
|
+
recordEffect(this.engine, "shake", "", this.node?.getPath() ?? "engine", magnitude, this.node?.wiringPath());
|
|
5951
6356
|
}
|
|
5952
6357
|
update(dt) {
|
|
5953
6358
|
const node = requirePosition(this);
|
|
@@ -5960,14 +6365,15 @@ var CameraShake = class extends Behavior {
|
|
|
5960
6365
|
}
|
|
5961
6366
|
return;
|
|
5962
6367
|
}
|
|
5963
|
-
this.t -= dt;
|
|
6368
|
+
this.t -= this.engine?.unscaledDelta || dt;
|
|
5964
6369
|
const k = this.magnitude * Math.max(0, this.t) / this.falloff;
|
|
5965
6370
|
if (this.t <= 0) this.magnitude = 0;
|
|
5966
6371
|
const next = pos.slice();
|
|
5967
6372
|
if (this.prev.length !== next.length) this.prev = new Array(next.length).fill(0);
|
|
5968
6373
|
for (let i = 0; i < next.length; i++) {
|
|
5969
6374
|
const scale = next.length >= 3 && i === 1 ? .5 : 1;
|
|
5970
|
-
this.
|
|
6375
|
+
this.shakeRng ??= effectRng(this.node);
|
|
6376
|
+
this.prev[i] = (this.shakeRng.next() * 2 - 1) * k * scale;
|
|
5971
6377
|
next[i] = (next[i] ?? 0) + this.prev[i];
|
|
5972
6378
|
}
|
|
5973
6379
|
node.position = next;
|
|
@@ -6014,19 +6420,21 @@ function hitStop(engine, seconds = .08) {
|
|
|
6014
6420
|
state.until = Math.max(state.until, engine.unscaledTime + seconds);
|
|
6015
6421
|
return;
|
|
6016
6422
|
}
|
|
6017
|
-
const restore = engine.timeScale;
|
|
6423
|
+
const restore = engine.timeScale === 0 ? 1 : engine.timeScale;
|
|
6018
6424
|
const entry = {
|
|
6019
6425
|
startedAt: engine.unscaledTime,
|
|
6020
6426
|
until: engine.unscaledTime + seconds,
|
|
6021
6427
|
restore,
|
|
6022
|
-
release: () => {}
|
|
6428
|
+
release: () => {},
|
|
6429
|
+
ownedAt: 0
|
|
6023
6430
|
};
|
|
6024
6431
|
hitStops.set(engine, entry);
|
|
6025
6432
|
engine.timeScale = 0;
|
|
6433
|
+
entry.ownedAt = engine.timeScaleWrites;
|
|
6026
6434
|
entry.release = engine.updated.connect(() => {
|
|
6027
6435
|
if (!liveHitStop(engine)) return;
|
|
6028
6436
|
if (engine.unscaledTime < entry.until) return;
|
|
6029
|
-
engine.timeScale = entry.restore;
|
|
6437
|
+
if (engine.timeScaleWrites === entry.ownedAt) engine.timeScale = entry.restore;
|
|
6030
6438
|
entry.release();
|
|
6031
6439
|
hitStops.delete(engine);
|
|
6032
6440
|
});
|
|
@@ -6053,7 +6461,7 @@ function liveHitStop(engine) {
|
|
|
6053
6461
|
entry.release();
|
|
6054
6462
|
hitStops.delete(engine);
|
|
6055
6463
|
}
|
|
6056
|
-
const hitStops = /* @__PURE__ */ new
|
|
6464
|
+
const hitStops = /* @__PURE__ */ new WeakMap();
|
|
6057
6465
|
//#endregion
|
|
6058
6466
|
//#region src/gameplay/lifetime.ts
|
|
6059
6467
|
/**
|
|
@@ -6458,13 +6866,40 @@ var Patrol = class extends Behavior {
|
|
|
6458
6866
|
const raw = this.points[i];
|
|
6459
6867
|
diagnose(this.node.tree?.engine ?? null, "error", `[incanto] ${this.node.getPath()}: waypoint ${i} (${JSON.stringify(raw)}) resolves to nothing — the patrol stops here and never moves again.`);
|
|
6460
6868
|
}
|
|
6461
|
-
/**
|
|
6869
|
+
/**
|
|
6870
|
+
* Resolve waypoint `i` to a position in the MOVER's frame, reading node paths
|
|
6871
|
+
* live.
|
|
6872
|
+
*
|
|
6873
|
+
* A node-path waypoint used to return the marker's raw `position`, which is
|
|
6874
|
+
* parent-relative — and it was then fed to `moveToward(mover.position, …)`,
|
|
6875
|
+
* i.e. read in the MOVER's frame. So a walker and its markers under different
|
|
6876
|
+
* parents walked a shifted copy of the path, silently, with the marker nodes
|
|
6877
|
+
* sitting right there in the scene. Measured on a tower-defence lane
|
|
6878
|
+
* organised the ordinary way (markers under `/root/Path`, spawns under a
|
|
6879
|
+
* container at [-50, 90]): every creep stacked motionless 90 px below and
|
|
6880
|
+
* 72 px short of the keep, `incanto-check` `1/1 scene(s) valid`, zero errors,
|
|
6881
|
+
* zero warnings, and the wave could be neither won nor lost.
|
|
6882
|
+
*
|
|
6883
|
+
* `Chase` has converted through world space since the identical bug was found
|
|
6884
|
+
* there ("comparing two nodes' raw `position` only worked while they happened
|
|
6885
|
+
* to share a parent"), and a `FollowCamera` aimed at a node uses its world
|
|
6886
|
+
* position too. Patrol was the outlier.
|
|
6887
|
+
*
|
|
6888
|
+
* A LITERAL array stays local, because that is the only frame an author can
|
|
6889
|
+
* mean when they type numbers into a behaviour's props.
|
|
6890
|
+
*
|
|
6891
|
+
* `reportDeadWaypoint` could not see this: it fires when a path resolves to
|
|
6892
|
+
* NOTHING, and a path that resolves into the wrong frame resolves fine.
|
|
6893
|
+
*/
|
|
6462
6894
|
pointAt(i) {
|
|
6463
6895
|
const raw = this.points[i];
|
|
6464
6896
|
if (Array.isArray(raw)) return raw;
|
|
6465
6897
|
if (typeof raw === "string") {
|
|
6466
6898
|
const marker = this.node.getNodeOrNull(raw);
|
|
6467
|
-
|
|
6899
|
+
if (!marker || !hasPosition$1(marker)) return null;
|
|
6900
|
+
const mover = this.moveParent ? this.node.parent : this.node;
|
|
6901
|
+
if (!mover || !hasPosition$1(mover)) return null;
|
|
6902
|
+
return localFromWorld(mover, worldPosition(marker));
|
|
6468
6903
|
}
|
|
6469
6904
|
return null;
|
|
6470
6905
|
}
|
|
@@ -6549,6 +6984,103 @@ function findCollector(node, group) {
|
|
|
6549
6984
|
return null;
|
|
6550
6985
|
}
|
|
6551
6986
|
//#endregion
|
|
6987
|
+
//#region src/gameplay/prefab-shelf.ts
|
|
6988
|
+
/**
|
|
6989
|
+
* A shelf of templates the GAME clones itself — towers, bolts, damage numbers.
|
|
6990
|
+
*
|
|
6991
|
+
* `Spawner` detaches its one prefab at enter, so a spawned enemy's whole
|
|
6992
|
+
* subtree is live and nobody has to think about it. A game that clones on its
|
|
6993
|
+
* own terms — a tower the player places, a bolt that tower fires — has no
|
|
6994
|
+
* spawner, so its templates sat in the tree as LIVE nodes: their behaviours
|
|
6995
|
+
* ticked, their turrets shot, and `visible: false` hid a node without switching
|
|
6996
|
+
* it off. The documented answer was to author every template `"enabled": false`
|
|
6997
|
+
* and wake the clone with `clone.behavior?.enable()`.
|
|
6998
|
+
*
|
|
6999
|
+
* That wakes ONE node, and the same docs teach that a node carries one
|
|
7000
|
+
* behaviour — so a prefab's parts live on children, and every one of those
|
|
7001
|
+
* stays asleep, silently and for good, because `enabled` is a pause switch that
|
|
7002
|
+
* never complains. Measured on a shipped tower defense: the bolt's
|
|
7003
|
+
* `Projectile` was woken and its sibling `Lifetime` was not, so every bolt that
|
|
7004
|
+
* MISSED flew forever — five still in the tree at 69 seconds, in a game that
|
|
7005
|
+
* passed its harness, its audit and every rung of the ladder.
|
|
7006
|
+
*
|
|
7007
|
+
* ```json
|
|
7008
|
+
* { "name": "Prefabs", "type": "Node2D", "script": { "name": "PrefabShelf" },
|
|
7009
|
+
* "children": [ { "name": "Tower", "type": "Node2D", "children": [] } ] }
|
|
7010
|
+
* ```
|
|
7011
|
+
* ```ts
|
|
7012
|
+
* const shelf = this.node.getNode('/Game/Prefabs').behavior as PrefabShelf;
|
|
7013
|
+
* const tower = shelf.make('Tower'); // detached, like `duplicateNode`
|
|
7014
|
+
* tower.position = at; // set it up BEFORE it readies
|
|
7015
|
+
* this.node.getNode('/Game/Towers').addChild(tower);
|
|
7016
|
+
* ```
|
|
7017
|
+
*
|
|
7018
|
+
* The children never ENTER the tree, so nothing has to be asleep, nothing has
|
|
7019
|
+
* to be hidden, and `make()` hands back a clone that is awake all the way down.
|
|
7020
|
+
*/
|
|
7021
|
+
var PrefabShelf = class extends Behavior {
|
|
7022
|
+
static props = {
|
|
7023
|
+
/**
|
|
7024
|
+
* Which children to take, comma-separated. Empty (the default) takes them
|
|
7025
|
+
* ALL, which is what a shelf is; name them when the node doubles as
|
|
7026
|
+
* something else and only some of its children are templates.
|
|
7027
|
+
*/
|
|
7028
|
+
prefabs: { default: "" } };
|
|
7029
|
+
static signals = [];
|
|
7030
|
+
prefabs = "";
|
|
7031
|
+
/** Templates, in the order they were authored. */
|
|
7032
|
+
held = /* @__PURE__ */ new Map();
|
|
7033
|
+
onEnterTree() {
|
|
7034
|
+
this.claim();
|
|
7035
|
+
}
|
|
7036
|
+
/** A shelf attached at runtime never saw `onEnterTree`; idempotent. */
|
|
7037
|
+
onReady() {
|
|
7038
|
+
this.claim();
|
|
7039
|
+
}
|
|
7040
|
+
claim() {
|
|
7041
|
+
const wanted = this.prefabs.split(",").map((name) => name.trim()).filter((name) => name !== "");
|
|
7042
|
+
const from = wanted.length > 0 ? wanted : [...this.node.children].map((child) => child.name);
|
|
7043
|
+
for (const name of from) {
|
|
7044
|
+
if (this.held.has(name)) continue;
|
|
7045
|
+
const template = this.node.children.find((child) => child.name === name);
|
|
7046
|
+
if (!template) throw new IncantoError("NODE_NOT_FOUND", `PrefabShelf on '${this.node.getPath()}': "prefabs" names '${name}', which is not a child of this node. It holds: ${this.childNames() || "(nothing)"}.`, {
|
|
7047
|
+
prop: "prefabs",
|
|
7048
|
+
path: this.node.getPath()
|
|
7049
|
+
});
|
|
7050
|
+
rememberAuthoredPath(template);
|
|
7051
|
+
this.node.removeChild(template);
|
|
7052
|
+
this.held.set(name, template);
|
|
7053
|
+
}
|
|
7054
|
+
}
|
|
7055
|
+
/** What this shelf holds, in authored order. */
|
|
7056
|
+
names() {
|
|
7057
|
+
return [...this.held.keys()];
|
|
7058
|
+
}
|
|
7059
|
+
/**
|
|
7060
|
+
* A live clone of one template, DETACHED — `addChild` it yourself.
|
|
7061
|
+
*
|
|
7062
|
+
* Detached for the same reason `duplicateNode` is, and it is not a
|
|
7063
|
+
* formality: `onReady` fires on attach, and a behaviour that reads its node
|
|
7064
|
+
* there (`FloatAway` banks its starting position) would bank the wrong one.
|
|
7065
|
+
* Set the clone up, then attach it.
|
|
7066
|
+
*
|
|
7067
|
+
* The clone is VISIBLE even if the template was authored hidden (a scene
|
|
7068
|
+
* migrating off the old `visible: false` shelf keeps working), and every
|
|
7069
|
+
* behaviour in it is however it was authored: awake unless the template
|
|
7070
|
+
* itself asks to sleep, which is now only ever a state machine's own doing.
|
|
7071
|
+
*/
|
|
7072
|
+
make(name) {
|
|
7073
|
+
const template = this.held.get(name);
|
|
7074
|
+
if (!template) throw new IncantoError("NODE_NOT_FOUND", `PrefabShelf on '${this.node.getPath()}': no template named '${name}'. This shelf holds: ${this.childNames() || "(nothing)"}.`, { path: this.node.getPath() });
|
|
7075
|
+
const clone = duplicateNode(template, rebuildOptions(this.node));
|
|
7076
|
+
if (typeof clone.visible === "boolean") clone.visible = true;
|
|
7077
|
+
return clone;
|
|
7078
|
+
}
|
|
7079
|
+
childNames() {
|
|
7080
|
+
return this.names().join(", ");
|
|
7081
|
+
}
|
|
7082
|
+
};
|
|
7083
|
+
//#endregion
|
|
6552
7084
|
//#region src/gameplay/projectile.ts
|
|
6553
7085
|
/**
|
|
6554
7086
|
* Constant-velocity motion in a straight line — bullets, arrows, thrown rocks.
|
|
@@ -6562,6 +7094,26 @@ function findCollector(node, group) {
|
|
|
6562
7094
|
* `null` (the engine's "any JSON" escape hatch) and `null` means `'forward'`.
|
|
6563
7095
|
* `gravity` adds a constant downward (+y, the 2D y-down convention) pull, for
|
|
6564
7096
|
* lobbed/arcing shots.
|
|
7097
|
+
*
|
|
7098
|
+
* **Every prop here is live.** `speed` and `direction` re-bake the heading when
|
|
7099
|
+
* written, and the heading is resolved on the FIRST STEP rather than at
|
|
7100
|
+
* `onReady` — because `onReady` is before the one place a game aims a spawned
|
|
7101
|
+
* bullet. `SpawnSource.spawn` ends with `parent.addChild(clone)` and `Spawner`
|
|
7102
|
+
* emits `spawned(node)` after that, so a handler that sets a rotation or a
|
|
7103
|
+
* direction was always too late. Measured at speed 100 over 60 frames:
|
|
7104
|
+
*
|
|
7105
|
+
* ```
|
|
7106
|
+
* A rotation 0 direction [0,1] -> pos [ 0, 100] authored: obeyed
|
|
7107
|
+
* B rotation 90 direction "forward" -> pos [ 0, 100] authored: obeyed
|
|
7108
|
+
* C rotation 90 set after addChild -> pos [100, 0] IGNORED
|
|
7109
|
+
* D direction [0,1] set after add -> pos [100, 0] IGNORED — and it read
|
|
7110
|
+
* back [0,1] while
|
|
7111
|
+
* travelling +x
|
|
7112
|
+
* ```
|
|
7113
|
+
*
|
|
7114
|
+
* What is deliberately NOT live is `rotation` after that first step: a
|
|
7115
|
+
* projectile that spins for looks would have its spin become its trajectory.
|
|
7116
|
+
* Call `aim()` to re-derive from the current rotation on purpose.
|
|
6565
7117
|
*/
|
|
6566
7118
|
var Projectile = class extends Behavior {
|
|
6567
7119
|
static props = {
|
|
@@ -6573,31 +7125,76 @@ var Projectile = class extends Behavior {
|
|
|
6573
7125
|
gravity: { default: 0 }
|
|
6574
7126
|
};
|
|
6575
7127
|
static signals = [];
|
|
6576
|
-
|
|
6577
|
-
|
|
7128
|
+
_speed = 300;
|
|
7129
|
+
_direction = null;
|
|
6578
7130
|
gravity = 0;
|
|
7131
|
+
/** The straight-line part, `direction × speed`. Empty until the first step. */
|
|
6579
7132
|
velocity = [];
|
|
6580
|
-
|
|
6581
|
-
|
|
7133
|
+
/**
|
|
7134
|
+
* The +y accumulated by `gravity`, kept APART from `velocity`.
|
|
7135
|
+
*
|
|
7136
|
+
* Re-aiming replaces the straight-line part; the fall is a fact about the
|
|
7137
|
+
* world, not about the shot, so an arrow re-aimed halfway down keeps falling
|
|
7138
|
+
* rather than snapping back to level.
|
|
7139
|
+
*/
|
|
7140
|
+
fall = 0;
|
|
7141
|
+
/** Units per second along `direction`. Writable at any time. */
|
|
7142
|
+
get speed() {
|
|
7143
|
+
return this._speed;
|
|
7144
|
+
}
|
|
7145
|
+
set speed(value) {
|
|
7146
|
+
this._speed = value;
|
|
7147
|
+
if (this.armed) this.aim();
|
|
7148
|
+
}
|
|
7149
|
+
/** A vector (`[x,y(,z)]`) or `'forward'`. Writable at any time. */
|
|
7150
|
+
get direction() {
|
|
7151
|
+
return this._direction;
|
|
7152
|
+
}
|
|
7153
|
+
set direction(value) {
|
|
7154
|
+
this._direction = value;
|
|
7155
|
+
if (this.armed) this.aim();
|
|
7156
|
+
else if (this.node) this.resolveDirection();
|
|
7157
|
+
}
|
|
7158
|
+
get armed() {
|
|
7159
|
+
return this.velocity.length > 0;
|
|
7160
|
+
}
|
|
7161
|
+
/**
|
|
7162
|
+
* Re-derive the heading from `direction` (or, for `'forward'`, from the
|
|
7163
|
+
* node's CURRENT rotation) and bake it into the velocity.
|
|
7164
|
+
*
|
|
7165
|
+
* This is what a `spawned(node)` handler calls after turning the clone, and
|
|
7166
|
+
* the only way a `'forward'` projectile ever re-reads its rotation.
|
|
7167
|
+
*/
|
|
7168
|
+
aim(direction = void 0) {
|
|
7169
|
+
if (direction !== void 0) this._direction = direction;
|
|
6582
7170
|
const node = requirePosition(this);
|
|
6583
7171
|
const dir = this.resolveDirection();
|
|
6584
|
-
this.velocity = dir.map((c) => c * this.
|
|
7172
|
+
this.velocity = dir.map((c) => c * this._speed);
|
|
6585
7173
|
while (this.velocity.length < node.position.length) this.velocity.push(0);
|
|
6586
7174
|
}
|
|
7175
|
+
onReady() {
|
|
7176
|
+
if (this.speed === 0 && this.gravity === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': a projectile with "speed" 0 and "gravity" 0 never moves — set one of them.`, { prop: "speed" });
|
|
7177
|
+
requirePosition(this);
|
|
7178
|
+
this.resolveDirection();
|
|
7179
|
+
}
|
|
6587
7180
|
update(dt) {
|
|
7181
|
+
if (!this.armed) this.aim();
|
|
6588
7182
|
const node = this.node;
|
|
6589
|
-
if (this.gravity !== 0) this.
|
|
7183
|
+
if (this.gravity !== 0) this.fall += this.gravity * dt;
|
|
6590
7184
|
const pos = node.position;
|
|
6591
7185
|
const n = Math.max(pos.length, this.velocity.length);
|
|
6592
7186
|
const next = new Array(n);
|
|
6593
|
-
for (let i = 0; i < n; i++)
|
|
7187
|
+
for (let i = 0; i < n; i++) {
|
|
7188
|
+
const v = (this.velocity[i] ?? 0) + (i === 1 ? this.fall : 0);
|
|
7189
|
+
next[i] = (pos[i] ?? 0) + v * dt;
|
|
7190
|
+
}
|
|
6594
7191
|
node.position = next;
|
|
6595
7192
|
}
|
|
6596
7193
|
/** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
|
|
6597
7194
|
resolveDirection() {
|
|
6598
|
-
if (Array.isArray(this.
|
|
6599
|
-
if (this.
|
|
6600
|
-
throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': "direction" must be a vector or 'forward', got ${JSON.stringify(this.
|
|
7195
|
+
if (Array.isArray(this._direction)) return normalize(this._direction);
|
|
7196
|
+
if (this._direction === null || this._direction === "forward") return this.forwardFromRotation();
|
|
7197
|
+
throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': "direction" must be a vector or 'forward', got ${JSON.stringify(this._direction)}.`, { prop: "direction" });
|
|
6601
7198
|
}
|
|
6602
7199
|
forwardFromRotation() {
|
|
6603
7200
|
const rot = this.node.rotation;
|
|
@@ -6664,6 +7261,7 @@ var SavePoint = class extends Behavior {
|
|
|
6664
7261
|
};
|
|
6665
7262
|
static signals = [
|
|
6666
7263
|
"saved",
|
|
7264
|
+
"saveFailed",
|
|
6667
7265
|
"restored",
|
|
6668
7266
|
"noSave",
|
|
6669
7267
|
"hasSave"
|
|
@@ -6747,9 +7345,16 @@ var SavePoint = class extends Behavior {
|
|
|
6747
7345
|
*/
|
|
6748
7346
|
save() {
|
|
6749
7347
|
const engine = this.engine;
|
|
7348
|
+
const state = engine.captureState();
|
|
7349
|
+
const failures = engine.lastCaptureFailures;
|
|
7350
|
+
if (failures.length > 0) {
|
|
7351
|
+
engine.log.error(`[incanto] SavePoint '${this.slot}' REFUSED to write: ${failures.length} behavior(s) threw out of serialize(), so this save is missing their run and would overwrite the slot that is not. First: ${failures[0]}`);
|
|
7352
|
+
this.emit("saveFailed", this.slot);
|
|
7353
|
+
return;
|
|
7354
|
+
}
|
|
6750
7355
|
this.store().write(this.slot, {
|
|
6751
7356
|
scene: this.scene || (engine.scene?.source.name ?? ""),
|
|
6752
|
-
state
|
|
7357
|
+
state,
|
|
6753
7358
|
label: this.label,
|
|
6754
7359
|
savedAt: Date.now(),
|
|
6755
7360
|
playtime: Math.round(this.elapsed)
|
|
@@ -6774,6 +7379,9 @@ var SavePoint = class extends Behavior {
|
|
|
6774
7379
|
return;
|
|
6775
7380
|
}
|
|
6776
7381
|
const report = this.engine.restoreState(slot.state);
|
|
7382
|
+
const here = this.scene || this.engine?.scene?.source.name;
|
|
7383
|
+
if (here && slot.scene && slot.scene !== here) diagnose(this.engine, "error", `[incanto] SavePoint '${this.slot}': this slot was saved in scene '${slot.scene}' and you are in '${here}'. Load that scene first — a save is restored INTO the scene it came from (incanto-save-slots.md).`);
|
|
7384
|
+
else if (report.expected > 0 && report.restored === 0) diagnose(this.engine, "error", `[incanto] SavePoint '${this.slot}': restored 0 of ${report.expected} saveable behaviour(s)` + (report.missing.length > 0 ? ` — ${report.missing.length} uid(s) in the save are not in this scene, starting with '${report.missing[0]}'. That save is from an older build, or from another scene.` : ". Nothing in the save matched a node here."));
|
|
6777
7385
|
this.emit("restored", report.restored);
|
|
6778
7386
|
}
|
|
6779
7387
|
/** Forget this slot — a "delete save" button. */
|
|
@@ -6813,7 +7421,8 @@ var ScoreKeeper = class extends Behavior {
|
|
|
6813
7421
|
"scoreChanged",
|
|
6814
7422
|
"won",
|
|
6815
7423
|
"lost",
|
|
6816
|
-
"lifeLost"
|
|
7424
|
+
"lifeLost",
|
|
7425
|
+
"livesChanged"
|
|
6817
7426
|
];
|
|
6818
7427
|
score = 0;
|
|
6819
7428
|
lives = 0;
|
|
@@ -6826,11 +7435,11 @@ var ScoreKeeper = class extends Behavior {
|
|
|
6826
7435
|
}
|
|
6827
7436
|
/** Add `n` to the score (negative subtracts), emit `scoreChanged`, check win. */
|
|
6828
7437
|
addScore(n) {
|
|
6829
|
-
this.setScore(this.score + n);
|
|
7438
|
+
this.setScore(this.score + requireNumber(n, this.node, "ScoreKeeper.addScore"));
|
|
6830
7439
|
}
|
|
6831
7440
|
/** Set the score to `value`, emit `scoreChanged`, check win. */
|
|
6832
7441
|
setScore(value) {
|
|
6833
|
-
this.score = value;
|
|
7442
|
+
this.score = requireNumber(value, this.node, "ScoreKeeper.setScore");
|
|
6834
7443
|
this.emit("scoreChanged", this.score);
|
|
6835
7444
|
if (this.scoreToWin > 0 && !this.hasWon && this.score >= this.scoreToWin) {
|
|
6836
7445
|
this.hasWon = true;
|
|
@@ -6842,6 +7451,7 @@ var ScoreKeeper = class extends Behavior {
|
|
|
6842
7451
|
if (this.lives <= 0 || this.hasLost) return;
|
|
6843
7452
|
this.lives -= 1;
|
|
6844
7453
|
this.emit("lifeLost", this.lives);
|
|
7454
|
+
this.emit("livesChanged", this.lives);
|
|
6845
7455
|
if (this.lives <= 0) {
|
|
6846
7456
|
this.hasLost = true;
|
|
6847
7457
|
this.emit("lost");
|
|
@@ -6863,6 +7473,17 @@ var ScoreKeeper = class extends Behavior {
|
|
|
6863
7473
|
if (typeof d.hasWon === "boolean") this.hasWon = d.hasWon;
|
|
6864
7474
|
if (typeof d.hasLost === "boolean") this.hasLost = d.hasLost;
|
|
6865
7475
|
}
|
|
7476
|
+
/**
|
|
7477
|
+
* The score line and the lives counter. NOT `won`/`lost`.
|
|
7478
|
+
*
|
|
7479
|
+
* Re-firing those on load is how a Continue lands straight on the game-over
|
|
7480
|
+
* screen it was loaded to escape — the flags come back through `hasWon` /
|
|
7481
|
+
* `hasLost` and the game reads them, rather than being told again.
|
|
7482
|
+
*/
|
|
7483
|
+
announce() {
|
|
7484
|
+
this.emit("scoreChanged", this.score);
|
|
7485
|
+
this.emit("livesChanged", this.lives);
|
|
7486
|
+
}
|
|
6866
7487
|
};
|
|
6867
7488
|
//#endregion
|
|
6868
7489
|
//#region src/gameplay/spawn-source.ts
|
|
@@ -6898,6 +7519,12 @@ var SpawnSource = class {
|
|
|
6898
7519
|
const cached = this.detached.get(prefab);
|
|
6899
7520
|
if (cached) return cached;
|
|
6900
7521
|
const node = this.behavior.node;
|
|
7522
|
+
const claim = absolutePrefabPath(node, prefab);
|
|
7523
|
+
const shared = claim ? node.tree?._templates.get(claim) ?? null : null;
|
|
7524
|
+
if (shared) {
|
|
7525
|
+
this.detached.set(prefab, shared);
|
|
7526
|
+
return shared;
|
|
7527
|
+
}
|
|
6901
7528
|
const template = prefab === "" ? null : node.getNodeOrNull(prefab);
|
|
6902
7529
|
if (!template) {
|
|
6903
7530
|
const behaviorName = this.behavior.constructor.name;
|
|
@@ -6906,6 +7533,8 @@ var SpawnSource = class {
|
|
|
6906
7533
|
path: node.getPath()
|
|
6907
7534
|
});
|
|
6908
7535
|
}
|
|
7536
|
+
if (claim) node.tree?._templates.set(claim, template);
|
|
7537
|
+
rememberAuthoredPath(template);
|
|
6909
7538
|
if (template.parent) template.parent.removeChild(template);
|
|
6910
7539
|
this.detached.set(prefab, template);
|
|
6911
7540
|
return template;
|
|
@@ -6932,8 +7561,8 @@ var SpawnSource = class {
|
|
|
6932
7561
|
* The scene loads, the spawner spawns, the clone is alive and visible — it is
|
|
6933
7562
|
* just somewhere else in the level, and nothing has anything to report.
|
|
6934
7563
|
*/
|
|
6935
|
-
spawn(template, opts) {
|
|
6936
|
-
const clone = duplicateNode(template);
|
|
7564
|
+
spawn(template, opts = void 0) {
|
|
7565
|
+
const clone = duplicateNode(template, rebuildOptions(this.behavior.node));
|
|
6937
7566
|
const at = opts?.at;
|
|
6938
7567
|
if (at && at.length > 0 && hasPosition$1(clone)) clone.position = add((opts?.parent ?? this.behavior.node) === this.behavior.node || !hasPosition$1(this.behavior.node) ? [] : this.behavior.node.position, at);
|
|
6939
7568
|
if (typeof clone.visible === "boolean") clone.visible = true;
|
|
@@ -6941,6 +7570,29 @@ var SpawnSource = class {
|
|
|
6941
7570
|
return clone;
|
|
6942
7571
|
}
|
|
6943
7572
|
};
|
|
7573
|
+
/**
|
|
7574
|
+
* The absolute path a relative prefab reference points at, resolved against the
|
|
7575
|
+
* spawner's own position — the key two spawners can agree on.
|
|
7576
|
+
*
|
|
7577
|
+
* Returns null for a form there is no single answer for (`%Unique` names a node
|
|
7578
|
+
* by identity, not by place), in which case the template is simply not shared —
|
|
7579
|
+
* which is the behaviour that already existed.
|
|
7580
|
+
*/
|
|
7581
|
+
function absolutePrefabPath(node, prefab) {
|
|
7582
|
+
if (prefab === "" || prefab.startsWith("%")) return null;
|
|
7583
|
+
if (prefab.startsWith("/")) return prefab;
|
|
7584
|
+
const parts = node.getPath().split("/");
|
|
7585
|
+
for (const step of prefab.split("/")) {
|
|
7586
|
+
if (step === "" || step === ".") continue;
|
|
7587
|
+
if (step === "..") {
|
|
7588
|
+
if (parts.length <= 1) return null;
|
|
7589
|
+
parts.pop();
|
|
7590
|
+
continue;
|
|
7591
|
+
}
|
|
7592
|
+
parts.push(step);
|
|
7593
|
+
}
|
|
7594
|
+
return parts.join("/");
|
|
7595
|
+
}
|
|
6944
7596
|
//#endregion
|
|
6945
7597
|
//#region src/gameplay/spawner.ts
|
|
6946
7598
|
/**
|
|
@@ -6999,7 +7651,11 @@ var Spawner = class extends Behavior {
|
|
|
6999
7651
|
_templateForTest() {
|
|
7000
7652
|
return this.template;
|
|
7001
7653
|
}
|
|
7654
|
+
onEnterTree() {
|
|
7655
|
+
this.template = this.source.resolveTemplate(this.prefab);
|
|
7656
|
+
}
|
|
7002
7657
|
onReady() {
|
|
7658
|
+
if (!(this.interval > 0) && this.max <= 0 && this.total <= 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Spawner on '${this.node.getPath()}': "interval" ${this.interval} spawns a clone every FRAME and neither "max" (live cap) nor "total" (lifetime cap) is set, so the scene grows without bound. Give it an interval, or a cap.`, { prop: "interval" });
|
|
7003
7659
|
this.template = this.source.resolveTemplate(this.prefab);
|
|
7004
7660
|
this.running = this.autoStart;
|
|
7005
7661
|
this.timer = this.interval;
|
|
@@ -7086,9 +7742,10 @@ var Wander = class extends Behavior {
|
|
|
7086
7742
|
pickGoal() {
|
|
7087
7743
|
this.timer = this.changeEvery;
|
|
7088
7744
|
const dims = this.origin.length;
|
|
7745
|
+
const up = dims === 3 ? 1 : -1;
|
|
7089
7746
|
const next = new Array(dims);
|
|
7090
7747
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
7091
|
-
for (let i = 0; i < dims; i++) next[i] = (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
|
|
7748
|
+
for (let i = 0; i < dims; i++) next[i] = i === up ? this.origin[i] ?? 0 : (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
|
|
7092
7749
|
if (distance$1(next, this.origin) <= this.radius) {
|
|
7093
7750
|
this.goal = next;
|
|
7094
7751
|
return;
|
|
@@ -7110,8 +7767,9 @@ var Wander = class extends Behavior {
|
|
|
7110
7767
|
* every `interval` seconds, then waits until they're ALL cleared (freed) before
|
|
7111
7768
|
* starting the next wave.
|
|
7112
7769
|
*
|
|
7113
|
-
* Signals: `
|
|
7114
|
-
* its last instance frees, `allCleared` after
|
|
7770
|
+
* Signals: `spawned(node)` per clone, `waveStarted(i)` when a wave begins
|
|
7771
|
+
* spawning, `waveCleared(i)` when its last instance frees, `allCleared` after
|
|
7772
|
+
* the final wave clears.
|
|
7115
7773
|
*/
|
|
7116
7774
|
var WaveSpawner = class extends Behavior {
|
|
7117
7775
|
static props = {
|
|
@@ -7124,6 +7782,7 @@ var WaveSpawner = class extends Behavior {
|
|
|
7124
7782
|
autoStart: { default: true }
|
|
7125
7783
|
};
|
|
7126
7784
|
static signals = [
|
|
7785
|
+
"spawned",
|
|
7127
7786
|
"waveStarted",
|
|
7128
7787
|
"waveCleared",
|
|
7129
7788
|
"allCleared"
|
|
@@ -7138,6 +7797,13 @@ var WaveSpawner = class extends Behavior {
|
|
|
7138
7797
|
timer = 0;
|
|
7139
7798
|
spawnedThisWave = 0;
|
|
7140
7799
|
announced = false;
|
|
7800
|
+
onEnterTree() {
|
|
7801
|
+
if (!Array.isArray(this.waves) || this.waves.length === 0) return;
|
|
7802
|
+
for (const raw of this.waves) {
|
|
7803
|
+
const prefab = raw.prefab;
|
|
7804
|
+
if (typeof prefab === "string" && prefab !== "") this.source.resolveTemplate(prefab);
|
|
7805
|
+
}
|
|
7806
|
+
}
|
|
7141
7807
|
onReady() {
|
|
7142
7808
|
if (!Array.isArray(this.waves) || this.waves.length === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `WaveSpawner on '${this.node.getPath()}': "waves" must be a non-empty array of { prefab, count, interval, delayBefore }.`, { prop: "waves" });
|
|
7143
7809
|
this.parsed = this.waves.map((raw, i) => this.parseWave(raw, i));
|
|
@@ -7148,6 +7814,38 @@ var WaveSpawner = class extends Behavior {
|
|
|
7148
7814
|
start() {
|
|
7149
7815
|
this.beginWave(0);
|
|
7150
7816
|
}
|
|
7817
|
+
/**
|
|
7818
|
+
* Which wave you were on — and nothing else.
|
|
7819
|
+
*
|
|
7820
|
+
* `behaviorsWithoutSave` is documented as "the list to read before shipping",
|
|
7821
|
+
* and nobody had read it against this repo's own examples. Five of them put a
|
|
7822
|
+
* `WaveSpawner` at the centre of the game (`fps-3d`, `tps-3d`, `tps-3d-neon`,
|
|
7823
|
+
* `tps-3d-ruins`, `dogfight-3d`), and every one of them saved a run on wave 3
|
|
7824
|
+
* and continued it on wave 1 — silently, because a save reloads the scene
|
|
7825
|
+
* from source and a fresh `onReady` honestly starts at the beginning.
|
|
7826
|
+
*
|
|
7827
|
+
* The wave RESTARTS rather than resuming mid-spawn, which is the same
|
|
7828
|
+
* checkpoint rule the rest of the save system follows: spawned entities are
|
|
7829
|
+
* never restored, so resuming "four enemies into wave 3" would mean resuming
|
|
7830
|
+
* a wave whose enemies do not exist. `waveStarted` fires again for the HUD.
|
|
7831
|
+
*/
|
|
7832
|
+
serialize() {
|
|
7833
|
+
return {
|
|
7834
|
+
wave: this.waveIndex,
|
|
7835
|
+
done: this.phase === "done"
|
|
7836
|
+
};
|
|
7837
|
+
}
|
|
7838
|
+
deserialize(data) {
|
|
7839
|
+
const d = data ?? {};
|
|
7840
|
+
if (d.done === true) {
|
|
7841
|
+
this.phase = "done";
|
|
7842
|
+
this.waveIndex = this.parsed.length - 1;
|
|
7843
|
+
return;
|
|
7844
|
+
}
|
|
7845
|
+
if (typeof d.wave !== "number" || !Number.isInteger(d.wave)) return;
|
|
7846
|
+
const wave = Math.max(0, Math.min(d.wave, this.parsed.length - 1));
|
|
7847
|
+
this.beginWave(wave);
|
|
7848
|
+
}
|
|
7151
7849
|
update(dt) {
|
|
7152
7850
|
if (this.phase === "idle" || this.phase === "done") return;
|
|
7153
7851
|
const wave = this.parsed[this.waveIndex];
|
|
@@ -7170,6 +7868,7 @@ var WaveSpawner = class extends Behavior {
|
|
|
7170
7868
|
const clone = this.source.spawn(this.source.resolveTemplate(wave.prefab));
|
|
7171
7869
|
this.live.add(clone);
|
|
7172
7870
|
this.spawnedThisWave += 1;
|
|
7871
|
+
this.emit("spawned", clone);
|
|
7173
7872
|
this.timer += wave.interval;
|
|
7174
7873
|
}
|
|
7175
7874
|
if (this.spawnedThisWave >= wave.count) this.phase = "clearing";
|
|
@@ -7426,7 +8125,8 @@ const GAMEPLAY_BEHAVIORS = {
|
|
|
7426
8125
|
WaveSpawner,
|
|
7427
8126
|
Projectile,
|
|
7428
8127
|
Buoyancy,
|
|
7429
|
-
FloatAway
|
|
8128
|
+
FloatAway,
|
|
8129
|
+
PrefabShelf
|
|
7430
8130
|
};
|
|
7431
8131
|
/**
|
|
7432
8132
|
* Register all built-in gameplay behaviors. Idempotent and hot-reload tolerant
|
|
@@ -7438,4 +8138,4 @@ function registerGameplayBehaviors(opts) {
|
|
|
7438
8138
|
for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
|
|
7439
8139
|
}
|
|
7440
8140
|
//#endregion
|
|
7441
|
-
export {
|
|
8141
|
+
export { DayNight as A, rejectScale as B, goToScene as C, effectRng as D, FollowCamera as E, Collector as F, StaticBody3D as G, CharacterBody3D as H, Chase as I, createCausticsQuad as J, Node3D as K, Water3D as L, DamageOnContact as M, Health as N, FloatAway as O, Currency as P, worldPosition as Q, WATER_CUTOUT_MAX as R, GameFlow as S, tolerateUnknownAction as T, PhysicsBody3D as U, Area3D as V, RigidBody3D as W, colliderFootDrop as X, WATER_MAX_RIPPLES as Y, localFromWorld as Z, CameraShake as _, Wander as a, screenFlash as b, SavePoint as c, Pickup as d, Patrol as f, Lifetime as g, MoveTo as h, WaveSpawner as i, phaseOf as j, FaceTarget as k, Projectile as l, Oscillate as m, registerGameplayBehaviors as n, Spawner as o, PathFollow as p, validateCollider3D as q, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, PrefabShelf as u, Cooldown as v, restartScene as w, Interactable as x, hitStop as y, WaterCutout3D as z };
|