pixospritz-core 0.10.1 → 1.0.1

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.
Files changed (157) hide show
  1. package/README.md +36 -286
  2. package/dist/bundle.js +13 -3
  3. package/dist/bundle.js.map +1 -1
  4. package/dist/style.css +1 -0
  5. package/package.json +43 -44
  6. package/src/components/WebGLView.jsx +318 -0
  7. package/src/css/pixos.css +372 -0
  8. package/src/engine/actions/animate.js +41 -0
  9. package/src/engine/actions/changezone.js +135 -0
  10. package/src/engine/actions/chat.js +109 -0
  11. package/src/engine/actions/dialogue.js +90 -0
  12. package/src/engine/actions/face.js +22 -0
  13. package/src/engine/actions/greeting.js +28 -0
  14. package/src/engine/actions/interact.js +86 -0
  15. package/src/engine/actions/move.js +67 -0
  16. package/src/engine/actions/patrol.js +109 -0
  17. package/src/engine/actions/prompt.js +185 -0
  18. package/src/engine/actions/script.js +42 -0
  19. package/src/engine/core/audio/AudioSystem.js +543 -0
  20. package/src/engine/core/cutscene/PxcPlayer.js +956 -0
  21. package/src/engine/core/cutscene/manager.js +243 -0
  22. package/src/engine/core/database/index.js +75 -0
  23. package/src/engine/core/debug/index.js +371 -0
  24. package/src/engine/core/hud/index.js +765 -0
  25. package/src/engine/core/index.js +540 -0
  26. package/src/engine/core/input/gamepad/Controller.js +71 -0
  27. package/src/engine/core/input/gamepad/ControllerButtons.js +231 -0
  28. package/src/engine/core/input/gamepad/ControllerStick.js +173 -0
  29. package/src/engine/core/input/gamepad/index.js +592 -0
  30. package/src/engine/core/input/keyboard.js +196 -0
  31. package/src/engine/core/input/manager.js +485 -0
  32. package/src/engine/core/input/mouse.js +203 -0
  33. package/src/engine/core/input/touch.js +175 -0
  34. package/src/engine/core/mode/manager.js +199 -0
  35. package/src/engine/core/net/manager.js +535 -0
  36. package/src/engine/core/queue/action.js +83 -0
  37. package/src/engine/core/queue/event.js +82 -0
  38. package/src/engine/core/queue/index.js +44 -0
  39. package/src/engine/core/queue/loadable.js +33 -0
  40. package/src/engine/core/render/CameraEffects.js +494 -0
  41. package/src/engine/core/render/FrustumCuller.js +417 -0
  42. package/src/engine/core/render/LODManager.js +285 -0
  43. package/src/engine/core/render/ParticleManager.js +529 -0
  44. package/src/engine/core/render/TextureAtlas.js +465 -0
  45. package/src/engine/core/render/camera.js +338 -0
  46. package/src/engine/core/render/light.js +197 -0
  47. package/src/engine/core/render/manager.js +1079 -0
  48. package/src/engine/core/render/shaders.js +110 -0
  49. package/src/engine/core/render/skybox.js +342 -0
  50. package/src/engine/core/resource/manager.js +133 -0
  51. package/src/engine/core/resource/object.js +611 -0
  52. package/src/engine/core/resource/texture.js +103 -0
  53. package/src/engine/core/resource/tileset.js +177 -0
  54. package/src/engine/core/scene/avatar.js +215 -0
  55. package/src/engine/core/scene/speech.js +138 -0
  56. package/src/engine/core/scene/sprite.js +702 -0
  57. package/src/engine/core/scene/spritz.js +189 -0
  58. package/src/engine/core/scene/world.js +681 -0
  59. package/src/engine/core/scene/zone.js +1167 -0
  60. package/src/engine/core/store/index.js +110 -0
  61. package/src/engine/dynamic/animatedSprite.js +64 -0
  62. package/src/engine/dynamic/animatedTile.js +98 -0
  63. package/src/engine/dynamic/avatar.js +110 -0
  64. package/src/engine/dynamic/map.js +174 -0
  65. package/src/engine/dynamic/sprite.js +255 -0
  66. package/src/engine/dynamic/spritz.js +119 -0
  67. package/src/engine/events/EventSystem.js +609 -0
  68. package/src/engine/events/camera.js +142 -0
  69. package/src/engine/events/chat.js +75 -0
  70. package/src/engine/events/menu.js +186 -0
  71. package/src/engine/scripting/CallbackManager.js +514 -0
  72. package/src/engine/scripting/PixoScriptInterpreter.js +81 -0
  73. package/src/engine/scripting/PixoScriptLibrary.js +704 -0
  74. package/src/engine/shaders/effects/index.js +450 -0
  75. package/src/engine/shaders/fs.js +222 -0
  76. package/src/engine/shaders/particles/fs.js +41 -0
  77. package/src/engine/shaders/particles/vs.js +61 -0
  78. package/src/engine/shaders/picker/fs.js +34 -0
  79. package/src/engine/shaders/picker/init.js +62 -0
  80. package/src/engine/shaders/picker/vs.js +42 -0
  81. package/src/engine/shaders/pxsl/README.md +250 -0
  82. package/src/engine/shaders/pxsl/index.js +25 -0
  83. package/src/engine/shaders/pxsl/library.js +608 -0
  84. package/src/engine/shaders/pxsl/manager.js +338 -0
  85. package/src/engine/shaders/pxsl/specification.js +363 -0
  86. package/src/engine/shaders/pxsl/transpiler.js +753 -0
  87. package/src/engine/shaders/skybox/cosmic/fs.js +147 -0
  88. package/src/engine/shaders/skybox/cosmic/vs.js +23 -0
  89. package/src/engine/shaders/skybox/matrix/fs.js +127 -0
  90. package/src/engine/shaders/skybox/matrix/vs.js +23 -0
  91. package/src/engine/shaders/skybox/morning/fs.js +109 -0
  92. package/src/engine/shaders/skybox/morning/vs.js +23 -0
  93. package/src/engine/shaders/skybox/neon/fs.js +119 -0
  94. package/src/engine/shaders/skybox/neon/vs.js +23 -0
  95. package/src/engine/shaders/skybox/sky/fs.js +114 -0
  96. package/src/engine/shaders/skybox/sky/vs.js +23 -0
  97. package/src/engine/shaders/skybox/sunset/fs.js +101 -0
  98. package/src/engine/shaders/skybox/sunset/vs.js +23 -0
  99. package/src/engine/shaders/transition/blur/fs.js +42 -0
  100. package/src/engine/shaders/transition/blur/vs.js +26 -0
  101. package/src/engine/shaders/transition/cross/fs.js +36 -0
  102. package/src/engine/shaders/transition/cross/vs.js +26 -0
  103. package/src/engine/shaders/transition/crossBlur/fs.js +41 -0
  104. package/src/engine/shaders/transition/crossBlur/vs.js +25 -0
  105. package/src/engine/shaders/transition/dissolve/fs.js +78 -0
  106. package/src/engine/shaders/transition/dissolve/vs.js +24 -0
  107. package/src/engine/shaders/transition/fade/fs.js +31 -0
  108. package/src/engine/shaders/transition/fade/vs.js +27 -0
  109. package/src/engine/shaders/transition/iris/fs.js +52 -0
  110. package/src/engine/shaders/transition/iris/vs.js +24 -0
  111. package/src/engine/shaders/transition/pixelate/fs.js +44 -0
  112. package/src/engine/shaders/transition/pixelate/vs.js +24 -0
  113. package/src/engine/shaders/transition/slide/fs.js +53 -0
  114. package/src/engine/shaders/transition/slide/vs.js +24 -0
  115. package/src/engine/shaders/transition/swirl/fs.js +39 -0
  116. package/src/engine/shaders/transition/swirl/vs.js +26 -0
  117. package/src/engine/shaders/transition/wipe/fs.js +50 -0
  118. package/src/engine/shaders/transition/wipe/vs.js +24 -0
  119. package/src/engine/shaders/vs.js +60 -0
  120. package/src/engine/utils/CameraController.js +506 -0
  121. package/src/engine/utils/ObjHelper.js +551 -0
  122. package/src/engine/utils/debug-logger.js +110 -0
  123. package/src/engine/utils/enums.js +305 -0
  124. package/src/engine/utils/generator.js +156 -0
  125. package/src/engine/utils/index.js +21 -0
  126. package/src/engine/utils/loaders/ActionLoader.js +77 -0
  127. package/src/engine/utils/loaders/AudioLoader.js +157 -0
  128. package/src/engine/utils/loaders/EventLoader.js +66 -0
  129. package/src/engine/utils/loaders/ObjectLoader.js +67 -0
  130. package/src/engine/utils/loaders/SpriteLoader.js +77 -0
  131. package/src/engine/utils/loaders/TilesetLoader.js +103 -0
  132. package/src/engine/utils/loaders/index.js +21 -0
  133. package/src/engine/utils/math/matrix4.js +367 -0
  134. package/src/engine/utils/math/vector.js +458 -0
  135. package/src/engine/utils/obj/_old_js/index.js +46 -0
  136. package/src/engine/utils/obj/_old_js/layout.js +308 -0
  137. package/src/engine/utils/obj/_old_js/material.js +711 -0
  138. package/src/engine/utils/obj/_old_js/mesh.js +761 -0
  139. package/src/engine/utils/obj/_old_js/utils.js +647 -0
  140. package/src/engine/utils/obj/index.js +24 -0
  141. package/src/engine/utils/obj/js/index.js +277 -0
  142. package/src/engine/utils/obj/js/loader.js +232 -0
  143. package/src/engine/utils/obj/layout.js +246 -0
  144. package/src/engine/utils/obj/material.js +665 -0
  145. package/src/engine/utils/obj/mesh.js +657 -0
  146. package/src/engine/utils/obj/ts/index.ts +72 -0
  147. package/src/engine/utils/obj/ts/layout.ts +265 -0
  148. package/src/engine/utils/obj/ts/material.ts +760 -0
  149. package/src/engine/utils/obj/ts/mesh.ts +785 -0
  150. package/src/engine/utils/obj/ts/utils.ts +501 -0
  151. package/src/engine/utils/obj/utils.js +428 -0
  152. package/src/engine/utils/resources.js +18 -0
  153. package/src/index.jsx +55 -0
  154. package/src/spritz/player.js +18 -0
  155. package/src/spritz/readme.md +18 -0
  156. package/LICENSE +0 -437
  157. package/dist/bundle.js.LICENSE.txt +0 -31
