nova64 0.2.4 → 0.2.6

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 (140) hide show
  1. package/README.md +25 -8
  2. package/bin/nova64.js +165 -0
  3. package/dist/assets/console-CY_kygm3.js +14 -0
  4. package/dist/assets/console-CY_kygm3.js.map +1 -0
  5. package/dist/assets/main-l0sNRNKZ.js.map +1 -0
  6. package/dist/assets/sky/studio/nx.png +0 -0
  7. package/dist/assets/sky/studio/ny.png +0 -0
  8. package/dist/assets/sky/studio/nz.png +0 -0
  9. package/dist/assets/sky/studio/px.png +0 -0
  10. package/dist/assets/sky/studio/py.png +0 -0
  11. package/dist/assets/sky/studio/pz.png +0 -0
  12. package/dist/assets/vanilla-Dcuy32gi.js +2 -0
  13. package/dist/assets/vanilla-Dcuy32gi.js.map +1 -0
  14. package/dist/console.html +899 -0
  15. package/dist/docs/BENCHMARK.md +77 -0
  16. package/dist/docs/CHEATSHEET.md +255 -0
  17. package/dist/docs/EFFECTS_API_GUIDE.md +577 -0
  18. package/dist/docs/EFFECTS_QUICK_REFERENCE.md +331 -0
  19. package/dist/docs/FONT_CHARACTER_REFERENCE.md +219 -0
  20. package/dist/docs/FREE_GLB_ASSETS.md +330 -0
  21. package/dist/docs/FULLSCREEN_BUTTON_FEATURE.md +296 -0
  22. package/dist/docs/GAMEPAD_SUPPORT.md +348 -0
  23. package/dist/docs/GAME_IMPROVEMENTS.md +278 -0
  24. package/dist/docs/GAME_QUALITY_STATUS.md +300 -0
  25. package/dist/docs/MIGRATION_GUIDE.md +553 -0
  26. package/dist/docs/NOVA64_3D_API.md +356 -0
  27. package/dist/docs/NOVA64_API_REFERENCE.md +1406 -0
  28. package/dist/docs/NOVA64_UI_API.md +503 -0
  29. package/dist/docs/UI_SYSTEM_SUMMARY.md +445 -0
  30. package/dist/docs/VOXEL_ENGINE_GUIDE.md +662 -0
  31. package/dist/docs/VOXEL_QUICK_REFERENCE.md +386 -0
  32. package/dist/docs/api-3d.html +750 -0
  33. package/dist/docs/api-effects.html +385 -0
  34. package/dist/docs/api-improvements.md +121 -0
  35. package/dist/docs/api-skybox.html +407 -0
  36. package/dist/docs/api-sprites.html +321 -0
  37. package/dist/docs/api-voxel.html +337 -0
  38. package/dist/docs/api.html +543 -0
  39. package/dist/docs/assets.html +306 -0
  40. package/dist/docs/audio.html +340 -0
  41. package/dist/docs/blogs.html +286 -0
  42. package/dist/docs/collision.html +316 -0
  43. package/dist/docs/console.html +247 -0
  44. package/dist/docs/editor.html +297 -0
  45. package/dist/docs/font.html +247 -0
  46. package/dist/docs/framebuffer.html +247 -0
  47. package/dist/docs/fullscreen-button.html +297 -0
  48. package/dist/docs/gpu-systems.html +247 -0
  49. package/dist/docs/index.html +580 -0
  50. package/dist/docs/input.html +491 -0
  51. package/dist/docs/physics.html +311 -0
  52. package/dist/docs/screens.html +311 -0
  53. package/dist/docs/storage.html +311 -0
  54. package/dist/docs/textinput.html +332 -0
  55. package/dist/docs/ui.html +488 -0
  56. package/dist/examples/3d-advanced/code.js +695 -0
  57. package/dist/examples/adventure-comic-3d/code.js +342 -0
  58. package/dist/examples/audio-lab/code.js +150 -0
  59. package/dist/examples/boids-flocking/code.js +270 -0
  60. package/dist/examples/crystal-cathedral-3d/code.js +706 -0
  61. package/dist/examples/cyberpunk-city-3d/code.js +1383 -0
  62. package/dist/examples/demoscene/README.md +192 -0
  63. package/dist/examples/demoscene/code.js +1081 -0
  64. package/dist/examples/demoscene/meta.json +21 -0
  65. package/dist/examples/dungeon-crawler-3d/code.js +1117 -0
  66. package/dist/examples/f-zero-nova-3d/code.js +865 -0
  67. package/dist/examples/f-zero-nova-3d/code_old.js +1555 -0
  68. package/dist/examples/fps-demo-3d/code.js +744 -0
  69. package/dist/examples/game-of-life-3d/code.js +338 -0
  70. package/dist/examples/generative-art/code.js +632 -0
  71. package/dist/examples/hello-3d/code.js +325 -0
  72. package/dist/examples/hello-skybox/code.js +183 -0
  73. package/dist/examples/hello-world/code.js +19 -0
  74. package/dist/examples/input-showcase/code.js +109 -0
  75. package/dist/examples/instancing-demo/code.js +315 -0
  76. package/dist/examples/minecraft-demo/code.js +387 -0
  77. package/dist/examples/model-viewer-3d/code.js +114 -0
  78. package/dist/examples/mystical-realm-3d/code.js +1203 -0
  79. package/dist/examples/nature-explorer-3d/code.js +1318 -0
  80. package/dist/examples/particles-demo/code.js +522 -0
  81. package/dist/examples/pbr-showcase/code.js +140 -0
  82. package/dist/examples/physics-demo-3d/code.js +948 -0
  83. package/dist/examples/screen-demo/code.js +267 -0
  84. package/dist/examples/shooter-demo-3d/code.js +1286 -0
  85. package/dist/examples/space-combat-3d/IMPLEMENTATION_SUMMARY.md +109 -0
  86. package/dist/examples/space-combat-3d/README.md +135 -0
  87. package/dist/examples/space-combat-3d/code.js +1332 -0
  88. package/dist/examples/space-harrier-3d/code.js +923 -0
  89. package/dist/examples/star-fox-nova-3d/code.js +1116 -0
  90. package/dist/examples/star-fox-nova-3d/code_backup.js +410 -0
  91. package/dist/examples/star-fox-nova-3d/code_broken.js +1821 -0
  92. package/dist/examples/storage-quest/code.js +209 -0
  93. package/dist/examples/strider-demo-3d/IMPROVEMENT_OPTIONS.md +285 -0
  94. package/dist/examples/strider-demo-3d/cache-test.html +132 -0
  95. package/dist/examples/strider-demo-3d/code-fixed.js +582 -0
  96. package/dist/examples/strider-demo-3d/code-old.js +1537 -0
  97. package/dist/examples/strider-demo-3d/code.js +1462 -0
  98. package/dist/examples/strider-demo-3d/code.js.bak2 +1169 -0
  99. package/dist/examples/strider-demo-3d/fix-game.sh +53 -0
  100. package/dist/examples/super-plumber-64/README.md +128 -0
  101. package/dist/examples/super-plumber-64/code.js +1185 -0
  102. package/dist/examples/super-plumber-64/index.html +88 -0
  103. package/dist/examples/test-2d-overlay/code.js +32 -0
  104. package/dist/examples/test-font/code.js +51 -0
  105. package/dist/examples/test-minimal/code.js +21 -0
  106. package/dist/examples/ui-demo/code.js +306 -0
  107. package/dist/examples/wing-commander-space/README.md +180 -0
  108. package/dist/examples/wing-commander-space/code.js +1285 -0
  109. package/dist/examples/wizardry-3d/CHANGELOG.md +366 -0
  110. package/dist/examples/wizardry-3d/code.js +3928 -0
  111. package/dist/index.html +666 -0
  112. package/dist/os9-shell/assets/index-DIHfrTaW.css +1 -0
  113. package/dist/os9-shell/assets/index-KchE_ngx.js +483 -0
  114. package/dist/os9-shell/assets/index-KchE_ngx.js.map +1 -0
  115. package/dist/os9-shell/index.html +23 -0
  116. package/dist/os9-shell/nova-icon.svg +12 -0
  117. package/index.html +6 -1
  118. package/package.json +37 -32
  119. package/public/assets/sky/studio/nx.png +0 -0
  120. package/public/assets/sky/studio/ny.png +0 -0
  121. package/public/assets/sky/studio/nz.png +0 -0
  122. package/public/assets/sky/studio/px.png +0 -0
  123. package/public/assets/sky/studio/py.png +0 -0
  124. package/public/assets/sky/studio/pz.png +0 -0
  125. package/public/os9-shell/assets/index-KchE_ngx.js +483 -0
  126. package/public/os9-shell/assets/index-KchE_ngx.js.map +1 -0
  127. package/public/os9-shell/index.html +10 -1
  128. package/runtime/api-2d.js +301 -21
  129. package/runtime/api-3d/pbr.js +45 -1
  130. package/runtime/api-3d.js +1 -0
  131. package/runtime/api-effects.js +90 -3
  132. package/runtime/api-gameutils.js +476 -0
  133. package/runtime/api-generative.js +610 -0
  134. package/runtime/api-skybox.js +54 -0
  135. package/runtime/api-voxel.js +139 -28
  136. package/runtime/gpu-threejs.js +13 -9
  137. package/runtime/ui.js +2 -2
  138. package/src/main.js +24 -1
  139. package/public/os9-shell/assets/index-B1Uvacma.js +0 -32825
  140. package/public/os9-shell/assets/index-B1Uvacma.js.map +0 -1