@@ -0,0 +1,1167 @@
1
+ /* *\
2
+ ** ----------------------------------------------- **
3
+ ** Calliope - Pixos Game Engine **
4
+ ** ----------------------------------------------- **
5
+ ** Copyright (c) 2020-2025 - Kyle Derby MacInnis **
6
+ ** **
7
+ ** Any unauthorized distribution or transfer **
8
+ ** of this work is strictly prohibited. **
9
+ ** **
10
+ ** All Rights Reserved. **
11
+ ** ----------------------------------------------- **
12
+ \* */
13
+
14
+ /**
15
+ * @fileoverview Zone class for Pixos game engine.
16
+ * Manages map zones, sprites, objects, and rendering.
17
+ */
18
+
19
+ import { Direction, mergeDeep } from '@Engine/utils/enums.js';
20
+ import Resources from '@Engine/utils/resources.js';
21
+ import ActionQueue from '@Engine/core/queue/index.js';
22
+ import { Vector } from '@Engine/utils/math/vector.js';
23
+ import { EventLoader, SpriteLoader, TilesetLoader, ActionLoader, ObjectLoader } from '@Engine/utils/loaders/index.js';
24
+ import { loadMap, dynamicCells } from '@Engine/dynamic/map.js';
25
+ import Loadable from '@Engine/core/queue/loadable.js';
26
+ import { debug } from '@Engine/utils/debug-logger.js';
27
+ import PixoScriptInterpreter from '@Engine/scripting/PixoScriptInterpreter.js';
28
+
29
+ /**
30
+ * @typedef {object} ZoneData
31
+ * @property {string} id - Zone ID.
32
+ * @property {number} objId - Object ID.
33
+ * @property {object[]} scripts - Scripts.
34
+ * @property {object} data - Zone data.
35
+ * @property {string[]} objects - Object IDs.
36
+ * @property {string[]} sprites - Sprite IDs.
37
+ * @property {Array<number[]>} selectedTiles - Selected tiles.
38
+ */
39
+
40
+ /**
41
+ * Zone - Represents a map zone with tiles, sprites, and objects.
42
+ */
43
+ export default class Zone extends Loadable {
44
+ /**
45
+ * Creates an instance of Zone.
46
+ * @param {string} zoneId - The zone ID.
47
+ * @param {import('./world.js').default} world - The world instance.
48
+ */
49
+ constructor(zoneId, world) {
50
+ super();
51
+ /** @type {string} */
52
+ this.spritzName = world.id;
53
+ /** @type {string} */
54
+ this.id = zoneId;
55
+ /** @type {number} */
56
+ this.objId = Math.round(Math.random() * 100);
57
+ /** @type {import('./world.js').default} */
58
+ this.world = world;
59
+ /** @type {object} */
60
+ this.data = {};
61
+ /** @type {Object.<string, object>} */
62
+ this.spriteDict = Object.create(null);
63
+ /** @type {object[]} */
64
+ this.spriteList = [];
65
+ /** @type {Object.<string, object>} */
66
+ this.objectDict = Object.create(null);
67
+ /** @type {object[]} */
68
+ this.objectList = [];
69
+ /** @type {Array<number[]>} */
70
+ this.selectedTiles = [];
71
+ /** @type {object[]} */
72
+ this.lights = [];
73
+ /** @type {object[]} */
74
+ this.spritz = [];
75
+ /** @type {object[]} */
76
+ this.scripts = this.scripts || [];
77
+ /** @type {number} */
78
+ this.lastKey = 0;
79
+ /** @type {import('../index.js').default} */
80
+ this.engine = world.engine;
81
+ /** @type {ActionQueue} */
82
+ this.onLoadActions = new ActionQueue();
83
+ /** @type {SpriteLoader} */
84
+ this.spriteLoader = new SpriteLoader(world.engine);
85
+ /** @type {ObjectLoader} */
86
+ this.objectLoader = new ObjectLoader(world.engine);
87
+ /** @type {typeof EventLoader} */
88
+ this.EventLoader = EventLoader;
89
+ /** @type {TilesetLoader} */
90
+ this.tsLoader = new TilesetLoader(world.engine);
91
+ /** @type {object|null} */
92
+ this.audio = null;
93
+ /** @type {Set<string>|null} */
94
+ this._selectedSet = null;
95
+ /** @type {number[]|null} */
96
+ this._highlight = null;
97
+ }
98
+
99
+ /**
100
+ * Gets the zone data.
101
+ * @returns {ZoneData} The zone data.
102
+ */
103
+ getZoneData = () => {
104
+ return {
105
+ id: this.id,
106
+ objId: this.objId,
107
+ scripts: this.scripts,
108
+ data: this.data,
109
+ objects: Object.keys(this.objectDict),
110
+ sprites: Object.keys(this.spriteDict),
111
+ selectedTiles: this.selectedTiles,
112
+ };
113
+ };
114
+
115
+ /**
116
+ * Called after tileset and actors are loaded.
117
+ */
118
+ afterTilesetAndActorsLoaded = () => {
119
+ if (
120
+ this.loaded ||
121
+ !this.tileset?.loaded ||
122
+ !this.spriteList.every((s) => s.loaded) ||
123
+ !this.objectList.every((o) => o.loaded)
124
+ ) return;
125
+
126
+ this.loaded = true;
127
+ this.loadScripts(true);
128
+ this.onLoadActions.run();
129
+ };
130
+
131
+ /**
132
+ * Attaches tileset listeners.
133
+ */
134
+ attachTilesetListeners = () => {
135
+ this.tileset.runWhenDefinitionLoaded(this.onTilesetDefinitionLoaded);
136
+ this.tileset.runWhenLoaded(this.afterTilesetAndActorsLoaded);
137
+ };
138
+
139
+ /**
140
+ * Finalizes the zone loading.
141
+ */
142
+ finalize = async () => {
143
+ for (const s of this.spriteList) s.runWhenLoaded(this.afterTilesetAndActorsLoaded);
144
+ for (const o of this.objectList) o.runWhenLoaded(this.afterTilesetAndActorsLoaded);
145
+ this.engine.networkManager.loadZone(this.id, this);
146
+ };
147
+
148
+ /**
149
+ * Loads the zone remotely.
150
+ */
151
+ loadRemote = async () => {
152
+ const res = await fetch(Resources.zoneRequestUrl(this.id));
153
+ if (!res.ok) return;
154
+ try {
155
+ const data = await res.json();
156
+ this.bounds = data.bounds;
157
+ this.size = [data.bounds[2] - data.bounds[0], data.bounds[3] - data.bounds[1]];
158
+
159
+ this.cells = typeof data.cells === 'function' ? data.cells(this.bounds, this) : data.cells;
160
+ this.sprites = typeof data.sprites === 'function' ? data.sprites(this.bounds, this) : data.sprites || [];
161
+ this.objects = typeof data.objects === 'function' ? data.objects(this.bounds, this) : data.objects || [];
162
+
163
+ this.tileset = await this.tsLoader.load(data.tileset, this.spritzName);
164
+ this.attachTilesetListeners();
165
+
166
+ if (this.audioSrc) this.audio = this.engine.resourceManager.audioLoader.load(this.audioSrc, true);
167
+
168
+ await Promise.all([
169
+ Promise.all(this.sprites.map(this.loadSprite)),
170
+ Promise.all(this.objects.map(this.loadObject)),
171
+ ]);
172
+
173
+ // If zone specifies a skyboxShader, set it
174
+ if (data.skyboxShader && this.engine.renderManager?.skyboxManager?.setSkyboxShader) {
175
+ await this.engine.renderManager.skyboxManager.setSkyboxShader(data.skyboxShader);
176
+ }
177
+
178
+ await this.finalize();
179
+ // If the zone JSON declares a mode, load mode scripts from the spritz package
180
+ try {
181
+ if (data.mode)
182
+ await this.loadMode(data.mode);
183
+ } catch (e) {
184
+ console.warn('zone mode load failed', e);
185
+ }
186
+ } catch (e) {
187
+ console.error('Error parsing zone ' + this.id, e);
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Loads the zone.
193
+ */
194
+ load = async () => {
195
+ try {
196
+ const mapModule = await import('../../../../spritz/' + this.spritzName + '/maps/' + this.id + '/map.js');
197
+ const data = mapModule.default;
198
+ Object.assign(this, data);
199
+
200
+ // dynamic cells (such as randomly generated)
201
+ if (typeof this.cells === 'function') this.cells = this.cells(this.bounds, this);
202
+ // Background audio
203
+ if (this.audioSrc) this.audio = this.engine.resourceManager.audioLoader.load(this.audioSrc, true);
204
+
205
+ // Load in tileset assets
206
+ this.size = [this.bounds[2] - this.bounds[0], this.bounds[3] - this.bounds[1]];
207
+ this.tileset = await this.tsLoader.load(this.tileset, this.spritzName);
208
+ this.attachTilesetListeners();
209
+
210
+ // dynamically add sprites (if appl.) - todo - possibly same thing for objects?
211
+ if (typeof this.sprites === 'function') this.sprites = this.sprites(this.bounds, this);
212
+ this.sprites = this.sprites || [];
213
+ this.objects = this.objects || [];
214
+
215
+ // populate
216
+ await Promise.all([
217
+ Promise.all(this.sprites.map(this.loadSprite)),
218
+ Promise.all(this.objects.map(this.loadObject)),
219
+ ]);
220
+
221
+ // If zone specifies a skyboxShader, set it
222
+ if (data.skyboxShader && this.engine.renderManager?.skyboxManager?.setSkyboxShader) {
223
+ await this.engine.renderManager.skyboxManager.setSkyboxShader(data.skyboxShader);
224
+ }
225
+
226
+ await this.finalize();
227
+ // If zone specifies a default mode name on the map data, attempt to load it from spritz package
228
+ try {
229
+ if (data.mode)
230
+ await this.loadMode(data.mode);
231
+ } catch (e) {
232
+ console.warn('zone mode load failed', e);
233
+ }
234
+
235
+ try {
236
+ this.engine.networkManager.joinZone(this.id);
237
+ } catch (e) {
238
+ console.warn('Network Error :: could not send zone commend to server')
239
+ }
240
+
241
+ } catch (e) {
242
+ console.error('Error parsing zone ' + this.id, e);
243
+ }
244
+ };
245
+
246
+ /**
247
+ * Loads a trigger from a zip archive.
248
+ * @param {string} trigger - The trigger name.
249
+ * @param {object} zip - The zip archive.
250
+ * @returns {function(): void} The trigger function.
251
+ */
252
+ loadTriggerFromZip = async (trigger, zip) => {
253
+ // Try Lua first
254
+ try {
255
+ const file = await zip.file(`triggers/${trigger}.pxs`);
256
+ if (file) {
257
+ const luaScript = await file.async('string');
258
+ return (_this, subject) => {
259
+ const interpreter = new PixoScriptInterpreter(_this.engine);
260
+ interpreter.setScope({ _this, zone: this, subject });
261
+ interpreter.initLibrary();
262
+ return interpreter.run(luaScript);
263
+ };
264
+ }
265
+ } catch (e) {
266
+ if (this.engine?.debug) console.warn('Lua trigger load failed', e);
267
+ }
268
+
269
+ // Try .pxs extension (PixoScript/Lua)
270
+ try {
271
+ const pxsFile = await zip.file(`triggers/${trigger}.pxs`);
272
+ if (pxsFile) {
273
+ const luaScript = await pxsFile.async('string');
274
+ return (_this, subject) => {
275
+ const interpreter = new PixoScriptInterpreter(_this.engine);
276
+ interpreter.setScope({ _this, zone: this, subject });
277
+ interpreter.initLibrary();
278
+ return interpreter.run(luaScript);
279
+ };
280
+ }
281
+ } catch (e) {
282
+ if (this.engine?.debug) console.warn('PixoScript trigger load failed', e);
283
+ }
284
+
285
+ // JS fallback (sandboxed) — no global eval
286
+ const jsFile = await zip.file(`triggers/${trigger}.js`);
287
+ if (jsFile) {
288
+ const triggerScript = await jsFile.async('string');
289
+ // new Function isolates scope; it receives (zone, engine) and must return a function
290
+ const factory = new Function('zone', 'engine', `${triggerScript}; return (typeof module !== 'undefined' && module.exports) ? module.exports : (typeof exports !== 'undefined' ? exports : (typeof trigger === 'function' ? trigger : null));`);
291
+ const fn = factory(this, this.engine);
292
+ if (typeof fn === 'function') return fn.bind(this, this);
293
+ }
294
+
295
+ return () => { };
296
+ };
297
+
298
+ /**
299
+ * Loads a mode from a zip archive.
300
+ * @param {string} modeName - The mode name.
301
+ * @param {object} zip - The zip archive.
302
+ */
303
+ loadModeFromZip = async (modeName, zip) => {
304
+ try {
305
+ debug('Zone', 'Loading Game Mode From Zip');
306
+
307
+ const setupFile = zip.file(`modes/${modeName}/setup.pxs`);
308
+ const updateFile = zip.file(`modes/${modeName}/update.pxs`);
309
+ const teardownFile = zip.file(`modes/${modeName}/teardown.pxs`);
310
+ const world = this.world;
311
+
312
+ const interpreter = new PixoScriptInterpreter(this.engine);
313
+ interpreter.setScope({ zone: this, map: this, _this: this });
314
+ interpreter.initLibrary();
315
+
316
+ const handlers = {};
317
+ if (setupFile) {
318
+ const script = await setupFile.async('string');
319
+ // run the setup registration (it likely calls pixos.register_mode)
320
+ debug('Zone', 'loadModeFromZip: running setup.pxs for mode', modeName);
321
+ await interpreter.run(script);
322
+ }
323
+ // If update file exists, load it as a function and register as handler
324
+ if (updateFile) {
325
+ const updateScript = await updateFile.async('string');
326
+ // wrap as a function and register to call on each frame via ModeManager
327
+ // We return a JS function that executes the Lua chunk each time
328
+ handlers.update = async (time, params) => {
329
+ try {
330
+ // create a fresh interpreter env for update to avoid state bleed
331
+ const ui = new PixoScriptInterpreter(this.engine);
332
+ ui.setScope({ zone: this, map: this, _this: this, time, params });
333
+ ui.initLibrary();
334
+ // The update.pxs is expected to return a function
335
+ const res = await ui.run(updateScript);
336
+ // If the script returned a callable (Lua function) we invoke it
337
+ if (typeof res === 'function') res(time, params);
338
+ } catch (e) { console.warn('mode update exec failed', e); }
339
+ };
340
+ }
341
+ if (teardownFile) {
342
+ const tdScript = await teardownFile.async('string');
343
+ handlers.teardown = async (params) => {
344
+ try {
345
+ const td = new PixoScriptInterpreter(this.engine);
346
+ td.setScope({ zone: this });
347
+ td.initLibrary();
348
+ const res = await td.run(tdScript);
349
+ // if there is a returned callback, we can run it
350
+ if (typeof res === 'function') res(params);
351
+ } catch (e) {
352
+ console.warn('mode teardown failed', e);
353
+ }
354
+ };
355
+ }
356
+
357
+ // If the setup script used pixos.register_mode, the ModeManager will
358
+ // already have the registration. But ensure we add handlers if not.
359
+ if (world && world.modeManager) {
360
+ const existing = world.modeManager.registered[modeName];
361
+ if (!existing) world.modeManager.register(modeName, handlers);
362
+ }
363
+ } catch (e) {
364
+ console.warn('loadModeFromZip failed', modeName, e);
365
+ }
366
+ };
367
+
368
+ /**
369
+ * Loads a mode.
370
+ * @param {string} modeName - The mode name.
371
+ */
372
+ loadMode = async (modeName) => {
373
+ try {
374
+ const world = this.world;
375
+ await this.loadModeFromZip(modeName, world.spritz.zip);
376
+ } catch (e) {
377
+ console.warn('loadMode failed', modeName, e);
378
+ }
379
+ };
380
+
381
+ /**
382
+ * Loads a zone from a zip archive.
383
+ * @param {object} zoneJson - The zone JSON.
384
+ * @param {object} cellJson - The cell JSON.
385
+ * @param {object} zip - The zip archive.
386
+ * @param {boolean} [skipCache=false] - Whether to skip cache.
387
+ */
388
+ loadZoneFromZip = async (zoneJson, cellJson, zip, skipCache = false) => {
389
+ try {
390
+ // Zone extensions
391
+ if (zoneJson.extends?.length) {
392
+ let extension = {};
393
+ await Promise.all(zoneJson.extends.map(async (file) => {
394
+ const str = await zip.file('maps/' + file + '/map.json').async('string');
395
+ extension = mergeDeep(extension, JSON.parse(str));
396
+ }));
397
+ zoneJson = Object.assign(extension, { ...zoneJson, extends: null });
398
+ }
399
+
400
+ // Cell extensions
401
+ if (cellJson.extends?.length) {
402
+ let cells = [];
403
+ await Promise.all(cellJson.extends.map(async (file) => {
404
+ const str = await zip.file('maps/' + file + '/cells.json').async('string');
405
+ const parsed = JSON.parse(str);
406
+ cells = cells.concat(parsed.cells ? parsed.cells : parsed);
407
+ }));
408
+ cellJson = cells.concat(cellJson.cells || []);
409
+ }
410
+
411
+ // Load heights.json if it exists
412
+ let heightsJson = null;
413
+ try {
414
+ const heightsFile = zip.file('maps/' + this.id + '/heights.json');
415
+ if (heightsFile) {
416
+ const heightsStr = await heightsFile.async('string');
417
+ heightsJson = JSON.parse(heightsStr);
418
+ debug('Zone', `Loaded heights.json for ${this.id}:`, heightsJson?.length, 'rows');
419
+ debug('Zone', `First row heights:`, heightsJson?.[0]);
420
+ debug('Zone', `Heights data sample:`, JSON.stringify(heightsJson?.slice(0, 3)));
421
+ } else {
422
+ debug('Zone', `No heights.json found for ${this.id}, using default geometry heights`);
423
+ }
424
+ } catch (e) {
425
+ console.warn(`[Zone] Failed to load heights.json for ${this.id}:`, e.message);
426
+ }
427
+
428
+ // Menus
429
+ if (zoneJson.menu) {
430
+ const menus = {};
431
+ await Promise.all(Object.keys(zoneJson.menu).map(async (id) => {
432
+ const menu = { ...zoneJson.menu[id], id };
433
+ if (menu.onOpen) menu.onOpen = (await this.loadTriggerFromZip(menu.onOpen, zip)).bind(this, this);
434
+ if (menu.trigger) menu.trigger = (await this.loadTriggerFromZip(menu.trigger, zip)).bind(this, this);
435
+ menus[id] = menu;
436
+ }));
437
+ this.menus = menus;
438
+ this.world.startMenu(this.menus);
439
+ }
440
+
441
+ // Tileset / map / cells
442
+ const tileset = await this.tsLoader.loadFromZip(zip, zoneJson.tileset, this.spritzName);
443
+ const cells = dynamicCells(cellJson, tileset.tiles);
444
+ const map = await loadMap.call(this, zoneJson, cells, zip, heightsJson);
445
+ Object.assign(this, map);
446
+
447
+ // Cells generator (string -> function)
448
+ if (typeof this.cells === 'string') {
449
+ try {
450
+ // Strict scope function (no global eval)
451
+ const fn = new Function('bounds', 'zone', `return (${this.cells})(bounds, zone);`);
452
+ this.cells = fn.call(this, this.bounds, this);
453
+ } catch (e) {
454
+ console.error('error loading cell function', e);
455
+ }
456
+ }
457
+
458
+ // Audio
459
+ if (zoneJson.mode) {
460
+ try { this.mode = zoneJson.mode } catch (e) { console.error('audio load', e); }
461
+ }
462
+
463
+ // Audio
464
+ if (zoneJson.audioSrc) {
465
+ try { this.audio = await this.engine.resourceManager.audioLoader.loadFromZip(zip, zoneJson.audioSrc, true); } catch (e) { console.error('audio load', e); }
466
+ }
467
+
468
+ // Lights
469
+ try {
470
+ this.lights = zoneJson.lights ?? [];
471
+ const lm = this.engine.renderManager.lightManager;
472
+ for (const l of this.lights) lm.addLight(l.id, l.pos, l.color, l.attenuation, l.direction, l.density, l.scatteringCoefficients, l.enabled);
473
+ } catch (e) { console.error('lights', e); }
474
+
475
+ // Tileset + size
476
+ this.tileset = tileset;
477
+ this.size = [this.bounds[2] - this.bounds[0], this.bounds[3] - this.bounds[1]];
478
+
479
+ // Sprite generators
480
+ if (typeof this.sprites === 'string') {
481
+ try {
482
+ const fn = new Function('bounds', 'zone', `return (${this.sprites})(bounds, zone);`);
483
+ this.sprites = fn.call(this, this.bounds, this);
484
+ } catch (e) { console.error('sprite fn', e); }
485
+ }
486
+ this.sprites = this.sprites || [];
487
+ this.objects = this.objects || [];
488
+
489
+ await Promise.all([
490
+ Promise.all(this.sprites.map((s) => this.loadSpriteFromZip(s, zip, skipCache))),
491
+ Promise.all(this.objects.map((o) => this.loadObjectFromZip(o, zip))),
492
+ ]);
493
+
494
+ this.attachTilesetListeners();
495
+
496
+ // If the loaded map object includes a 'mode' property attempt to load a mode module
497
+ // todo - look into whether this should be updated or moved - not sure if the zone should control the mode like this.
498
+ // in some cases, it makes sense, but I feel like if multiple zones are loaded, there could be conflicts, and the idea of
499
+ // the zone controlling gameplay could be confusing in some cases, but for "battle zones" it kind of makes sense - but this could
500
+ // be done via scripts - so possibly something which could be fully scripted instead of this kind of logic - and instead
501
+ // I will likely move this to the world object - and then it will be the 'initial' mode.
502
+ try {
503
+ if (this.mode)
504
+ await this.loadMode(this.mode);
505
+ } catch (e) {
506
+ console.warn('zone mode load failed', e);
507
+ }
508
+
509
+ await this.finalize();
510
+
511
+ } catch (e) {
512
+ console.error('Error parsing json zone ' + this.id, e);
513
+ }
514
+ };
515
+
516
+ /**
517
+ * Runs when the zone is deleted.
518
+ */
519
+ runWhenDeleted = () => {
520
+ for (const l of this.lights) this.engine.renderManager.lightManager.removeLight(l.id);
521
+ };
522
+
523
+ /**
524
+ * Called when tileset definition is loaded.
525
+ */
526
+ onTilesetDefinitionLoaded = () => {
527
+ const width = this.size[0];
528
+ const height = this.size[1];
529
+ const rm = this.engine.renderManager;
530
+ const gl = this.engine.gl;
531
+
532
+ // Guard: Check if cells are properly loaded
533
+ if (!this.cells || this.cells.length === 0) {
534
+ console.error('[Zone.onTilesetDefinitionLoaded] No cells data - tileset may be missing tiles definition');
535
+ return;
536
+ }
537
+
538
+ this.cellVertexPosBuf = Array.from({ length: height }, () => new Array(width));
539
+ this.cellVertexTexBuf = Array.from({ length: height }, () => new Array(width));
540
+ this.cellPickingId = Array.from({ length: height }, () => new Array(width));
541
+ this.walkability = new Uint16Array(width * height);
542
+
543
+ // Precompute
544
+ let k = 0;
545
+ for (let j = 0; j < height; j++) {
546
+ for (let i = 0; i < width; i++, k++) {
547
+ const cell = this.cells[k];
548
+
549
+ // Guard: Skip if cell is undefined (tile lookup failed)
550
+ if (!cell || !Array.isArray(cell)) {
551
+ console.warn(`[Zone] Cell [${j},${i}] is undefined - missing tile in tileset`);
552
+ // Create empty buffers
553
+ this.cellVertexPosBuf[j][i] = rm.createBuffer(new Float32Array([]), gl.STATIC_DRAW, 3);
554
+ this.cellVertexTexBuf[j][i] = rm.createBuffer(new Float32Array([]), gl.STATIC_DRAW, 2);
555
+ this.cellPickingId[j][i] = rm.pickingManager.nextPickingId();
556
+ this.walkability[k] = 0;
557
+ continue;
558
+ }
559
+
560
+ const layers = Math.floor(cell.length / 3);
561
+
562
+ let cellVertices = [];
563
+ let cellTex = [];
564
+ let walk = Direction.All;
565
+
566
+ // Get height override for this cell if heights data exists
567
+ const heightOverride = this.heights && this.heights[j] && typeof this.heights[j][i] === 'number'
568
+ ? this.heights[j][i]
569
+ : null;
570
+
571
+ // Debug first few cells - show null/number for diagnostics
572
+ if (k < 5) {
573
+ console.log(`[Zone.finalize] Cell [${j},${i}] heightOverride:`, heightOverride);
574
+ }
575
+
576
+ for (let l = 0; l < layers; l++) {
577
+ const tileId = cell[3 * l];
578
+ const tileVariant = cell[3 * l + 1];
579
+ let z = cell[3 * l + 2];
580
+ if (typeof z !== 'number') z = 0;
581
+ const tilePos = [this.bounds[0] + i, this.bounds[1] + j, z];
582
+ walk &= this.tileset.getWalkability(tileId);
583
+
584
+ // Pass height override to getTileVertices
585
+ cellVertices = cellVertices.concat(
586
+ this.tileset.getTileVertices(tileId, tilePos, heightOverride)
587
+ );
588
+ cellTex = cellTex.concat(this.tileset.getTileTexCoords(tileId, tileVariant));
589
+ }
590
+
591
+ // override walkability if provided
592
+ if (cell.length === 3 * layers + 1) walk = cell[3 * layers];
593
+ this.walkability[k] = walk;
594
+
595
+ // GPU buffers
596
+ const vPos = rm.createBuffer(new Float32Array(cellVertices), gl.STATIC_DRAW, 3);
597
+ const vTex = rm.createBuffer(new Float32Array(cellTex), gl.STATIC_DRAW, 2);
598
+ this.cellVertexPosBuf[j][i] = vPos;
599
+ this.cellVertexTexBuf[j][i] = vTex;
600
+
601
+ // Picking ID packed as floats [0..1, 0..1, 0..1, 255]
602
+ this.cellPickingId[j][i] = [
603
+ (this.objId & 0xff) / 255,
604
+ (j & 0xff) / 255,
605
+ (i & 0xff) / 255,
606
+ 255,
607
+ ];
608
+ }
609
+ }
610
+ };
611
+
612
+ /**
613
+ * Loads scripts.
614
+ * @param {boolean} [refresh=false] - Whether to refresh.
615
+ */
616
+ loadScripts = (refresh = false) => {
617
+ if (this.world.isPaused) return;
618
+ // CRITICAL: Zone load scripts must run even when paused
619
+ // They are responsible for initializing the zone state
620
+ const zone = this;
621
+ for (const x of this.scripts) {
622
+ if (x.id === 'load-spritz' && refresh) {
623
+ // Call trigger immediately when loading/refreshing
624
+ try {
625
+ x.trigger.call(zone);
626
+ } catch (e) {
627
+ console.error('[Zone.loadScripts] Error calling load-spritz trigger:', e);
628
+ }
629
+ }
630
+ }
631
+ };
632
+
633
+ /**
634
+ * Loads an object.
635
+ * @param {object} data - The object data.
636
+ */
637
+ loadObject = async (data) => {
638
+ data.zone = this;
639
+ if (!this.objectDict[data.id]) {
640
+ const obj = await this.objectLoader.load(data, (o) => o.onLoad(o));
641
+ this.world.objectDict[data.id] = this.objectDict[data.id] = obj;
642
+ this.objectList.push(obj);
643
+ this.world.objectList.push(obj);
644
+ }
645
+ };
646
+
647
+ /**
648
+ * Loads an object from a zip archive.
649
+ * @param {object} data - The object data.
650
+ * @param {object} zip - The zip archive.
651
+ */
652
+ loadObjectFromZip = async (data, zip) => {
653
+ data.zone = this;
654
+ if (!this.objectDict[data.id]) {
655
+ const obj = await this.objectLoader.loadFromZip(zip, data, async (o) => o.onLoadFromZip(o, zip));
656
+ this.world.objectDict[data.id] = this.objectDict[data.id] = obj;
657
+ this.objectList.push(obj);
658
+ this.world.objectList.push(obj);
659
+ }
660
+ };
661
+
662
+ /**
663
+ * Loads a sprite.
664
+ * @param {object} data - The sprite data.
665
+ */
666
+ loadSprite = async (data) => {
667
+ data.zone = this;
668
+ if (!this.spriteDict[data.id]) {
669
+ const spr = await this.spriteLoader.load(data.type, this.spritzName, (s) => s.onLoad(data));
670
+ this.world.spriteDict[data.id] = this.spriteDict[data.id] = spr;
671
+ this.spriteList.push(spr);
672
+ this.world.spriteList.push(spr);
673
+ }
674
+ };
675
+
676
+ /**
677
+ * Loads a sprite from a zip archive.
678
+ * @param {object} data - The sprite data.
679
+ * @param {object} zip - The zip archive.
680
+ */
681
+ loadSpriteFromZip = async (data, zip) => {
682
+ data.zone = this;
683
+ if (!this.spriteDict[data.id]) {
684
+ const spr = await this.spriteLoader.loadFromZip(zip, data.type, this.spritzName, async (s) => s.onLoadFromZip(data, zip));
685
+ this.world.spriteDict[data.id] = this.spriteDict[data.id] = spr;
686
+ this.spriteList.push(spr);
687
+ this.world.spriteList.push(spr);
688
+ }
689
+ };
690
+
691
+ /**
692
+ * Adds a sprite.
693
+ * @param {object} sprite - The sprite.
694
+ */
695
+ addSprite = (sprite) => {
696
+ sprite.zone = this;
697
+ this.world.spriteDict[sprite.id] = this.spriteDict[sprite.id] = sprite;
698
+ this.spriteList.push(sprite);
699
+ this.world.spriteList.push(sprite);
700
+ };
701
+
702
+ /**
703
+ * Removes a sprite.
704
+ * @param {string} id - The sprite ID.
705
+ */
706
+ removeSprite = (id) => {
707
+ const keep = (s) => {
708
+ if (s.id !== id) return true; s.removeAllActions(); return false;
709
+ };
710
+ this.spriteList = this.spriteList.filter(keep);
711
+ this.world.spriteList = this.world.spriteList.filter(keep);
712
+ delete this.spriteDict[id];
713
+ delete this.world.spriteDict[id];
714
+ };
715
+
716
+ /**
717
+ * Removes all sprites.
718
+ */
719
+ removeAllSprites = () => {
720
+ for (const s of this.spriteList) this.removeSprite(s.id);
721
+ };
722
+
723
+ /**
724
+ * Gets a sprite by ID.
725
+ * @param {string} id - The sprite ID.
726
+ * @returns {object|null} The sprite.
727
+ */
728
+ getSpriteById = (id) => this.spriteDict[id];
729
+
730
+ /**
731
+ * Adds a portal.
732
+ * @param {object[]} sprites - The sprites.
733
+ * @param {number} x - The x position.
734
+ * @param {number} y - The y position.
735
+ * @returns {object[]} The sprites.
736
+ */
737
+ addPortal = (sprites, x, y) => {
738
+ if (!this.portals?.length) return sprites;
739
+ const h = this.getHeight(x, y);
740
+ if (h !== 0) return sprites;
741
+
742
+ const make = (portal) => { portal.pos = new Vector(x, y, h); sprites.push(portal); };
743
+ if (this.portals.length > 0) {
744
+ if (((x * y) % 3) === 0) make(this.portals.shift()); else make(this.portals.pop());
745
+ }
746
+ return sprites;
747
+ };
748
+
749
+ /**
750
+ * Gets the height at a position.
751
+ * @param {number} x - The x position.
752
+ * @param {number} y - The y position.
753
+ * @returns {number} The height.
754
+ */
755
+ getHeight = (x, y) => {
756
+ if (!this.isInZone(x, y)) {
757
+ if (this.engine?.debug) console.error(`Height out of bounds [${x}, ${y}]`);
758
+ return 0;
759
+ }
760
+
761
+ const i = Math.floor(x), j = Math.floor(y);
762
+ const dp0 = x - i, dp1 = y - j;
763
+
764
+ // index into cells
765
+ const idx = (j - this.bounds[1]) * this.size[0] + (i - this.bounds[0]);
766
+ const cell = this.cells[idx];
767
+ const n = Math.floor(cell.length / 3);
768
+
769
+ // Get height override from heights.json if it exists for this cell
770
+ const heightOverride = this.heights && this.heights[j - this.bounds[1]] && typeof this.heights[j - this.bounds[1]][i - this.bounds[0]] === 'number'
771
+ ? this.heights[j - this.bounds[1]][i - this.bounds[0]]
772
+ : null;
773
+
774
+ // local helper without allocations
775
+ const triUV = (t) => {
776
+ const ux = t[1][0] - t[0][0];
777
+ const uy = t[1][1] - t[0][1];
778
+ const vx = t[2][0] - t[0][0];
779
+ const vy = t[2][1] - t[0][1];
780
+ const d = 1 / (ux * vy - uy * vx);
781
+ const T0 = d * vy, T1 = -d * vx, T2 = -d * uy, T3 = d * ux;
782
+ const px = dp0 - t[0][0];
783
+ const py = dp1 - t[0][1];
784
+ return [px * T0 + py * T1, px * T2 + py * T3];
785
+ };
786
+
787
+ for (let l = 0; l < n; l++) {
788
+ const poly = this.tileset.getTileWalkPoly(cell[3 * l]);
789
+ if (!poly) continue;
790
+ const baseZ = (typeof cell[3 * l + 2] === 'number') ? cell[3 * l + 2] : 0;
791
+ // Add heightOverride to baseZ (heights.json is an offset, not a replacement)
792
+ const heightOffset = heightOverride !== null ? heightOverride : 0;
793
+
794
+ for (let p = 0; p < poly.length; p++) {
795
+ const uv = triUV(poly[p]);
796
+ const w = uv[0] + uv[1];
797
+ if (uv[0] >= 0 && uv[1] >= 0 && w <= 1) {
798
+ const t = poly[p];
799
+ const computed = baseZ + heightOffset + (1 - w) * t[0][2] + uv[0] * t[1][2] + uv[1] * t[2][2];
800
+ if (this.engine?.debug) {
801
+ this.__getHeightLogCount = (this.__getHeightLogCount || 0) + 1;
802
+ if (this.__getHeightLogCount < 4) console.log(`[Zone.getHeight] sample (x=${x},y=${y}) -> i=${i}, j=${j}, baseZ=${baseZ}, heightOffset=${heightOffset}, uv=[${uv[0].toFixed(2)},${uv[1].toFixed(2)}], w=${w.toFixed(2)}, computed=${computed.toFixed(2)}`);
803
+ }
804
+ return computed;
805
+ }
806
+ }
807
+ }
808
+
809
+ // No polygon matches - this shouldn't happen if walkPoly is properly defined
810
+ // Use the walkPoly itself for fallback by finding closest triangle
811
+ if (n > 0) {
812
+ const poly = this.tileset.getTileWalkPoly(cell[0]);
813
+ if (poly && poly.length > 0) {
814
+ const baseZ = (typeof cell[2] === 'number') ? cell[2] : 0;
815
+ const heightOffset = heightOverride !== null ? heightOverride : 0;
816
+ // Use first triangle's average as fallback
817
+ const t = poly[0];
818
+ const avgZ = baseZ + heightOffset + (t[0][2] + t[1][2] + t[2][2]) / 3;
819
+ if (this.engine?.debug) console.log(`[Zone.getHeight] walkPoly fallback for (${x},${y}), using avg of first tri = ${avgZ.toFixed(2)}`);
820
+ return avgZ;
821
+ }
822
+ }
823
+
824
+ // Final fallback: add heightOffset to cell base z
825
+ const baseZ = (typeof cell[2] === 'number') ? cell[2] : 0;
826
+ const heightOffset = heightOverride !== null ? heightOverride : 0;
827
+ return baseZ + heightOffset;
828
+ };
829
+
830
+ /**
831
+ * Draws a row.
832
+ * @param {number} row - The row.
833
+ * @param {Set<string>} selectedSet - The selected set.
834
+ * @param {number[]} highlight - The highlight.
835
+ * @param {object} rm - The render manager.
836
+ * @param {object} shaderProgram - The shader program.
837
+ * @param {object} pickerProgram - The picker program.
838
+ * @param {WebGLRenderingContext} gl - The WebGL context.
839
+ */
840
+ drawRow = (row, selectedSet, highlight, rm, shaderProgram, pickerProgram, gl) => {
841
+ // Guard: Check if row data exists
842
+ if (!this.cellVertexPosBuf || !this.cellVertexPosBuf[row]) {
843
+ return; // Skip row if not initialized
844
+ }
845
+
846
+ // Attach tileset once per row (sprites may switch textures between rows)
847
+ this.tileset.texture.attach();
848
+ const vPosRow = this.cellVertexPosBuf[row];
849
+ const vTexRow = this.cellVertexTexBuf[row];
850
+ const width = this.size[0];
851
+
852
+ // If we have cached picking IDs, use them without checks in the loop
853
+ const pickingRow = this.cellPickingId[row];
854
+
855
+ for (let cell = 0; cell < width; cell++) {
856
+ const vPos = vPosRow[cell];
857
+ const vTex = vTexRow[cell];
858
+
859
+ // Guard: Skip cells with no vertices (empty or failed tile lookup)
860
+ if (!vPos || !vTex || vPos.numItems === 0) {
861
+ continue;
862
+ }
863
+
864
+ rm.bindBuffer(vPos, shaderProgram.aVertexPosition);
865
+ rm.bindBuffer(vTex, shaderProgram.aTextureCoord);
866
+
867
+ const id = pickingRow[cell];
868
+ pickerProgram.setMatrixUniforms({ id });
869
+ shaderProgram.setMatrixUniforms({
870
+ id,
871
+ isSelected: selectedSet ? selectedSet.has(`${row},${cell}`) : false,
872
+ sampler: 1.0,
873
+ colorMultiplier: highlight,
874
+ });
875
+ gl.drawArrays(gl.TRIANGLES, 0, vPos.numItems);
876
+ if (rm.debug) rm.debug.tilesDrawn++;
877
+ }
878
+ };
879
+
880
+ /**
881
+ * Draws a cell.
882
+ * @param {number} row - The row.
883
+ * @param {number} cell - The cell.
884
+ */
885
+ drawCell = (row, cell) => {
886
+ const rm = this.engine.renderManager;
887
+ const gl = this.engine.gl;
888
+ const shader = rm.shaderProgram;
889
+ const picker = rm.effectPrograms['picker'];
890
+ const isPickerPass = rm.isPickerPass;
891
+
892
+ const vPos = this.cellVertexPosBuf[row][cell];
893
+ const vTex = this.cellVertexTexBuf[row][cell];
894
+ rm.bindBuffer(vPos, shader.aVertexPosition);
895
+ rm.bindBuffer(vTex, shader.aTextureCoord);
896
+
897
+ const id = this.cellPickingId[row][cell];
898
+
899
+ if (isPickerPass) {
900
+ // During picker pass, only set picker shader uniforms
901
+ picker.setMatrixUniforms({ id });
902
+ } else {
903
+ // During normal render, set main shader uniforms
904
+ shader.setMatrixUniforms({
905
+ id,
906
+ isSelected: !!this._selectedSet?.has(`${row},${cell}`),
907
+ sampler: 1.0,
908
+ colorMultiplier: this._highlight || [1, 1, 0, 1],
909
+ });
910
+ }
911
+ gl.drawArrays(gl.TRIANGLES, 0, vPos.numItems);
912
+ };
913
+
914
+ /**
915
+ * Draws the zone.
916
+ */
917
+ draw = () => {
918
+ if (!this.loaded) return;
919
+
920
+ const rm = this.engine.renderManager;
921
+ const gl = this.engine.gl;
922
+ const shaderProgram = rm.shaderProgram;
923
+ const pickerProgram = rm.effectPrograms['picker'];
924
+
925
+ // Build selected set once per frame
926
+ const sel = this.selectedTiles;
927
+ this.selectedSet = (sel && sel.length) ? new Set(sel.map((t) => `${t[0]},${t[1]}`)) : null;
928
+ this.highlight = (this.engine.frameCount & 0x8) ? [1, 0, 0, 1] : [1, 1, 0, 1];
929
+
930
+ // look into this
931
+ const ensureSortedByY = (arr) => {
932
+ for (let i = 1; i < arr.length; i++) if (arr[i - 1].pos.y > arr[i].pos.y) { arr.sort((a, b) => a.pos.y - b.pos.y); break; }
933
+ };
934
+ ensureSortedByY(this.spriteList);
935
+ ensureSortedByY(this.objectList);
936
+
937
+ rm.mvPushMatrix();
938
+ // Do not reinitialize the camera when FreeCam is active — FreeCam edits camera.uViewMat directly
939
+ if (!this.engine._freecamActive) rm.camera.setCamera();
940
+
941
+ let si = 0; // sprite index
942
+ let oi = 0; // object index
943
+
944
+ // Need to update to handle the different directions (there are some issues with clipping on other angles)
945
+ const drawForward = this.engine.renderManager.camera.cameraDir === 'N' ||
946
+ this.engine.renderManager.camera.cameraDir === 'NE' ||
947
+ this.engine.renderManager.camera.cameraDir === 'NW' ||
948
+ this.engine.renderManager.camera.cameraDir === 'E';
949
+
950
+ if (drawForward) {
951
+ for (let j = 0; j < this.size[1]; j++) {
952
+ this.drawRow(j, this.selectedSet, this.highlight, rm, shaderProgram, pickerProgram, gl);
953
+ while (oi < this.objectList.length && (this.objectList[oi].pos.y - this.bounds[1]) <= j) this.objectList[oi++].draw();
954
+ while (si < this.spriteList.length && (this.spriteList[si].pos.y - this.bounds[1]) <= j) this.spriteList[si++].draw(this.engine);
955
+ }
956
+ } else {
957
+ for (let j = this.size[1] - 1; j >= 0; j--) {
958
+ this.drawRow(j, this.selectedSet, this.highlight, rm, shaderProgram, pickerProgram, gl);
959
+ while (oi < this.objectList.length && (this.bounds[1] - this.objectList[oi].pos.y) <= j) this.objectList[oi++].draw();
960
+ while (si < this.spriteList.length && (this.bounds[1] - this.spriteList[si].pos.y) <= j) this.spriteList[si++].draw(this.engine);
961
+ }
962
+ }
963
+
964
+ while (oi < this.objectList.length) this.objectList[oi++].draw();
965
+ while (si < this.spriteList.length) this.spriteList[si++].draw(this.engine);
966
+
967
+ rm.mvPopMatrix();
968
+ };
969
+
970
+ /**
971
+ * Ticks the zone.
972
+ * @param {number} time - The time.
973
+ * @param {boolean} isPaused - Whether paused.
974
+ */
975
+ tick = (time, isPaused) => {
976
+ if (!this.loaded || isPaused) return;
977
+ this.checkInput(time);
978
+ for (const s of this.spriteList) s.tickOuter(time);
979
+ };
980
+
981
+ /**
982
+ * Checks input.
983
+ * @param {number} time - The time.
984
+ */
985
+ checkInput = async (time) => {
986
+ if (time <= this.lastKey + 200) return;
987
+ this.engine.gamepad.checkInput();
988
+ this.lastKey = time;
989
+ // todo - look into hooks - game modes (allow for scripting keymaps)
990
+ };
991
+
992
+ /**
993
+ * Checks if a position is in the zone.
994
+ * @param {number} x - The x position.
995
+ * @param {number} y - The y position.
996
+ * @returns {boolean} Whether in zone.
997
+ */
998
+ isInZone = (x, y) => (x >= this.bounds[0] && y >= this.bounds[1] && x < this.bounds[2] && y < this.bounds[3]);
999
+
1000
+ /**
1001
+ * Handles selection.
1002
+ * @param {number} row - The row.
1003
+ * @param {number} cell - The cell.
1004
+ */
1005
+ onSelect = async (row, cell) => {
1006
+ // allow active mode to intercept selection
1007
+ try {
1008
+ if (this.world?.modeManager && this.world.modeManager.handleSelect) {
1009
+ debug('Zone', 'Running Custom Select Handler')
1010
+ const handled = await this.world.modeManager.handleSelect(this, row, cell, 'tile');
1011
+ if (handled) return; // mode consumed selection
1012
+ }
1013
+ } catch (e) { console.warn('mode selection handler error', e); }
1014
+ // toggle select
1015
+ let removed = false;
1016
+ this.selectedTiles = this.selectedTiles.filter((t) => {
1017
+ const keep = !(t[0] === row && t[1] === cell);
1018
+ if (!keep) removed = true;
1019
+ return keep;
1020
+ });
1021
+ if (removed) return;
1022
+
1023
+ this.selectedTiles.push([row, cell]);
1024
+ if (!this.selectTrigger) return;
1025
+
1026
+ // Lua trigger from spritz zip
1027
+ try {
1028
+ let file = this.engine.spritz.zip.file(`triggers/${this.selectTrigger}.pxs`);
1029
+ if (!file) file = this.engine.spritz.zip.file(`triggers/${this.selectTrigger}.pxs`);
1030
+ if (!file) throw new Error('No Lua Script Found');
1031
+ const luaScript = await file.async('string');
1032
+ const interpreter = new PixoScriptInterpreter(this.engine);
1033
+ interpreter.setScope({ _this: this, zone: this, subject: new interpreter.pxs.Table([row, cell]) });
1034
+ interpreter.initLibrary();
1035
+ return await interpreter.run(luaScript);
1036
+ } catch (e) {
1037
+ if (this.engine?.debug) console.warn('select trigger missing', e);
1038
+ }
1039
+ };
1040
+
1041
+ /**
1042
+ * Checks if walkable.
1043
+ * @param {number} x - The x position.
1044
+ * @param {number} y - The y position.
1045
+ * @param {number} direction - The direction.
1046
+ * @returns {boolean|null} Whether walkable.
1047
+ */
1048
+ isWalkable = (x, y, direction) => {
1049
+ if (!this.isInZone(x, y)) return null;
1050
+
1051
+ // sprites (values, not keys)
1052
+ for (const sId in this.spriteDict) {
1053
+ const s = this.spriteDict[sId];
1054
+ if (s.pos.x !== x || s.pos.y !== y) continue;
1055
+ if (!s.walkable && !s.blocking && s.override) return true; // bypass/override
1056
+ if (!s.walkable && s.blocking) return false; // blocking
1057
+ }
1058
+
1059
+ // objects (AABB-lite checks)
1060
+ for (const oId in this.objectDict) {
1061
+ const o = this.objectDict[oId];
1062
+ const minX = o.pos.x - o.scale.x * (o.size.x / 2);
1063
+ const minY = o.pos.y - o.scale.y * (o.size.y / 2);
1064
+ const withinX = (xx, a, b, inc = false) => (inc ? (xx >= a && xx <= b) : (xx > a && xx < b));
1065
+ const xHit = withinX(x, minX, o.pos.x, true);
1066
+ const yHit = withinX(y, minY, o.pos.y, true);
1067
+
1068
+ if (!o.walkable && xHit && yHit && !o.blocking && o.override) return true;
1069
+ if (!o.walkable && ((o.pos.x === x && o.pos.y === y) || (xHit && yHit)) && o.blocking) return false;
1070
+ }
1071
+
1072
+ // tile walkability
1073
+ return (this.walkability[(y - this.bounds[1]) * this.size[0] + (x - this.bounds[0])] & direction) !== 0;
1074
+ };
1075
+
1076
+ /**
1077
+ * Checks if within range.
1078
+ * @param {number} x - The value.
1079
+ * @param {number} a - The min.
1080
+ * @param {number} b - The max.
1081
+ * @param {boolean} [include=false] - Whether inclusive.
1082
+ * @returns {boolean} Whether within.
1083
+ */
1084
+ within = (x, a, b, include = false) => (include ? (x >= a && x <= b) : (x > a && x < b));
1085
+
1086
+ /**
1087
+ * Triggers a script.
1088
+ * @param {string} id - The script ID.
1089
+ */
1090
+ triggerScript = (id) => {
1091
+ for (const x of this.scripts) if (x.id === id) this.runWhenLoaded(x.trigger.bind(this));
1092
+ };
1093
+
1094
+ /**
1095
+ * Moves a sprite.
1096
+ * @param {string} id - The sprite ID.
1097
+ * @param {number[]} location - The location.
1098
+ * @param {boolean} [running=false] - Whether running.
1099
+ * @returns {Promise} The promise.
1100
+ */
1101
+ moveSprite = async (id, location, running = false) => new Promise(async (resolve) => {
1102
+ const sprite = this.getSpriteById(id);
1103
+ await sprite.addAction(new ActionLoader(this.engine, 'patrol', [sprite.pos.toArray(), location, running ? 200 : 600, this], sprite, resolve));
1104
+ });
1105
+
1106
+ /**
1107
+ * Shows sprite dialogue.
1108
+ * @param {string} id - The sprite ID.
1109
+ * @param {string} dialogue - The dialogue.
1110
+ * @param {object} [options={ autoclose: true }] - The options.
1111
+ * @returns {Promise} The promise.
1112
+ */
1113
+ spriteDialogue = async (id, dialogue, options = { autoclose: true }) => new Promise(async (resolve) => {
1114
+ const sprite = this.getSpriteById(id);
1115
+ await sprite.addAction(new ActionLoader(this.engine, 'dialogue', [dialogue, false, options], sprite, resolve));
1116
+ });
1117
+
1118
+ /**
1119
+ * Runs actions.
1120
+ * @param {object[]} actions - The actions.
1121
+ * @returns {Promise} The promise.
1122
+ */
1123
+ runActions = async (actions) => {
1124
+ const scope = this;
1125
+ let p = Promise.resolve();
1126
+ for (const action of actions) {
1127
+ p = p.then(async () => {
1128
+ if (!action) return;
1129
+ try {
1130
+ action.scope = action.scope || scope;
1131
+ if (action.sprite) {
1132
+ const sprite = action.scope.getSpriteById(action.sprite);
1133
+ if (sprite && action.action) {
1134
+ const args = [...action.args];
1135
+ const options = args.pop();
1136
+ await sprite.addAction(new ActionLoader(scope.engine, action.action, [...args, { ...options }], sprite, () => { }));
1137
+ }
1138
+ }
1139
+ if (action.trigger) {
1140
+ const avatar = action.scope.getSpriteById('avatar');
1141
+ if (avatar) await avatar.addAction(new ActionLoader(scope.engine, 'script', [action.trigger, action.scope, () => { }], avatar));
1142
+ }
1143
+ } catch (e) {
1144
+ console.warn('runActions error', e?.message || e);
1145
+ }
1146
+ });
1147
+ }
1148
+ return p.catch((err) => { if (this.engine?.debug) console.warn('runActions chain', err); });
1149
+ };
1150
+
1151
+ /**
1152
+ * Plays a cutscene.
1153
+ * @param {string} id - The cutscene ID.
1154
+ * @param {object} [spritz=null] - The spritz.
1155
+ * @returns {Promise} The promise.
1156
+ */
1157
+ playCutScene = async (id, spritz = null) => {
1158
+ const seq = spritz || this.spritz;
1159
+ for (const x of seq) {
1160
+ try {
1161
+ x.currentStep = x.currentStep || 0;
1162
+ if (x.currentStep > seq.length) continue;
1163
+ if (x.id === id) await this.runActions(x.actions);
1164
+ } catch (e) { console.error(e); }
1165
+ }
1166
+ };
1167
+ }