@@ -0,0 +1,1462 @@
1
+ // ========================================================================
2
+ // GAUNTLET 64 — Isometric Action RPG
3
+ // Top-down hack-and-slash through procedural forests, crypts, and ruins
4
+ // Inspired by Gauntlet, Diablo, and Hades
5
+ // ========================================================================
6
+
7
+ const W = 640,
8
+ H = 360;
9
+ const TILE = 2; // world-space tile size
10
+ const MAP_W = 32; // tiles wide
11
+ const MAP_H = 32; // tiles tall
12
+ const ISO_ANGLE = 0.7; // camera pitch (radians-ish, used for placement)
13
+
14
+ // Tile types: 0=floor, 1=wall, 2=tree, 3=water, 4=exit, 5=spawner
15
+ const T_FLOOR = 0,
16
+ T_WALL = 1,
17
+ T_TREE = 2,
18
+ T_WATER = 3,
19
+ T_EXIT = 4,
20
+ T_SPAWNER = 5;
21
+
22
+ // Gameplay
23
+ const PLAYER_SPD = 9;
24
+ const PLAYER_ATK_CD = 0.25;
25
+ const PLAYER_ATK_RANGE = 2.2;
26
+ const PLAYER_ATK_ARC = Math.PI * 0.6;
27
+ const ENEMY_SPD = 3;
28
+ const DASH_SPD = 28;
29
+ const DASH_DUR = 0.15;
30
+ const DASH_CD = 0.6;
31
+
32
+ // ---- STATE ----
33
+ let state, t, level;
34
+ let map, mapMeshes, treeMeshes, waterMeshes;
35
+ let player, enemies, pickups, projectiles, particles, floats;
36
+ let playerProjectiles;
37
+ let exitPos;
38
+ let score, kills, totalKills;
39
+ let camX, camZ;
40
+ let spawnTimers;
41
+ let shake;
42
+ let cooldowns;
43
+ let playerHit;
44
+ let boss; // current boss reference (null if no boss)
45
+
46
+ // player classes
47
+ const CLASSES = [
48
+ {
49
+ name: 'WARRIOR',
50
+ color: 0x4466cc,
51
+ hp: 120,
52
+ atk: 18,
53
+ spd: 1.0,
54
+ atkRange: 2.4,
55
+ desc: 'High HP, strong melee',
56
+ },
57
+ {
58
+ name: 'VALKYRIE',
59
+ color: 0xcc44aa,
60
+ hp: 100,
61
+ atk: 14,
62
+ spd: 1.15,
63
+ atkRange: 2.2,
64
+ desc: 'Fast and balanced',
65
+ },
66
+ {
67
+ name: 'WIZARD',
68
+ color: 0x8844cc,
69
+ hp: 70,
70
+ atk: 22,
71
+ spd: 0.9,
72
+ atkRange: 3.0,
73
+ ranged: true,
74
+ projColor: 0xbb66ff,
75
+ projSpd: 16,
76
+ desc: 'Powerful ranged magic',
77
+ },
78
+ {
79
+ name: 'ELF',
80
+ color: 0x44cc66,
81
+ hp: 85,
82
+ atk: 12,
83
+ spd: 1.3,
84
+ atkRange: 2.8,
85
+ ranged: true,
86
+ projColor: 0x88ff44,
87
+ projSpd: 22,
88
+ desc: 'Fastest, ranged arrows',
89
+ },
90
+ ];
91
+ let classIdx = 0;
92
+
93
+ // Environment themes per level
94
+ const THEMES = [
95
+ {
96
+ name: 'ENCHANTED FOREST',
97
+ floor: 0x3a6b3a,
98
+ wall: 0x5a4030,
99
+ fog: 0x336644,
100
+ sky: 0x88bbaa,
101
+ treeCol: 0x2a7a2a,
102
+ accent: 0x55cc77,
103
+ },
104
+ {
105
+ name: 'DARK CRYPTS',
106
+ floor: 0x3a3a4a,
107
+ wall: 0x555566,
108
+ fog: 0x222233,
109
+ sky: 0x334455,
110
+ treeCol: 0x444455,
111
+ accent: 0x8888cc,
112
+ },
113
+ {
114
+ name: 'BURNING RUINS',
115
+ floor: 0x4a3020,
116
+ wall: 0x6a3a20,
117
+ fog: 0x442200,
118
+ sky: 0x663300,
119
+ treeCol: 0x553311,
120
+ accent: 0xff6633,
121
+ },
122
+ {
123
+ name: 'FROZEN WASTES',
124
+ floor: 0x667788,
125
+ wall: 0x8899aa,
126
+ fog: 0x556677,
127
+ sky: 0xaabbcc,
128
+ treeCol: 0x99aacc,
129
+ accent: 0x44ddff,
130
+ },
131
+ {
132
+ name: 'DEMON REALM',
133
+ floor: 0x3a1a2a,
134
+ wall: 0x5a2244,
135
+ fog: 0x330022,
136
+ sky: 0x440033,
137
+ treeCol: 0x551133,
138
+ accent: 0xff2266,
139
+ },
140
+ ];
141
+
142
+ function theme() {
143
+ return THEMES[Math.min(level, THEMES.length - 1)];
144
+ }
145
+
146
+ // ========================================================================
147
+ // INIT
148
+ // ========================================================================
149
+ export function init() {
150
+ state = 'classSelect';
151
+ t = 0;
152
+ level = 0;
153
+ score = 0;
154
+ kills = 0;
155
+ totalKills = 0;
156
+ classIdx = 0;
157
+ shake = createShake();
158
+
159
+ // Set up isometric camera
160
+ setCameraFOV(45);
161
+ setAmbientLight(0xffffff, 0.6);
162
+ setLightDirection(-0.5, -1, -0.3);
163
+ enableBloom(0.5, 0.4, 0.4);
164
+ enableVignette(1.1, 0.9);
165
+ setFog(0x336644, 20, 60);
166
+
167
+ // Placeholder camera while on menus
168
+ setCameraPosition(16, 30, 40);
169
+ setCameraTarget(16, 0, 16);
170
+ }
171
+
172
+ function startLevel() {
173
+ state = 'playing';
174
+ enemies = [];
175
+ pickups = [];
176
+ projectiles = [];
177
+ playerProjectiles = [];
178
+ particles = [];
179
+ floats = createFloatingTextSystem();
180
+ spawnTimers = [];
181
+
182
+ generateMap();
183
+ buildMapMeshes();
184
+ createPlayer();
185
+ cooldowns = createCooldownSet({ dash: DASH_CD, attack: PLAYER_ATK_CD });
186
+ playerHit = createHitState({ invulnDuration: 0.8, blinkRate: 25 });
187
+ spawnEnemies();
188
+
189
+ // Boss on every 3rd floor (floor 3, 6, 9...)
190
+ boss = null;
191
+ if ((level + 1) % 3 === 0) {
192
+ spawnBoss();
193
+ }
194
+
195
+ const th = theme();
196
+ setFog(th.fog, 15, 50);
197
+ }
198
+
199
+ // ========================================================================
200
+ // MAP GENERATION
201
+ // ========================================================================
202
+ function generateMap() {
203
+ map = [];
204
+ for (let i = 0; i < MAP_W * MAP_H; i++) map[i] = T_FLOOR;
205
+
206
+ // Border walls
207
+ for (let x = 0; x < MAP_W; x++) {
208
+ setTile(x, 0, T_WALL);
209
+ setTile(x, MAP_H - 1, T_WALL);
210
+ }
211
+ for (let y = 0; y < MAP_H; y++) {
212
+ setTile(0, y, T_WALL);
213
+ setTile(MAP_W - 1, y, T_WALL);
214
+ }
215
+
216
+ // Generate rooms with corridors (BSP-like)
217
+ const rooms = [];
218
+ const roomCount = 5 + Math.min(level, 5);
219
+
220
+ // Fill interior with walls first, then carve rooms
221
+ for (let x = 1; x < MAP_W - 1; x++) for (let y = 1; y < MAP_H - 1; y++) setTile(x, y, T_WALL);
222
+
223
+ for (let i = 0; i < roomCount; i++) {
224
+ const rw = 4 + Math.floor(Math.random() * 5);
225
+ const rh = 4 + Math.floor(Math.random() * 5);
226
+ const rx = 2 + Math.floor(Math.random() * (MAP_W - rw - 4));
227
+ const ry = 2 + Math.floor(Math.random() * (MAP_H - rh - 4));
228
+ rooms.push({
229
+ x: rx,
230
+ y: ry,
231
+ w: rw,
232
+ h: rh,
233
+ cx: rx + Math.floor(rw / 2),
234
+ cy: ry + Math.floor(rh / 2),
235
+ });
236
+ for (let x = rx; x < rx + rw; x++) for (let y = ry; y < ry + rh; y++) setTile(x, y, T_FLOOR);
237
+ }
238
+
239
+ // Connect rooms with corridors
240
+ for (let i = 1; i < rooms.length; i++) {
241
+ const a = rooms[i - 1],
242
+ b = rooms[i];
243
+ let cx = a.cx,
244
+ cy = a.cy;
245
+ while (cx !== b.cx) {
246
+ setTile(cx, cy, T_FLOOR);
247
+ cx += cx < b.cx ? 1 : -1;
248
+ }
249
+ while (cy !== b.cy) {
250
+ setTile(cx, cy, T_FLOOR);
251
+ cy += cy < b.cy ? 1 : -1;
252
+ }
253
+ }
254
+
255
+ // Scatter trees/obstacles in open areas
256
+ const treeDensity = level < 2 ? 0.08 : 0.05;
257
+ for (let x = 2; x < MAP_W - 2; x++) {
258
+ for (let y = 2; y < MAP_H - 2; y++) {
259
+ if (getTile(x, y) === T_FLOOR && Math.random() < treeDensity) {
260
+ setTile(x, y, T_TREE);
261
+ }
262
+ }
263
+ }
264
+
265
+ // Place water patches
266
+ const waterPatches = 1 + Math.floor(Math.random() * 2);
267
+ for (let i = 0; i < waterPatches; i++) {
268
+ const wx = 4 + Math.floor(Math.random() * (MAP_W - 8));
269
+ const wy = 4 + Math.floor(Math.random() * (MAP_H - 8));
270
+ const ws = 2 + Math.floor(Math.random() * 3);
271
+ for (let dx = 0; dx < ws; dx++)
272
+ for (let dy = 0; dy < ws; dy++)
273
+ if (getTile(wx + dx, wy + dy) === T_FLOOR) setTile(wx + dx, wy + dy, T_WATER);
274
+ }
275
+
276
+ // Player start = center of first room
277
+ // Exit = center of last room
278
+ const startRoom = rooms[0];
279
+ const endRoom = rooms[rooms.length - 1];
280
+
281
+ // Clear space around start
282
+ for (let dx = -1; dx <= 1; dx++)
283
+ for (let dy = -1; dy <= 1; dy++) setTile(startRoom.cx + dx, startRoom.cy + dy, T_FLOOR);
284
+
285
+ exitPos = { x: endRoom.cx, y: endRoom.cy };
286
+ setTile(exitPos.x, exitPos.y, T_EXIT);
287
+
288
+ // Place spawners in rooms (not first room)
289
+ for (let i = 1; i < rooms.length; i++) {
290
+ if (Math.random() < 0.6 + level * 0.05) {
291
+ const r = rooms[i];
292
+ const sx = r.x + 1 + Math.floor(Math.random() * (r.w - 2));
293
+ const sy = r.y + 1 + Math.floor(Math.random() * (r.h - 2));
294
+ if (getTile(sx, sy) === T_FLOOR && !(sx === exitPos.x && sy === exitPos.y)) {
295
+ setTile(sx, sy, T_SPAWNER);
296
+ }
297
+ }
298
+ }
299
+
300
+ player = { tx: startRoom.cx, ty: startRoom.cy };
301
+ }
302
+
303
+ function setTile(x, y, v) {
304
+ if (x >= 0 && x < MAP_W && y >= 0 && y < MAP_H) map[y * MAP_W + x] = v;
305
+ }
306
+ function getTile(x, y) {
307
+ return x >= 0 && x < MAP_W && y >= 0 && y < MAP_H ? map[y * MAP_W + x] : T_WALL;
308
+ }
309
+
310
+ function tileBlocking(x, y) {
311
+ const t2 = getTile(x, y);
312
+ return t2 === T_WALL || t2 === T_TREE || t2 === T_WATER;
313
+ }
314
+
315
+ // ========================================================================
316
+ // BUILD 3D MESHES
317
+ // ========================================================================
318
+ function buildMapMeshes() {
319
+ // Clean up old meshes
320
+ if (mapMeshes) mapMeshes.forEach(m => destroyMesh(m));
321
+ if (treeMeshes) treeMeshes.forEach(m => destroyMesh(m));
322
+ if (waterMeshes) waterMeshes.forEach(m => destroyMesh(m));
323
+ mapMeshes = [];
324
+ treeMeshes = [];
325
+ waterMeshes = [];
326
+
327
+ const th = theme();
328
+
329
+ // Ground plane
330
+ const groundSize = MAP_W * TILE;
331
+ const gm = createPlane(groundSize, groundSize, th.floor, [groundSize / 2, 0, groundSize / 2]);
332
+ setRotation(gm, -Math.PI / 2, 0, 0);
333
+ mapMeshes.push(gm);
334
+
335
+ for (let x = 0; x < MAP_W; x++) {
336
+ for (let y = 0; y < MAP_H; y++) {
337
+ const tile = getTile(x, y);
338
+ const wx = x * TILE + TILE / 2;
339
+ const wz = y * TILE + TILE / 2;
340
+
341
+ if (tile === T_WALL) {
342
+ const h = 2 + Math.random() * 1.5;
343
+ const m = createCube(TILE, th.wall, [wx, h / 2, wz]);
344
+ setScale(m, 1, h / TILE, 1);
345
+ mapMeshes.push(m);
346
+ } else if (tile === T_TREE) {
347
+ // Trunk
348
+ const trunk = createCylinder(0.2, 2, 0x553311, [wx, 1, wz]);
349
+ treeMeshes.push(trunk);
350
+ // Canopy
351
+ const canopy = createSphere(1.2 + Math.random() * 0.5, th.treeCol, [
352
+ wx,
353
+ 2.5 + Math.random() * 0.5,
354
+ wz,
355
+ ]);
356
+ treeMeshes.push(canopy);
357
+ } else if (tile === T_WATER) {
358
+ const m = createPlane(TILE, TILE, 0x2255aa, [wx, 0.05, wz]);
359
+ setRotation(m, -Math.PI / 2, 0, 0);
360
+ waterMeshes.push(m);
361
+ } else if (tile === T_EXIT) {
362
+ // Glowing exit portal
363
+ const portal = createCylinder(0.8, 3, 0x44ffaa, [wx, 1.5, wz]);
364
+ mapMeshes.push(portal);
365
+ const ring = createTorus(1.2, 0.15, 0xaaffdd, [wx, 0.5, wz]);
366
+ setRotation(ring, Math.PI / 2, 0, 0);
367
+ mapMeshes.push(ring);
368
+ } else if (tile === T_SPAWNER) {
369
+ const spawner = createCube(1.2, 0x663333, [wx, 0.6, wz]);
370
+ setScale(spawner, 1, 0.6, 1);
371
+ mapMeshes.push(spawner);
372
+ // Skull decor
373
+ const skull = createSphere(0.3, 0xddccaa, [wx, 1.2, wz]);
374
+ mapMeshes.push(skull);
375
+ spawnTimers.push({
376
+ x,
377
+ y,
378
+ timer: 2 + Math.random() * 3,
379
+ hp: 3 + level,
380
+ mesh: spawner,
381
+ skullMesh: skull,
382
+ alive: true,
383
+ });
384
+ }
385
+ }
386
+ }
387
+
388
+ // Decorative ambient objects around the edges
389
+ for (let i = 0; i < 20; i++) {
390
+ const angle = Math.random() * Math.PI * 2;
391
+ const dist = MAP_W * TILE * 0.6 + Math.random() * 15;
392
+ const dx = Math.cos(angle) * dist + (MAP_W * TILE) / 2;
393
+ const dz = Math.sin(angle) * dist + (MAP_H * TILE) / 2;
394
+ const h = 5 + Math.random() * 10;
395
+ const c = [0x334433, 0x443344, 0x333344][Math.floor(Math.random() * 3)];
396
+ const m = createCube(3 + Math.random() * 4, c, [dx, h / 2, dz]);
397
+ setScale(m, 1, h / 3, 1);
398
+ mapMeshes.push(m);
399
+ }
400
+ }
401
+
402
+ // ========================================================================
403
+ // PLAYER
404
+ // ========================================================================
405
+ function createPlayer() {
406
+ const cls = CLASSES[classIdx];
407
+ player = {
408
+ x: player.tx * TILE + TILE / 2,
409
+ z: player.ty * TILE + TILE / 2,
410
+ y: 0,
411
+ vx: 0,
412
+ vz: 0,
413
+ hp: cls.hp,
414
+ maxHp: cls.hp,
415
+ atk: cls.atk,
416
+ spdMul: cls.spd,
417
+ facing: 0, // angle in radians
418
+ atkAnim: 0,
419
+ potions: 2,
420
+ xp: 0,
421
+ lvl: 1,
422
+ xpNext: 50,
423
+ keys: 0,
424
+ dashTimer: 0,
425
+ dashDX: 0,
426
+ dashDZ: 0,
427
+ meshBody: null,
428
+ meshHead: null,
429
+ meshWeapon: null,
430
+ };
431
+
432
+ player.meshBody = createCube(0.8, cls.color, [player.x, 0.7, player.z]);
433
+ setScale(player.meshBody, 0.8, 1.2, 0.6);
434
+ player.meshHead = createSphere(0.3, 0xeeddcc, [player.x, 1.6, player.z]);
435
+ player.meshWeapon = createCube(0.1, 0xccccdd, [player.x + 0.6, 1.0, player.z]);
436
+ setScale(player.meshWeapon, 0.1, 1.2, 0.1);
437
+
438
+ camX = player.x;
439
+ camZ = player.z;
440
+ }
441
+
442
+ // ========================================================================
443
+ // ENEMIES
444
+ // ========================================================================
445
+ const ENEMY_TYPES = [
446
+ { name: 'Ghost', color: 0x88aacc, hp: 15, atk: 5, spd: 2.5, xp: 8, score: 20 },
447
+ { name: 'Grunt', color: 0xaa5533, hp: 25, atk: 8, spd: 2.0, xp: 12, score: 30 },
448
+ { name: 'Demon', color: 0xcc2244, hp: 40, atk: 12, spd: 1.5, xp: 20, score: 50 },
449
+ { name: 'Sorcerer', color: 0x8844cc, hp: 30, atk: 10, spd: 1.8, xp: 25, score: 60, shoots: true },
450
+ { name: 'Death', color: 0x222222, hp: 80, atk: 20, spd: 2.5, xp: 50, score: 150 },
451
+ ];
452
+
453
+ function spawnEnemies() {
454
+ // Initial enemies in rooms
455
+ const baseCount = 6 + level * 3;
456
+ for (let i = 0; i < baseCount; i++) {
457
+ spawnRandomEnemy();
458
+ }
459
+ // Place pickups
460
+ const pickupCount = 4 + Math.floor(Math.random() * 3);
461
+ for (let i = 0; i < pickupCount; i++) {
462
+ const pos = randomFloorTile();
463
+ if (pos) {
464
+ const types = ['food', 'food', 'potion', 'gold', 'gold', 'gold'];
465
+ const type = types[Math.floor(Math.random() * types.length)];
466
+ const colors = { food: 0x44cc44, potion: 0x4444ff, gold: 0xffdd00 };
467
+ const m =
468
+ type === 'gold'
469
+ ? createCylinder(0.25, 0.15, colors[type], [pos.x * TILE + 1, 0.3, pos.y * TILE + 1])
470
+ : createSphere(0.25, colors[type], [pos.x * TILE + 1, 0.5, pos.y * TILE + 1]);
471
+ pickups.push({
472
+ x: pos.x * TILE + 1,
473
+ z: pos.y * TILE + 1,
474
+ type,
475
+ mesh: m,
476
+ bob: Math.random() * 6,
477
+ });
478
+ }
479
+ }
480
+ }
481
+
482
+ function spawnRandomEnemy() {
483
+ const pos = randomFloorTile();
484
+ if (!pos) return;
485
+ // Don't spawn too close to player
486
+ const px = pos.x * TILE + 1,
487
+ pz = pos.y * TILE + 1;
488
+ const dx = px - player.x,
489
+ dz2 = pz - player.z;
490
+ if (Math.sqrt(dx * dx + dz2 * dz2) < 8) return;
491
+ spawnEnemyAt(px, pz);
492
+ }
493
+
494
+ function spawnEnemyAt(wx, wz) {
495
+ const maxType = Math.min(ENEMY_TYPES.length - 1, 1 + Math.floor(level / 2));
496
+ const typeIdx = Math.floor(Math.random() * (maxType + 1));
497
+ const et = ENEMY_TYPES[typeIdx];
498
+ const scaledHp = Math.floor(et.hp * (1 + level * 0.15));
499
+ const scaledAtk = Math.floor(et.atk * (1 + level * 0.1));
500
+ const m = createCube(0.7, et.color, [wx, 0.6, wz]);
501
+ if (typeIdx >= 3) {
502
+ setScale(m, 0.9, 1.3, 0.9); // taller for sorcerer/death
503
+ }
504
+ enemies.push({
505
+ x: wx,
506
+ z: wz,
507
+ y: 0,
508
+ hp: scaledHp,
509
+ maxHp: scaledHp,
510
+ atk: scaledAtk,
511
+ spd: et.spd,
512
+ xp: et.xp,
513
+ scorePts: et.score,
514
+ type: typeIdx,
515
+ shoots: et.shoots || false,
516
+ facing: Math.random() * Math.PI * 2,
517
+ mesh: m,
518
+ flash: 0,
519
+ deathT: 0,
520
+ alive: true,
521
+ shotCD: 2 + Math.random() * 2,
522
+ moveT: 0,
523
+ });
524
+ }
525
+
526
+ function randomFloorTile() {
527
+ for (let attempts = 0; attempts < 100; attempts++) {
528
+ const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
529
+ const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
530
+ if (getTile(x, y) === T_FLOOR) return { x, y };
531
+ }
532
+ return null;
533
+ }
534
+
535
+ // ========================================================================
536
+ // UPDATE
537
+ // ========================================================================
538
+ export function update() {
539
+ const dt = 1 / 60;
540
+ t += dt;
541
+
542
+ if (state === 'classSelect') {
543
+ if (keyp('ArrowLeft') || keyp('KeyA'))
544
+ classIdx = (classIdx + CLASSES.length - 1) % CLASSES.length;
545
+ if (keyp('ArrowRight') || keyp('KeyD')) classIdx = (classIdx + 1) % CLASSES.length;
546
+ if (keyp('Space') || keyp('Enter')) startLevel();
547
+ return;
548
+ }
549
+ if (state === 'dead') {
550
+ if (keyp('Space') || keyp('Enter')) init();
551
+ return;
552
+ }
553
+ if (state === 'levelComplete') {
554
+ if (keyp('Space') || keyp('Enter')) {
555
+ level++;
556
+ cleanupLevel();
557
+ startLevel();
558
+ }
559
+ return;
560
+ }
561
+
562
+ updatePlayer(dt);
563
+ updateEnemies(dt);
564
+ updateSpawners(dt);
565
+ updateProjectiles(dt);
566
+ updatePlayerProjectiles(dt);
567
+ updatePickups(dt);
568
+ updateParticles(dt);
569
+ updateBoss(dt);
570
+ floats.update(dt);
571
+ updateShake(shake, dt);
572
+ updateCamera(dt);
573
+ }
574
+
575
+ // ========================================================================
576
+ // PLAYER UPDATE
577
+ // ========================================================================
578
+ function updatePlayer(dt) {
579
+ const cls = CLASSES[classIdx];
580
+ const spd = PLAYER_SPD * player.spdMul;
581
+ let mx = 0,
582
+ mz = 0;
583
+
584
+ // Cooldown & dash update
585
+ updateCooldowns(cooldowns, dt);
586
+ if (player.dashTimer > 0) {
587
+ player.dashTimer -= dt;
588
+ const nx = player.x + player.dashDX * DASH_SPD * dt;
589
+ const nz = player.z + player.dashDZ * DASH_SPD * dt;
590
+ if (!tileBlocking(Math.floor(nx / TILE), Math.floor(player.z / TILE))) player.x = nx;
591
+ if (!tileBlocking(Math.floor(player.x / TILE), Math.floor(nz / TILE))) player.z = nz;
592
+ playerHit.invulnTimer = Math.max(playerHit.invulnTimer, 0.05);
593
+ }
594
+
595
+ // 8-directional movement
596
+ if (key('ArrowUp') || key('KeyW')) mz = -1;
597
+ if (key('ArrowDown') || key('KeyS')) mz = 1;
598
+ if (key('ArrowLeft') || key('KeyA')) mx = -1;
599
+ if (key('ArrowRight') || key('KeyD')) mx = 1;
600
+
601
+ // Normalize diagonal
602
+ if (mx !== 0 && mz !== 0) {
603
+ mx *= 0.707;
604
+ mz *= 0.707;
605
+ }
606
+
607
+ if (mx !== 0 || mz !== 0) {
608
+ player.facing = Math.atan2(mx, -mz);
609
+ }
610
+
611
+ // Dash trigger
612
+ if ((keyp('ShiftLeft') || keyp('ShiftRight') || keyp('KeyK')) && useCooldown(cooldowns.dash)) {
613
+ player.dashTimer = DASH_DUR;
614
+ player.dashDX = mx !== 0 || mz !== 0 ? mx : Math.sin(player.facing);
615
+ player.dashDZ = mx !== 0 || mz !== 0 ? mz : -Math.cos(player.facing);
616
+ spawnParts(player.x, 0.3, player.z, 6, 0xaaddff);
617
+ sfx('jump');
618
+ }
619
+
620
+ // Normal movement (skip during dash)
621
+ if (player.dashTimer <= 0) {
622
+ const nx = player.x + mx * spd * dt;
623
+ const nz = player.z + mz * spd * dt;
624
+ if (!tileBlocking(Math.floor(nx / TILE), Math.floor(player.z / TILE))) player.x = nx;
625
+ if (!tileBlocking(Math.floor(player.x / TILE), Math.floor(nz / TILE))) player.z = nz;
626
+ }
627
+
628
+ // Attack
629
+ updateHitState(playerHit, dt);
630
+ if (player.atkAnim > 0) player.atkAnim -= dt;
631
+
632
+ if (
633
+ (keyp('Space') || keyp('KeyZ') || keyp('KeyX') || keyp('KeyJ')) &&
634
+ useCooldown(cooldowns.attack)
635
+ ) {
636
+ player.atkAnim = 0.2;
637
+
638
+ if (cls.ranged) {
639
+ // Ranged attack: fire a projectile
640
+ sfx('jump');
641
+ const pm = createSphere(0.18, cls.projColor, [player.x, 0.9, player.z]);
642
+ playerProjectiles.push({
643
+ x: player.x,
644
+ z: player.z,
645
+ y: 0.9,
646
+ vx: Math.sin(player.facing) * cls.projSpd,
647
+ vz: -Math.cos(player.facing) * cls.projSpd,
648
+ mesh: pm,
649
+ timer: 1.5,
650
+ dmg: player.atk,
651
+ });
652
+ spawnParts(
653
+ player.x + Math.sin(player.facing) * 0.5,
654
+ 0.9,
655
+ player.z - Math.cos(player.facing) * 0.5,
656
+ 3,
657
+ cls.projColor
658
+ );
659
+ } else {
660
+ // Melee attack: hit enemies in arc
661
+ sfx('explosion');
662
+ triggerShake(shake, 0.3);
663
+ const range = cls.atkRange;
664
+ for (const e of enemies) {
665
+ if (!e.alive) continue;
666
+ const dx = e.x - player.x,
667
+ dz2 = e.z - player.z;
668
+ const dist = Math.sqrt(dx * dx + dz2 * dz2);
669
+ if (dist > range) continue;
670
+ const angleToE = Math.atan2(dx, -dz2);
671
+ let angleDiff = angleToE - player.facing;
672
+ while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
673
+ while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
674
+ if (Math.abs(angleDiff) < PLAYER_ATK_ARC / 2) {
675
+ damageEnemy(e, player.atk);
676
+ }
677
+ }
678
+ // Hit spawners
679
+ for (const sp of spawnTimers) {
680
+ if (!sp.alive) continue;
681
+ const sx = sp.x * TILE + 1,
682
+ sz = sp.y * TILE + 1;
683
+ const dx = sx - player.x,
684
+ dz2 = sz - player.z;
685
+ if (Math.sqrt(dx * dx + dz2 * dz2) < range) {
686
+ sp.hp -= player.atk;
687
+ if (sp.hp <= 0) {
688
+ sp.alive = false;
689
+ destroyMesh(sp.mesh);
690
+ destroyMesh(sp.skullMesh);
691
+ setTile(sp.x, sp.y, T_FLOOR);
692
+ score += 100;
693
+ spawnParts(sx, 0.5, sz, 10, 0xff4444);
694
+ floats.spawn('DESTROYED +100', sx, 2, { z: sz, duration: 1, color: 0xffff64 });
695
+ sfx('coin');
696
+ }
697
+ }
698
+ }
699
+
700
+ // Attack visual particles
701
+ const ax = player.x + Math.sin(player.facing) * 1.5;
702
+ const az = player.z - Math.cos(player.facing) * 1.5;
703
+ spawnParts(ax, 0.8, az, 5, cls.color);
704
+ }
705
+ }
706
+
707
+ // Use potion
708
+ if ((keyp('KeyP') || keyp('KeyQ')) && player.potions > 0 && player.hp < player.maxHp) {
709
+ player.potions--;
710
+ player.hp = Math.min(player.hp + 40, player.maxHp);
711
+ spawnParts(player.x, 1, player.z, 8, 0x44ff44);
712
+ floats.spawn('HEALED!', player.x, 2, { z: player.z, duration: 0.8, color: 0xffff64 });
713
+ sfx('coin');
714
+ }
715
+
716
+ // Check exit (boss must be dead first)
717
+ const ptx = Math.floor(player.x / TILE),
718
+ ptz = Math.floor(player.z / TILE);
719
+ if (ptx === exitPos.x && ptz === exitPos.y && (!boss || !boss.alive)) {
720
+ state = 'levelComplete';
721
+ score += 200 + level * 50;
722
+ }
723
+
724
+ // Sync player mesh
725
+ const vis = isVisible(playerHit, t);
726
+ const bob = (mx !== 0 || mz !== 0) && player.y === 0 ? Math.abs(Math.sin(t * 10)) * 0.15 : 0;
727
+ setPosition(player.meshBody, player.x, 0.7 + bob, player.z);
728
+ setPosition(player.meshHead, player.x, 1.6 + bob, player.z);
729
+ setRotation(player.meshBody, 0, player.facing, 0);
730
+
731
+ // Weapon swing
732
+ const weapAngle =
733
+ player.atkAnim > 0
734
+ ? player.facing + Math.sin((1 - player.atkAnim / 0.2) * Math.PI) * 1.2
735
+ : player.facing;
736
+ const wDist = player.atkAnim > 0 ? 1.0 : 0.6;
737
+ setPosition(
738
+ player.meshWeapon,
739
+ player.x + Math.sin(weapAngle) * wDist,
740
+ 1.0 + bob,
741
+ player.z - Math.cos(weapAngle) * wDist
742
+ );
743
+ setRotation(player.meshWeapon, 0, weapAngle, 0.3);
744
+
745
+ if (vis) {
746
+ setScale(player.meshBody, 0.8, 1.2, 0.6);
747
+ setScale(player.meshHead, 1, 1, 1);
748
+ } else {
749
+ setScale(player.meshBody, 0.01, 0.01, 0.01);
750
+ setScale(player.meshHead, 0.01, 0.01, 0.01);
751
+ }
752
+ }
753
+
754
+ function hurtPlayer(dmg) {
755
+ if (!triggerHit(playerHit)) return;
756
+ player.hp -= dmg;
757
+ triggerShake(shake, 0.5);
758
+ sfx('explosion');
759
+ if (player.hp <= 0) {
760
+ state = 'dead';
761
+ triggerShake(shake, 1.0);
762
+ spawnParts(player.x, 1, player.z, 20, 0xff4444);
763
+ }
764
+ }
765
+
766
+ function gainXP(amount) {
767
+ player.xp += amount;
768
+ while (player.xp >= player.xpNext) {
769
+ player.xp -= player.xpNext;
770
+ player.lvl++;
771
+ player.xpNext = Math.floor(player.xpNext * 1.4);
772
+ player.maxHp += 10;
773
+ player.hp = Math.min(player.hp + 20, player.maxHp);
774
+ player.atk += 2;
775
+ floats.spawn(`LEVEL UP! LVL ${player.lvl}`, player.x, 3, {
776
+ z: player.z,
777
+ duration: 2,
778
+ color: 0xffff64,
779
+ });
780
+ spawnParts(player.x, 1, player.z, 15, 0xffdd44);
781
+ sfx('coin');
782
+ }
783
+ }
784
+
785
+ // ========================================================================
786
+ // ENEMY UPDATE
787
+ // ========================================================================
788
+ function updateEnemies(dt) {
789
+ for (let i = enemies.length - 1; i >= 0; i--) {
790
+ const e = enemies[i];
791
+
792
+ if (!e.alive) {
793
+ e.deathT -= dt;
794
+ if (e.deathT <= 0) {
795
+ destroyMesh(e.mesh);
796
+ enemies.splice(i, 1);
797
+ continue;
798
+ }
799
+ setPosition(e.mesh, e.x, e.deathT * 3, e.z);
800
+ setRotation(e.mesh, t * 10, t * 8, 0);
801
+ setScale(e.mesh, e.deathT * 2, e.deathT * 2, e.deathT * 2);
802
+ continue;
803
+ }
804
+
805
+ if (e.flash > 0) e.flash -= dt;
806
+
807
+ // Move toward player
808
+ const dx = player.x - e.x,
809
+ dz = player.z - e.z;
810
+ const dist = Math.sqrt(dx * dx + dz * dz);
811
+
812
+ if (dist < 25) {
813
+ e.moveT += dt;
814
+ const ang = Math.atan2(dx, -dz);
815
+ e.facing = ang;
816
+
817
+ // Move (with some randomness)
818
+ const wobble = Math.sin(e.moveT * 2 + i) * 0.3;
819
+ const mx = Math.sin(ang + wobble) * e.spd * dt;
820
+ const mz = -Math.cos(ang + wobble) * e.spd * dt;
821
+
822
+ const nx = e.x + mx,
823
+ nz = e.z + mz;
824
+ if (!tileBlocking(Math.floor(nx / TILE), Math.floor(e.z / TILE))) e.x = nx;
825
+ if (!tileBlocking(Math.floor(e.x / TILE), Math.floor(nz / TILE))) e.z = nz;
826
+
827
+ // Melee contact
828
+ if (dist < 1.2) {
829
+ hurtPlayer(e.atk);
830
+ }
831
+
832
+ // Ranged attack
833
+ if (e.shoots) {
834
+ e.shotCD -= dt;
835
+ if (e.shotCD <= 0 && dist < 15 && dist > 3) {
836
+ e.shotCD = 2.5;
837
+ const pAng = Math.atan2(dx, -dz);
838
+ const pm = createSphere(0.15, 0xcc44ff, [e.x, 0.8, e.z]);
839
+ projectiles.push({
840
+ x: e.x,
841
+ z: e.z,
842
+ y: 0.8,
843
+ vx: Math.sin(pAng) * 8,
844
+ vz: -Math.cos(pAng) * 8,
845
+ mesh: pm,
846
+ timer: 3,
847
+ dmg: e.atk,
848
+ });
849
+ }
850
+ }
851
+ }
852
+
853
+ // Render
854
+ const bob2 = Math.sin(t * 3 + i) * 0.1;
855
+ setPosition(e.mesh, e.x, 0.6 + bob2, e.z);
856
+ setRotation(e.mesh, 0, e.facing, 0);
857
+ }
858
+ }
859
+
860
+ function damageEnemy(e, dmg) {
861
+ e.hp -= dmg;
862
+ e.flash = 0.12;
863
+ if (e.hp <= 0) {
864
+ e.alive = false;
865
+ e.deathT = 0.4;
866
+ kills++;
867
+ totalKills++;
868
+ score += e.scorePts;
869
+ gainXP(e.xp);
870
+ spawnParts(e.x, 0.5, e.z, 8, 0xff8844);
871
+ floats.spawn(`+${e.scorePts}`, e.x, 1.5, { z: e.z, duration: 0.7, color: 0xffff64 });
872
+ sfx('coin');
873
+ // Random drops
874
+ if (Math.random() < 0.15) {
875
+ const m = createSphere(0.2, 0x44cc44, [e.x, 0.4, e.z]);
876
+ pickups.push({ x: e.x, z: e.z, type: 'food', mesh: m, bob: Math.random() * 6 });
877
+ }
878
+ // If this was the boss, mark it dead
879
+ if (boss && e === boss) {
880
+ boss.alive = false;
881
+ floats.spawn('BOSS SLAIN!', e.x, 3, { z: e.z, duration: 2, color: 0xffdd00 });
882
+ sfx('explosion');
883
+ triggerShake(shake, 8);
884
+ score += 500 + level * 100;
885
+ // Drop extra loot
886
+ for (let k = 0; k < 5; k++) {
887
+ const ox = e.x + (Math.random() - 0.5) * 3;
888
+ const oz = e.z + (Math.random() - 0.5) * 3;
889
+ const m2 = createCylinder(0.25, 0.15, 0xffdd00, [ox, 0.3, oz]);
890
+ pickups.push({ x: ox, z: oz, type: 'gold', mesh: m2, bob: Math.random() * 6 });
891
+ }
892
+ }
893
+ } else {
894
+ floats.spawn('HIT', e.x, 1.5, { z: e.z, duration: 0.3, color: 0xffff64 });
895
+ }
896
+ }
897
+
898
+ // ========================================================================
899
+ // SPAWNERS
900
+ // ========================================================================
901
+ function updateSpawners(dt) {
902
+ for (const sp of spawnTimers) {
903
+ if (!sp.alive) continue;
904
+ sp.timer -= dt;
905
+ if (sp.timer <= 0 && enemies.length < 30) {
906
+ sp.timer = 4 + Math.random() * 3 - level * 0.2;
907
+ const wx = sp.x * TILE + 1,
908
+ wz = sp.y * TILE + 1;
909
+ spawnEnemyAt(wx + (Math.random() - 0.5) * 3, wz + (Math.random() - 0.5) * 3);
910
+ spawnParts(wx, 0.5, wz, 4, 0xff2222);
911
+ }
912
+ }
913
+ }
914
+
915
+ // ========================================================================
916
+ // PROJECTILES, PICKUPS, PARTICLES
917
+ // ========================================================================
918
+ function updateProjectiles(dt) {
919
+ for (let i = projectiles.length - 1; i >= 0; i--) {
920
+ const p = projectiles[i];
921
+ p.x += p.vx * dt;
922
+ p.z += p.vz * dt;
923
+ p.timer -= dt;
924
+ setPosition(p.mesh, p.x, p.y, p.z);
925
+ // Hit player
926
+ const dx = p.x - player.x,
927
+ dz = p.z - player.z;
928
+ if (Math.sqrt(dx * dx + dz * dz) < 0.8) {
929
+ hurtPlayer(p.dmg);
930
+ destroyMesh(p.mesh);
931
+ projectiles.splice(i, 1);
932
+ continue;
933
+ }
934
+ // Hit wall
935
+ if (tileBlocking(Math.floor(p.x / TILE), Math.floor(p.z / TILE))) {
936
+ destroyMesh(p.mesh);
937
+ projectiles.splice(i, 1);
938
+ continue;
939
+ }
940
+ if (p.timer <= 0) {
941
+ destroyMesh(p.mesh);
942
+ projectiles.splice(i, 1);
943
+ }
944
+ }
945
+ }
946
+
947
+ function updatePlayerProjectiles(dt) {
948
+ for (let i = playerProjectiles.length - 1; i >= 0; i--) {
949
+ const p = playerProjectiles[i];
950
+ p.x += p.vx * dt;
951
+ p.z += p.vz * dt;
952
+ p.timer -= dt;
953
+ setPosition(p.mesh, p.x, p.y, p.z);
954
+ // Hit enemies
955
+ let hit = false;
956
+ for (const e of enemies) {
957
+ if (!e.alive) continue;
958
+ const dx = p.x - e.x,
959
+ dz = p.z - e.z;
960
+ if (Math.sqrt(dx * dx + dz * dz) < 1.0) {
961
+ damageEnemy(e, p.dmg);
962
+ hit = true;
963
+ break;
964
+ }
965
+ }
966
+ // Hit spawners
967
+ if (!hit) {
968
+ for (const sp of spawnTimers) {
969
+ if (!sp.alive) continue;
970
+ const sx = sp.x * TILE + 1,
971
+ sz = sp.y * TILE + 1;
972
+ const dx = p.x - sx,
973
+ dz = p.z - sz;
974
+ if (Math.sqrt(dx * dx + dz * dz) < 1.2) {
975
+ sp.hp -= p.dmg;
976
+ hit = true;
977
+ if (sp.hp <= 0) {
978
+ sp.alive = false;
979
+ destroyMesh(sp.mesh);
980
+ destroyMesh(sp.skullMesh);
981
+ setTile(sp.x, sp.y, T_FLOOR);
982
+ score += 100;
983
+ spawnParts(sx, 0.5, sz, 10, 0xff4444);
984
+ floats.spawn('DESTROYED +100', sx, 2, { z: sz, duration: 1, color: 0xffff64 });
985
+ sfx('coin');
986
+ }
987
+ break;
988
+ }
989
+ }
990
+ }
991
+ if (hit || tileBlocking(Math.floor(p.x / TILE), Math.floor(p.z / TILE)) || p.timer <= 0) {
992
+ if (hit) spawnParts(p.x, p.y, p.z, 4, 0xffdd44);
993
+ destroyMesh(p.mesh);
994
+ playerProjectiles.splice(i, 1);
995
+ }
996
+ }
997
+ }
998
+
999
+ function updatePickups(dt) {
1000
+ for (let i = pickups.length - 1; i >= 0; i--) {
1001
+ const p = pickups[i];
1002
+ p.bob += dt * 3;
1003
+ setPosition(p.mesh, p.x, 0.4 + Math.sin(p.bob) * 0.15, p.z);
1004
+ setRotation(p.mesh, 0, t * 2, 0);
1005
+ const dx = p.x - player.x,
1006
+ dz = p.z - player.z;
1007
+ if (Math.sqrt(dx * dx + dz * dz) < 1.3) {
1008
+ destroyMesh(p.mesh);
1009
+ pickups.splice(i, 1);
1010
+ if (p.type === 'food') {
1011
+ player.hp = Math.min(player.hp + 15, player.maxHp);
1012
+ floats.spawn('FOOD +15HP', p.x, 1, { z: p.z, duration: 0.8, color: 0xffff64 });
1013
+ } else if (p.type === 'potion') {
1014
+ player.potions++;
1015
+ floats.spawn('POTION', p.x, 1, { z: p.z, duration: 0.8, color: 0xffff64 });
1016
+ } else if (p.type === 'gold') {
1017
+ score += 25;
1018
+ floats.spawn('+25 GOLD', p.x, 1, { z: p.z, duration: 0.8, color: 0xffff64 });
1019
+ }
1020
+ sfx('coin');
1021
+ spawnParts(p.x, 0.5, p.z, 4, 0xffdd00);
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ function spawnParts(x, y, z, n, col) {
1027
+ for (let i = 0; i < n; i++) {
1028
+ const a = Math.random() * Math.PI * 2,
1029
+ s = 1 + Math.random() * 3;
1030
+ const m = createSphere(0.08, col, [x, y, z]);
1031
+ particles.push({
1032
+ x,
1033
+ y,
1034
+ z,
1035
+ vx: Math.cos(a) * s,
1036
+ vy: 2 + Math.random() * 3,
1037
+ vz: Math.sin(a) * s,
1038
+ life: 0.4 + Math.random() * 0.4,
1039
+ mesh: m,
1040
+ });
1041
+ }
1042
+ }
1043
+
1044
+ function updateParticles(dt) {
1045
+ for (let i = particles.length - 1; i >= 0; i--) {
1046
+ const p = particles[i];
1047
+ p.vy -= 12 * dt;
1048
+ p.x += p.vx * dt;
1049
+ p.y += p.vy * dt;
1050
+ p.z += p.vz * dt;
1051
+ p.life -= dt;
1052
+ setPosition(p.mesh, p.x, p.y, p.z);
1053
+ if (p.life <= 0) {
1054
+ destroyMesh(p.mesh);
1055
+ particles.splice(i, 1);
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ // ========================================================================
1061
+ // CAMERA (isometric top-down)
1062
+ // ========================================================================
1063
+ function updateCamera(dt) {
1064
+ camX += (player.x - camX) * 0.12;
1065
+ camZ += (player.z - camZ) * 0.12;
1066
+ // Isometric: elevated, angled view — closer for better action feel
1067
+ const [sx, sy] = getShakeOffset(shake);
1068
+ setCameraPosition(camX - 6 + sx, 18 + sy, camZ + 14);
1069
+ setCameraTarget(camX + sx * 0.5, 0, camZ + sy * 0.5);
1070
+ }
1071
+
1072
+ // ========================================================================
1073
+ // BOSS SYSTEM
1074
+ // ========================================================================
1075
+ const BOSS_TYPES = [
1076
+ { name: 'FOREST GUARDIAN', color: 0x228844, hp: 200, atk: 20, spd: 2.5, score: 500 },
1077
+ { name: 'CRYPT LORD', color: 0x445588, hp: 300, atk: 28, spd: 2.2, score: 700 },
1078
+ { name: 'RUIN TITAN', color: 0x886633, hp: 400, atk: 35, spd: 2.8, score: 900 },
1079
+ { name: 'WASTELAND BEAST', color: 0xaa4422, hp: 500, atk: 42, spd: 3.0, score: 1100 },
1080
+ { name: 'DEMON KING', color: 0xcc1133, hp: 700, atk: 50, spd: 3.5, score: 1500 },
1081
+ ];
1082
+
1083
+ function spawnBoss() {
1084
+ const bossIdx = Math.min(Math.floor(level / 3), BOSS_TYPES.length - 1);
1085
+ const bt = BOSS_TYPES[bossIdx];
1086
+ // Place boss near center of map
1087
+ const cx = Math.floor(MAP_W / 2) * TILE + 1;
1088
+ const cz = Math.floor(MAP_H / 2) * TILE + 1;
1089
+ const scaledHp = Math.floor(bt.hp * (1 + level * 0.1));
1090
+ const scaledAtk = Math.floor(bt.atk * (1 + level * 0.08));
1091
+ const m = createCube(1.4, bt.color, [cx, 1.2, cz]);
1092
+ setScale(m, 1.5, 2.0, 1.5);
1093
+ const bossEnemy = {
1094
+ x: cx,
1095
+ z: cz,
1096
+ y: 0,
1097
+ hp: scaledHp,
1098
+ maxHp: scaledHp,
1099
+ atk: scaledAtk,
1100
+ spd: bt.spd,
1101
+ xp: 100 + level * 20,
1102
+ scorePts: bt.score,
1103
+ type: ENEMY_TYPES.length - 1, // use Death-tier visuals
1104
+ shoots: true,
1105
+ mesh: m,
1106
+ flash: 0,
1107
+ deathT: 0,
1108
+ alive: true,
1109
+ shotCD: 1.5,
1110
+ moveT: 0,
1111
+ facing: 0,
1112
+ // Boss-specific
1113
+ isBoss: true,
1114
+ bossName: bt.name,
1115
+ chargeCD: 4,
1116
+ charging: false,
1117
+ chargeTimer: 0,
1118
+ chargeDx: 0,
1119
+ chargeDz: 0,
1120
+ slamCD: 6,
1121
+ slamTimer: 0,
1122
+ };
1123
+ enemies.push(bossEnemy);
1124
+ boss = bossEnemy;
1125
+ floats.spawn(`⚠ ${bt.name} ⚠`, cx, 4, { z: cz, duration: 2.5, color: 0xff4444 });
1126
+ sfx('explosion');
1127
+ triggerShake(shake, 5);
1128
+ }
1129
+
1130
+ function updateBoss(dt) {
1131
+ if (!boss || !boss.alive) return;
1132
+ const dx = player.x - boss.x;
1133
+ const dz = player.z - boss.z;
1134
+ const dist = Math.sqrt(dx * dx + dz * dz);
1135
+
1136
+ // Charge attack — rush toward player
1137
+ boss.chargeCD -= dt;
1138
+ if (boss.charging) {
1139
+ boss.chargeTimer -= dt;
1140
+ boss.x += boss.chargeDx * 25 * dt;
1141
+ boss.z += boss.chargeDz * 25 * dt;
1142
+ // Bounds
1143
+ boss.x = Math.max(TILE, Math.min((MAP_W - 1) * TILE, boss.x));
1144
+ boss.z = Math.max(TILE, Math.min((MAP_H - 1) * TILE, boss.z));
1145
+ setPosition(boss.mesh, boss.x, 1.2, boss.z);
1146
+ // Damage on contact during charge
1147
+ if (dist < 2.5) {
1148
+ hurtPlayer(boss.atk);
1149
+ boss.charging = false;
1150
+ }
1151
+ if (boss.chargeTimer <= 0) {
1152
+ boss.charging = false;
1153
+ // Slam on landing
1154
+ triggerShake(shake, 4);
1155
+ spawnParts(boss.x, 0.5, boss.z, 15, 0xff4400);
1156
+ sfx('explosion');
1157
+ // AoE damage near landing
1158
+ if (dist < 4) hurtPlayer(Math.floor(boss.atk * 0.6));
1159
+ }
1160
+ } else if (boss.chargeCD <= 0 && dist < 18 && dist > 5) {
1161
+ // Start charging toward player
1162
+ boss.charging = true;
1163
+ boss.chargeTimer = 0.4;
1164
+ const d = Math.sqrt(dx * dx + dz * dz);
1165
+ boss.chargeDx = dx / d;
1166
+ boss.chargeDz = dz / d;
1167
+ boss.chargeCD = 4 + Math.random() * 2;
1168
+ floats.spawn('CHARGE!', boss.x, 3, { z: boss.z, duration: 0.5, color: 0xff0000 });
1169
+ }
1170
+
1171
+ // AoE slam — periodic ground pound
1172
+ boss.slamCD -= dt;
1173
+ if (boss.slamCD <= 0 && !boss.charging && dist < 6) {
1174
+ boss.slamCD = 5 + Math.random() * 3;
1175
+ triggerShake(shake, 6);
1176
+ spawnParts(boss.x, 0.5, boss.z, 20, 0xffaa00);
1177
+ sfx('explosion');
1178
+ if (dist < 5) hurtPlayer(Math.floor(boss.atk * 0.8));
1179
+ floats.spawn('SLAM!', boss.x, 3, { z: boss.z, duration: 0.6, color: 0xff6600 });
1180
+ }
1181
+
1182
+ // Boss bob animation
1183
+ const bob3 = Math.sin(t * 2) * 0.15 + (boss.charging ? 0.3 : 0);
1184
+ setPosition(boss.mesh, boss.x, 1.2 + bob3, boss.z);
1185
+ }
1186
+
1187
+ // ========================================================================
1188
+ // CLEANUP between levels
1189
+ // ========================================================================
1190
+ function cleanupLevel() {
1191
+ if (mapMeshes) mapMeshes.forEach(m => destroyMesh(m));
1192
+ if (treeMeshes) treeMeshes.forEach(m => destroyMesh(m));
1193
+ if (waterMeshes) waterMeshes.forEach(m => destroyMesh(m));
1194
+ enemies.forEach(e => {
1195
+ if (e.mesh) destroyMesh(e.mesh);
1196
+ });
1197
+ pickups.forEach(p => {
1198
+ if (p.mesh) destroyMesh(p.mesh);
1199
+ });
1200
+ projectiles.forEach(p => {
1201
+ if (p.mesh) destroyMesh(p.mesh);
1202
+ });
1203
+ particles.forEach(p => {
1204
+ if (p.mesh) destroyMesh(p.mesh);
1205
+ });
1206
+ playerProjectiles.forEach(p => {
1207
+ if (p.mesh) destroyMesh(p.mesh);
1208
+ });
1209
+ if (player.meshBody) destroyMesh(player.meshBody);
1210
+ if (player.meshHead) destroyMesh(player.meshHead);
1211
+ if (player.meshWeapon) destroyMesh(player.meshWeapon);
1212
+ mapMeshes = [];
1213
+ treeMeshes = [];
1214
+ waterMeshes = [];
1215
+ enemies = [];
1216
+ pickups = [];
1217
+ projectiles = [];
1218
+ playerProjectiles = [];
1219
+ particles = [];
1220
+ }
1221
+
1222
+ // ========================================================================
1223
+ // DRAW — 2D HUD
1224
+ // ========================================================================
1225
+ export function draw() {
1226
+ if (state === 'classSelect') return drawClassSelect();
1227
+ if (state === 'dead') return drawDead();
1228
+ if (state === 'levelComplete') return drawLevelComplete();
1229
+
1230
+ // ── HUD panel ──
1231
+ rect(8, 8, 200, 82, rgba8(0, 0, 0, 180), true);
1232
+ rect(8, 8, 200, 82, rgba8(100, 150, 200, 100), false);
1233
+
1234
+ // HP bar
1235
+ const hpP = player.hp / player.maxHp;
1236
+ const hpC =
1237
+ hpP > 0.5 ? rgba8(50, 220, 80) : hpP > 0.25 ? rgba8(220, 200, 50) : rgba8(220, 50, 50);
1238
+ print('HP', 14, 14, rgba8(200, 200, 200));
1239
+ rect(40, 14, 150, 7, rgba8(30, 30, 30), true);
1240
+ rect(40, 14, Math.floor(150 * hpP), 7, hpC, true);
1241
+ print(`${player.hp}/${player.maxHp}`, 80, 14, rgba8(255, 255, 255));
1242
+
1243
+ // XP bar
1244
+ const xpP = player.xp / player.xpNext;
1245
+ print('XP', 14, 26, rgba8(180, 180, 255));
1246
+ rect(40, 26, 150, 5, rgba8(20, 20, 40), true);
1247
+ rect(40, 26, Math.floor(150 * xpP), 5, rgba8(120, 120, 255), true);
1248
+
1249
+ print(`LVL ${player.lvl} ATK ${player.atk}`, 14, 38, rgba8(220, 220, 255));
1250
+ print(`SCORE ${score}`, 14, 50, rgba8(255, 215, 0));
1251
+ print(`POTIONS ${player.potions}`, 14, 62, rgba8(100, 100, 255));
1252
+
1253
+ // Level & theme
1254
+ const th = theme();
1255
+ print(`FLOOR ${level + 1}: ${th.name}`, 440, 14, rgba8(180, 180, 220, 200));
1256
+ print(`KILLS ${kills}`, 550, 28, rgba8(200, 120, 120));
1257
+
1258
+ // Boss HP bar (big, prominent at top center)
1259
+ if (boss && boss.alive) {
1260
+ const bossHpP = boss.hp / boss.maxHp;
1261
+ const bossBarW = 240;
1262
+ const bossBarX = 320 - bossBarW / 2;
1263
+ rect(bossBarX - 2, 6, bossBarW + 4, 18, rgba8(0, 0, 0, 200), true);
1264
+ rect(bossBarX, 8, bossBarW, 14, rgba8(40, 10, 10), true);
1265
+ rect(bossBarX, 8, Math.floor(bossBarW * bossHpP), 14, rgba8(220, 40, 40), true);
1266
+ rect(bossBarX - 2, 6, bossBarW + 4, 18, rgba8(200, 100, 50), false);
1267
+ printCentered(`⚔ ${boss.bossName} ⚔`, 320, 10, rgba8(255, 200, 100));
1268
+ }
1269
+
1270
+ // Exit locked warning
1271
+ if (boss && boss.alive) {
1272
+ const ptx2 = Math.floor(player.x / TILE),
1273
+ ptz2 = Math.floor(player.z / TILE);
1274
+ if (ptx2 === exitPos.x && ptz2 === exitPos.y) {
1275
+ printCentered('EXIT LOCKED — DEFEAT THE BOSS!', 320, 180, rgba8(255, 80, 80));
1276
+ }
1277
+ }
1278
+
1279
+ // Enemy count
1280
+ const alive = enemies.filter(e => e.alive).length;
1281
+ const spawners = spawnTimers.filter(s => s.alive).length;
1282
+ print(`ENEMIES ${alive} SPAWNERS ${spawners}`, 400, 344, rgba8(180, 130, 130, 160));
1283
+
1284
+ // Enemy HP bars (drawn above enemies on screen)
1285
+ for (const e of enemies) {
1286
+ if (!e.alive || e.hp >= e.maxHp) continue;
1287
+ const dx = e.x - camX,
1288
+ dz = e.z - camZ;
1289
+ if (Math.abs(dx) > 20 || Math.abs(dz) > 20) continue;
1290
+ const sx = Math.floor(320 + (e.x - player.x) * 12);
1291
+ const sy = Math.floor(150 - (e.z - player.z) * 6);
1292
+ const bw = 20,
1293
+ bh = 3;
1294
+ const hp = e.hp / e.maxHp;
1295
+ rect(sx - bw / 2, sy, bw, bh, rgba8(40, 0, 0, 180), true);
1296
+ rect(sx - bw / 2, sy, Math.floor(bw * hp), bh, rgba8(220, 50, 50), true);
1297
+ }
1298
+
1299
+ // Dash cooldown indicator
1300
+ if (!cooldownReady(cooldowns.dash)) {
1301
+ const dP = cooldownProgress(cooldowns.dash);
1302
+ print('DASH', 14, 78, rgba8(120, 160, 220, 120));
1303
+ rect(50, 78, 40, 5, rgba8(30, 30, 50), true);
1304
+ rect(50, 78, Math.floor(40 * dP), 5, rgba8(100, 180, 255), true);
1305
+ } else {
1306
+ print('DASH RDY', 14, 78, rgba8(100, 200, 255, 180));
1307
+ }
1308
+
1309
+ // Minimap
1310
+ drawMinimap();
1311
+
1312
+ // Floating texts (3D world-space → isometric screen projection)
1313
+ drawFloatingTexts3D(floats, (x, y, z) => [
1314
+ Math.floor(320 + (x - player.x) * 12),
1315
+ Math.floor(180 - y * 12 - (z - player.z) * 6),
1316
+ ]);
1317
+
1318
+ // Controls hint
1319
+ print('WASD Move SPC/Z Attack SHIFT Dash P Potion', 100, 352, rgba8(80, 80, 110, 110));
1320
+ }
1321
+
1322
+ // ---- MINIMAP ----
1323
+ function drawMinimap() {
1324
+ const mmX = W - 78,
1325
+ mmY = 56;
1326
+ const mmS = 2; // pixel per tile
1327
+ rect(mmX - 2, mmY - 2, MAP_W * mmS + 4, MAP_H * mmS + 4, rgba8(0, 0, 0, 180), true);
1328
+
1329
+ for (let x = 0; x < MAP_W; x++) {
1330
+ for (let y = 0; y < MAP_H; y++) {
1331
+ const tile = getTile(x, y);
1332
+ let col = 0;
1333
+ if (tile === T_FLOOR) col = rgba8(60, 60, 60, 120);
1334
+ else if (tile === T_WALL) col = rgba8(100, 80, 60);
1335
+ else if (tile === T_TREE) col = rgba8(30, 80, 30);
1336
+ else if (tile === T_WATER) col = rgba8(30, 50, 120);
1337
+ else if (tile === T_EXIT) col = rgba8(50, 255, 150);
1338
+ else if (tile === T_SPAWNER) col = rgba8(200, 50, 50);
1339
+ else continue;
1340
+ rect(mmX + x * mmS, mmY + y * mmS, mmS, mmS, col, true);
1341
+ }
1342
+ }
1343
+
1344
+ // Player dot (blinking)
1345
+ if (Math.sin(t * 8) > 0) {
1346
+ const px = Math.floor(player.x / TILE),
1347
+ pz = Math.floor(player.z / TILE);
1348
+ rect(mmX + px * mmS, mmY + pz * mmS, mmS + 1, mmS + 1, rgba8(255, 255, 0), true);
1349
+ }
1350
+
1351
+ // Enemy dots
1352
+ for (const e of enemies) {
1353
+ if (!e.alive) continue;
1354
+ const ex = Math.floor(e.x / TILE),
1355
+ ez = Math.floor(e.z / TILE);
1356
+ rect(mmX + ex * mmS, mmY + ez * mmS, mmS, mmS, rgba8(255, 80, 80), true);
1357
+ }
1358
+ }
1359
+
1360
+ // ---- SCREENS ----
1361
+ function drawClassSelect() {
1362
+ rect(0, 0, W, H, rgba8(10, 8, 20, 230), true);
1363
+ printCentered('GAUNTLET 64', 320, 40, rgba8(255, 200, 80));
1364
+ printCentered('ISOMETRIC ACTION RPG', 320, 65, rgba8(150, 140, 120));
1365
+
1366
+ // Class cards
1367
+ for (let i = 0; i < CLASSES.length; i++) {
1368
+ const c = CLASSES[i];
1369
+ const cx = 80 + i * 130;
1370
+ const selected = i === classIdx;
1371
+ const border = selected ? rgba8(255, 255, 100) : rgba8(80, 80, 100);
1372
+ const bg = selected ? rgba8(40, 40, 60, 220) : rgba8(20, 20, 30, 180);
1373
+ rect(cx, 100, 110, 140, bg, true);
1374
+ rect(cx, 100, 110, 140, border, false);
1375
+
1376
+ // Color swatch
1377
+ const r = (c.color >> 16) & 0xff,
1378
+ g = (c.color >> 8) & 0xff,
1379
+ b = c.color & 0xff;
1380
+ rect(cx + 35, 110, 40, 40, rgba8(r, g, b), true);
1381
+
1382
+ print(c.name, cx + 8, 158, selected ? rgba8(255, 255, 200) : rgba8(160, 160, 180));
1383
+ print(`HP: ${c.hp}`, cx + 8, 174, rgba8(100, 200, 100));
1384
+ print(`ATK: ${c.atk}`, cx + 8, 186, rgba8(200, 100, 100));
1385
+ print(`SPD: ${(c.spd * 100).toFixed(0)}%`, cx + 8, 198, rgba8(100, 150, 255));
1386
+ print(c.desc, cx + 4, 216, rgba8(120, 120, 140));
1387
+ }
1388
+
1389
+ const pulse = Math.sin(t * 3) * 0.5 + 0.5;
1390
+ printCentered(
1391
+ 'LEFT/RIGHT to choose | SPACE to start',
1392
+ 320,
1393
+ 270,
1394
+ rgba8(255, 255, 100, Math.floor(100 + pulse * 155))
1395
+ );
1396
+ printCentered('WASD Move | SPC/Z Attack | SHIFT Dash | P Potion', 320, 300, rgba8(140, 140, 170));
1397
+ printCentered('Destroy spawners! Find the exit portal!', 320, 320, rgba8(120, 160, 140));
1398
+ }
1399
+
1400
+ function drawDead() {
1401
+ rect(0, 0, W, H, rgba8(50, 0, 0, 210), true);
1402
+ printCentered('YOU HAVE FALLEN', 320, 80, rgba8(255, 60, 60));
1403
+ printCentered(`${CLASSES[classIdx].name} Level ${player.lvl}`, 320, 120, rgba8(200, 200, 200));
1404
+ printCentered(
1405
+ `Score: ${score} | Kills: ${totalKills} | Floor: ${level + 1}`,
1406
+ 320,
1407
+ 150,
1408
+ rgba8(180, 180, 200)
1409
+ );
1410
+
1411
+ const rating =
1412
+ totalKills >= 50
1413
+ ? 'LEGENDARY'
1414
+ : totalKills >= 30
1415
+ ? 'HEROIC'
1416
+ : totalKills >= 15
1417
+ ? 'BRAVE'
1418
+ : 'NOVICE';
1419
+ const rc =
1420
+ totalKills >= 50
1421
+ ? rgba8(255, 215, 0)
1422
+ : totalKills >= 30
1423
+ ? rgba8(200, 100, 255)
1424
+ : rgba8(100, 200, 100);
1425
+ printCentered(rating, 320, 190, rc);
1426
+
1427
+ const pulse = Math.sin(t * 3) * 0.5 + 0.5;
1428
+ printCentered(
1429
+ 'PRESS SPACE TO TRY AGAIN',
1430
+ 320,
1431
+ 250,
1432
+ rgba8(255, 200, 150, Math.floor(100 + pulse * 155))
1433
+ );
1434
+ }
1435
+
1436
+ function drawLevelComplete() {
1437
+ rect(0, 0, W, H, rgba8(0, 20, 40, 210), true);
1438
+ printCentered(`FLOOR ${level + 1} COMPLETE!`, 320, 80, rgba8(100, 255, 200));
1439
+ printCentered(`Score: ${score} | Kills: ${kills}`, 320, 130, rgba8(200, 200, 200));
1440
+ printCentered(`${CLASSES[classIdx].name} Level ${player.lvl}`, 320, 160, rgba8(180, 180, 220));
1441
+
1442
+ if (level < THEMES.length - 1) {
1443
+ const nextIsBoss = (level + 2) % 3 === 0;
1444
+ printCentered(
1445
+ `Next: ${THEMES[level + 1].name}${nextIsBoss ? ' ⚔ BOSS FLOOR ⚔' : ''}`,
1446
+ 320,
1447
+ 200,
1448
+ nextIsBoss ? rgba8(255, 100, 100) : rgba8(200, 180, 140)
1449
+ );
1450
+ } else {
1451
+ printCentered('The depths grow darker...', 320, 200, rgba8(200, 100, 100));
1452
+ }
1453
+
1454
+ kills = 0; // reset per-floor kills
1455
+ const pulse = Math.sin(t * 3) * 0.5 + 0.5;
1456
+ printCentered(
1457
+ 'PRESS SPACE TO CONTINUE',
1458
+ 320,
1459
+ 260,
1460
+ rgba8(255, 255, 100, Math.floor(100 + pulse * 155))
1461
+ );
1462
+ }