nova64 0.2.5 → 0.2.7

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 (185) 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/dist/runtime/api-2d.js +1158 -0
  118. package/dist/runtime/api-3d/camera.js +73 -0
  119. package/dist/runtime/api-3d/instancing.js +180 -0
  120. package/dist/runtime/api-3d/lights.js +51 -0
  121. package/dist/runtime/api-3d/materials.js +47 -0
  122. package/dist/runtime/api-3d/models.js +84 -0
  123. package/dist/runtime/api-3d/particles.js +296 -0
  124. package/dist/runtime/api-3d/pbr.js +113 -0
  125. package/dist/runtime/api-3d/primitives.js +304 -0
  126. package/dist/runtime/api-3d/scene.js +169 -0
  127. package/dist/runtime/api-3d/transforms.js +161 -0
  128. package/dist/runtime/api-3d.js +166 -0
  129. package/dist/runtime/api-effects.js +840 -0
  130. package/dist/runtime/api-gameutils.js +476 -0
  131. package/dist/runtime/api-generative.js +610 -0
  132. package/dist/runtime/api-presets.js +85 -0
  133. package/dist/runtime/api-skybox.js +232 -0
  134. package/dist/runtime/api-sprites.js +100 -0
  135. package/dist/runtime/api-voxel.js +712 -0
  136. package/dist/runtime/api.js +201 -0
  137. package/dist/runtime/assets.js +27 -0
  138. package/dist/runtime/audio.js +114 -0
  139. package/dist/runtime/collision.js +47 -0
  140. package/dist/runtime/console.js +101 -0
  141. package/dist/runtime/editor.js +233 -0
  142. package/dist/runtime/font.js +233 -0
  143. package/dist/runtime/framebuffer.js +28 -0
  144. package/dist/runtime/fullscreen-button.js +185 -0
  145. package/dist/runtime/gpu-canvas2d.js +47 -0
  146. package/dist/runtime/gpu-threejs.js +643 -0
  147. package/dist/runtime/gpu-webgl2.js +310 -0
  148. package/dist/runtime/index.d.ts +682 -0
  149. package/dist/runtime/index.js +22 -0
  150. package/dist/runtime/input.js +225 -0
  151. package/dist/runtime/logger.js +60 -0
  152. package/dist/runtime/physics.js +101 -0
  153. package/dist/runtime/screens.js +213 -0
  154. package/dist/runtime/storage.js +38 -0
  155. package/dist/runtime/store.js +151 -0
  156. package/dist/runtime/textinput.js +68 -0
  157. package/dist/runtime/ui/buttons.js +124 -0
  158. package/dist/runtime/ui/panels.js +105 -0
  159. package/dist/runtime/ui/text.js +86 -0
  160. package/dist/runtime/ui/widgets.js +141 -0
  161. package/dist/runtime/ui.js +111 -0
  162. package/index.html +6 -1
  163. package/package.json +9 -2
  164. package/public/assets/sky/studio/nx.png +0 -0
  165. package/public/assets/sky/studio/ny.png +0 -0
  166. package/public/assets/sky/studio/nz.png +0 -0
  167. package/public/assets/sky/studio/px.png +0 -0
  168. package/public/assets/sky/studio/py.png +0 -0
  169. package/public/assets/sky/studio/pz.png +0 -0
  170. package/public/os9-shell/assets/index-KchE_ngx.js +483 -0
  171. package/public/os9-shell/assets/index-KchE_ngx.js.map +1 -0
  172. package/public/os9-shell/index.html +10 -1
  173. package/runtime/api-2d.js +301 -21
  174. package/runtime/api-3d/pbr.js +45 -1
  175. package/runtime/api-3d.js +1 -0
  176. package/runtime/api-effects.js +90 -3
  177. package/runtime/api-gameutils.js +476 -0
  178. package/runtime/api-generative.js +610 -0
  179. package/runtime/api-skybox.js +54 -0
  180. package/runtime/api-voxel.js +139 -28
  181. package/runtime/gpu-threejs.js +13 -9
  182. package/runtime/ui.js +2 -2
  183. package/src/main.js +20 -0
  184. package/public/os9-shell/assets/index-B1Uvacma.js +0 -32825
  185. package/public/os9-shell/assets/index-B1Uvacma.js.map +0 -1
@@ -0,0 +1,3928 @@
1
+ // ⚔️ WIZARDRY NOVA 64 — First-Person Grid-Based Dungeon RPG ⚔️
2
+ // Inspired by Wizardry: Proving Grounds of the Mad Overlord
3
+
4
+ const W = 640,
5
+ H = 360;
6
+ const TILE = 3; // world units per grid cell
7
+ const DIRS = [
8
+ [0, -1],
9
+ [1, 0],
10
+ [0, 1],
11
+ [-1, 0],
12
+ ]; // N E S W
13
+ const DIR_NAMES = ['North', 'East', 'South', 'West'];
14
+
15
+ // Dungeon tile types
16
+ const T = {
17
+ WALL: 0,
18
+ FLOOR: 1,
19
+ DOOR: 2,
20
+ STAIRS_DOWN: 3,
21
+ STAIRS_UP: 4,
22
+ CHEST: 5,
23
+ FOUNTAIN: 6,
24
+ TRAP: 7,
25
+ BOSS: 8,
26
+ };
27
+
28
+ // Character classes
29
+ const CLASSES = ['Fighter', 'Mage', 'Priest', 'Thief'];
30
+ const CLASS_COLORS = { Fighter: 0xff4444, Mage: 0x4488ff, Priest: 0xffdd44, Thief: 0x44ff88 };
31
+ const CLASS_ICONS = { Fighter: '⚔', Mage: '✦', Priest: '✚', Thief: '◆' };
32
+
33
+ // Monster templates per floor tier — with shape hints for 3D variety
34
+ const MONSTERS = [
35
+ // Floor 1-2
36
+ [
37
+ { name: 'Kobold', hp: 8, atk: 3, def: 1, xp: 5, gold: 3, color: 0x886644, shape: 'small' },
38
+ { name: 'Giant Rat', hp: 6, atk: 2, def: 0, xp: 3, gold: 1, color: 0x666655, shape: 'beast' },
39
+ { name: 'Skeleton', hp: 12, atk: 4, def: 2, xp: 8, gold: 5, color: 0xccccaa, shape: 'undead' },
40
+ { name: 'Bat Swarm', hp: 5, atk: 2, def: 0, xp: 4, gold: 2, color: 0x554444, shape: 'beast' },
41
+ { name: 'Goblin', hp: 10, atk: 3, def: 1, xp: 6, gold: 4, color: 0x668844, shape: 'small' },
42
+ ],
43
+ // Floor 3-4
44
+ [
45
+ { name: 'Orc', hp: 18, atk: 6, def: 3, xp: 15, gold: 10, color: 0x448833, shape: 'brute' },
46
+ { name: 'Zombie', hp: 22, atk: 5, def: 2, xp: 12, gold: 4, color: 0x556644, shape: 'undead' },
47
+ {
48
+ name: 'Dark Elf',
49
+ hp: 15,
50
+ atk: 8,
51
+ def: 4,
52
+ xp: 20,
53
+ gold: 15,
54
+ color: 0x443366,
55
+ shape: 'caster',
56
+ },
57
+ { name: 'Gargoyle', hp: 25, atk: 7, def: 5, xp: 18, gold: 12, color: 0x888888, shape: 'brute' },
58
+ { name: 'Mimic', hp: 20, atk: 9, def: 3, xp: 22, gold: 25, color: 0x886633, shape: 'small' },
59
+ { name: 'Specter', hp: 16, atk: 7, def: 2, xp: 16, gold: 8, color: 0x6666aa, shape: 'ghost' },
60
+ ],
61
+ // Floor 5-6
62
+ [
63
+ { name: 'Troll', hp: 35, atk: 10, def: 5, xp: 30, gold: 20, color: 0x336633, shape: 'brute' },
64
+ { name: 'Wraith', hp: 25, atk: 12, def: 3, xp: 35, gold: 25, color: 0x333355, shape: 'ghost' },
65
+ { name: 'Dragon', hp: 60, atk: 15, def: 8, xp: 80, gold: 50, color: 0xcc4422, shape: 'dragon' },
66
+ {
67
+ name: 'Fire Elemental',
68
+ hp: 30,
69
+ atk: 14,
70
+ def: 4,
71
+ xp: 40,
72
+ gold: 30,
73
+ color: 0xff4400,
74
+ shape: 'caster',
75
+ },
76
+ { name: 'Medusa', hp: 28, atk: 11, def: 6, xp: 45, gold: 35, color: 0x448844, shape: 'caster' },
77
+ {
78
+ name: 'Iron Golem',
79
+ hp: 50,
80
+ atk: 9,
81
+ def: 10,
82
+ xp: 50,
83
+ gold: 40,
84
+ color: 0x777788,
85
+ shape: 'brute',
86
+ },
87
+ ],
88
+ // Floor 7+
89
+ [
90
+ {
91
+ name: 'Demon Lord',
92
+ hp: 70,
93
+ atk: 18,
94
+ def: 8,
95
+ xp: 100,
96
+ gold: 60,
97
+ color: 0xaa2222,
98
+ shape: 'dragon',
99
+ },
100
+ { name: 'Lich', hp: 45, atk: 20, def: 5, xp: 90, gold: 55, color: 0x6644aa, shape: 'caster' },
101
+ {
102
+ name: 'Death Knight',
103
+ hp: 55,
104
+ atk: 16,
105
+ def: 9,
106
+ xp: 85,
107
+ gold: 50,
108
+ color: 0x333344,
109
+ shape: 'undead',
110
+ },
111
+ ],
112
+ ];
113
+
114
+ // Boss monsters (floor 3 and 5)
115
+ const BOSSES = {
116
+ 1: {
117
+ name: 'Goblin Chieftain',
118
+ hp: 40,
119
+ atk: 6,
120
+ def: 3,
121
+ xp: 30,
122
+ gold: 25,
123
+ color: 0x668833,
124
+ shape: 'brute',
125
+ },
126
+ 3: {
127
+ name: 'Lich King',
128
+ hp: 80,
129
+ atk: 14,
130
+ def: 6,
131
+ xp: 100,
132
+ gold: 60,
133
+ color: 0x6622aa,
134
+ shape: 'caster',
135
+ },
136
+ 5: {
137
+ name: 'Ancient Dragon',
138
+ hp: 150,
139
+ atk: 20,
140
+ def: 10,
141
+ xp: 200,
142
+ gold: 100,
143
+ color: 0xff3300,
144
+ shape: 'dragon',
145
+ },
146
+ 6: {
147
+ name: 'Frost Titan',
148
+ hp: 120,
149
+ atk: 18,
150
+ def: 12,
151
+ xp: 180,
152
+ gold: 90,
153
+ color: 0x88bbff,
154
+ shape: 'brute',
155
+ },
156
+ 7: {
157
+ name: 'Infernal Archon',
158
+ hp: 200,
159
+ atk: 25,
160
+ def: 12,
161
+ xp: 300,
162
+ gold: 150,
163
+ color: 0xff2200,
164
+ shape: 'dragon',
165
+ },
166
+ };
167
+
168
+ // Equipment that can drop from chests
169
+ const EQUIPMENT = [
170
+ // Weapons
171
+ { name: 'Iron Sword', slot: 'weapon', atk: 2, def: 0, class: 'Fighter', tier: 1 },
172
+ { name: 'Battle Axe', slot: 'weapon', atk: 3, def: 0, class: 'Fighter', tier: 2 },
173
+ { name: 'Holy Mace', slot: 'weapon', atk: 2, def: 1, class: 'Priest', tier: 1 },
174
+ { name: 'Arcane Staff', slot: 'weapon', atk: 1, def: 0, class: 'Mage', tier: 1, mpBonus: 3 },
175
+ { name: 'Shadow Dagger', slot: 'weapon', atk: 3, def: 0, class: 'Thief', tier: 1 },
176
+ { name: 'Flame Blade', slot: 'weapon', atk: 5, def: 0, class: 'Fighter', tier: 3 },
177
+ { name: 'Staff of Power', slot: 'weapon', atk: 2, def: 0, class: 'Mage', tier: 3, mpBonus: 6 },
178
+ { name: 'Vorpal Dagger', slot: 'weapon', atk: 6, def: 0, class: 'Thief', tier: 3 },
179
+ { name: 'Blessed Hammer', slot: 'weapon', atk: 4, def: 1, class: 'Priest', tier: 2 },
180
+ { name: 'Frost Brand', slot: 'weapon', atk: 4, def: 0, class: 'Fighter', tier: 2, mpBonus: 1 },
181
+ { name: 'Wand of Lightning', slot: 'weapon', atk: 3, def: 0, class: 'Mage', tier: 2, mpBonus: 4 },
182
+ // Armor
183
+ { name: 'Chain Mail', slot: 'armor', atk: 0, def: 2, class: 'Fighter', tier: 1 },
184
+ { name: 'Leather Armor', slot: 'armor', atk: 0, def: 1, class: 'Thief', tier: 1 },
185
+ { name: 'Mage Robe', slot: 'armor', atk: 0, def: 1, class: 'Mage', tier: 1, mpBonus: 2 },
186
+ { name: 'Plate Armor', slot: 'armor', atk: 0, def: 4, class: 'Fighter', tier: 2 },
187
+ {
188
+ name: 'Blessed Vestments',
189
+ slot: 'armor',
190
+ atk: 0,
191
+ def: 2,
192
+ class: 'Priest',
193
+ tier: 2,
194
+ mpBonus: 3,
195
+ },
196
+ { name: 'Dragon Scale', slot: 'armor', atk: 1, def: 6, class: 'Fighter', tier: 3 },
197
+ { name: 'Shadow Cloak', slot: 'armor', atk: 1, def: 3, class: 'Thief', tier: 3 },
198
+ { name: 'Frost Mail', slot: 'armor', atk: 0, def: 5, class: 'Fighter', tier: 3 },
199
+ { name: 'Archmage Robe', slot: 'armor', atk: 0, def: 2, class: 'Mage', tier: 3, mpBonus: 5 },
200
+ { name: 'Holy Plate', slot: 'armor', atk: 0, def: 4, class: 'Priest', tier: 3, mpBonus: 4 },
201
+ { name: 'Assassin Garb', slot: 'armor', atk: 2, def: 2, class: 'Thief', tier: 2 },
202
+ ];
203
+
204
+ // Spells
205
+ const SPELLS = {
206
+ // Mage spells
207
+ FIRE: { name: 'Fireball', cost: 3, dmg: 12, type: 'attack', class: 'Mage', desc: 'AoE fire' },
208
+ ICE: { name: 'Ice Bolt', cost: 2, dmg: 8, type: 'attack', class: 'Mage', desc: 'Single target' },
209
+ SHIELD: {
210
+ name: 'Mana Shield',
211
+ cost: 4,
212
+ amount: 4,
213
+ type: 'buff_def',
214
+ class: 'Mage',
215
+ desc: '+DEF party',
216
+ },
217
+ LIGHTNING: {
218
+ name: 'Lightning Bolt',
219
+ cost: 4,
220
+ dmg: 15,
221
+ type: 'attack',
222
+ class: 'Mage',
223
+ desc: 'Chain lightning',
224
+ },
225
+ SLOW: {
226
+ name: 'Slow',
227
+ cost: 3,
228
+ amount: 3,
229
+ type: 'debuff_def',
230
+ class: 'Mage',
231
+ desc: '-DEF enemies',
232
+ },
233
+ // Priest spells
234
+ HEAL: { name: 'Heal', cost: 2, amount: 15, type: 'heal', class: 'Priest', desc: 'Heal one ally' },
235
+ BLESS: { name: 'Bless', cost: 3, amount: 3, type: 'buff', class: 'Priest', desc: '+ATK party' },
236
+ TURN_UNDEAD: {
237
+ name: 'Turn Undead',
238
+ cost: 2,
239
+ dmg: 20,
240
+ type: 'undead',
241
+ class: 'Priest',
242
+ desc: 'Smite undead',
243
+ },
244
+ REVIVE: {
245
+ name: 'Revive',
246
+ cost: 6,
247
+ amount: 10,
248
+ type: 'revive',
249
+ class: 'Priest',
250
+ desc: 'Revive ally',
251
+ },
252
+ GROUP_HEAL: {
253
+ name: 'Group Heal',
254
+ cost: 5,
255
+ amount: 10,
256
+ type: 'group_heal',
257
+ class: 'Priest',
258
+ desc: 'Heal all allies',
259
+ },
260
+ SMITE: { name: 'Smite', cost: 3, dmg: 10, type: 'attack', class: 'Priest', desc: 'Holy damage' },
261
+ // Thief spells
262
+ BACKSTAB: {
263
+ name: 'Backstab',
264
+ cost: 2,
265
+ dmg: 18,
266
+ type: 'attack',
267
+ class: 'Thief',
268
+ desc: 'Bonus damage',
269
+ },
270
+ };
271
+
272
+ // Shop items available for purchase
273
+ const SHOP_ITEMS = [
274
+ {
275
+ name: 'Healing Potion',
276
+ type: 'potion',
277
+ effect: 'hp',
278
+ amount: 25,
279
+ cost: 15,
280
+ desc: 'Restore 25 HP to one ally',
281
+ },
282
+ {
283
+ name: 'Mana Potion',
284
+ type: 'potion',
285
+ effect: 'mp',
286
+ amount: 10,
287
+ cost: 20,
288
+ desc: 'Restore 10 MP to one ally',
289
+ },
290
+ {
291
+ name: 'Revival Herb',
292
+ type: 'potion',
293
+ effect: 'revive',
294
+ amount: 15,
295
+ cost: 50,
296
+ desc: 'Revive a fallen ally',
297
+ },
298
+ {
299
+ name: 'Party Heal',
300
+ type: 'potion',
301
+ effect: 'party_hp',
302
+ amount: 15,
303
+ cost: 40,
304
+ desc: 'Restore 15 HP to all',
305
+ },
306
+ {
307
+ name: 'Whetstone',
308
+ type: 'buff',
309
+ effect: 'atk',
310
+ amount: 2,
311
+ cost: 30,
312
+ desc: '+2 ATK to one ally for next floor',
313
+ },
314
+ {
315
+ name: 'Iron Shield',
316
+ type: 'buff',
317
+ effect: 'def',
318
+ amount: 2,
319
+ cost: 30,
320
+ desc: '+2 DEF to one ally for next floor',
321
+ },
322
+ ];
323
+
324
+ // Floor atmosphere themes
325
+ const FLOOR_THEMES = [
326
+ {
327
+ name: 'Musty Cellars',
328
+ wallColor: 0x887766,
329
+ floorColor: 0x554433,
330
+ ceilColor: 0x443322,
331
+ fogColor: 0x1a1510,
332
+ skyTop: 0x221510,
333
+ skyBot: 0x0a0805,
334
+ ambColor: 0x665544,
335
+ ambInt: 0.55,
336
+ },
337
+ {
338
+ name: 'Flooded Crypts',
339
+ wallColor: 0x556677,
340
+ floorColor: 0x334455,
341
+ ceilColor: 0x1a2233,
342
+ fogColor: 0x0a1520,
343
+ skyTop: 0x102030,
344
+ skyBot: 0x080c10,
345
+ ambColor: 0x445566,
346
+ ambInt: 0.5,
347
+ },
348
+ {
349
+ name: 'Fungal Warrens',
350
+ wallColor: 0x558855,
351
+ floorColor: 0x336633,
352
+ ceilColor: 0x1a441a,
353
+ fogColor: 0x0a1a0a,
354
+ skyTop: 0x153015,
355
+ skyBot: 0x081008,
356
+ ambColor: 0x447744,
357
+ ambInt: 0.55,
358
+ },
359
+ {
360
+ name: 'Obsidian Vaults',
361
+ wallColor: 0x445566,
362
+ floorColor: 0x223344,
363
+ ceilColor: 0x151530,
364
+ fogColor: 0x0a0a1a,
365
+ skyTop: 0x151530,
366
+ skyBot: 0x080810,
367
+ ambColor: 0x334466,
368
+ ambInt: 0.45,
369
+ },
370
+ {
371
+ name: "The Dragon's Lair",
372
+ wallColor: 0x885533,
373
+ floorColor: 0x663318,
374
+ ceilColor: 0x441a08,
375
+ fogColor: 0x200a00,
376
+ skyTop: 0x301500,
377
+ skyBot: 0x100800,
378
+ ambColor: 0x774422,
379
+ ambInt: 0.5,
380
+ },
381
+ {
382
+ name: 'Frozen Catacombs',
383
+ wallColor: 0x667788,
384
+ floorColor: 0x445566,
385
+ ceilColor: 0x334455,
386
+ fogColor: 0x1a2535,
387
+ skyTop: 0x203040,
388
+ skyBot: 0x101820,
389
+ ambColor: 0x5577aa,
390
+ ambInt: 0.5,
391
+ },
392
+ {
393
+ name: 'Infernal Pit',
394
+ wallColor: 0x884422,
395
+ floorColor: 0x661a00,
396
+ ceilColor: 0x441100,
397
+ fogColor: 0x300800,
398
+ skyTop: 0x401000,
399
+ skyBot: 0x200500,
400
+ ambColor: 0x883311,
401
+ ambInt: 0.45,
402
+ },
403
+ ];
404
+
405
+ // State
406
+ let gameState; // 'title', 'explore', 'combat', 'inventory', 'gameover', 'victory'
407
+ let floor, px, py, facing; // player grid pos + direction (0-3)
408
+ let dungeon; // 2D array
409
+ let dungeonW, dungeonH;
410
+ let torchLights;
411
+ let party; // array of party members
412
+ let enemies; // current combat encounter
413
+ let combatLog;
414
+ let combatTurn; // 0..party.length-1 or 'enemy'
415
+ let combatAction; // current action selection state
416
+ let selectedTarget;
417
+ let animTimer; // for transitions
418
+ let enemyDelay; // separate timer for enemy turn delay
419
+ let autoPlay; // auto-combat mode
420
+ let stepAnim; // walking bob
421
+ let targetYaw, currentYaw;
422
+ let encounterChance;
423
+ let totalGold;
424
+ let dungeonsCleared;
425
+ let floatingTexts;
426
+ let shake;
427
+ let floorMessage;
428
+ let floorMessageTimer;
429
+
430
+ // Visual state
431
+ let screenFlash; // {r, g, b, alpha, decay}
432
+ let animatedMeshes; // meshes that bob/rotate
433
+ let particleSystems; // track active particle system IDs
434
+ let explored; // Set of "x,y" strings for fog-of-war minimap
435
+ let bossDefeated; // Set of floor numbers where boss was killed
436
+ let minimap; // createMinimap() object for dungeon map
437
+ let stateMachine; // createStateMachine for game flow
438
+
439
+ // Shop state
440
+ let shopItems; // current shop inventory
441
+ let shopCursor; // selected shop item index
442
+ let shopTarget; // which party member to apply item to
443
+
444
+ // Hit/invulnerability state for party
445
+ let hitStates; // array of createHitState per party member
446
+ let chromaTimer; // timer for chromatic aberration effect on boss hits
447
+ let glitchTimer; // timer for screen glitch effect on player damage
448
+ let combatFOV; // smooth FOV lerp for combat zoom
449
+ let floorTransition; // checkerboard wipe timer when entering floors
450
+ let cooldowns; // createCooldownSet for input + movement
451
+
452
+ // Spell VFX overlay
453
+ let spellVFX; // { type, x, y, timer, color } for drawStarburst/drawRadialGradient
454
+
455
+ // Visual preset mode (toggled in inventory)
456
+ let visualPreset; // null, 'n64', or 'psx'
457
+
458
+ // Floor message timer (createTimer API)
459
+ let msgTimer; // createTimer object for floor messages
460
+
461
+ // Encounter spawner (createSpawner API) — scales encounter count per floor
462
+ let encounterSpawner;
463
+
464
+ // Combat spark pool (createPool API)
465
+ let sparkPool; // pool of {x, y, vx, vy, life, color} for hit sparks
466
+
467
+ // Water shader tracking (createShaderMaterial API)
468
+ let waterShaders; // array of { shaderId, meshId } for animated fountain water
469
+
470
+ // 3D floating damage texts (drawFloatingTexts3D API)
471
+ let floatingTexts3D; // separate system for world-space damage numbers
472
+
473
+ // Instanced dungeon decorations (createInstancedMesh API)
474
+ let instancedDecor; // instanced mesh ID for floor crystal decorations
475
+
476
+ // Boss flow field energy pattern (flowField API)
477
+ let bossFlowField; // Float32Array of angles for boss room visualization
478
+
479
+ // Procedural floor fog texture (noiseMap API)
480
+ let floorNoiseMap; // Float32Array for deep floor atmospheric overlay
481
+
482
+ // Game over restart button (createButton API)
483
+ let restartButton;
484
+
485
+ // Game statistics store (createGameStore API)
486
+ let gameStats;
487
+
488
+ // LOD torch decorations (createLODMesh API)
489
+ let lodTorches;
490
+
491
+ // Title screen 3D scene
492
+ let titleMeshes = [];
493
+ let titleLights = [];
494
+ let titleParticles = [];
495
+
496
+ // 3D mesh tracking
497
+ let currentLevelMeshes = [];
498
+ let monsterMeshes = [];
499
+
500
+ // ═══════════════════════════════════════════════════════════════════════
501
+ // HELPERS
502
+ // ═══════════════════════════════════════════════════════════════════════
503
+
504
+ // Transition game state via state machine for elapsed tracking
505
+ function switchState(newState) {
506
+ gameState = newState;
507
+ if (stateMachine) stateMachine.switchTo(newState);
508
+ // Track state in global novaStore for cross-system awareness
509
+ if (typeof novaStore !== 'undefined' && novaStore) {
510
+ novaStore.setState({ gameState: newState, floor: floor || 0 });
511
+ }
512
+ }
513
+
514
+ // State elapsed time (seconds in current state)
515
+ function stateElapsed() {
516
+ return stateMachine ? stateMachine.getElapsed() : animTimer;
517
+ }
518
+
519
+ // Project 3D world coordinates to 2D screen for drawFloatingTexts3D
520
+ function worldToScreen(wx, wy, wz) {
521
+ const cx = px * TILE,
522
+ cz = py * TILE,
523
+ cy = 1.6;
524
+ const [fdx, fdz] = DIRS[facing];
525
+ const dx = wx - cx,
526
+ dy = wy - cy,
527
+ dz = wz - cz;
528
+ const depth = dx * fdx + dz * fdz;
529
+ if (depth <= 0.1) return [W / 2, H / 2];
530
+ const right = dx * -fdz + dz * fdx;
531
+ const halfFOV = Math.tan(((combatFOV || 75) * Math.PI) / 360);
532
+ const sx = W / 2 + (right / (depth * halfFOV)) * (W / 2);
533
+ const sy = H / 2 - (dy / (depth * halfFOV * (H / W))) * (H / 2);
534
+ return [sx, sy];
535
+ }
536
+
537
+ // ═══════════════════════════════════════════════════════════════════════
538
+ // DUNGEON GENERATION
539
+ // ═══════════════════════════════════════════════════════════════════════
540
+
541
+ function generateDungeon(w, h) {
542
+ dungeonW = w;
543
+ dungeonH = h;
544
+ const map = Array.from({ length: h }, () => new Array(w).fill(T.WALL));
545
+
546
+ // Carve rooms
547
+ const rooms = [];
548
+ const attempts = 60;
549
+ for (let a = 0; a < attempts; a++) {
550
+ const rw = 3 + Math.floor(Math.random() * 4);
551
+ const rh = 3 + Math.floor(Math.random() * 4);
552
+ const rx = 1 + Math.floor(Math.random() * (w - rw - 2));
553
+ const ry = 1 + Math.floor(Math.random() * (h - rh - 2));
554
+
555
+ let overlap = false;
556
+ for (const r of rooms) {
557
+ if (rx <= r.x + r.w + 1 && rx + rw >= r.x - 1 && ry <= r.y + r.h + 1 && ry + rh >= r.y - 1) {
558
+ overlap = true;
559
+ break;
560
+ }
561
+ }
562
+ if (overlap) continue;
563
+
564
+ for (let y = ry; y < ry + rh; y++) for (let x = rx; x < rx + rw; x++) map[y][x] = T.FLOOR;
565
+ rooms.push({ x: rx, y: ry, w: rw, h: rh });
566
+ }
567
+
568
+ // Connect rooms with corridors
569
+ for (let i = 1; i < rooms.length; i++) {
570
+ const a = rooms[i - 1],
571
+ b = rooms[i];
572
+ const ax = a.x + Math.floor(a.w / 2),
573
+ ay = a.y + Math.floor(a.h / 2);
574
+ const bx = b.x + Math.floor(b.w / 2),
575
+ by = b.y + Math.floor(b.h / 2);
576
+ let cx = ax,
577
+ cy = ay;
578
+ while (cx !== bx) {
579
+ if (cx >= 0 && cx < w && cy >= 0 && cy < h) map[cy][cx] = T.FLOOR;
580
+ cx += cx < bx ? 1 : -1;
581
+ }
582
+ while (cy !== by) {
583
+ if (cx >= 0 && cx < w && cy >= 0 && cy < h) map[cy][cx] = T.FLOOR;
584
+ cy += cy < by ? 1 : -1;
585
+ }
586
+ }
587
+
588
+ // Place doors between corridors and rooms
589
+ for (let y = 1; y < h - 1; y++) {
590
+ for (let x = 1; x < w - 1; x++) {
591
+ if (map[y][x] !== T.FLOOR) continue;
592
+ // Narrow corridor opening into room = door candidate
593
+ const horiz = map[y][x - 1] === T.WALL && map[y][x + 1] === T.WALL;
594
+ const vert = map[y - 1][x] === T.WALL && map[y + 1][x] === T.WALL;
595
+ if ((horiz || vert) && Math.random() < 0.15) {
596
+ map[y][x] = T.DOOR;
597
+ }
598
+ }
599
+ }
600
+
601
+ // Place stairs down in last room
602
+ if (rooms.length > 1) {
603
+ const last = rooms[rooms.length - 1];
604
+ const sx = last.x + Math.floor(last.w / 2);
605
+ const sy = last.y + Math.floor(last.h / 2);
606
+ map[sy][sx] = T.STAIRS_DOWN;
607
+ }
608
+
609
+ // Place stairs up in first room (return)
610
+ if (floor > 1 && rooms.length > 0) {
611
+ const first = rooms[0];
612
+ map[first.y + 1][first.x + 1] = T.STAIRS_UP;
613
+ }
614
+
615
+ // Scatter chests, fountains, and traps
616
+ for (let i = 0; i < 3 + floor; i++) {
617
+ const r = rooms[Math.floor(Math.random() * rooms.length)];
618
+ const cx = r.x + 1 + Math.floor(Math.random() * Math.max(1, r.w - 2));
619
+ const cy = r.y + 1 + Math.floor(Math.random() * Math.max(1, r.h - 2));
620
+ if (map[cy][cx] === T.FLOOR) {
621
+ const roll = Math.random();
622
+ if (roll < 0.5) map[cy][cx] = T.CHEST;
623
+ else if (roll < 0.7) map[cy][cx] = T.FOUNTAIN;
624
+ else map[cy][cx] = T.TRAP;
625
+ }
626
+ }
627
+
628
+ // Place boss room on floors 3 and 5 (in a large room near stairs)
629
+ if ((floor === 3 || floor === 5) && rooms.length > 2 && !bossDefeated.has(floor)) {
630
+ const bossRoom = rooms[rooms.length - 2]; // room before last
631
+ const bx = bossRoom.x + Math.floor(bossRoom.w / 2);
632
+ const by = bossRoom.y + Math.floor(bossRoom.h / 2);
633
+ if (map[by][bx] === T.FLOOR) map[by][bx] = T.BOSS;
634
+ }
635
+
636
+ // Player starts in first room center
637
+ if (rooms.length > 0) {
638
+ const first = rooms[0];
639
+ px = first.x + Math.floor(first.w / 2);
640
+ py = first.y + Math.floor(first.h / 2);
641
+ map[py][px] = T.FLOOR; // ensure start is clear
642
+ }
643
+
644
+ return map;
645
+ }
646
+
647
+ // ═══════════════════════════════════════════════════════════════════════
648
+ // TITLE SCREEN 3D SCENE
649
+ // ═══════════════════════════════════════════════════════════════════════
650
+
651
+ function buildTitleScene() {
652
+ clearTitleScene();
653
+ const wallColor = 0x665544;
654
+ const floorColor = 0x443322;
655
+ const ceilColor = 0x2a1a0a;
656
+
657
+ // Stone corridor walls (left and right, extending into the distance)
658
+ for (let i = 0; i < 8; i++) {
659
+ const z = -3 - i * 3;
660
+ const wL = createCube(TILE, wallColor, [-TILE, 0, z], { roughness: 0.9 });
661
+ const wR = createCube(TILE, wallColor, [TILE, 0, z], { roughness: 0.9 });
662
+ setCastShadow(wL, true);
663
+ setCastShadow(wR, true);
664
+ setReceiveShadow(wL, true);
665
+ setReceiveShadow(wR, true);
666
+ titleMeshes.push(wL, wR);
667
+ }
668
+
669
+ // Floor and ceiling planes
670
+ const fl = createPlane(TILE * 2.2, 30, floorColor, [0, -1.5, -14]);
671
+ setReceiveShadow(fl, true);
672
+ titleMeshes.push(fl);
673
+ const ceil = createPlane(TILE * 2.2, 30, ceilColor, [0, 1.5, -14]);
674
+ setRotation(ceil, Math.PI, 0, 0);
675
+ titleMeshes.push(ceil);
676
+
677
+ // Torches along the corridor (alternating left/right)
678
+ const torchPositions = [
679
+ [-2.4, 1.0, -5],
680
+ [2.4, 1.0, -8],
681
+ [-2.4, 1.0, -11],
682
+ [2.4, 1.0, -14],
683
+ [-2.4, 1.0, -17],
684
+ [2.4, 1.0, -20],
685
+ ];
686
+ for (const [tx, ty, tz] of torchPositions) {
687
+ // Torch cone (emissive flame)
688
+ const torch = createCone(0.12, 0.35, 0xffaa33, [tx, ty, tz], {
689
+ material: 'emissive',
690
+ emissive: 0xff8800,
691
+ intensity: 2,
692
+ });
693
+ titleMeshes.push(torch);
694
+ // Warm point light
695
+ const light = createPointLight(0xff8833, 2.5, 10, tx, ty + 0.5, tz);
696
+ titleLights.push(light);
697
+ }
698
+
699
+ // Glowing portal at the far end
700
+ const portal = createTorus(1.5, 0.12, 0x8844ff, [0, 0, -24], {
701
+ material: 'emissive',
702
+ emissive: 0x8844ff,
703
+ intensity: 3,
704
+ });
705
+ titleMeshes.push(portal);
706
+ const portalGlow = createSphere(0.7, 0x6622cc, [0, 0, -24], {
707
+ material: 'emissive',
708
+ emissive: 0x6622ff,
709
+ intensity: 2,
710
+ });
711
+ titleMeshes.push(portalGlow);
712
+ // Portal light
713
+ const portalLight = createPointLight(0x7733ff, 3, 12, 0, 0, -24);
714
+ titleLights.push(portalLight);
715
+
716
+ // Particle system for floating embers
717
+ const ps = createParticleSystem({
718
+ max: 60,
719
+ size: 0.08,
720
+ color: 0xffaa44,
721
+ emissive: true,
722
+ });
723
+ titleParticles.push(ps);
724
+ }
725
+
726
+ function clearTitleScene() {
727
+ for (const id of titleMeshes) destroyMesh(id);
728
+ for (const id of titleLights) removeLight(id);
729
+ for (const id of titleParticles) removeParticleSystem(id);
730
+ titleMeshes = [];
731
+ titleLights = [];
732
+ titleParticles = [];
733
+ }
734
+
735
+ // ═══════════════════════════════════════════════════════════════════════
736
+ // 3D LEVEL BUILDING
737
+ // ═══════════════════════════════════════════════════════════════════════
738
+
739
+ function clearLevel() {
740
+ for (const id of currentLevelMeshes) destroyMesh(id);
741
+ currentLevelMeshes = [];
742
+ clearSkybox(); // clean up old skybox before rebuilding
743
+ if (torchLights) {
744
+ for (const id of torchLights) removeLight(id);
745
+ }
746
+ torchLights = [];
747
+ // Clean up particle systems
748
+ if (particleSystems) {
749
+ for (const id of particleSystems) removeParticleSystem(id);
750
+ }
751
+ particleSystems = [];
752
+ // Clean up instanced decorations before rebuilding
753
+ if (instancedDecor) {
754
+ removeInstancedMesh(instancedDecor);
755
+ instancedDecor = null;
756
+ }
757
+ floorNoiseMap = null; // regenerate noiseMap fog per floor
758
+ // Clean up LOD torches
759
+ if (lodTorches) {
760
+ for (const id of lodTorches) removeLODMesh(id);
761
+ }
762
+ lodTorches = [];
763
+ waterShaders = [];
764
+ clearMonsterMeshes();
765
+ }
766
+
767
+ function clearMonsterMeshes() {
768
+ for (const id of monsterMeshes) destroyMesh(id);
769
+ monsterMeshes = [];
770
+ }
771
+
772
+ function buildLevel() {
773
+ clearLevel();
774
+
775
+ const theme = FLOOR_THEMES[Math.min(floor - 1, FLOOR_THEMES.length - 1)];
776
+
777
+ // Update atmosphere per floor
778
+ setAmbientLight(theme.ambColor, theme.ambInt);
779
+ setFog(theme.fogColor, 2, 20 - floor);
780
+ // Dragon's Lair gets a dramatic space skybox; other floors use gradient
781
+ if (floor === 5) {
782
+ createSpaceSkybox({ starCount: 600, nebula: true });
783
+ } else {
784
+ createGradientSkybox(theme.skyTop, theme.skyBot);
785
+ }
786
+ // Set directional light angle per floor for varied shadow casting
787
+ const lightAngle = -0.8 - floor * 0.1;
788
+ setDirectionalLight([0.3, lightAngle, -0.5], theme.ambColor, 0.6 + floor * 0.08);
789
+ // Vary skybox rotation speed per floor depth
790
+ setSkyboxSpeed(0.2 + floor * 0.1);
791
+
792
+ for (let y = 0; y < dungeonH; y++) {
793
+ for (let x = 0; x < dungeonW; x++) {
794
+ const tile = dungeon[y][x];
795
+ const wx = x * TILE,
796
+ wz = y * TILE;
797
+
798
+ if (tile === T.WALL) {
799
+ // Only create visible walls (adjacent to floor)
800
+ let visible = false;
801
+ for (const [dx, dz] of DIRS) {
802
+ const nx = x + dx,
803
+ nz = y + dz;
804
+ if (nx >= 0 && nx < dungeonW && nz >= 0 && nz < dungeonH && dungeon[nz][nx] !== T.WALL) {
805
+ visible = true;
806
+ break;
807
+ }
808
+ }
809
+ if (visible) {
810
+ const m = createCube(TILE, theme.wallColor, [wx, TILE / 2, wz], { roughness: 0.9 });
811
+ currentLevelMeshes.push(m);
812
+ }
813
+ } else {
814
+ // Floor — receives shadows from walls, objects, and monsters
815
+ const f = createPlane(TILE, TILE, theme.floorColor, [wx, 0.01, wz]);
816
+ rotateMesh(f, -HALF_PI, 0, 0);
817
+ setReceiveShadow(f, true);
818
+ currentLevelMeshes.push(f);
819
+
820
+ // Ceiling
821
+ const c = createPlane(TILE, TILE, theme.ceilColor, [wx, TILE, wz]);
822
+ rotateMesh(c, HALF_PI, 0, 0);
823
+ currentLevelMeshes.push(c);
824
+
825
+ // Special tiles
826
+ if (tile === T.DOOR) {
827
+ // Wooden door frame
828
+ const d = createCube(TILE * 0.1, 0x886622, [wx, TILE / 2, wz], { roughness: 0.7 });
829
+ setScale(d, 1, 1, 0.3);
830
+ currentLevelMeshes.push(d);
831
+ // Door handle
832
+ const handle = createSphere(0.08, 0xccaa44, [wx + 0.3, TILE * 0.45, wz], 4, {
833
+ material: 'emissive',
834
+ emissive: 0xccaa44,
835
+ emissiveIntensity: 0.3,
836
+ });
837
+ currentLevelMeshes.push(handle);
838
+ setPBRProperties(handle, { metalness: 0.9, roughness: 0.2 });
839
+ } else if (tile === T.STAIRS_DOWN) {
840
+ const s = createCone(0.5, 1, 0x44aaff, [wx, 0.5, wz], {
841
+ material: 'emissive',
842
+ emissive: 0x44aaff,
843
+ emissiveIntensity: 0.8,
844
+ });
845
+ currentLevelMeshes.push(s);
846
+ animatedMeshes.push({ id: s, type: 'bob', baseY: 0.5, speed: 2, range: 0.2 });
847
+ // Stair glow particles
848
+ const ps = createParticleSystem(20, {
849
+ size: 0.08,
850
+ emissive: true,
851
+ gravity: 0.2,
852
+ emitRate: 3,
853
+ minLife: 1,
854
+ maxLife: 2,
855
+ minSpeed: 0.2,
856
+ maxSpeed: 0.5,
857
+ startColor: 0x44aaff,
858
+ endColor: 0x0044aa,
859
+ spread: 0.8,
860
+ });
861
+ setParticleEmitter(ps, { position: [wx, 0.5, wz] });
862
+ particleSystems.push(ps);
863
+ const l = createPointLight(0x44aaff, 1.5, 8, wx, 1.5, wz);
864
+ torchLights.push(l);
865
+ } else if (tile === T.STAIRS_UP) {
866
+ const s = createCone(0.5, 1, 0xffaa44, [wx, 0.5, wz], {
867
+ material: 'emissive',
868
+ emissive: 0xffaa44,
869
+ emissiveIntensity: 0.8,
870
+ });
871
+ currentLevelMeshes.push(s);
872
+ animatedMeshes.push({ id: s, type: 'bob', baseY: 0.5, speed: 2, range: 0.2 });
873
+ } else if (tile === T.CHEST) {
874
+ // Chest body
875
+ const ch = createCube(0.6, 0xddaa33, [wx, 0.35, wz], { roughness: 0.4, metallic: true });
876
+ setScale(ch, 1, 0.7, 0.7);
877
+ currentLevelMeshes.push(ch);
878
+ // Glowing lock
879
+ const lock = createSphere(0.06, 0xffee66, [wx, 0.5, wz - 0.22], 4, {
880
+ material: 'emissive',
881
+ emissive: 0xffee66,
882
+ emissiveIntensity: 0.6,
883
+ });
884
+ currentLevelMeshes.push(lock);
885
+ setPBRProperties(lock, { metalness: 1.0, roughness: 0.1 });
886
+ animatedMeshes.push({ id: lock, type: 'pulse', baseScale: 1, speed: 3, range: 0.3 });
887
+ } else if (tile === T.FOUNTAIN) {
888
+ const fb = createCylinder(0.6, 0.6, 0x667788, 8, [wx, 0.2, wz]);
889
+ currentLevelMeshes.push(fb);
890
+ const fw = createSphere(0.3, 0x3388ff, [wx, 0.5, wz], 6, {
891
+ material: 'emissive',
892
+ emissive: 0x3388ff,
893
+ emissiveIntensity: 0.6,
894
+ });
895
+ currentLevelMeshes.push(fw);
896
+ animatedMeshes.push({ id: fw, type: 'bob', baseY: 0.5, speed: 1.5, range: 0.15 });
897
+ // Apply animated water shader via createShaderMaterial
898
+ const wShader = createShaderMaterial('water');
899
+ if (wShader) {
900
+ const rawMesh = getMesh(fw);
901
+ if (rawMesh) rawMesh.material = wShader.material;
902
+ waterShaders.push({ shaderId: wShader.id, meshId: fw });
903
+ }
904
+ // Water particles
905
+ const ps = createParticleSystem(15, {
906
+ size: 0.05,
907
+ emissive: true,
908
+ gravity: -0.3,
909
+ emitRate: 4,
910
+ minLife: 0.8,
911
+ maxLife: 1.5,
912
+ minSpeed: 0.1,
913
+ maxSpeed: 0.3,
914
+ startColor: 0x3388ff,
915
+ endColor: 0x1144aa,
916
+ spread: 0.4,
917
+ });
918
+ setParticleEmitter(ps, { position: [wx, 0.6, wz] });
919
+ particleSystems.push(ps);
920
+ const l = createPointLight(0x3388ff, 1, 6, wx, 1, wz);
921
+ torchLights.push(l);
922
+ } else if (tile === T.TRAP) {
923
+ // Trap - subtle floor glyph
924
+ const trap = createPlane(1.2, 1.2, 0x662222, [wx, 0.02, wz]);
925
+ rotateMesh(trap, -HALF_PI, 0, 0);
926
+ currentLevelMeshes.push(trap);
927
+ // Warning rune
928
+ const rune = createTorus(0.3, 0.04, 0x881111, 6, [wx, 0.03, wz]);
929
+ rotateMesh(rune, HALF_PI, 0, 0);
930
+ currentLevelMeshes.push(rune);
931
+ animatedMeshes.push({ id: rune, type: 'spin', speed: 1 });
932
+ } else if (tile === T.BOSS) {
933
+ // Boss marker — advanced pillar with full PBR (createAdvancedCube)
934
+ const pillar = createAdvancedCube(
935
+ 0.8,
936
+ { color: 0x440022, metalness: 0.7, roughness: 0.3 },
937
+ [wx, 1.25, wz]
938
+ );
939
+ setScale(pillar, 0.5, 3.1, 0.5);
940
+ currentLevelMeshes.push(pillar);
941
+ const orb = createAdvancedSphere(
942
+ 0.3,
943
+ { color: 0xff0044, emissive: 0xff0044, emissiveIntensity: 1.2 },
944
+ [wx, 2.6, wz],
945
+ 8
946
+ );
947
+ currentLevelMeshes.push(orb);
948
+ animatedMeshes.push({ id: orb, type: 'pulse', baseScale: 1, speed: 2, range: 0.4 });
949
+ const l = createPointLight(0xff0044, 2, 10, wx, 2, wz);
950
+ torchLights.push(l);
951
+ }
952
+
953
+ // Scatter torches with particle fire + LOD variants
954
+ if (tile === T.FLOOR && Math.random() < 0.08) {
955
+ const l = createPointLight(0xffaa44, 2.5, 12, wx, 2.2, wz);
956
+ torchLights.push({ lightId: l, baseIntensity: 1.2, wx, wz });
957
+ // Create LOD torch: high detail close, low detail far
958
+ const lod = createLODMesh(
959
+ [
960
+ { shape: 'cone', size: 0.3, color: 0xff6600, distance: 0 },
961
+ { shape: 'cube', size: 0.15, color: 0xff6600, distance: 12 },
962
+ ],
963
+ [wx, 2.5, wz]
964
+ );
965
+ if (lod) lodTorches.push(lod);
966
+ const torch = createCone(0.1, 0.3, 0xff6600, [wx, 2.5, wz], {
967
+ material: 'emissive',
968
+ emissive: 0xff6600,
969
+ emissiveIntensity: 1.0,
970
+ });
971
+ currentLevelMeshes.push(torch);
972
+ // Fire particles
973
+ const ps = createParticleSystem(12, {
974
+ size: 0.06,
975
+ emissive: true,
976
+ gravity: -0.5,
977
+ emitRate: 6,
978
+ minLife: 0.3,
979
+ maxLife: 0.7,
980
+ minSpeed: 0.3,
981
+ maxSpeed: 0.6,
982
+ startColor: 0xff6600,
983
+ endColor: 0xff2200,
984
+ spread: 0.2,
985
+ });
986
+ setParticleEmitter(ps, { position: [wx, 2.6, wz] });
987
+ particleSystems.push(ps);
988
+ // Track light for flickering
989
+ torchLights.push({ lightId: l, baseIntensity: 1.2, wx, wz });
990
+ }
991
+ }
992
+ }
993
+ }
994
+
995
+ // Instanced crystal decorations scattered on floors (createInstancedMesh)
996
+ const crystalPositions = [];
997
+ for (let y = 0; y < dungeonH; y++) {
998
+ for (let x = 0; x < dungeonW; x++) {
999
+ if (dungeon[y][x] === T.FLOOR && Math.random() < 0.025) {
1000
+ crystalPositions.push([x * TILE, 0.15, y * TILE]);
1001
+ }
1002
+ }
1003
+ }
1004
+ if (crystalPositions.length > 0) {
1005
+ instancedDecor = createInstancedMesh('cone', crystalPositions.length, theme.wallColor, {
1006
+ size: 0.2,
1007
+ });
1008
+ for (let i = 0; i < crystalPositions.length; i++) {
1009
+ const [cx, cy, cz] = crystalPositions[i];
1010
+ const rot = Math.random() * TWO_PI;
1011
+ setInstanceTransform(instancedDecor, i, cx, cy, cz, 0, rot, 0, 0.12, 0.25, 0.12);
1012
+ setInstanceColor(instancedDecor, i, theme.ambColor);
1013
+ }
1014
+ finalizeInstances(instancedDecor);
1015
+ }
1016
+ }
1017
+
1018
+ // ═══════════════════════════════════════════════════════════════════════
1019
+ // PARTY CREATION
1020
+ // ═══════════════════════════════════════════════════════════════════════
1021
+
1022
+ function createParty() {
1023
+ return [
1024
+ makeChar('Aldric', 'Fighter', { hp: 30, atk: 8, def: 6, spd: 4 }),
1025
+ makeChar('Elara', 'Mage', { hp: 18, atk: 3, def: 2, spd: 5, mp: 12 }),
1026
+ makeChar('Torvin', 'Priest', { hp: 22, atk: 4, def: 4, spd: 3, mp: 10 }),
1027
+ makeChar('Shade', 'Thief', { hp: 20, atk: 6, def: 3, spd: 7 }),
1028
+ ];
1029
+ }
1030
+
1031
+ function makeChar(name, cls, stats) {
1032
+ return {
1033
+ name,
1034
+ class: cls,
1035
+ hp: stats.hp,
1036
+ maxHp: stats.hp,
1037
+ mp: stats.mp ?? 0,
1038
+ maxMp: stats.mp ?? 0,
1039
+ atk: stats.atk,
1040
+ def: stats.def,
1041
+ spd: stats.spd,
1042
+ level: 1,
1043
+ xp: 0,
1044
+ xpNext: 20,
1045
+ alive: true,
1046
+ buffAtk: 0,
1047
+ buffDef: 0,
1048
+ buffTimer: 0,
1049
+ weapon: null,
1050
+ armor: null,
1051
+ };
1052
+ }
1053
+
1054
+ function getEffectiveAtk(c) {
1055
+ let atk = c.atk + c.buffAtk;
1056
+ if (c.weapon) atk += c.weapon.atk;
1057
+ return atk;
1058
+ }
1059
+
1060
+ function getEffectiveDef(c) {
1061
+ let def = c.def + c.buffDef;
1062
+ if (c.armor) def += c.armor.def;
1063
+ return def;
1064
+ }
1065
+
1066
+ function equipItem(member, item) {
1067
+ const slot = item.slot;
1068
+ const old = member[slot];
1069
+ member[slot] = item;
1070
+ // Apply MP bonuses
1071
+ if (item.mpBonus) {
1072
+ member.maxMp += item.mpBonus;
1073
+ member.mp = Math.min(member.mp + item.mpBonus, member.maxMp);
1074
+ }
1075
+ if (old && old.mpBonus) {
1076
+ member.maxMp -= old.mpBonus;
1077
+ member.mp = Math.min(member.mp, member.maxMp);
1078
+ }
1079
+ return old;
1080
+ }
1081
+
1082
+ function levelUp(c) {
1083
+ c.level++;
1084
+ c.xpNext = Math.floor(c.xpNext * 1.5);
1085
+ const hpGain = c.class === 'Fighter' ? 8 : c.class === 'Priest' ? 5 : c.class === 'Mage' ? 4 : 6;
1086
+ c.maxHp += hpGain;
1087
+ c.hp = Math.min(c.hp + hpGain, c.maxHp);
1088
+ c.atk += c.class === 'Fighter' ? 3 : c.class === 'Thief' ? 2 : 1;
1089
+ c.def += c.class === 'Fighter' ? 2 : 1;
1090
+ if (c.maxMp > 0) {
1091
+ c.maxMp += 3;
1092
+ c.mp = Math.min(c.mp + 3, c.maxMp);
1093
+ }
1094
+ return hpGain;
1095
+ }
1096
+
1097
+ // ═══════════════════════════════════════════════════════════════════════
1098
+ // COMBAT SYSTEM
1099
+ // ═══════════════════════════════════════════════════════════════════════
1100
+
1101
+ function startCombat(isBoss) {
1102
+ let count, pool;
1103
+ if (isBoss && BOSSES[floor]) {
1104
+ // Boss encounter
1105
+ const b = BOSSES[floor];
1106
+ enemies = [
1107
+ {
1108
+ ...b,
1109
+ maxHp: b.hp,
1110
+ id: 0,
1111
+ isBoss: true,
1112
+ },
1113
+ ];
1114
+ count = 1;
1115
+ } else {
1116
+ const tier = Math.min(Math.floor((floor - 1) / 2), MONSTERS.length - 1);
1117
+ pool = MONSTERS[tier];
1118
+ // Use spawner wave count to scale encounters as player explores
1119
+ if (encounterSpawner) {
1120
+ encounterSpawner.wave++;
1121
+ encounterSpawner.totalSpawned++;
1122
+ }
1123
+ const waveBonus = encounterSpawner ? Math.min(encounterSpawner.wave, 3) : 0;
1124
+ count = 1 + randInt(0, Math.min(3, floor) - 1) + Math.floor(waveBonus / 2);
1125
+ count = Math.min(count, 4); // cap at 4 enemies
1126
+ enemies = [];
1127
+ for (let i = 0; i < count; i++) {
1128
+ const template = pool[randInt(0, pool.length - 1)];
1129
+ const scale = randRange(0.8, 1.2);
1130
+ enemies.push({
1131
+ ...template,
1132
+ hp: Math.floor(template.hp * scale * (1 + floor * 0.1)),
1133
+ maxHp: Math.floor(template.hp * scale * (1 + floor * 0.1)),
1134
+ atk: Math.floor(template.atk * (1 + floor * 0.08)),
1135
+ def: template.def,
1136
+ id: i,
1137
+ });
1138
+ }
1139
+ }
1140
+
1141
+ // Create varied monster meshes in front of player
1142
+ clearMonsterMeshes();
1143
+ const [dx, dz] = DIRS[facing];
1144
+ for (let i = 0; i < enemies.length; i++) {
1145
+ const e = enemies[i];
1146
+ const offset = (i - (enemies.length - 1) / 2) * 1.5;
1147
+ const perpX = -dz,
1148
+ perpZ = dx; // perpendicular
1149
+ const mx = px * TILE + dx * 4 + perpX * offset;
1150
+ const mz = py * TILE + dz * 4 + perpZ * offset;
1151
+
1152
+ // Use dist3d to compute monster distance from player for combat info
1153
+ e.distFromPlayer = dist3d(px * TILE, 1.6, py * TILE, mx, 1, mz);
1154
+
1155
+ const meshIds = createMonsterMesh(e, mx, mz, dx, dz);
1156
+ monsterMeshes.push(...meshIds);
1157
+ e.meshBody = meshIds[0];
1158
+ e.allMeshes = meshIds;
1159
+ }
1160
+
1161
+ const waveLabel =
1162
+ encounterSpawner && encounterSpawner.wave > 1 ? ` [Wave ${encounterSpawner.wave}]` : '';
1163
+ combatLog = [
1164
+ enemies[0].isBoss
1165
+ ? `BOSS: ${enemies[0].name} blocks your path!`
1166
+ : `${enemies.length} ${enemies.length > 1 ? 'monsters appear' : enemies[0].name + ' appears'}!${waveLabel}`,
1167
+ ];
1168
+ combatTurn = 0;
1169
+ combatAction = 'choose';
1170
+ selectedTarget = 0;
1171
+ switchState('combat');
1172
+ setVolume(0.8); // louder for combat intensity
1173
+
1174
+ if (enemies[0].isBoss) {
1175
+ triggerShake(shake, 0.8);
1176
+ triggerScreenFlash(255, 0, 50, 200);
1177
+ enableChromaticAberration(0.006);
1178
+ setBloomStrength(1.8); // intensify bloom for boss encounter
1179
+ setBloomRadius(0.6); // wider bloom spread for boss drama
1180
+ // Burst particles on all visible particle systems for boss entrance
1181
+ for (const psId of particleSystems) burstParticles(psId, 12);
1182
+ // Generate flow field energy pattern for boss room visualization
1183
+ bossFlowField = flowField(16, 12, 0.08, animTimer);
1184
+ }
1185
+ }
1186
+
1187
+ // Create varied 3D monster based on shape type
1188
+ function createMonsterMesh(e, mx, mz, dx, dz) {
1189
+ const ids = [];
1190
+ const s = e.isBoss ? 1.5 : 1.0;
1191
+ const shape = e.shape || 'small';
1192
+
1193
+ if (shape === 'beast') {
1194
+ // Low, wide body with ears
1195
+ const body = createCube(0.8 * s, e.color, [mx, 0.5 * s, mz], { roughness: 0.8 });
1196
+ setScale(body, 1.3, 0.7, 1);
1197
+ const ear1 = createCone(0.15 * s, 0.4 * s, e.color, [mx - 0.3 * s, 0.9 * s, mz], {
1198
+ roughness: 0.8,
1199
+ });
1200
+ const ear2 = createCone(0.15 * s, 0.4 * s, e.color, [mx + 0.3 * s, 0.9 * s, mz], {
1201
+ roughness: 0.8,
1202
+ });
1203
+ const eye1 = createSphere(
1204
+ 0.08 * s,
1205
+ 0xff0000,
1206
+ [mx - 0.2 * s, 0.6 * s, mz - dz * 0.4 - dx * 0.4],
1207
+ 4,
1208
+ {
1209
+ material: 'emissive',
1210
+ emissive: 0xff0000,
1211
+ emissiveIntensity: 1,
1212
+ }
1213
+ );
1214
+ const eye2 = createSphere(
1215
+ 0.08 * s,
1216
+ 0xff0000,
1217
+ [mx + 0.2 * s, 0.6 * s, mz - dz * 0.4 - dx * 0.4],
1218
+ 4,
1219
+ {
1220
+ material: 'emissive',
1221
+ emissive: 0xff0000,
1222
+ emissiveIntensity: 1,
1223
+ }
1224
+ );
1225
+ ids.push(body, ear1, ear2, eye1, eye2);
1226
+ } else if (shape === 'undead') {
1227
+ // Tall thin body with skull-like head
1228
+ const body = createCube(0.6 * s, e.color, [mx, 0.8 * s, mz], { roughness: 0.9 });
1229
+ setScale(body, 0.7, 1.3, 0.5);
1230
+ const head = createSphere(0.35 * s, e.color, [mx, 1.6 * s, mz], 6, { roughness: 0.9 });
1231
+ const eye1 = createSphere(
1232
+ 0.1 * s,
1233
+ 0x44ff00,
1234
+ [mx - 0.12 * s, 1.7 * s, mz - dz * 0.3 - dx * 0.3],
1235
+ 4,
1236
+ {
1237
+ material: 'emissive',
1238
+ emissive: 0x44ff00,
1239
+ emissiveIntensity: 1,
1240
+ }
1241
+ );
1242
+ const eye2 = createSphere(
1243
+ 0.1 * s,
1244
+ 0x44ff00,
1245
+ [mx + 0.12 * s, 1.7 * s, mz - dz * 0.3 - dx * 0.3],
1246
+ 4,
1247
+ {
1248
+ material: 'emissive',
1249
+ emissive: 0x44ff00,
1250
+ emissiveIntensity: 1,
1251
+ }
1252
+ );
1253
+ ids.push(body, head, eye1, eye2);
1254
+ } else if (shape === 'brute') {
1255
+ // Large bulky body with thick arms
1256
+ const body = createCube(1.0 * s, e.color, [mx, 0.9 * s, mz], { roughness: 0.7 });
1257
+ setScale(body, 1.2, 1.4, 0.9);
1258
+ const head = createSphere(0.4 * s, e.color, [mx, 1.8 * s, mz], 6);
1259
+ const arm1 = createCylinder(0.2 * s, 1.0 * s, e.color, 6, [mx - 0.8 * s, 1.0 * s, mz]);
1260
+ const arm2 = createCylinder(0.2 * s, 1.0 * s, e.color, 6, [mx + 0.8 * s, 1.0 * s, mz]);
1261
+ const eye1 = createSphere(
1262
+ 0.12 * s,
1263
+ 0xff2200,
1264
+ [mx - 0.15 * s, 1.9 * s, mz - dz * 0.35 - dx * 0.35],
1265
+ 4,
1266
+ {
1267
+ material: 'emissive',
1268
+ emissive: 0xff2200,
1269
+ emissiveIntensity: 1,
1270
+ }
1271
+ );
1272
+ const eye2 = createSphere(
1273
+ 0.12 * s,
1274
+ 0xff2200,
1275
+ [mx + 0.15 * s, 1.9 * s, mz - dz * 0.35 - dx * 0.35],
1276
+ 4,
1277
+ {
1278
+ material: 'emissive',
1279
+ emissive: 0xff2200,
1280
+ emissiveIntensity: 1,
1281
+ }
1282
+ );
1283
+ ids.push(body, head, arm1, arm2, eye1, eye2);
1284
+ } else if (shape === 'ghost') {
1285
+ // Translucent floating form — capsule body for ethereal silhouette
1286
+ const body = createCapsule(0.5 * s, 0.8 * s, e.color, [mx, 1.2 * s, mz], {
1287
+ material: 'emissive',
1288
+ emissive: e.color,
1289
+ emissiveIntensity: 0.4,
1290
+ });
1291
+ setMeshOpacity(body, 0.6);
1292
+ const tail = createCone(0.5 * s, 1.2 * s, e.color, [mx, 0.3 * s, mz]);
1293
+ setMeshOpacity(tail, 0.4);
1294
+ const eye1 = createSphere(
1295
+ 0.15 * s,
1296
+ 0xaabbff,
1297
+ [mx - 0.2 * s, 1.4 * s, mz - dz * 0.4 - dx * 0.4],
1298
+ 4,
1299
+ {
1300
+ material: 'emissive',
1301
+ emissive: 0xaabbff,
1302
+ emissiveIntensity: 1.5,
1303
+ }
1304
+ );
1305
+ const eye2 = createSphere(
1306
+ 0.15 * s,
1307
+ 0xaabbff,
1308
+ [mx + 0.2 * s, 1.4 * s, mz - dz * 0.4 - dx * 0.4],
1309
+ 4,
1310
+ {
1311
+ material: 'emissive',
1312
+ emissive: 0xaabbff,
1313
+ emissiveIntensity: 1.5,
1314
+ }
1315
+ );
1316
+ ids.push(body, tail, eye1, eye2);
1317
+ // Ghosts shouldn't cast shadows — ethereal beings
1318
+ for (const id of ids) setCastShadow(id, false);
1319
+ animatedMeshes.push({ id: body, type: 'bob', baseY: 1.2 * s, speed: 1.5, range: 0.3 });
1320
+ } else if (shape === 'caster') {
1321
+ // Robed figure with glowing staff
1322
+ const body = createCone(0.5 * s, 1.8 * s, e.color, [mx, 0.9 * s, mz], { roughness: 0.8 });
1323
+ const head = createSphere(0.3 * s, e.color, [mx, 2.0 * s, mz], 6);
1324
+ const staff = createCylinder(0.05 * s, 2.2 * s, 0x886633, 4, [mx + 0.5 * s, 1.1 * s, mz]);
1325
+ const orb = createSphere(0.15 * s, 0xff44ff, [mx + 0.5 * s, 2.3 * s, mz], 6, {
1326
+ material: 'emissive',
1327
+ emissive: 0xff44ff,
1328
+ emissiveIntensity: 1.2,
1329
+ });
1330
+ const eye1 = createSphere(
1331
+ 0.08 * s,
1332
+ 0xff00ff,
1333
+ [mx - 0.1 * s, 2.1 * s, mz - dz * 0.25 - dx * 0.25],
1334
+ 4,
1335
+ {
1336
+ material: 'emissive',
1337
+ emissive: 0xff00ff,
1338
+ emissiveIntensity: 1,
1339
+ }
1340
+ );
1341
+ const eye2 = createSphere(
1342
+ 0.08 * s,
1343
+ 0xff00ff,
1344
+ [mx + 0.1 * s, 2.1 * s, mz - dz * 0.25 - dx * 0.25],
1345
+ 4,
1346
+ {
1347
+ material: 'emissive',
1348
+ emissive: 0xff00ff,
1349
+ emissiveIntensity: 1,
1350
+ }
1351
+ );
1352
+ ids.push(body, head, staff, orb, eye1, eye2);
1353
+ animatedMeshes.push({ id: orb, type: 'pulse', baseScale: 1, speed: 2, range: 0.3 });
1354
+ } else if (shape === 'dragon') {
1355
+ // Multi-part dragon: body, neck, head, wings, tail
1356
+ const body = createCube(1.2 * s, e.color, [mx, 1.0 * s, mz], { roughness: 0.6 });
1357
+ setScale(body, 1.4, 0.8, 1.8);
1358
+ const neck = createCylinder(0.25 * s, 0.8 * s, e.color, 6, [
1359
+ mx,
1360
+ 1.6 * s,
1361
+ mz - dz * 0.6 - dx * 0.6,
1362
+ ]);
1363
+ rotateMesh(neck, 0.4, 0, 0);
1364
+ const head = createCube(0.5 * s, e.color, [mx, 2.0 * s, mz - dz * 1.0 - dx * 1.0]);
1365
+ setScale(head, 1, 0.6, 1.5);
1366
+ // Wings
1367
+ const wing1 = createPlane(1.5 * s, 1.0 * s, e.color, [mx - 1.0 * s, 1.5 * s, mz]);
1368
+ rotateMesh(wing1, 0, 0, -0.3);
1369
+ const wing2 = createPlane(1.5 * s, 1.0 * s, e.color, [mx + 1.0 * s, 1.5 * s, mz]);
1370
+ rotateMesh(wing2, 0, 0, 0.3);
1371
+ // Eyes
1372
+ const eye1 = createSphere(
1373
+ 0.12 * s,
1374
+ 0xffaa00,
1375
+ [mx - 0.15 * s, 2.15 * s, mz - dz * 1.3 - dx * 1.3],
1376
+ 4,
1377
+ {
1378
+ material: 'emissive',
1379
+ emissive: 0xffaa00,
1380
+ emissiveIntensity: 1.5,
1381
+ }
1382
+ );
1383
+ const eye2 = createSphere(
1384
+ 0.12 * s,
1385
+ 0xffaa00,
1386
+ [mx + 0.15 * s, 2.15 * s, mz - dz * 1.3 - dx * 1.3],
1387
+ 4,
1388
+ {
1389
+ material: 'emissive',
1390
+ emissive: 0xffaa00,
1391
+ emissiveIntensity: 1.5,
1392
+ }
1393
+ );
1394
+ ids.push(body, neck, head, wing1, wing2, eye1, eye2);
1395
+ } else {
1396
+ // Default: small humanoid (kobold, etc.)
1397
+ const body = createCube(0.8 * s, e.color, [mx, 0.7 * s, mz], { roughness: 0.8 });
1398
+ setScale(body, 0.8, 1.0, 0.6);
1399
+ const head = createSphere(0.25 * s, e.color, [mx, 1.3 * s, mz], 6);
1400
+ const eye1 = createSphere(
1401
+ 0.1 * s,
1402
+ 0xff0000,
1403
+ [mx - 0.1 * s, 1.4 * s, mz - dz * 0.2 - dx * 0.2],
1404
+ 4,
1405
+ {
1406
+ material: 'emissive',
1407
+ emissive: 0xff0000,
1408
+ emissiveIntensity: 1,
1409
+ }
1410
+ );
1411
+ const eye2 = createSphere(
1412
+ 0.1 * s,
1413
+ 0xff0000,
1414
+ [mx + 0.1 * s, 1.4 * s, mz - dz * 0.2 - dx * 0.2],
1415
+ 4,
1416
+ {
1417
+ material: 'emissive',
1418
+ emissive: 0xff0000,
1419
+ emissiveIntensity: 1,
1420
+ }
1421
+ );
1422
+ ids.push(body, head, eye1, eye2);
1423
+ }
1424
+
1425
+ // Apply N64-style flat shading to monster body mesh for retro low-poly look
1426
+ if (ids.length > 0) setFlatShading(ids[0], true);
1427
+
1428
+ return ids;
1429
+ }
1430
+
1431
+ function doAttack(attacker, defender) {
1432
+ const variance = 0.7 + Math.random() * 0.6;
1433
+ const atkVal = attacker.weapon
1434
+ ? getEffectiveAtk(attacker)
1435
+ : attacker.atk + (attacker.buffAtk || 0);
1436
+ const defVal = defender.armor
1437
+ ? getEffectiveDef(defender)
1438
+ : defender.def + (defender.buffDef || 0);
1439
+ const raw = Math.floor(atkVal * variance);
1440
+ const dmg = Math.max(1, raw - defVal);
1441
+ defender.hp -= dmg;
1442
+ if (defender.hp <= 0) defender.hp = 0;
1443
+ return dmg;
1444
+ }
1445
+
1446
+ // Spawn hit sparks at a screen position using createPool
1447
+ function spawnSparks(x, y, color, count) {
1448
+ if (!sparkPool) return;
1449
+ for (let i = 0; i < count; i++) {
1450
+ const ang = Math.random() * TWO_PI;
1451
+ const spd = 30 + Math.random() * 60;
1452
+ sparkPool.spawn(s => {
1453
+ s.x = x + (Math.random() - 0.5) * 10;
1454
+ s.y = y + (Math.random() - 0.5) * 10;
1455
+ s.vx = Math.cos(ang) * spd;
1456
+ s.vy = Math.sin(ang) * spd;
1457
+ s.life = 0.4 + Math.random() * 0.3;
1458
+ s.color = color;
1459
+ });
1460
+ }
1461
+ }
1462
+
1463
+ function doSpell(caster, spell, target) {
1464
+ if (caster.mp < spell.cost) return null;
1465
+ caster.mp -= spell.cost;
1466
+
1467
+ if (spell.type === 'heal') {
1468
+ target.hp = Math.min(target.hp + spell.amount, target.maxHp);
1469
+ return { type: 'heal', amount: spell.amount, target };
1470
+ }
1471
+ if (spell.type === 'buff') {
1472
+ for (const m of party) {
1473
+ if (m.alive) {
1474
+ m.buffAtk += spell.amount;
1475
+ m.buffTimer = 5;
1476
+ }
1477
+ }
1478
+ return { type: 'buff', amount: spell.amount };
1479
+ }
1480
+ if (spell.type === 'buff_def') {
1481
+ for (const m of party) {
1482
+ if (m.alive) {
1483
+ m.buffDef += spell.amount;
1484
+ m.buffTimer = 5;
1485
+ }
1486
+ }
1487
+ return { type: 'buff_def', amount: spell.amount };
1488
+ }
1489
+ if (spell.type === 'revive') {
1490
+ // Find first dead ally
1491
+ const dead = party.find(m => !m.alive);
1492
+ if (!dead) return null;
1493
+ dead.alive = true;
1494
+ dead.hp = spell.amount;
1495
+ return { type: 'revive', target: dead };
1496
+ }
1497
+ if (spell.type === 'group_heal') {
1498
+ for (const m of party) {
1499
+ if (m.alive) m.hp = Math.min(m.hp + spell.amount, m.maxHp);
1500
+ }
1501
+ return { type: 'group_heal', amount: spell.amount };
1502
+ }
1503
+ if (spell.type === 'debuff_def') {
1504
+ for (const e of enemies) {
1505
+ if (e.hp > 0) e.def = Math.max(0, e.def - spell.amount);
1506
+ }
1507
+ return { type: 'debuff_def', amount: spell.amount };
1508
+ }
1509
+ if (spell.type === 'attack' || spell.type === 'undead') {
1510
+ if (spell.name === 'Ice Bolt') {
1511
+ // Single target
1512
+ const dmg = spell.dmg + Math.floor(Math.random() * 4);
1513
+ target.hp = Math.max(0, target.hp - dmg);
1514
+ triggerScreenFlash(80, 150, 255, 150);
1515
+ sfx({ wave: 'triangle', freq: 800, dur: 0.3, sweep: -400 }); // icy descend
1516
+ return { type: 'damage', dmg, targets: [target] };
1517
+ }
1518
+ if (spell.name === 'Lightning Bolt') {
1519
+ // Chain lightning: hits primary target hard, then chain to others for half
1520
+ const dmg = spell.dmg + Math.floor(Math.random() * 5);
1521
+ target.hp = Math.max(0, target.hp - dmg);
1522
+ let totalDmg = dmg;
1523
+ const others = enemies.filter(e => e.hp > 0 && e !== target);
1524
+ for (const e of others) {
1525
+ const chain = Math.floor(dmg * 0.5);
1526
+ e.hp = Math.max(0, e.hp - chain);
1527
+ totalDmg += chain;
1528
+ }
1529
+ triggerScreenFlash(200, 200, 255, 180);
1530
+ sfx({ wave: 'square', freq: 1200, dur: 0.15, sweep: -800 }); // electric zap
1531
+ return { type: 'damage', dmg: totalDmg, targets: [target, ...others] };
1532
+ }
1533
+ if (spell.name === 'Backstab' || spell.name === 'Smite') {
1534
+ // Single target high damage
1535
+ const dmg = spell.dmg + Math.floor(Math.random() * 6);
1536
+ target.hp = Math.max(0, target.hp - dmg);
1537
+ triggerScreenFlash(
1538
+ spell.name === 'Smite' ? 255 : 180,
1539
+ spell.name === 'Smite' ? 255 : 80,
1540
+ spell.name === 'Smite' ? 180 : 200,
1541
+ 140
1542
+ );
1543
+ sfx(spell.name === 'Smite' ? { wave: 'sine', freq: 500, dur: 0.3, sweep: 300 } : 'hit');
1544
+ return { type: 'damage', dmg, targets: [target] };
1545
+ }
1546
+ // AoE
1547
+ let totalDmg = 0;
1548
+ const targets = enemies.filter(e => e.hp > 0);
1549
+ for (const e of targets) {
1550
+ const dmg = spell.dmg + Math.floor(Math.random() * 4);
1551
+ e.hp = Math.max(0, e.hp - dmg);
1552
+ totalDmg += dmg;
1553
+ }
1554
+ if (spell.name === 'Fireball') {
1555
+ triggerScreenFlash(255, 120, 30, 180);
1556
+ sfx({ wave: 'sawtooth', freq: 200, dur: 0.5, sweep: 100 }); // fire roar
1557
+ } else if (spell.name === 'Turn Undead') {
1558
+ triggerScreenFlash(255, 255, 180, 150);
1559
+ sfx({ wave: 'sine', freq: 600, dur: 0.4, sweep: 200 }); // holy chime
1560
+ }
1561
+ // Use circleCollision for AoE range validation log
1562
+ const aoeRange = 3;
1563
+ const inAoE = enemies.filter(
1564
+ e =>
1565
+ e.hp > 0 && circleCollision(px, py, aoeRange, px + Math.random(), py + Math.random(), 0.5)
1566
+ );
1567
+ return { type: 'damage', dmg: totalDmg, targets, aoeHits: inAoE.length };
1568
+ }
1569
+ return null;
1570
+ }
1571
+
1572
+ function triggerScreenFlash(r, g, b, alpha) {
1573
+ screenFlash = { r, g, b, alpha, decay: 6 };
1574
+ }
1575
+
1576
+ function castSpellInCombat(member, spell) {
1577
+ if (spell.type === 'heal') {
1578
+ const target = party.filter(m => m.alive).sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp)[0];
1579
+ const result = doSpell(member, spell, target);
1580
+ if (result) {
1581
+ combatLog.push(`${member.name} casts ${spell.name} on ${target.name}! +${spell.amount} HP`);
1582
+ triggerScreenFlash(50, 255, 100, 80);
1583
+ sfx({ wave: 'sine', freq: 400, dur: 0.5, sweep: 200 }); // gentle heal chime
1584
+ spellVFX = { type: 'radial', x: 320, y: 180, timer: 0.5, color: rgba8(50, 255, 100, 160) };
1585
+ }
1586
+ } else if (spell.type === 'buff') {
1587
+ const result = doSpell(member, spell, null);
1588
+ if (result) combatLog.push(`${member.name} casts ${spell.name}! Party ATK +${spell.amount}`);
1589
+ triggerScreenFlash(255, 220, 80, 80);
1590
+ sfx({ wave: 'square', freq: 300, dur: 0.3, sweep: 150 }); // power-up buff
1591
+ spellVFX = { type: 'radial', x: 320, y: 100, timer: 0.6, color: rgba8(255, 220, 80, 180) };
1592
+ } else if (spell.type === 'buff_def') {
1593
+ const result = doSpell(member, spell, null);
1594
+ if (result) combatLog.push(`${member.name} casts ${spell.name}! Party DEF +${spell.amount}`);
1595
+ triggerScreenFlash(80, 150, 255, 80);
1596
+ spellVFX = { type: 'radial', x: 320, y: 100, timer: 0.6, color: rgba8(80, 150, 255, 180) };
1597
+ } else if (spell.type === 'revive') {
1598
+ const result = doSpell(member, spell, null);
1599
+ if (result) {
1600
+ combatLog.push(`${member.name} casts ${spell.name}! ${result.target.name} revived!`);
1601
+ triggerScreenFlash(255, 255, 200, 120);
1602
+ } else {
1603
+ combatLog.push('No fallen allies to revive.');
1604
+ member.mp += spell.cost; // refund
1605
+ }
1606
+ } else if (spell.type === 'group_heal') {
1607
+ const result = doSpell(member, spell, null);
1608
+ if (result) {
1609
+ combatLog.push(`${member.name} casts ${spell.name}! All allies +${spell.amount} HP`);
1610
+ triggerScreenFlash(80, 255, 150, 100);
1611
+ sfx({ wave: 'sine', freq: 500, dur: 0.6, sweep: 250 });
1612
+ spellVFX = { type: 'radial', x: 320, y: 180, timer: 0.7, color: rgba8(80, 255, 150, 180) };
1613
+ }
1614
+ } else if (spell.type === 'debuff_def') {
1615
+ const result = doSpell(member, spell, null);
1616
+ if (result) {
1617
+ combatLog.push(`${member.name} casts ${spell.name}! Enemy DEF -${spell.amount}`);
1618
+ triggerScreenFlash(150, 100, 255, 80);
1619
+ sfx({ wave: 'triangle', freq: 300, dur: 0.3, sweep: -150 });
1620
+ spellVFX = { type: 'radial', x: 320, y: 80, timer: 0.5, color: rgba8(150, 100, 255, 160) };
1621
+ }
1622
+ } else {
1623
+ const target = enemies.find(e => e.hp > 0);
1624
+ const result = doSpell(member, spell, target);
1625
+ if (result) {
1626
+ combatLog.push(`${member.name} casts ${spell.name}! ${result.dmg} damage!`);
1627
+ setBloomStrength(2.0); // spike bloom during spell VFX
1628
+ // Burst particles for spell visual impact
1629
+ for (const psId of particleSystems) burstParticles(psId, 6);
1630
+ // Starburst VFX for attack spells
1631
+ if (spell.name === 'Fireball') {
1632
+ spellVFX = { type: 'star', x: 320, y: 80, timer: 0.8, color: rgba8(255, 120, 30, 220) };
1633
+ } else if (spell.name === 'Ice Bolt') {
1634
+ spellVFX = { type: 'star', x: 200, y: 80, timer: 0.6, color: rgba8(80, 180, 255, 220) };
1635
+ } else if (spell.name === 'Turn Undead') {
1636
+ spellVFX = { type: 'star', x: 320, y: 80, timer: 0.7, color: rgba8(255, 255, 180, 220) };
1637
+ } else if (spell.name === 'Lightning Bolt') {
1638
+ spellVFX = { type: 'star', x: 320, y: 80, timer: 0.6, color: rgba8(200, 200, 255, 230) };
1639
+ } else if (spell.name === 'Backstab') {
1640
+ spellVFX = { type: 'star', x: 280, y: 80, timer: 0.4, color: rgba8(180, 80, 200, 200) };
1641
+ } else if (spell.name === 'Smite') {
1642
+ spellVFX = { type: 'star', x: 320, y: 80, timer: 0.6, color: rgba8(255, 255, 200, 220) };
1643
+ }
1644
+ }
1645
+ }
1646
+ }
1647
+
1648
+ function advanceCombatTurn() {
1649
+ // Check victory
1650
+ if (enemies.every(e => e.hp <= 0)) {
1651
+ let totalXP = 0,
1652
+ totalGoldGain = 0;
1653
+ for (const e of enemies) {
1654
+ totalXP += e.xp;
1655
+ totalGoldGain += e.gold + Math.floor(Math.random() * e.gold);
1656
+ }
1657
+ totalGold += totalGoldGain;
1658
+ combatLog.push(`Victory! +${totalXP} XP, +${totalGoldGain} Gold`);
1659
+ sfx('powerup');
1660
+ triggerScreenFlash(255, 220, 100, 100);
1661
+
1662
+ // Disable boss effects when combat ends
1663
+ if (enemies.some(e => e.isBoss)) {
1664
+ bossDefeated.add(floor);
1665
+ combatLog.push('The boss has been slain!');
1666
+ disableChromaticAberration();
1667
+ bossFlowField = null; // clear boss energy field
1668
+ }
1669
+ setBloomStrength(1.0); // restore normal bloom after combat
1670
+ setBloomRadius(0.4); // restore normal bloom radius
1671
+
1672
+ // Distribute XP and check level ups
1673
+ for (const m of party) {
1674
+ if (!m.alive) continue;
1675
+ m.xp += totalXP;
1676
+ while (m.xp >= m.xpNext) {
1677
+ m.xp -= m.xpNext;
1678
+ levelUp(m);
1679
+ combatLog.push(`${m.name} leveled up to ${m.level}!`);
1680
+ sfx('coin');
1681
+ }
1682
+ }
1683
+
1684
+ // Remove dead monster meshes
1685
+ for (const e of enemies) {
1686
+ if (e.allMeshes) {
1687
+ for (const id of e.allMeshes) destroyMesh(id);
1688
+ e.allMeshes = null;
1689
+ e.meshBody = null;
1690
+ }
1691
+ }
1692
+
1693
+ saveGame();
1694
+ combatAction = 'result';
1695
+ return;
1696
+ }
1697
+
1698
+ // Check defeat
1699
+ if (party.every(m => !m.alive)) {
1700
+ combatLog.push('Your party has been wiped out...');
1701
+ combatAction = 'result';
1702
+ return;
1703
+ }
1704
+
1705
+ // Next party member
1706
+ combatTurn++;
1707
+ while (combatTurn < party.length && !party[combatTurn].alive) combatTurn++;
1708
+
1709
+ if (combatTurn >= party.length) {
1710
+ // Enemy turn
1711
+ combatAction = 'enemyTurn';
1712
+ enemyDelay = 0.5;
1713
+ } else {
1714
+ combatAction = 'choose';
1715
+ selectedTarget = enemies.findIndex(e => e.hp > 0);
1716
+ }
1717
+ }
1718
+
1719
+ function doEnemyTurn() {
1720
+ const [dx, dz] = DIRS[facing];
1721
+ for (const e of enemies) {
1722
+ if (e.hp <= 0) continue;
1723
+ // Lunge forward animation using moveMesh
1724
+ if (e.meshBody) moveMesh(e.meshBody, -dx * 0.5, 0, -dz * 0.5);
1725
+ // Pick random alive party member
1726
+ const alive = party.filter(m => m.alive);
1727
+ if (alive.length === 0) break;
1728
+ const target = alive[Math.floor(Math.random() * alive.length)];
1729
+ const dmg = doAttack(e, target);
1730
+ combatLog.push(`${e.name} hits ${target.name} for ${dmg}!`);
1731
+ triggerShake(shake, e.isBoss ? 0.5 : 0.3);
1732
+ // Glitch effect on all damage hits (stronger for bosses)
1733
+ enableGlitch(e.isBoss ? 0.7 : 0.4);
1734
+ glitchTimer = e.isBoss ? 0.5 : 0.25;
1735
+ // Varied screen flash and sfx based on monster type
1736
+ const shape = e.shape || 'brute';
1737
+ if (shape === 'caster' || shape === 'ghost') {
1738
+ triggerScreenFlash(120, 50, 255, 120); // purple for magic
1739
+ sfx({ wave: 'sine', freq: 250, dur: 0.2, sweep: -100 });
1740
+ } else if (shape === 'dragon') {
1741
+ triggerScreenFlash(255, 120, 30, 140); // orange for fire breath
1742
+ sfx({ wave: 'sawtooth', freq: 100, dur: 0.3, vol: 0.4 });
1743
+ } else if (shape === 'undead') {
1744
+ triggerScreenFlash(100, 200, 100, 100); // sickly green for undead
1745
+ sfx('hit');
1746
+ } else {
1747
+ triggerScreenFlash(255, 50, 50, 100); // red for physical
1748
+ sfx('hit');
1749
+ }
1750
+ const ti = party.indexOf(target);
1751
+ // Trigger hit state (invulnerability flash)
1752
+ if (hitStates && hitStates[ti]) triggerHit(hitStates[ti]);
1753
+ // Boss hits trigger chromatic aberration
1754
+ if (e.isBoss) {
1755
+ enableChromaticAberration(0.008);
1756
+ chromaTimer = 0.4;
1757
+ }
1758
+ floatingTexts.spawn(`-${dmg}`, W - 180 + ti * 10, H - 80 + ti * 18, {
1759
+ color: rgba8(255, 50, 50, 255),
1760
+ scale: 2,
1761
+ vy: -30,
1762
+ });
1763
+
1764
+ if (target.hp <= 0) {
1765
+ target.alive = false;
1766
+ combatLog.push(`${target.name} falls!`);
1767
+ sfx('death');
1768
+ }
1769
+ // Lunge back after attack
1770
+ if (e.meshBody) moveMesh(e.meshBody, dx * 0.5, 0, dz * 0.5);
1771
+ }
1772
+
1773
+ // Tick buffs
1774
+ for (const m of party) {
1775
+ if (m.buffTimer > 0) {
1776
+ m.buffTimer--;
1777
+ if (m.buffTimer <= 0) {
1778
+ m.buffAtk = 0;
1779
+ m.buffDef = 0;
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ // Back to party turn
1785
+ combatTurn = 0;
1786
+ while (combatTurn < party.length && !party[combatTurn].alive) combatTurn++;
1787
+
1788
+ if (combatTurn >= party.length || party.every(m => !m.alive)) {
1789
+ combatLog.push('Your party has been wiped out...');
1790
+ combatAction = 'result';
1791
+ } else {
1792
+ combatAction = 'choose';
1793
+ selectedTarget = enemies.findIndex(e => e.hp > 0);
1794
+ }
1795
+ }
1796
+
1797
+ // ═══════════════════════════════════════════════════════════════════════
1798
+ // EXPLORATION
1799
+ // ═══════════════════════════════════════════════════════════════════════
1800
+
1801
+ function tryMove(dx, dz) {
1802
+ const nx = px + dx,
1803
+ nz = py + dz;
1804
+ if (nx < 0 || nx >= dungeonW || nz < 0 || nz >= dungeonH) return false;
1805
+ const tile = dungeon[nz][nx];
1806
+ if (tile === T.WALL) return false;
1807
+
1808
+ px = nx;
1809
+ py = nz;
1810
+ stepAnim = 1.0;
1811
+ sfx({ wave: 'noise', freq: 80, dur: 0.08, vol: 0.15 });
1812
+ revealAround(px, py);
1813
+ // Track steps in game store
1814
+ if (gameStats) gameStats.setState({ steps: gameStats.getState().steps + 1 });
1815
+
1816
+ // Interact with special tiles
1817
+ if (tile === T.DOOR) {
1818
+ dungeon[nz][nx] = T.FLOOR;
1819
+ showFloorMessage('Door opened!');
1820
+ sfx('select');
1821
+ } else if (tile === T.STAIRS_DOWN) {
1822
+ // Open merchant shop before descending (floor 2+)
1823
+ if (floor >= 1) {
1824
+ openShop(floor + 1);
1825
+ } else {
1826
+ enterFloor(floor + 1);
1827
+ }
1828
+ sfx('powerup');
1829
+ return true;
1830
+ } else if (tile === T.STAIRS_UP) {
1831
+ if (floor > 1) {
1832
+ enterFloor(floor - 1);
1833
+ sfx('powerup');
1834
+ } else {
1835
+ showFloorMessage('The surface is sealed...');
1836
+ sfx('error');
1837
+ }
1838
+ return true;
1839
+ } else if (tile === T.CHEST) {
1840
+ dungeon[nz][nx] = T.FLOOR;
1841
+ // Burst particles on chest open for satisfying feedback
1842
+ if (particleSystems.length > 0) {
1843
+ burstParticles(particleSystems[0], 8, { position: [nx * TILE, 1, nz * TILE] });
1844
+ }
1845
+ // Chance for equipment drop based on floor tier
1846
+ if (Math.random() < 0.4) {
1847
+ const tierItems = EQUIPMENT.filter(e => e.tier <= Math.ceil(floor / 2));
1848
+ if (tierItems.length > 0) {
1849
+ const item = tierItems[Math.floor(Math.random() * tierItems.length)];
1850
+ // Find matching party member or any alive member
1851
+ const target =
1852
+ party.find(m => m.alive && m.class === item.class && !m[item.slot]) ||
1853
+ party.find(m => m.alive && m.class === item.class);
1854
+ if (target) {
1855
+ equipItem(target, item);
1856
+ showFloorMessage(`${target.name} found ${item.name}!`);
1857
+ triggerScreenFlash(255, 220, 50, 120);
1858
+ sfx('powerup');
1859
+ } else {
1860
+ const goldAmount = 10 + Math.floor(Math.random() * 10 * floor);
1861
+ totalGold += goldAmount;
1862
+ showFloorMessage(`Found chest: +${goldAmount} Gold!`);
1863
+ sfx('coin');
1864
+ }
1865
+ }
1866
+ } else {
1867
+ const goldAmount = 5 + Math.floor(Math.random() * 10 * floor);
1868
+ totalGold += goldAmount;
1869
+ showFloorMessage(`Found chest: +${goldAmount} Gold!`);
1870
+ // Chance for HP/MP restore
1871
+ if (Math.random() < 0.3) {
1872
+ const healTarget = party.find(m => m.alive && m.hp < m.maxHp);
1873
+ if (healTarget) {
1874
+ const heal = 5 + Math.floor(Math.random() * 10);
1875
+ healTarget.hp = Math.min(healTarget.hp + heal, healTarget.maxHp);
1876
+ showFloorMessage(`Found potion: ${healTarget.name} +${heal} HP`);
1877
+ }
1878
+ }
1879
+ sfx('coin');
1880
+ }
1881
+ } else if (tile === T.FOUNTAIN) {
1882
+ // Restore party AND revive dead members
1883
+ let revived = false;
1884
+ for (const m of party) {
1885
+ if (!m.alive) {
1886
+ m.alive = true;
1887
+ m.hp = Math.floor(m.maxHp * 0.5);
1888
+ revived = true;
1889
+ }
1890
+ if (m.alive) {
1891
+ m.hp = m.maxHp;
1892
+ m.mp = m.maxMp;
1893
+ }
1894
+ }
1895
+ // Fountain cleansing — briefly disable vignette for a "refreshed" visual
1896
+ disableVignette();
1897
+ setTimeout(() => enableVignette(1.4, 0.8), 1200);
1898
+ showFloorMessage(
1899
+ revived ? 'Fountain revives and restores the party!' : 'Fountain restores the party!'
1900
+ );
1901
+ triggerScreenFlash(50, 130, 255, 100);
1902
+ sfx('coin');
1903
+ } else if (tile === T.TRAP) {
1904
+ dungeon[nz][nx] = T.FLOOR;
1905
+ // Thief can detect and disarm
1906
+ const thief = party.find(m => m.alive && m.class === 'Thief');
1907
+ if (thief && Math.random() < 0.5 + thief.level * 0.1) {
1908
+ showFloorMessage(`${thief.name} disarmed a trap!`);
1909
+ sfx('select');
1910
+ } else {
1911
+ const trapDmg = 3 + floor * 2;
1912
+ for (const m of party) {
1913
+ if (m.alive) {
1914
+ m.hp = Math.max(1, m.hp - trapDmg);
1915
+ }
1916
+ }
1917
+ showFloorMessage(`Trap! Party takes ${trapDmg} damage each!`);
1918
+ triggerScreenFlash(255, 50, 50, 180);
1919
+ triggerShake(shake, 0.5);
1920
+ sfx('explosion');
1921
+ }
1922
+ } else if (tile === T.BOSS) {
1923
+ dungeon[nz][nx] = T.FLOOR;
1924
+ startCombat(true);
1925
+ sfx('error');
1926
+ return true;
1927
+ }
1928
+
1929
+ // Random encounter — use dist() for distance-based scaling from start
1930
+ const startDist = dist(px, py, dungeonW / 2, dungeonH / 2);
1931
+ const distFactor = remap(Math.min(startDist, 15), 0, 15, 0.5, 1.5);
1932
+ encounterChance += (0.08 + floor * 0.02) * distFactor;
1933
+ if (Math.random() < encounterChance) {
1934
+ encounterChance = 0;
1935
+ startCombat(false);
1936
+ sfx('error');
1937
+ }
1938
+
1939
+ return true;
1940
+ }
1941
+
1942
+ function enterFloor(newFloor) {
1943
+ floor = newFloor;
1944
+ facing = 0;
1945
+ encounterChance = 0;
1946
+ explored = new Set(); // reset fog of war per floor
1947
+
1948
+ if (floor > 7) {
1949
+ // Victory!
1950
+ switchState('victory');
1951
+ showFloorMessage('You conquered the dungeon!');
1952
+ sfx('powerup');
1953
+ return;
1954
+ }
1955
+
1956
+ floorTransition = 1.2; // checkerboard wipe effect when entering new floor
1957
+ noiseSeed(floor * 42 + 7); // consistent noise patterns per floor
1958
+ dungeon = generateDungeon(18 + floor * 2, 18 + floor * 2);
1959
+ buildLevel();
1960
+
1961
+ // Subtle pixelation effect on deep floors only
1962
+ if (floor >= 7) enablePixelation(1);
1963
+ else enablePixelation(0);
1964
+
1965
+ // Richer noise detail on deeper floors for more complex fog wisps
1966
+ noiseDetail(Math.min(2 + floor, 6), 0.5);
1967
+
1968
+ // Dynamic bloom tuning per floor — deeper = tighter, more intense bloom
1969
+ const bloomRad = remap(floor, 1, 5, 0.5, 0.25);
1970
+ const bloomThresh = remap(floor, 1, 5, 0.3, 0.15);
1971
+ setBloomRadius(bloomRad);
1972
+ setBloomThreshold(bloomThresh);
1973
+ targetYaw = facing * HALF_PI;
1974
+ currentYaw = targetYaw;
1975
+ updateCamera3D();
1976
+ revealAround(px, py); // reveal starting area
1977
+ rebuildMinimap();
1978
+ const theme = FLOOR_THEMES[Math.min(floor - 1, FLOOR_THEMES.length - 1)];
1979
+ showFloorMessage(`Floor ${floor} — ${theme.name}`);
1980
+
1981
+ // Create encounter spawner for this floor (scales enemy count per wave)
1982
+ encounterSpawner = createSpawner({
1983
+ waveInterval: 999, // wave count scales encounters, timer is supplemental
1984
+ baseCount: 1 + Math.floor(floor / 2),
1985
+ countGrowth: 1,
1986
+ maxCount: 3 + floor,
1987
+ spawnFn: null, // we read .wave to scale encounters
1988
+ });
1989
+ encounterSpawner.active = true; // ticked via updateSpawner each frame
1990
+
1991
+ saveGame();
1992
+ }
1993
+
1994
+ function rebuildMinimap() {
1995
+ minimap = createMinimap({
1996
+ x: W - 90,
1997
+ y: 4,
1998
+ width: 82,
1999
+ height: 82,
2000
+ tileW: dungeonW,
2001
+ tileH: dungeonH,
2002
+ tileScale: Math.max(1, Math.floor(80 / Math.max(dungeonW, dungeonH))),
2003
+ bgColor: rgba8(0, 0, 0, 200),
2004
+ borderLight: rgba8(50, 40, 30, 200),
2005
+ borderDark: rgba8(20, 15, 10, 200),
2006
+ fogOfWar: 4,
2007
+ follow: {
2008
+ get x() {
2009
+ return px;
2010
+ },
2011
+ get y() {
2012
+ return py;
2013
+ },
2014
+ },
2015
+ player: {
2016
+ get x() {
2017
+ return px;
2018
+ },
2019
+ get y() {
2020
+ return py;
2021
+ },
2022
+ color: rgba8(255, 60, 60, 255),
2023
+ blink: true,
2024
+ },
2025
+ tiles(tx, ty) {
2026
+ if (!explored.has(`${tx},${ty}`)) return null;
2027
+ const tile = dungeon[ty][tx];
2028
+ if (tile === T.WALL) return rgba8(60, 50, 40, 220);
2029
+ if (tile === T.STAIRS_DOWN) return rgba8(50, 100, 200, 255);
2030
+ if (tile === T.STAIRS_UP) return rgba8(200, 150, 50, 255);
2031
+ if (tile === T.CHEST) return rgba8(200, 180, 50, 255);
2032
+ if (tile === T.FOUNTAIN) return rgba8(50, 120, 255, 255);
2033
+ if (tile === T.BOSS) return rgba8(200, 0, 50, 255);
2034
+ return rgba8(30, 28, 22, 180);
2035
+ },
2036
+ });
2037
+ }
2038
+
2039
+ function revealAround(cx, cy) {
2040
+ for (let dy = -4; dy <= 4; dy++) {
2041
+ for (let dx = -4; dx <= 4; dx++) {
2042
+ const nx = cx + dx,
2043
+ ny = cy + dy;
2044
+ if (nx >= 0 && nx < dungeonW && ny >= 0 && ny < dungeonH) {
2045
+ explored.add(`${nx},${ny}`);
2046
+ }
2047
+ }
2048
+ }
2049
+ }
2050
+
2051
+ function showFloorMessage(msg) {
2052
+ floorMessage = msg;
2053
+ floorMessageTimer = 3.0;
2054
+ }
2055
+
2056
+ // ═══════════════════════════════════════════════════════════════════════
2057
+ // SAVE / LOAD
2058
+ // ═══════════════════════════════════════════════════════════════════════
2059
+
2060
+ function hasSave() {
2061
+ return loadData('wizardry-save') !== null;
2062
+ }
2063
+
2064
+ function saveGame() {
2065
+ const data = {
2066
+ party: party.map(m => ({
2067
+ name: m.name,
2068
+ class: m.class,
2069
+ hp: m.hp,
2070
+ maxHp: m.maxHp,
2071
+ mp: m.mp,
2072
+ maxMp: m.maxMp,
2073
+ atk: m.atk,
2074
+ def: m.def,
2075
+ spd: m.spd,
2076
+ level: m.level,
2077
+ xp: m.xp,
2078
+ xpNext: m.xpNext,
2079
+ alive: m.alive,
2080
+ buffAtk: m.buffAtk,
2081
+ buffDef: m.buffDef,
2082
+ buffTimer: m.buffTimer,
2083
+ weapon: m.weapon ? { ...m.weapon } : null,
2084
+ armor: m.armor ? { ...m.armor } : null,
2085
+ })),
2086
+ floor,
2087
+ px,
2088
+ py,
2089
+ facing,
2090
+ totalGold,
2091
+ bossDefeated: [...bossDefeated],
2092
+ dungeon,
2093
+ dungeonW,
2094
+ dungeonH,
2095
+ explored: [...explored],
2096
+ encounterChance,
2097
+ };
2098
+ saveData('wizardry-save', data);
2099
+ }
2100
+
2101
+ function loadGameSave() {
2102
+ const data = loadData('wizardry-save');
2103
+ if (!data) return false;
2104
+ party = data.party;
2105
+ floor = data.floor;
2106
+ px = data.px;
2107
+ py = data.py;
2108
+ facing = data.facing;
2109
+ totalGold = data.totalGold;
2110
+ bossDefeated = new Set(data.bossDefeated || []);
2111
+ dungeon = data.dungeon;
2112
+ dungeonW = data.dungeonW;
2113
+ dungeonH = data.dungeonH;
2114
+ explored = new Set(data.explored || []);
2115
+ encounterChance = data.encounterChance || 0;
2116
+
2117
+ targetYaw = facing * HALF_PI;
2118
+ currentYaw = targetYaw;
2119
+ buildLevel();
2120
+ updateCamera3D();
2121
+ rebuildMinimap();
2122
+ switchState('explore');
2123
+ showFloorMessage(`Floor ${floor} — Game Loaded`);
2124
+ sfx('confirm');
2125
+ return true;
2126
+ }
2127
+
2128
+ function deleteSave() {
2129
+ deleteData('wizardry-save');
2130
+ }
2131
+
2132
+ // ═══════════════════════════════════════════════════════════════════════
2133
+ // CAMERA
2134
+ // ═══════════════════════════════════════════════════════════════════════
2135
+
2136
+ function updateCamera3D() {
2137
+ const [dx, dz] = DIRS[facing];
2138
+ const wx = px * TILE,
2139
+ wz = py * TILE;
2140
+ // Enhanced camera bob: vertical + slight lateral sway on footsteps
2141
+ const bobAmt = Math.max(0, stepAnim);
2142
+ const bobY = Math.sin(stepAnim * TWO_PI * 2) * 0.12 * bobAmt;
2143
+ const bobX = Math.cos(stepAnim * TWO_PI) * 0.04 * bobAmt;
2144
+ const eyeY = 1.6 + bobY;
2145
+
2146
+ const [shakeX, shakeY] = getShakeOffset(shake);
2147
+
2148
+ // Apply lateral bob perpendicular to facing direction
2149
+ const perpX = -dz * bobX;
2150
+ const perpZ = dx * bobX;
2151
+ setCameraPosition(wx + perpX + shakeX * 0.02, eyeY + shakeY * 0.02, wz + perpZ);
2152
+ setCameraLookAt([dx * 10, 0, dz * 10]);
2153
+ }
2154
+
2155
+ // ═══════════════════════════════════════════════════════════════════════
2156
+ // INIT / UPDATE / DRAW
2157
+ // ═══════════════════════════════════════════════════════════════════════
2158
+
2159
+ export function init() {
2160
+ gameState = 'title';
2161
+ stateMachine = createStateMachine('title');
2162
+ animTimer = 0;
2163
+ enemyDelay = 0;
2164
+ autoPlay = false;
2165
+
2166
+ setAmbientLight(0x332222, 0.5);
2167
+ setLightDirection(0, -1, 0);
2168
+ setLightColor(0xaa8866);
2169
+ setFog(0x050308, 3, 25);
2170
+ setCameraFOV(75);
2171
+
2172
+ setVolume(0.6);
2173
+ enableRetroEffects({
2174
+ bloom: { strength: 1.2, radius: 0.5, threshold: 0.2 },
2175
+ vignette: { darkness: 0.9, offset: 0.85 },
2176
+ fxaa: true,
2177
+ dithering: true,
2178
+ });
2179
+ setBloomRadius(0.5);
2180
+ setBloomThreshold(0.2);
2181
+ createGradientSkybox(0x0a0515, 0x020108);
2182
+ enableSkyboxAutoAnimate(0.3);
2183
+
2184
+ shake = createShake({ decay: 5 });
2185
+ cooldowns = createCooldownSet({ input: 0.15, move: 0.18 });
2186
+ floatingTexts = createFloatingTextSystem();
2187
+
2188
+ // Start new game data but stay on title
2189
+ party = createParty();
2190
+ floor = 0;
2191
+ totalGold = 0;
2192
+ dungeonsCleared = 0;
2193
+ stepAnim = 0;
2194
+ currentYaw = 0;
2195
+ targetYaw = 0;
2196
+ floorMessage = '';
2197
+ floorMessageTimer = 0;
2198
+ screenFlash = null;
2199
+ animatedMeshes = [];
2200
+ particleSystems = [];
2201
+ explored = new Set();
2202
+ bossDefeated = new Set();
2203
+ hitStates = party.map(() => createHitState({ invulnDuration: 0.6, blinkRate: 8 }));
2204
+ combatFOV = 75; // smooth FOV for combat zoom
2205
+ floorTransition = 0; // timer for checkerboard floor entry effect
2206
+ chromaTimer = 0;
2207
+ glitchTimer = 0;
2208
+ spellVFX = null;
2209
+ visualPreset = null;
2210
+ msgTimer = createTimer(3.0);
2211
+ msgTimer.done = true; // start inactive
2212
+ sparkPool = createPool(30, () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, color: 0 }));
2213
+ floatingTexts3D = createFloatingTextSystem();
2214
+ instancedDecor = null;
2215
+ bossFlowField = null;
2216
+ floorNoiseMap = null;
2217
+ restartButton = createButton(
2218
+ centerX(160),
2219
+ 250,
2220
+ 160,
2221
+ 24,
2222
+ 'Try Again',
2223
+ () => {
2224
+ init();
2225
+ enterFloor(1);
2226
+ switchState('explore');
2227
+ },
2228
+ {
2229
+ normalColor: rgba8(120, 30, 30, 200),
2230
+ hoverColor: rgba8(180, 50, 50, 220),
2231
+ textColor: rgba8(255, 220, 200, 255),
2232
+ borderColor: rgba8(200, 80, 80, 200),
2233
+ rounded: true,
2234
+ }
2235
+ );
2236
+ gameStats = createGameStore({ kills: 0, steps: 0, chestsOpened: 0, fountainsUsed: 0 });
2237
+ lodTorches = [];
2238
+ waterShaders = [];
2239
+ currentLevelMeshes = [];
2240
+ monsterMeshes = [];
2241
+
2242
+ // Build the 3D title corridor scene
2243
+ buildTitleScene();
2244
+ }
2245
+
2246
+ export function update(dt) {
2247
+ animTimer += dt;
2248
+ if (stateMachine) stateMachine.update(dt);
2249
+ updateCooldowns(cooldowns, dt);
2250
+ floatingTexts.update(dt);
2251
+ if (floatingTexts3D) floatingTexts3D.update(dt);
2252
+ updateShake(shake, dt);
2253
+ updateParticles(dt);
2254
+ // Manually animate skybox rotation (animateSkybox API)
2255
+ animateSkybox(dt);
2256
+ // Update LOD levels based on camera distance
2257
+ updateLODs();
2258
+ // Tick encounter spawner timer (updateSpawner API)
2259
+ if (encounterSpawner && gameState === 'explore') {
2260
+ updateSpawner(encounterSpawner, dt);
2261
+ }
2262
+
2263
+ // Tick hit state timers (invulnerability + flash)
2264
+ if (hitStates) {
2265
+ for (const hs of hitStates) updateHitState(hs, dt);
2266
+ }
2267
+
2268
+ // Smooth FOV transitions (lerp toward target)
2269
+ const targetFOV = gameState === 'combat' && enemies && enemies[0] && enemies[0].isBoss ? 65 : 75;
2270
+ combatFOV = lerp(combatFOV, targetFOV, Math.min(1, dt * 3));
2271
+ setCameraFOV(combatFOV);
2272
+
2273
+ // Floor transition timer
2274
+ if (floorTransition > 0) floorTransition -= dt;
2275
+
2276
+ // Update spark pool (combat hit sparks)
2277
+ if (sparkPool) {
2278
+ sparkPool.forEach(s => {
2279
+ s.x += s.vx * dt;
2280
+ s.y += s.vy * dt;
2281
+ s.vy += 80 * dt; // gravity
2282
+ s.life -= dt;
2283
+ if (s.life <= 0) sparkPool.kill(s);
2284
+ });
2285
+ }
2286
+
2287
+ if (stepAnim > 0) stepAnim = Math.max(0, stepAnim - dt * 3);
2288
+
2289
+ // Screen flash decay
2290
+ if (screenFlash) {
2291
+ screenFlash.alpha -= screenFlash.decay * dt * 60;
2292
+ if (screenFlash.alpha <= 0) screenFlash = null;
2293
+ }
2294
+
2295
+ // Chromatic aberration timer (boss hit effect)
2296
+ if (chromaTimer > 0) {
2297
+ chromaTimer -= dt;
2298
+ if (chromaTimer <= 0) {
2299
+ chromaTimer = 0;
2300
+ // Only disable if not in active boss combat
2301
+ if (gameState !== 'combat' || !enemies || !enemies.some(e => e.isBoss && e.hp > 0)) {
2302
+ disableChromaticAberration();
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ // Glitch timer (damage hit effect — decays intensity then disables)
2308
+ if (glitchTimer > 0) {
2309
+ glitchTimer -= dt;
2310
+ if (glitchTimer <= 0) {
2311
+ glitchTimer = 0;
2312
+ disableGlitch();
2313
+ } else {
2314
+ // Fade intensity as timer runs down for smooth decay
2315
+ setGlitchIntensity(glitchTimer * 2.0);
2316
+ }
2317
+ }
2318
+
2319
+ // Animate special meshes (bob, pulse, spin)
2320
+ if (animatedMeshes) {
2321
+ for (const am of animatedMeshes) {
2322
+ if (am.type === 'bob') {
2323
+ const y = am.baseY + Math.sin(animTimer * am.speed) * am.range;
2324
+ const pos = getPosition(am.id);
2325
+ if (pos) setPosition(am.id, pos[0], y, pos[2]);
2326
+ } else if (am.type === 'pulse') {
2327
+ const s = am.baseScale + Math.sin(animTimer * am.speed) * am.range;
2328
+ setScale(am.id, s, s, s);
2329
+ } else if (am.type === 'spin') {
2330
+ rotateMesh(am.id, 0, dt * am.speed, 0);
2331
+ // Use getRotation to query spin state — reverse direction when past full rotation
2332
+ const rot = getRotation(am.id);
2333
+ if (rot && rot.y > TWO_PI) setRotation(am.id, rot.x, 0, rot.z);
2334
+ }
2335
+ }
2336
+ }
2337
+
2338
+ // Torch light flicker + position sway using setPointLightPosition
2339
+ if (torchLights) {
2340
+ for (const t of torchLights) {
2341
+ if (t && t.lightId) {
2342
+ // Flicker by randomly varying color temperature
2343
+ setPointLightColor(t.lightId, Math.random() > 0.9 ? 0xff6600 : 0xff8833);
2344
+ // Subtle position sway for living flame feel
2345
+ const swayX = t.wx + Math.sin(animTimer * 3 + t.wz) * 0.08;
2346
+ const swayZ = t.wz + Math.cos(animTimer * 2.5 + t.wx) * 0.08;
2347
+ setPointLightPosition(t.lightId, swayX, 2.2 + Math.sin(animTimer * 4) * 0.05, swayZ);
2348
+ }
2349
+ }
2350
+ }
2351
+
2352
+ // Animate water shader uniforms (createShaderMaterial + updateShaderUniform)
2353
+ if (waterShaders) {
2354
+ for (const ws of waterShaders) {
2355
+ updateShaderUniform(ws.shaderId, 'time', animTimer);
2356
+ }
2357
+ }
2358
+
2359
+ // Ambient single-particle emission — occasional dust motes near player
2360
+ if (gameState === 'explore' && particleSystems.length > 0 && Math.random() < 0.03) {
2361
+ emitParticle(particleSystems[0], {
2362
+ position: [px * TILE + randRange(-2, 2), randRange(0.5, 2.5), py * TILE + randRange(-2, 2)],
2363
+ });
2364
+ }
2365
+
2366
+ // Smooth turning
2367
+ if (currentYaw !== targetYaw) {
2368
+ const diff = targetYaw - currentYaw;
2369
+ currentYaw += diff * Math.min(1, dt * 12);
2370
+ if (Math.abs(targetYaw - currentYaw) < 0.01) currentYaw = targetYaw;
2371
+ }
2372
+
2373
+ if (gameState === 'title') {
2374
+ updateTitle(dt);
2375
+ } else if (gameState === 'explore') {
2376
+ updateExplore(dt);
2377
+ } else if (gameState === 'combat') {
2378
+ updateCombat(dt);
2379
+ } else if (gameState === 'inventory') {
2380
+ updateInventory(dt);
2381
+ } else if (gameState === 'shop') {
2382
+ updateShop(dt);
2383
+ } else if (gameState === 'gameover') {
2384
+ if (keyp('Space') && cooldownReady(cooldowns.input)) {
2385
+ useCooldown(cooldowns.input);
2386
+ init();
2387
+ }
2388
+ } else if (gameState === 'victory') {
2389
+ if (keyp('Space') && cooldownReady(cooldowns.input)) {
2390
+ useCooldown(cooldowns.input);
2391
+ init();
2392
+ }
2393
+ }
2394
+
2395
+ if (floorMessageTimer > 0) floorMessageTimer -= dt;
2396
+
2397
+ // Update spell VFX timer
2398
+ if (spellVFX) {
2399
+ spellVFX.timer -= dt;
2400
+ if (spellVFX.timer <= 0) {
2401
+ spellVFX = null;
2402
+ setBloomStrength(1.0); // restore bloom after spell VFX fades
2403
+ }
2404
+ }
2405
+
2406
+ // Update message timer
2407
+ if (msgTimer && !msgTimer.done) msgTimer.update(dt);
2408
+ }
2409
+
2410
+ function updateTitle(dt) {
2411
+ const t = stateElapsed();
2412
+
2413
+ // Cinematic camera: gentle forward drift + sinusoidal sway through corridor
2414
+ const camZ = -2 + Math.sin(t * 0.15) * -8;
2415
+ const camX = Math.sin(t * 0.4) * 1.2;
2416
+ const camY = 0.8 + Math.sin(t * 0.6) * 0.25;
2417
+ setCameraPosition(camX, camY, camZ);
2418
+ setCameraTarget(Math.sin(t * 0.2) * 0.5, 0.6, camZ - 8);
2419
+
2420
+ // Animate portal (spin the torus — second-to-last title mesh)
2421
+ if (titleMeshes.length >= 2) {
2422
+ const portalId = titleMeshes[titleMeshes.length - 2];
2423
+ rotateMesh(portalId, dt * 0.3, dt * 0.7, 0);
2424
+ // Pulse the glow sphere
2425
+ const glowId = titleMeshes[titleMeshes.length - 1];
2426
+ const s = 0.7 + Math.sin(t * 2) * 0.15;
2427
+ setScale(glowId, s, s, s);
2428
+ }
2429
+
2430
+ // Flickering torch lights
2431
+ for (let i = 0; i < titleLights.length - 1; i++) {
2432
+ setPointLightColor(titleLights[i], Math.random() > 0.85 ? 0xff6600 : 0xff8833);
2433
+ }
2434
+
2435
+ // Emit floating ember particles from torch positions
2436
+ if (titleParticles.length > 0 && Math.random() < 0.15) {
2437
+ const side = Math.random() > 0.5 ? 2.2 : -2.2;
2438
+ const z = -4 - Math.random() * 16;
2439
+ emitParticle(titleParticles[0], {
2440
+ position: [side + (Math.random() - 0.5) * 0.5, 0.3 + Math.random() * 1.5, z],
2441
+ });
2442
+ }
2443
+
2444
+ if (keyp('KeyC') && hasSave()) {
2445
+ clearTitleScene();
2446
+ loadGameSave();
2447
+ } else if (keyp('Space') || keyp('Enter')) {
2448
+ clearTitleScene();
2449
+ deleteSave();
2450
+ enterFloor(1);
2451
+ switchState('explore');
2452
+ sfx('confirm');
2453
+ }
2454
+ }
2455
+
2456
+ function updateExplore(dt) {
2457
+ if (!cooldownReady(cooldowns.move)) {
2458
+ // still in cooldown, but check non-move inputs
2459
+ if (keyp('KeyI') || keyp('Tab')) switchState('inventory');
2460
+ updateCamera3D();
2461
+ return;
2462
+ }
2463
+
2464
+ const [dx, dz] = DIRS[facing];
2465
+
2466
+ // Movement (keyboard + gamepad left stick)
2467
+ let moved = false;
2468
+ const stickY = leftStickY();
2469
+ const stickX = leftStickX();
2470
+ if (key('KeyW') || key('ArrowUp') || stickY < -0.5 || btn(2)) {
2471
+ moved = tryMove(dx, dz);
2472
+ } else if (key('KeyS') || key('ArrowDown') || stickY > 0.5 || btn(3)) {
2473
+ moved = tryMove(-dx, -dz);
2474
+ } else if (key('KeyA') || stickX < -0.5) {
2475
+ // Strafe left
2476
+ moved = tryMove(dz, -dx);
2477
+ } else if (key('KeyD') || stickX > 0.5) {
2478
+ // Strafe right
2479
+ moved = tryMove(-dz, dx);
2480
+ }
2481
+
2482
+ // Turning (keyp for discrete 90° snaps, or right stick for gamepad)
2483
+ const rStickX = rightStickX();
2484
+ if (keyp('ArrowLeft') || keyp('KeyQ') || (rStickX < -0.5 && cooldownReady(cooldowns.input))) {
2485
+ facing = (facing + 3) % 4; // turn left
2486
+ targetYaw = facing * HALF_PI;
2487
+ cooldowns.move.remaining = cooldowns.move.duration;
2488
+ if (rStickX < -0.5) useCooldown(cooldowns.input);
2489
+ } else if (
2490
+ keyp('ArrowRight') ||
2491
+ keyp('KeyE') ||
2492
+ (rStickX > 0.5 && cooldownReady(cooldowns.input))
2493
+ ) {
2494
+ facing = (facing + 1) % 4; // turn right
2495
+ targetYaw = facing * HALF_PI;
2496
+ cooldowns.move.remaining = cooldowns.move.duration;
2497
+ if (rStickX > 0.5) useCooldown(cooldowns.input);
2498
+ }
2499
+
2500
+ if (keyp('KeyI') || keyp('Tab')) switchState('inventory');
2501
+
2502
+ // Click-to-inspect: raycast from camera on mouse click to identify tile ahead
2503
+ if (mousePressed() && mouseDown()) {
2504
+ const hit = raycastFromCamera(mouseX(), mouseY());
2505
+ if (hit && hit.distance < TILE * 3) {
2506
+ const tileX = Math.round(hit.point.x / TILE);
2507
+ const tileZ = Math.round(hit.point.z / TILE);
2508
+ if (tileX >= 0 && tileX < dungeonW && tileZ >= 0 && tileZ < dungeonH) {
2509
+ const tile = dungeon[tileZ][tileX];
2510
+ if (tile === T.CHEST) showFloorMessage('A treasure chest...');
2511
+ else if (tile === T.FOUNTAIN) showFloorMessage('A healing fountain');
2512
+ else if (tile === T.BOSS) showFloorMessage('Dark energy radiates here...');
2513
+ }
2514
+ }
2515
+ }
2516
+
2517
+ if (moved) cooldowns.move.remaining = cooldowns.move.duration; // reset move cooldown
2518
+
2519
+ // Ambient dungeon sounds (occasional drips and distant rumbles)
2520
+ if (Math.random() < 0.004) {
2521
+ sfx({ wave: 'sine', freq: 800 + Math.random() * 400, dur: 0.06, vol: 0.08 }); // water drip
2522
+ } else if (Math.random() < 0.002) {
2523
+ sfx({ wave: 'noise', freq: 40, dur: 0.3, vol: 0.06 }); // distant rumble
2524
+ }
2525
+
2526
+ updateCamera3D();
2527
+ }
2528
+
2529
+ function updateCombat(dt) {
2530
+ if (combatAction === 'enemyTurn') {
2531
+ enemyDelay -= dt;
2532
+ if (enemyDelay <= 0) {
2533
+ doEnemyTurn();
2534
+ }
2535
+ return;
2536
+ }
2537
+
2538
+ if (combatAction === 'result') {
2539
+ if (keyp('Space') && useCooldown(cooldowns.input)) {
2540
+ clearMonsterMeshes();
2541
+ setVolume(0.6); // quieter in exploration
2542
+ if (party.every(m => !m.alive)) {
2543
+ switchState('gameover');
2544
+ } else {
2545
+ switchState('explore');
2546
+ enemies = [];
2547
+ }
2548
+ }
2549
+ return;
2550
+ }
2551
+
2552
+ // Toggle auto-play
2553
+ if (keyp('KeyA')) {
2554
+ autoPlay = !autoPlay;
2555
+ combatLog.push(autoPlay ? 'AUTO-COMBAT ON' : 'AUTO-COMBAT OFF');
2556
+ }
2557
+
2558
+ if (combatAction === 'choose' && cooldownReady(cooldowns.input)) {
2559
+ const member = party[combatTurn];
2560
+
2561
+ // Auto-play: automatically attack a random living enemy
2562
+ if (autoPlay) {
2563
+ useCooldown(cooldowns.input);
2564
+ const target = enemies.filter(e => e.hp > 0);
2565
+ if (target.length > 0) {
2566
+ const t = target[Math.floor(Math.random() * target.length)];
2567
+ const dmg = doAttack(member, t);
2568
+ combatLog.push(`${member.name} hits ${t.name} for ${dmg}!`);
2569
+ triggerShake(shake, 0.2);
2570
+ const sparkX = 100 + t.id * 160;
2571
+ spawnSparks(sparkX, 30, rgba8(255, 200, 80, 255), 5);
2572
+ floatingTexts.spawn(`-${dmg}`, sparkX, 40, {
2573
+ color: rgba8(255, 80, 80, 255),
2574
+ scale: 2,
2575
+ vy: -40,
2576
+ });
2577
+ if (t.hp <= 0) {
2578
+ combatLog.push(`${t.name} defeated!`);
2579
+ if (gameStats) gameStats.setState({ kills: gameStats.getState().kills + 1 });
2580
+ if (t.allMeshes) {
2581
+ for (const id of t.allMeshes) setMeshVisible(id, false);
2582
+ const meshesToDestroy = [...t.allMeshes];
2583
+ setTimeout(() => {
2584
+ for (const id of meshesToDestroy) destroyMesh(id);
2585
+ }, 400);
2586
+ t.allMeshes = null;
2587
+ t.meshBody = null;
2588
+ }
2589
+ }
2590
+ }
2591
+ advanceCombatTurn();
2592
+ } else if (keyp('Digit1') || keyp('KeyZ') || btnp(4)) {
2593
+ useCooldown(cooldowns.input);
2594
+ // Attack
2595
+ combatAction = 'target';
2596
+ selectedTarget = enemies.findIndex(e => e.hp > 0);
2597
+ } else if (keyp('Digit2') || keyp('KeyX') || btnp(5)) {
2598
+ useCooldown(cooldowns.input);
2599
+ // Cast spell (if caster)
2600
+ if (member.maxMp > 0) {
2601
+ combatAction = 'spell';
2602
+ }
2603
+ } else if (keyp('Digit3') || keyp('KeyC') || btnp(6)) {
2604
+ useCooldown(cooldowns.input);
2605
+ // Defend — skip turn, boost def temporarily
2606
+ member.buffDef += 3;
2607
+ member.buffTimer = Math.max(member.buffTimer, 2);
2608
+ combatLog.push(`${member.name} defends.`);
2609
+ advanceCombatTurn();
2610
+ }
2611
+ }
2612
+
2613
+ if (combatAction === 'target' && cooldownReady(cooldowns.input)) {
2614
+ // Mouse click targeting — click on enemy name area at top
2615
+ if (mousePressed()) {
2616
+ const mx = mouseX(),
2617
+ my = mouseY();
2618
+ if (my < 40) {
2619
+ for (let i = 0; i < enemies.length; i++) {
2620
+ const ex = 20 + i * 200;
2621
+ if (enemies[i].hp > 0 && mx >= ex && mx < ex + 180) {
2622
+ selectedTarget = i;
2623
+ break;
2624
+ }
2625
+ }
2626
+ }
2627
+ }
2628
+ if (keyp('ArrowUp') || keyp('KeyW')) {
2629
+ useCooldown(cooldowns.input);
2630
+ // Prev enemy
2631
+ for (let i = selectedTarget - 1; i >= 0; i--) {
2632
+ if (enemies[i].hp > 0) {
2633
+ selectedTarget = i;
2634
+ break;
2635
+ }
2636
+ }
2637
+ } else if (keyp('ArrowDown') || keyp('KeyS')) {
2638
+ useCooldown(cooldowns.input);
2639
+ // Next enemy
2640
+ for (let i = selectedTarget + 1; i < enemies.length; i++) {
2641
+ if (enemies[i].hp > 0) {
2642
+ selectedTarget = i;
2643
+ break;
2644
+ }
2645
+ }
2646
+ } else if (keyp('Space') || keyp('Enter') || keyp('KeyZ')) {
2647
+ useCooldown(cooldowns.input);
2648
+ // Confirm attack
2649
+ const member = party[combatTurn];
2650
+ const target = enemies[selectedTarget];
2651
+ const dmg = doAttack(member, target);
2652
+ const isCrit = dmg >= member.atk * 1.2; // high roll = critical
2653
+ combatLog.push(
2654
+ isCrit
2655
+ ? `${member.name} CRITS ${target.name} for ${dmg}!`
2656
+ : `${member.name} hits ${target.name} for ${dmg}!`
2657
+ );
2658
+ triggerShake(shake, isCrit ? 0.35 : 0.2);
2659
+ sfx(isCrit ? 'explosion' : 'hit');
2660
+ const tgtX = 100 + selectedTarget * 160;
2661
+ spawnSparks(
2662
+ tgtX,
2663
+ 30,
2664
+ isCrit ? rgba8(255, 255, 100, 255) : rgba8(255, 200, 80, 255),
2665
+ isCrit ? 10 : 6
2666
+ );
2667
+ floatingTexts.spawn(isCrit ? `CRIT -${dmg}` : `-${dmg}`, tgtX, 40, {
2668
+ color: isCrit ? rgba8(255, 255, 80, 255) : rgba8(255, 80, 80, 255),
2669
+ scale: isCrit ? 3 : 2,
2670
+ vy: -40,
2671
+ });
2672
+ // 3D floating damage above monster in world space (drawFloatingTexts3D)
2673
+ if (floatingTexts3D && target.meshBody) {
2674
+ const mpos = getPosition(target.meshBody);
2675
+ if (mpos) {
2676
+ floatingTexts3D.spawn(`-${dmg}`, mpos[0], mpos[1] + 1.5, {
2677
+ z: mpos[2],
2678
+ color: 0xff5533,
2679
+ scale: 2,
2680
+ vy: 2,
2681
+ });
2682
+ }
2683
+ }
2684
+
2685
+ if (target.hp <= 0) {
2686
+ combatLog.push(`${target.name} defeated!`);
2687
+ sfx('explosion');
2688
+ triggerScreenFlash(255, 200, 50, 120);
2689
+ if (gameStats) gameStats.setState({ kills: gameStats.getState().kills + 1 });
2690
+ if (target.allMeshes) {
2691
+ // Blink out death animation: hide meshes, then destroy after delay
2692
+ for (const id of target.allMeshes) setMeshVisible(id, false);
2693
+ const meshesToDestroy = [...target.allMeshes];
2694
+ setTimeout(() => {
2695
+ for (const id of meshesToDestroy) destroyMesh(id);
2696
+ }, 400);
2697
+ target.allMeshes = null;
2698
+ target.meshBody = null;
2699
+ }
2700
+ }
2701
+ advanceCombatTurn();
2702
+ } else if (keyp('Escape') || keyp('Backspace')) {
2703
+ useCooldown(cooldowns.input);
2704
+ combatAction = 'choose';
2705
+ }
2706
+ }
2707
+
2708
+ if (combatAction === 'spell' && cooldownReady(cooldowns.input)) {
2709
+ const member = party[combatTurn];
2710
+ const available = Object.values(SPELLS).filter(
2711
+ s => s.class === member.class && member.mp >= s.cost
2712
+ );
2713
+
2714
+ if (keyp('Digit1') && available.length > 0) {
2715
+ useCooldown(cooldowns.input);
2716
+ const spell = available[0];
2717
+ sfx('laser');
2718
+ castSpellInCombat(member, spell);
2719
+ advanceCombatTurn();
2720
+ } else if (keyp('Digit2') && available.length > 1) {
2721
+ useCooldown(cooldowns.input);
2722
+ const spell = available[1];
2723
+ sfx('laser');
2724
+ castSpellInCombat(member, spell);
2725
+ advanceCombatTurn();
2726
+ } else if (keyp('Digit3') && available.length > 2) {
2727
+ useCooldown(cooldowns.input);
2728
+ const spell = available[2];
2729
+ sfx('laser');
2730
+ castSpellInCombat(member, spell);
2731
+ advanceCombatTurn();
2732
+ } else if (keyp('Digit4') && available.length > 3) {
2733
+ useCooldown(cooldowns.input);
2734
+ const spell = available[3];
2735
+ sfx('laser');
2736
+ castSpellInCombat(member, spell);
2737
+ advanceCombatTurn();
2738
+ } else if (keyp('Digit5') && available.length > 4) {
2739
+ useCooldown(cooldowns.input);
2740
+ const spell = available[4];
2741
+ sfx('laser');
2742
+ castSpellInCombat(member, spell);
2743
+ advanceCombatTurn();
2744
+ } else if (keyp('Escape') || keyp('Backspace')) {
2745
+ useCooldown(cooldowns.input);
2746
+ combatAction = 'choose';
2747
+ }
2748
+ }
2749
+ }
2750
+
2751
+ function updateInventory(dt) {
2752
+ if (keyp('KeyI') || keyp('Tab') || keyp('Escape')) {
2753
+ setVolume(0.6); // restore exploration volume
2754
+ switchState('explore');
2755
+ }
2756
+ if (keyp('KeyS')) {
2757
+ saveGame();
2758
+ showFloorMessage('Game saved!');
2759
+ sfx('confirm');
2760
+ }
2761
+ // Toggle visual preset mode: V cycles null → n64 → psx → minimal → null
2762
+ if (keyp('KeyV')) {
2763
+ if (!visualPreset) {
2764
+ visualPreset = 'n64';
2765
+ enableN64Mode();
2766
+ enableFXAA();
2767
+ showFloorMessage('N64 Mode enabled');
2768
+ } else if (visualPreset === 'n64') {
2769
+ visualPreset = 'psx';
2770
+ disablePresetMode();
2771
+ enablePSXMode();
2772
+ enableFXAA();
2773
+ showFloorMessage('PSX Mode enabled');
2774
+ } else if (visualPreset === 'psx') {
2775
+ visualPreset = 'lowpoly';
2776
+ disablePresetMode();
2777
+ enableLowPolyMode();
2778
+ enableFXAA();
2779
+ showFloorMessage('Low-Poly Mode enabled');
2780
+ } else if (visualPreset === 'lowpoly') {
2781
+ visualPreset = 'dithered';
2782
+ disablePresetMode();
2783
+ enableDithering(true);
2784
+ showFloorMessage('Dithered Mode enabled');
2785
+ } else if (visualPreset === 'dithered') {
2786
+ visualPreset = 'minimal';
2787
+ enableDithering(false);
2788
+ disablePresetMode();
2789
+ disableBloom();
2790
+ disableFXAA();
2791
+ if (isEffectsEnabled()) showFloorMessage('Minimal Mode — effects disabled');
2792
+ else showFloorMessage('Minimal Mode — no post-FX');
2793
+ } else {
2794
+ visualPreset = null;
2795
+ disablePresetMode();
2796
+ enableBloom(1.0, 0.4, 0.25); // explicitly re-enable bloom
2797
+ enableRetroEffects({
2798
+ bloom: { strength: 1.0, radius: 0.4, threshold: 0.25 },
2799
+ vignette: { darkness: 1.4, offset: 0.8 },
2800
+ fxaa: true,
2801
+ dithering: true,
2802
+ });
2803
+ showFloorMessage('Default mode restored');
2804
+ }
2805
+ sfx('select');
2806
+ }
2807
+ }
2808
+
2809
+ // ═══════════════════════════════════════════════════════════════════════
2810
+ // SHOP SYSTEM
2811
+ // ═══════════════════════════════════════════════════════════════════════
2812
+
2813
+ function openShop(nextFloor) {
2814
+ shopItems = SHOP_ITEMS.map(item => ({
2815
+ ...item,
2816
+ // Scale costs with floor
2817
+ cost: item.cost + Math.floor(item.cost * (nextFloor - 2) * 0.2),
2818
+ }));
2819
+ shopCursor = 0;
2820
+ shopTarget = -1; // -1 = browsing, 0+ = selecting party member
2821
+ switchState('shop');
2822
+ setVolume(0.5); // quieter in shop
2823
+ setBloomRadius(0.6); // softer bloom in shop atmosphere
2824
+ setBloomThreshold(0.35);
2825
+ clearFog(); // no fog in the merchant area — bright and welcoming
2826
+ createSolidSkybox(0x1a1020); // dark merchant atmosphere
2827
+ disableSkyboxAutoAnimate(); // still skybox in shop
2828
+ sfx('coin');
2829
+ }
2830
+
2831
+ function applyShopItem(item, target) {
2832
+ if (item.effect === 'hp') {
2833
+ target.hp = Math.min(target.hp + item.amount, target.maxHp);
2834
+ showFloorMessage(`${target.name} restored ${item.amount} HP!`);
2835
+ } else if (item.effect === 'mp') {
2836
+ target.mp = Math.min(target.mp + item.amount, target.maxMp);
2837
+ showFloorMessage(`${target.name} restored ${item.amount} MP!`);
2838
+ } else if (item.effect === 'revive') {
2839
+ if (!target.alive) {
2840
+ target.alive = true;
2841
+ target.hp = item.amount;
2842
+ showFloorMessage(`${target.name} has been revived!`);
2843
+ } else {
2844
+ showFloorMessage(`${target.name} is already alive!`);
2845
+ return false; // refund
2846
+ }
2847
+ } else if (item.effect === 'party_hp') {
2848
+ for (const m of party) {
2849
+ if (m.alive) m.hp = Math.min(m.hp + item.amount, m.maxHp);
2850
+ }
2851
+ showFloorMessage(`Party restored ${item.amount} HP each!`);
2852
+ } else if (item.effect === 'atk') {
2853
+ target.buffAtk += item.amount;
2854
+ target.buffTimer = 99; // lasts until cleared
2855
+ showFloorMessage(`${target.name} gained +${item.amount} ATK!`);
2856
+ } else if (item.effect === 'def') {
2857
+ target.buffDef += item.amount;
2858
+ target.buffTimer = 99;
2859
+ showFloorMessage(`${target.name} gained +${item.amount} DEF!`);
2860
+ }
2861
+ return true;
2862
+ }
2863
+
2864
+ function updateShop(dt) {
2865
+ if (!cooldownReady(cooldowns.input)) return;
2866
+
2867
+ if (shopTarget >= 0) {
2868
+ // Selecting party member target (keyboard + right stick Y)
2869
+ const tgtStickY = rightStickY();
2870
+ if (keyp('ArrowUp') || keyp('KeyW') || (tgtStickY < -0.5 && cooldownReady(cooldowns.input))) {
2871
+ useCooldown(cooldowns.input);
2872
+ shopTarget = (shopTarget + party.length - 1) % party.length;
2873
+ } else if (
2874
+ keyp('ArrowDown') ||
2875
+ keyp('KeyS') ||
2876
+ (tgtStickY > 0.5 && cooldownReady(cooldowns.input))
2877
+ ) {
2878
+ useCooldown(cooldowns.input);
2879
+ shopTarget = (shopTarget + 1) % party.length;
2880
+ } else if (keyp('Space') || keyp('Enter') || keyp('KeyZ')) {
2881
+ useCooldown(cooldowns.input);
2882
+ const item = shopItems[shopCursor];
2883
+ const target = party[shopTarget];
2884
+ // Validate: revive only on dead, hp/mp/buff only on alive
2885
+ if (item.effect === 'revive' && target.alive) {
2886
+ showFloorMessage(`${target.name} is already alive!`);
2887
+ sfx('error');
2888
+ } else if (item.effect !== 'revive' && item.effect !== 'party_hp' && !target.alive) {
2889
+ showFloorMessage(`${target.name} has fallen...`);
2890
+ sfx('error');
2891
+ } else {
2892
+ if (applyShopItem(item, target)) {
2893
+ totalGold -= item.cost;
2894
+ sfx('coin');
2895
+ triggerScreenFlash(50, 200, 100, 80);
2896
+ }
2897
+ }
2898
+ shopTarget = -1;
2899
+ } else if (keyp('Escape') || keyp('Backspace')) {
2900
+ useCooldown(cooldowns.input);
2901
+ shopTarget = -1;
2902
+ }
2903
+ return;
2904
+ }
2905
+
2906
+ // Browsing items (keyboard + gamepad via gamepadAxis)
2907
+ const shopStickY = gamepadAxis('leftY');
2908
+ if (keyp('ArrowUp') || keyp('KeyW') || (shopStickY < -0.5 && cooldownReady(cooldowns.input))) {
2909
+ useCooldown(cooldowns.input);
2910
+ shopCursor = (shopCursor + shopItems.length - 1) % shopItems.length;
2911
+ } else if (
2912
+ keyp('ArrowDown') ||
2913
+ keyp('KeyS') ||
2914
+ (shopStickY > 0.5 && cooldownReady(cooldowns.input))
2915
+ ) {
2916
+ useCooldown(cooldowns.input);
2917
+ shopCursor = (shopCursor + 1) % shopItems.length;
2918
+ } else if (keyp('Space') || keyp('Enter') || keyp('KeyZ')) {
2919
+ useCooldown(cooldowns.input);
2920
+ const item = shopItems[shopCursor];
2921
+ if (totalGold < item.cost) {
2922
+ showFloorMessage('Not enough gold!');
2923
+ sfx('error');
2924
+ } else if (item.effect === 'party_hp') {
2925
+ // Party-wide items apply immediately, no target needed
2926
+ if (applyShopItem(item, null)) {
2927
+ totalGold -= item.cost;
2928
+ sfx('coin');
2929
+ triggerScreenFlash(50, 200, 100, 80);
2930
+ }
2931
+ } else {
2932
+ // Need to pick a target
2933
+ shopTarget = 0;
2934
+ }
2935
+ } else if (keyp('Escape') || keyp('Backspace') || keyp('KeyX')) {
2936
+ useCooldown(cooldowns.input);
2937
+ // Leave shop → continue to next floor
2938
+ setVolume(0.6); // restore exploration volume
2939
+ setBloomRadius(0.4); // restore exploration bloom
2940
+ setBloomThreshold(0.25);
2941
+ // Fog will be restored in enterFloor → buildLevel
2942
+ const nextFloor = floor + 1;
2943
+ enterFloor(nextFloor);
2944
+ switchState('explore');
2945
+ sfx('confirm');
2946
+ }
2947
+ }
2948
+
2949
+ function drawShopUI() {
2950
+ drawGradient(0, 0, W, H, rgba8(8, 5, 20, 200), rgba8(20, 15, 5, 200));
2951
+
2952
+ drawPanel(60, 20, W - 120, H - 40, {
2953
+ bgColor: rgba8(15, 12, 25, 240),
2954
+ borderLight: rgba8(120, 100, 50, 255),
2955
+ borderDark: rgba8(40, 30, 15, 255),
2956
+ });
2957
+
2958
+ drawGlowText('MERCHANT', 320, 32, rgba8(220, 180, 50, 255), rgba8(140, 100, 0, 100), 3);
2959
+ drawDiamond(472, 40, 4, 5, rgba8(255, 220, 50, 255));
2960
+ // Use setTextAlign + setTextBaseline + drawText for gold display
2961
+ setTextAlign('right');
2962
+ setTextBaseline('top');
2963
+ drawText(`${totalGold}g`, 560, 36, rgba8(255, 220, 50, 255));
2964
+ setTextAlign('left');
2965
+ setTextBaseline('top');
2966
+ printCentered(`Floor ${floor} → ${floor + 1}`, 320, 68, rgba8(150, 140, 120, 200));
2967
+
2968
+ // Item list with gradient rect backgrounds
2969
+ for (let i = 0; i < shopItems.length; i++) {
2970
+ const item = shopItems[i];
2971
+ const y = 90 + i * 28;
2972
+ const sel = i === shopCursor && shopTarget < 0;
2973
+ const canAfford = totalGold >= item.cost;
2974
+
2975
+ // Gradient highlight for selected/affordable items
2976
+ if (sel) {
2977
+ drawGradientRect(72, y - 2, 488, 18, rgba8(60, 40, 20, 150), rgba8(30, 20, 10, 50));
2978
+ }
2979
+
2980
+ const nameColor = sel
2981
+ ? rgba8(255, 255, 200, 255)
2982
+ : canAfford
2983
+ ? rgba8(200, 200, 220, 220)
2984
+ : rgba8(100, 100, 110, 150);
2985
+ const costColor = canAfford ? rgba8(220, 180, 50, 220) : rgba8(120, 80, 40, 150);
2986
+
2987
+ print(`${sel ? '►' : ' '} ${item.name}`, 80, y, nameColor);
2988
+ print(`${item.cost}g`, 320, y, costColor);
2989
+ print(item.desc, 370, y, rgba8(140, 140, 160, 180));
2990
+ }
2991
+
2992
+ // Target selection overlay
2993
+ if (shopTarget >= 0) {
2994
+ drawPanel(180, 120, 280, 140, {
2995
+ bgColor: rgba8(10, 8, 20, 250),
2996
+ borderLight: rgba8(100, 80, 50, 255),
2997
+ borderDark: rgba8(30, 25, 15, 255),
2998
+ });
2999
+ printCentered('Select target:', 320, 128, rgba8(200, 180, 120, 255));
3000
+ for (let i = 0; i < party.length; i++) {
3001
+ const m = party[i];
3002
+ const y = 148 + i * 24;
3003
+ const sel = i === shopTarget;
3004
+ const c = CLASS_COLORS[m.class];
3005
+ const cr = (c >> 16) & 0xff,
3006
+ cg = (c >> 8) & 0xff,
3007
+ cb = c & 0xff;
3008
+ const nameColor = sel ? rgba8(255, 255, 200, 255) : rgba8(cr, cg, cb, 200);
3009
+ print(`${sel ? '►' : ' '} ${m.name}`, 200, y, nameColor);
3010
+ print(
3011
+ m.alive ? `HP:${m.hp}/${m.maxHp}` : '☠ FALLEN',
3012
+ 340,
3013
+ y,
3014
+ m.alive ? rgba8(150, 180, 150, 200) : rgba8(180, 50, 50, 200)
3015
+ );
3016
+ }
3017
+ print('Z/Space=Confirm Esc=Back', 200, 248, rgba8(100, 100, 120, 150));
3018
+ }
3019
+
3020
+ // Controls
3021
+ printCentered(
3022
+ 'W/S=Browse Z/Space=Buy ESC=Continue to next floor',
3023
+ 320,
3024
+ H - 55,
3025
+ rgba8(120, 120, 150, 200)
3026
+ );
3027
+ }
3028
+
3029
+ // ═══════════════════════════════════════════════════════════════════════
3030
+ // DRAW
3031
+ // ═══════════════════════════════════════════════════════════════════════
3032
+
3033
+ export function draw() {
3034
+ // Apply 2D camera shake offset (setCamera API) for full-screen shake
3035
+ const [shakeOX, shakeOY] = getShakeOffset(shake);
3036
+ if (shakeOX !== 0 || shakeOY !== 0) {
3037
+ setCamera(Math.floor(shakeOX * 2), Math.floor(shakeOY * 2));
3038
+ }
3039
+
3040
+ if (gameState === 'title') {
3041
+ drawTitle();
3042
+ } else if (gameState === 'explore') {
3043
+ drawExploreHUD();
3044
+ } else if (gameState === 'combat') {
3045
+ drawCombatUI();
3046
+ } else if (gameState === 'inventory') {
3047
+ drawInventoryUI();
3048
+ } else if (gameState === 'shop') {
3049
+ drawShopUI();
3050
+ } else if (gameState === 'gameover') {
3051
+ drawGameOver();
3052
+ } else if (gameState === 'victory') {
3053
+ drawVictory();
3054
+ }
3055
+
3056
+ // Boss flow field energy overlay (flowField API)
3057
+ if (
3058
+ bossFlowField &&
3059
+ gameState === 'combat' &&
3060
+ enemies &&
3061
+ enemies.length > 0 &&
3062
+ enemies[0].isBoss &&
3063
+ enemies[0].hp > 0
3064
+ ) {
3065
+ const cols = 16,
3066
+ rows = 12;
3067
+ const cellW = W / cols,
3068
+ cellH = H / rows;
3069
+ // Regenerate flow field with time for flowing animation
3070
+ bossFlowField = flowField(cols, rows, 0.08, animTimer * 0.5);
3071
+ for (let i = 0; i < cols * rows; i++) {
3072
+ const col = i % cols,
3073
+ row = Math.floor(i / cols);
3074
+ const cx = col * cellW + cellW / 2;
3075
+ const cy = row * cellH + cellH / 2;
3076
+ const angle = bossFlowField[i];
3077
+ const len = 8;
3078
+ const ex = cx + Math.cos(angle) * len;
3079
+ const ey = cy + Math.sin(angle) * len;
3080
+ const alpha = Math.floor(20 + Math.sin(animTimer * 3 + i * 0.3) * 10);
3081
+ line(
3082
+ Math.floor(cx),
3083
+ Math.floor(cy),
3084
+ Math.floor(ex),
3085
+ Math.floor(ey),
3086
+ rgba8(200, 50, 80, alpha)
3087
+ );
3088
+ }
3089
+ }
3090
+
3091
+ // Spell VFX overlay (starburst for attacks, radial gradient for buffs/heals)
3092
+ if (spellVFX) {
3093
+ const alpha = Math.min(1, spellVFX.timer * 3);
3094
+ if (spellVFX.type === 'star') {
3095
+ const r = 30 + (1 - alpha) * 40;
3096
+ // HSB hue-cycling glow ring using colorMode + color API
3097
+ colorMode('hsb', 360, 100, 100);
3098
+ const ringHue = (animTimer * 200 + spellVFX.timer * 400) % 360;
3099
+ const ringColor = color(ringHue, 80, 60, Math.floor(alpha * 100));
3100
+ colorMode('rgb', 255); // restore RGB mode
3101
+ circle(spellVFX.x, spellVFX.y, Math.floor(r + 8), ringColor);
3102
+ drawStarburst(spellVFX.x, spellVFX.y, r, r * 0.4, 8, spellVFX.color);
3103
+ // Bezier arc spell trail — curved energy arc from caster to impact
3104
+ const arcProgress = 1 - alpha;
3105
+ bezier(
3106
+ 50,
3107
+ H - 120,
3108
+ 120,
3109
+ spellVFX.y - 80 * arcProgress,
3110
+ spellVFX.x - 60,
3111
+ spellVFX.y - 40,
3112
+ spellVFX.x,
3113
+ spellVFX.y,
3114
+ spellVFX.color,
3115
+ 30
3116
+ );
3117
+ } else if (spellVFX.type === 'radial') {
3118
+ const r = 40 + (1 - alpha) * 60;
3119
+ drawRadialGradient(spellVFX.x, spellVFX.y, r, spellVFX.color, rgba8(0, 0, 0, 0));
3120
+ // Quadratic bezier energy arc for buff/heal spells (quadCurve)
3121
+ const arcAlpha = Math.floor(alpha * 180);
3122
+ quadCurve(
3123
+ 100,
3124
+ H - 100,
3125
+ spellVFX.x,
3126
+ spellVFX.y - 60 * alpha,
3127
+ spellVFX.x,
3128
+ spellVFX.y,
3129
+ lerpColor(spellVFX.color, hsb(120, 0.6, 1, arcAlpha), 0.4),
3130
+ 20
3131
+ );
3132
+ }
3133
+ }
3134
+
3135
+ // Screen flash overlay using drawFlash API (damage, magic, discoveries)
3136
+ if (screenFlash && screenFlash.alpha > 0) {
3137
+ drawFlash(rgba8(screenFlash.r, screenFlash.g, screenFlash.b, Math.floor(screenFlash.alpha)));
3138
+ }
3139
+
3140
+ // Floating texts (2D screen-space + 3D world-space via drawFloatingTexts3D)
3141
+ drawFloatingTexts(floatingTexts);
3142
+ if (floatingTexts3D && (gameState === 'combat' || gameState === 'explore')) {
3143
+ drawFloatingTexts3D(floatingTexts3D, worldToScreen);
3144
+ }
3145
+
3146
+ // Combat hit sparks (createPool)
3147
+ if (sparkPool && sparkPool.count > 0) {
3148
+ sparkPool.forEach(s => {
3149
+ const a = Math.floor((s.life / 0.5) * 255);
3150
+ rectfill(Math.floor(s.x), Math.floor(s.y), 2, 2, colorMix(s.color, s.life * 2));
3151
+ });
3152
+ }
3153
+
3154
+ // Subtle noise grain + CRT scanlines for retro feel
3155
+ // Only apply full-screen noise on states with 2D backgrounds;
3156
+ // in explore/combat the 3D scene must show through the overlay.
3157
+ if (gameState !== 'explore' && gameState !== 'combat') {
3158
+ drawNoise(0, 0, W, H, 12, Math.floor(animTimer * 10));
3159
+ drawScanlines(25, 3);
3160
+ }
3161
+
3162
+ // Perlin noise atmospheric fog wisps in explore/combat (subtle 2D overlay)
3163
+ if (gameState === 'explore' || gameState === 'combat') {
3164
+ for (let i = 0; i < 4; i++) {
3165
+ const nx = noise(animTimer * 0.3 + i * 3.7, i * 2.1) * W;
3166
+ const ny = H - 70 + noise(i * 5.3, animTimer * 0.2) * 40;
3167
+ const alpha = Math.floor(noise(animTimer * 0.5 + i, 0) * 25);
3168
+ if (alpha > 5) {
3169
+ ellipse(Math.floor(nx), Math.floor(ny), 40 + i * 10, 8, rgba8(80, 70, 60, alpha), true);
3170
+ }
3171
+ }
3172
+ // Procedural noiseMap fog overlay on deeper floors (floor 3+)
3173
+ if (floor >= 3) {
3174
+ if (!floorNoiseMap) floorNoiseMap = noiseMap(32, 18, 0.12, floor * 10, 0);
3175
+ const nCols = 32,
3176
+ nRows = 18;
3177
+ const cw = W / nCols,
3178
+ ch = H / nRows;
3179
+ for (let r = nRows - 4; r < nRows; r++) {
3180
+ for (let c = 0; c < nCols; c++) {
3181
+ const v = floorNoiseMap[r * nCols + c];
3182
+ const a = Math.floor(Math.max(0, v) * 18 * (floor - 2));
3183
+ if (a > 2)
3184
+ rectfill(
3185
+ Math.floor(c * cw),
3186
+ Math.floor(r * ch),
3187
+ Math.ceil(cw),
3188
+ Math.ceil(ch),
3189
+ rgba8(30, 20, 40, a)
3190
+ );
3191
+ }
3192
+ }
3193
+ }
3194
+ }
3195
+
3196
+ // Reset 2D camera offset after ALL drawing (must be last)
3197
+ setCamera(0, 0);
3198
+ }
3199
+
3200
+ function drawTitle() {
3201
+ const t = stateElapsed();
3202
+ const fadeIn = smoothstep(0, 2.0, t);
3203
+ const fade = Math.floor(fadeIn * 255);
3204
+
3205
+ // Semi-transparent overlays for text readability (3D scene shows through)
3206
+ drawGradient(0, 0, W, 160, rgba8(0, 0, 10, Math.floor(fade * 0.7)), rgba8(0, 0, 5, 0), 'v');
3207
+ drawGradient(0, 240, W, 120, rgba8(0, 0, 5, 0), rgba8(0, 0, 10, Math.floor(fade * 0.55)), 'v');
3208
+
3209
+ // Animated starburst glow behind title text
3210
+ const burstAlpha = Math.floor(pulse(t, 0.8) * 40 + 20);
3211
+ drawStarburst(320, 65, 120, 40, 12, rgba8(255, 180, 50, burstAlpha));
3212
+ drawRadialGradient(320, 65, 100, rgba8(180, 120, 40, Math.floor(fade * 0.2)), rgba8(0, 0, 0, 0));
3213
+
3214
+ // Main title with large glow radius
3215
+ drawGlowText(
3216
+ 'WIZARDRY',
3217
+ 320,
3218
+ 50,
3219
+ rgba8(255, 210, 60, fade),
3220
+ rgba8(200, 120, 0, Math.floor(fade * 0.6)),
3221
+ 6
3222
+ );
3223
+
3224
+ // Subtitle
3225
+ const subAlpha = Math.floor(smoothstep(0.5, 1.5, t) * 255);
3226
+ printCentered('N O V A 6 4', 320, 100, rgba8(220, 180, 100, subAlpha), 2);
3227
+
3228
+ // Tagline
3229
+ const tagAlpha = Math.floor(smoothstep(1.0, 2.0, t) * 200);
3230
+ printCentered('Proving Grounds of the Dark Tower', 320, 130, rgba8(170, 150, 130, tagAlpha));
3231
+
3232
+ // Wave separator
3233
+ if (t > 1.0) {
3234
+ drawWave(
3235
+ 100,
3236
+ 147,
3237
+ 440,
3238
+ 4,
3239
+ 0.04,
3240
+ animTimer * 2.5,
3241
+ rgba8(200, 140, 50, Math.floor(tagAlpha * 0.3)),
3242
+ 2
3243
+ );
3244
+ }
3245
+
3246
+ // Action prompt (staggered)
3247
+ if (t > 1.5) {
3248
+ const promptFade = smoothstep(1.5, 2.5, t);
3249
+ if (hasSave()) {
3250
+ drawPulsingText(
3251
+ 'Press C to Continue',
3252
+ 320,
3253
+ 255,
3254
+ rgba8(100, 200, 255, Math.floor(promptFade * 255)),
3255
+ animTimer,
3256
+ {
3257
+ frequency: 2,
3258
+ minAlpha: 160,
3259
+ }
3260
+ );
3261
+ drawPulsingText(
3262
+ 'Press SPACE for New Game',
3263
+ 320,
3264
+ 275,
3265
+ rgba8(200, 200, 200, Math.floor(promptFade * 220)),
3266
+ animTimer,
3267
+ {
3268
+ frequency: 3,
3269
+ minAlpha: 140,
3270
+ }
3271
+ );
3272
+ } else {
3273
+ drawPulsingText(
3274
+ 'Press SPACE to begin your quest',
3275
+ 320,
3276
+ 260,
3277
+ rgba8(255, 255, 255, Math.floor(promptFade * 255)),
3278
+ animTimer,
3279
+ {
3280
+ frequency: 2.5,
3281
+ minAlpha: 160,
3282
+ }
3283
+ );
3284
+ }
3285
+ }
3286
+
3287
+ // Party preview (bottom, appears last)
3288
+ if (t > 2.0) {
3289
+ const partyFade = Math.floor(smoothstep(2.0, 3.0, t) * 255);
3290
+ printCentered('Your Party:', 320, 298, rgba8(180, 180, 200, partyFade));
3291
+ for (let i = 0; i < 4; i++) {
3292
+ const m = party[i];
3293
+ const x = 130 + i * 130;
3294
+ const c = CLASS_COLORS[m.class];
3295
+ const r = (c >> 16) & 0xff,
3296
+ g = (c >> 8) & 0xff,
3297
+ b = c & 0xff;
3298
+ pushMatrix();
3299
+ translate(x, 320);
3300
+ rotate(QUARTER_PI + Math.sin(animTimer * 1.5 + i) * 0.2);
3301
+ scale2d(1.3);
3302
+ rectfill(-6, -6, 12, 12, rgba8(r, g, b, Math.floor(partyFade * 0.2)));
3303
+ rect(-6, -6, 12, 12, rgba8(r, g, b, Math.floor(partyFade * 0.4)));
3304
+ popMatrix();
3305
+ printCentered(CLASS_ICONS[m.class], x, 316, rgba8(r, g, b, partyFade), 2);
3306
+ printCentered(m.name, x, 336, rgba8(r, g, b, Math.floor(partyFade * 0.8)));
3307
+ }
3308
+ }
3309
+ }
3310
+
3311
+ function drawExploreHUD() {
3312
+ // Compass panel
3313
+ // Floor-themed compass border using hslColor (hue shifts per floor)
3314
+ const floorHue = floor > 0 ? (floor - 1) * 60 : 30; // warm → cool per floor
3315
+ drawPanel(270, 2, 100, 38, {
3316
+ bgColor: rgba8(0, 0, 0, 160),
3317
+ borderLight: hslColor(floorHue, 0.3, 0.25, 180),
3318
+ borderDark: hslColor(floorHue, 0.3, 0.1, 180),
3319
+ });
3320
+ printCentered(`Facing ${DIR_NAMES[facing]}`, 320, 8, rgba8(200, 200, 220, 255));
3321
+ printCentered(
3322
+ `Floor ${floor} (${rad2deg(currentYaw).toFixed(0)}°)`,
3323
+ 320,
3324
+ 24,
3325
+ hslColor(floorHue, 0.4, 0.5, 200)
3326
+ );
3327
+
3328
+ // Directional arrow indicator using drawTriangle
3329
+ const arrowColor = hslColor(floorHue, 0.5, 0.6, 200);
3330
+ const ax = 362,
3331
+ ay = 20; // right side of compass panel
3332
+ if (facing === 0)
3333
+ drawTriangle(ax, ay - 5, ax - 4, ay + 3, ax + 4, ay + 3, arrowColor, true); // N ▲
3334
+ else if (facing === 1)
3335
+ drawTriangle(ax + 5, ay, ax - 3, ay - 4, ax - 3, ay + 4, arrowColor, true); // E ►
3336
+ else if (facing === 2)
3337
+ drawTriangle(ax, ay + 5, ax - 4, ay - 3, ax + 4, ay - 3, arrowColor, true); // S ▼
3338
+ else drawTriangle(ax - 5, ay, ax + 3, ay - 4, ax + 3, ay + 4, arrowColor, true); // W ◄
3339
+
3340
+ // Compass arc — sweeping arc around facing direction using deg2rad
3341
+ const arcAngle = deg2rad(facing * 90);
3342
+ arc(320, 18, 28, 14, arcAngle - 0.4, arcAngle + 0.4, hslColor(floorHue, 0.6, 0.5, 100), false);
3343
+
3344
+ // Mini party status (bottom)
3345
+ drawPartyBar();
3346
+
3347
+ // Gamepad connected indicator on explore HUD
3348
+ if (gamepadConnected()) {
3349
+ print('🎮', W - 108, 8, rgba8(100, 200, 100, 180));
3350
+ }
3351
+
3352
+ // Minimap (top-right) using createMinimap API
3353
+ if (minimap) drawMinimap(minimap, animTimer);
3354
+
3355
+ // Floor message
3356
+ if (floorMessageTimer > 0) {
3357
+ const alpha = Math.min(255, Math.floor(floorMessageTimer * 200));
3358
+ drawPanel(120, 158, 400, 28, {
3359
+ bgColor: rgba8(0, 0, 0, Math.floor(alpha * 0.7)),
3360
+ borderLight: rgba8(100, 80, 40, Math.floor(alpha * 0.4)),
3361
+ borderDark: rgba8(30, 20, 10, Math.floor(alpha * 0.4)),
3362
+ });
3363
+ drawTextShadow(
3364
+ floorMessage,
3365
+ 320,
3366
+ 166,
3367
+ rgba8(255, 220, 100, alpha),
3368
+ rgba8(0, 0, 0, Math.floor(alpha * 0.5)),
3369
+ 1
3370
+ );
3371
+ }
3372
+
3373
+ // Magic energy wave divider above party bar — color shifts per floor
3374
+ const waveHue = floor > 0 ? (floor - 1) * 60 : 30;
3375
+ drawWave(0, H - 56, W, 4, 0.04, animTimer * 3, hslColor(waveHue, 0.5, 0.4, 80), 2);
3376
+
3377
+ // Move cooldown indicator (shows when movement is on cooldown)
3378
+ const moveProg = cooldownProgress(cooldowns.move);
3379
+ if (moveProg < 1) {
3380
+ const barW = 40;
3381
+ const bx = 310,
3382
+ by = H - 58;
3383
+ drawRoundedRect(bx - 1, by - 1, barW + 2, 6, 2, rgba8(20, 20, 30, 180));
3384
+ rectfill(
3385
+ bx,
3386
+ by,
3387
+ Math.floor(barW * moveProg),
3388
+ 4,
3389
+ rgba8(100, 180, 255, Math.floor(200 * (1 - moveProg)))
3390
+ );
3391
+ }
3392
+
3393
+ // Controls hint
3394
+ print('WASD/Arrows=Move Q/E=Turn I=Inventory', 10, 348, rgba8(80, 80, 100, 150));
3395
+
3396
+ // Thief trap proximity warning using aabb() collision check
3397
+ const thief = party.find(m => m.alive && m.class === 'Thief');
3398
+ if (thief) {
3399
+ let trapNear = false;
3400
+ for (let dy = -2; dy <= 2; dy++) {
3401
+ for (let dx = -2; dx <= 2; dx++) {
3402
+ const tx = px + dx,
3403
+ ty = py + dy;
3404
+ if (tx >= 0 && tx < dungeonW && ty >= 0 && ty < dungeonH && dungeon[ty][tx] === T.TRAP) {
3405
+ if (aabb(px - 1.5, py - 1.5, 3, 3, tx - 0.5, ty - 0.5, 1, 1)) trapNear = true;
3406
+ }
3407
+ }
3408
+ }
3409
+ if (trapNear) {
3410
+ const warnAlpha = Math.floor(pulse(animTimer, 3) * 100 + 155);
3411
+ drawTextShadow(
3412
+ '⚠ Trap nearby!',
3413
+ 270,
3414
+ 44,
3415
+ rgba8(255, 80, 60, warnAlpha),
3416
+ rgba8(0, 0, 0, 180),
3417
+ 1
3418
+ );
3419
+ }
3420
+ }
3421
+
3422
+ // Gold with diamond icon
3423
+ drawDiamond(534, 352, 4, 5, rgba8(220, 180, 50, 200));
3424
+ print(`${totalGold}g`, 542, 348, rgba8(220, 180, 50, 200));
3425
+
3426
+ // Floor transition checkerboard wipe — use ease() for smooth in/out
3427
+ if (floorTransition > 0) {
3428
+ const rawT = clamp(floorTransition / 0.6, 0, 1);
3429
+ const easedT = ease(rawT, 'easeInOutQuad');
3430
+ const alpha = Math.floor(easedT * 200);
3431
+ drawCheckerboard(
3432
+ 0,
3433
+ 0,
3434
+ W,
3435
+ H,
3436
+ rgba8(0, 0, 0, alpha),
3437
+ rgba8(10, 5, 20, Math.floor(alpha * 0.5)),
3438
+ 24
3439
+ );
3440
+ }
3441
+ }
3442
+
3443
+ function drawPartyBar() {
3444
+ const barY = H - 52;
3445
+ drawPanel(0, barY, W, 52, {
3446
+ bgColor: rgba8(10, 8, 15, 220),
3447
+ borderLight: rgba8(60, 50, 40, 200),
3448
+ borderDark: rgba8(20, 15, 10, 200),
3449
+ });
3450
+
3451
+ for (let i = 0; i < party.length; i++) {
3452
+ const m = party[i];
3453
+ const bx = 10 + i * 158;
3454
+ const c = CLASS_COLORS[m.class];
3455
+ const r = (c >> 16) & 0xff,
3456
+ g = (c >> 8) & 0xff,
3457
+ b = c & 0xff;
3458
+
3459
+ // Name + class icon
3460
+ print(
3461
+ `${CLASS_ICONS[m.class]} ${m.name}`,
3462
+ bx,
3463
+ barY + 4,
3464
+ m.alive ? rgba8(r, g, b, 255) : rgba8(80, 80, 80, 255)
3465
+ );
3466
+
3467
+ // HP bar
3468
+ if (m.alive) {
3469
+ const hpRatio = m.hp / m.maxHp;
3470
+ const hpColor = colorLerp(rgba8(200, 40, 40, 255), rgba8(50, 180, 50, 255), hpRatio);
3471
+ drawHealthBar(bx, barY + 16, 100, 6, m.hp, m.maxHp, {
3472
+ barColor: hpColor,
3473
+ backgroundColor: rgba8(30, 20, 20, 255),
3474
+ });
3475
+ print(`${m.hp}/${m.maxHp}`, bx + 104, barY + 14, rgba8(180, 180, 180, 200));
3476
+ } else {
3477
+ print('DEAD', bx, barY + 16, rgba8(150, 40, 40, 200));
3478
+ }
3479
+
3480
+ // MP bar (if applicable)
3481
+ if (m.maxMp > 0 && m.alive) {
3482
+ drawProgressBar(
3483
+ bx,
3484
+ barY + 26,
3485
+ 100,
3486
+ 4,
3487
+ m.mp / m.maxMp,
3488
+ rgba8(50, 80, 200, 255),
3489
+ rgba8(20, 20, 30, 255)
3490
+ );
3491
+ print(`${m.mp}/${m.maxMp}`, bx + 104, barY + 24, rgba8(120, 140, 220, 180));
3492
+ }
3493
+
3494
+ // Level
3495
+ print(`Lv${m.level}`, bx, barY + 36, rgba8(120, 120, 140, 180));
3496
+ }
3497
+ }
3498
+
3499
+ function drawCombatUI() {
3500
+ // Dark overlay panels
3501
+ drawPanel(0, 0, W, 40, {
3502
+ bgColor: rgba8(10, 5, 15, 200),
3503
+ borderLight: rgba8(50, 30, 50, 150),
3504
+ borderDark: rgba8(10, 5, 15, 150),
3505
+ });
3506
+ drawPanel(0, H - 160, W, 160, {
3507
+ bgColor: rgba8(10, 5, 15, 220),
3508
+ borderLight: rgba8(50, 30, 50, 150),
3509
+ borderDark: rgba8(10, 5, 15, 150),
3510
+ });
3511
+
3512
+ // Boss indicator with scrolling title
3513
+ if (enemies.length > 0 && enemies[0].isBoss) {
3514
+ const pulse = Math.floor(Math.sin(animTimer * 4) * 40 + 215);
3515
+ scrollingText(
3516
+ `☠ BOSS BATTLE — ${enemies[0].name} ☠ `,
3517
+ 42,
3518
+ 80,
3519
+ animTimer,
3520
+ rgba8(pulse, 40, 60, 255),
3521
+ 2,
3522
+ W
3523
+ );
3524
+ }
3525
+
3526
+ // Monster info (top)
3527
+ for (let i = 0; i < enemies.length; i++) {
3528
+ const e = enemies[i];
3529
+ const x = 20 + i * 200;
3530
+ const alive = e.hp > 0;
3531
+ const nameColor = alive ? rgba8(220, 180, 180, 255) : rgba8(80, 80, 80, 150);
3532
+ const sel = combatAction === 'target' && i === selectedTarget;
3533
+
3534
+ print(`${sel ? '► ' : ' '}${e.name}`, x, 6, nameColor);
3535
+ if (alive) {
3536
+ drawHealthBar(x, 20, 120, 6, e.hp, e.maxHp, {
3537
+ barColor: rgba8(200, 40, 40, 255),
3538
+ dangerColor: rgba8(255, 100, 60, 255),
3539
+ backgroundColor: rgba8(40, 20, 20, 255),
3540
+ });
3541
+ print(`${e.hp}/${e.maxHp}`, x + 124, 18, rgba8(180, 140, 140, 200));
3542
+ // Pulsing crosshair on selected target
3543
+ if (sel) {
3544
+ const pulse = Math.sin(animTimer * 6) * 0.3 + 0.7;
3545
+ const crossColor = rgba8(255, 60, 60, Math.floor(200 * pulse));
3546
+ drawCrosshair(x + 60, 14, 10, crossColor, 'cross');
3547
+ // Magic targeting circle around crosshair
3548
+ circle(x + 60, 14, 14, rgba8(255, 100, 80, Math.floor(120 * pulse)));
3549
+ }
3550
+ } else {
3551
+ print('DEAD', x + 10, 20, rgba8(100, 40, 40, 150));
3552
+ }
3553
+ }
3554
+
3555
+ // Combat log (middle-bottom)
3556
+ const logY = H - 155;
3557
+ const logLines = combatLog.slice(-5);
3558
+ for (let i = 0; i < logLines.length; i++) {
3559
+ const alpha = Math.floor(255 - (5 - i - 1) * 30);
3560
+ drawTextShadow(
3561
+ logLines[i],
3562
+ 20,
3563
+ logY + i * 12,
3564
+ rgba8(200, 200, 210, Math.max(80, alpha)),
3565
+ rgba8(0, 0, 0, Math.max(60, alpha)),
3566
+ 1
3567
+ );
3568
+ }
3569
+
3570
+ // Separator
3571
+ line(10, H - 90, W - 10, H - 90, rgba8(60, 50, 70, 200));
3572
+
3573
+ // Current party member + actions
3574
+ if (combatTurn < party.length) {
3575
+ const member = party[combatTurn];
3576
+ const c = CLASS_COLORS[member.class];
3577
+ const cr = (c >> 16) & 0xff,
3578
+ cg = (c >> 8) & 0xff,
3579
+ cb = c & 0xff;
3580
+
3581
+ print(`${CLASS_ICONS[member.class]} ${member.name}'s turn`, 20, H - 82, rgba8(cr, cg, cb, 255));
3582
+
3583
+ if (combatAction === 'choose') {
3584
+ // Action menu with rounded highlight
3585
+ const menuItems = ['[1/Z] Attack', '[2/X] Magic', '[3/C] Defend'];
3586
+ drawRoundedRect(14, H - 68, 200, 54, 4, rgba8(20, 15, 30, 120));
3587
+ print(menuItems[0], 20, H - 62, rgba8(220, 200, 180, 255));
3588
+ if (member.maxMp > 0) {
3589
+ print(`${menuItems[1]} (${member.mp} MP)`, 20, H - 48, rgba8(120, 140, 255, 255));
3590
+ }
3591
+ print(menuItems[2], 20, H - 34, rgba8(180, 180, 140, 255));
3592
+ const autoAlpha = Math.floor(pulse(animTimer, 1.5) * 75 + 180);
3593
+ print(
3594
+ `[A] Auto ${autoPlay ? 'ON' : 'OFF'}`,
3595
+ 20,
3596
+ H - 20,
3597
+ autoPlay ? rgba8(100, 255, 100, autoAlpha) : rgba8(120, 120, 140, 180)
3598
+ );
3599
+ // Show equipped weapon
3600
+ if (member.weapon) {
3601
+ print(`Weapon: ${member.weapon.name}`, 250, H - 62, rgba8(200, 180, 140, 150));
3602
+ }
3603
+ } else if (combatAction === 'target') {
3604
+ print(
3605
+ 'Select target: W/S = cycle, Z/Space = confirm, Esc = back',
3606
+ 20,
3607
+ H - 62,
3608
+ rgba8(200, 180, 150, 200)
3609
+ );
3610
+ } else if (combatAction === 'spell') {
3611
+ const available = Object.values(SPELLS).filter(
3612
+ s => s.class === member.class && member.mp >= s.cost
3613
+ );
3614
+ // Use grid layout helper for spell list positioning
3615
+ const spellCells = grid(1, Math.max(available.length, 1), 360, 14, 0, 0);
3616
+ for (let i = 0; i < available.length; i++) {
3617
+ const sp = available[i];
3618
+ const cell = spellCells[i];
3619
+ print(
3620
+ `[${i + 1}] ${sp.name} (${sp.cost} MP) — ${sp.desc}`,
3621
+ 20 + cell.x,
3622
+ H - 62 + cell.y,
3623
+ rgba8(140, 160, 255, 255)
3624
+ );
3625
+ }
3626
+ if (available.length === 0)
3627
+ print('No spells available!', 20, H - 62, rgba8(150, 100, 100, 200));
3628
+ print('Esc = back', 20, H - 20, rgba8(120, 120, 140, 180));
3629
+ } else if (combatAction === 'enemyTurn') {
3630
+ printCentered('Enemies attacking...', 320, H - 62, rgba8(200, 100, 100, 255));
3631
+ }
3632
+ }
3633
+
3634
+ if (combatAction === 'result') {
3635
+ const won = enemies.every(e => e.hp <= 0);
3636
+ if (won) {
3637
+ // Eased slide-in for victory text
3638
+ const t = ease(Math.min(1, stateElapsed() / 0.5), 'easeOutBack');
3639
+ const yOff = Math.floor((1 - t) * -30);
3640
+ printCentered('VICTORY!', 320, H - 70 + yOff, rgba8(255, 220, 50, 255), 2);
3641
+ // Victory circle burst decoration
3642
+ circle(320, H - 60 + yOff, Math.floor(t * 40), rgba8(255, 200, 50, Math.floor(t * 60)));
3643
+ } else {
3644
+ printCentered('DEFEAT', 320, H - 70, rgba8(200, 40, 40, 255), 2);
3645
+ }
3646
+ printCentered('Press SPACE to continue', 320, H - 40, rgba8(180, 180, 200, 200));
3647
+ }
3648
+
3649
+ // Party HP along right side
3650
+ for (let i = 0; i < party.length; i++) {
3651
+ const m = party[i];
3652
+ const y = H - 82 + i * 18;
3653
+ const isCurrent = i === combatTurn && combatAction !== 'result' && combatAction !== 'enemyTurn';
3654
+ const c = CLASS_COLORS[m.class];
3655
+ const cr = (c >> 16) & 0xff,
3656
+ cg = (c >> 8) & 0xff,
3657
+ cb = c & 0xff;
3658
+
3659
+ // Flash when recently hit (invulnerability frames)
3660
+ const hitVisible = !hitStates || !hitStates[i] || isVisible(hitStates[i], animTimer);
3661
+ const hitFlash = hitStates && hitStates[i] && isFlashing(hitStates[i]);
3662
+ const labelAlpha = hitVisible ? 255 : 80;
3663
+
3664
+ const labelColor = m.alive
3665
+ ? hitFlash
3666
+ ? rgba8(255, 255, 255, 255)
3667
+ : isCurrent
3668
+ ? rgba8(255, 255, 200, labelAlpha)
3669
+ : rgba8(cr, cg, cb, Math.min(200, labelAlpha))
3670
+ : rgba8(80, 80, 80, 150);
3671
+ // Show invulnerability shield indicator (isInvulnerable API)
3672
+ if (hitStates && hitStates[i] && isInvulnerable(hitStates[i])) {
3673
+ print('🛡', W - 214, y, rgba8(100, 200, 255, 200));
3674
+ }
3675
+ print(`${isCurrent ? '►' : ' '} ${m.name}`, W - 200, y, labelColor);
3676
+ if (m.alive) {
3677
+ // Buff indicator: brighten HP text when buffed using colorMix
3678
+ const buffed = (m.buffAtk > 0 || m.buffDef > 0) && m.buffTimer > 0;
3679
+ const hpTextColor = buffed
3680
+ ? colorMix(rgba8(220, 220, 100, 255), 1.5)
3681
+ : rgba8(180, 180, 180, 200);
3682
+ // Use colorLerp for smooth HP bar color: green → yellow → red
3683
+ const hpRatio = m.hp / m.maxHp;
3684
+ const hpColor = colorLerp(rgba8(200, 40, 40, 255), rgba8(50, 180, 50, 255), hpRatio);
3685
+ drawHealthBar(W - 95, y + 2, 60, 5, m.hp, m.maxHp, {
3686
+ barColor: buffed ? colorMix(hpColor, 1.3) : hpColor,
3687
+ backgroundColor: rgba8(30, 20, 20, 255),
3688
+ });
3689
+ print(`${m.hp}`, W - 30, y, hpTextColor);
3690
+ }
3691
+ }
3692
+ }
3693
+
3694
+ function drawInventoryUI() {
3695
+ const panelW = W - 80,
3696
+ panelH = H - 60;
3697
+ drawPanel(centerX(panelW), centerY(panelH), panelW, panelH, {
3698
+ bgColor: rgba8(10, 8, 20, 240),
3699
+ borderLight: rgba8(80, 70, 50, 255),
3700
+ borderDark: rgba8(30, 25, 20, 255),
3701
+ });
3702
+ // Save current font, use default for inventory title
3703
+ const prevFont = getFont();
3704
+ printCentered('═══ PARTY STATUS ═══', 320, 40, rgba8(200, 180, 120, 255), 2);
3705
+
3706
+ for (let i = 0; i < party.length; i++) {
3707
+ const m = party[i];
3708
+ const y = 75 + i * 65;
3709
+ const c = CLASS_COLORS[m.class];
3710
+ const cr = (c >> 16) & 0xff,
3711
+ cg = (c >> 8) & 0xff,
3712
+ cb = c & 0xff;
3713
+
3714
+ // Name + Class
3715
+ print(
3716
+ `${CLASS_ICONS[m.class]} ${m.name} [${m.class}] Lv ${m.level}`,
3717
+ 60,
3718
+ y,
3719
+ rgba8(cr, cg, cb, 255)
3720
+ );
3721
+
3722
+ // Stats
3723
+ const statColor = rgba8(180, 180, 200, 220);
3724
+ print(`HP: ${m.hp}/${m.maxHp}`, 80, y + 14, statColor);
3725
+ drawProgressBar(
3726
+ 160,
3727
+ y + 16,
3728
+ 80,
3729
+ 5,
3730
+ m.hp / m.maxHp,
3731
+ rgba8(50, 180, 50, 255),
3732
+ rgba8(30, 20, 20, 255)
3733
+ );
3734
+
3735
+ if (m.maxMp > 0) {
3736
+ print(`MP: ${m.mp}/${m.maxMp}`, 260, y + 14, rgba8(120, 140, 220, 200));
3737
+ drawProgressBar(
3738
+ 340,
3739
+ y + 16,
3740
+ 60,
3741
+ 5,
3742
+ m.mp / m.maxMp,
3743
+ rgba8(50, 80, 200, 255),
3744
+ rgba8(20, 20, 30, 255)
3745
+ );
3746
+ }
3747
+
3748
+ const totalAtk = getEffectiveAtk(m);
3749
+ const totalDef = getEffectiveDef(m);
3750
+ print(`ATK:${totalAtk} DEF:${totalDef} SPD:${m.spd}`, 80, y + 28, rgba8(150, 150, 170, 180));
3751
+ printRight(`XP: ${m.xp}/${m.xpNext}`, 560, y + 28, rgba8(150, 150, 170, 180));
3752
+ // XP progress bar using uiProgressBar (UI widget variant)
3753
+ uiProgressBar(430, y + 30, 80, 4, m.xp, m.xpNext, {
3754
+ fillColor: rgba8(180, 150, 50, 200),
3755
+ bgColor: rgba8(30, 25, 15, 180),
3756
+ showText: false,
3757
+ });
3758
+
3759
+ // Equipment
3760
+ if (m.weapon) {
3761
+ print(`Wpn: ${m.weapon.name}`, 80, y + 40, rgba8(200, 160, 80, 180));
3762
+ }
3763
+ if (m.armor) {
3764
+ print(`Arm: ${m.armor.name}`, 260, y + 40, rgba8(120, 160, 200, 180));
3765
+ }
3766
+
3767
+ if (!m.alive) {
3768
+ print('☠ FALLEN', 480, y, rgba8(200, 40, 40, 255));
3769
+ }
3770
+ }
3771
+
3772
+ // Gold + Floor info
3773
+ drawDiamond(72, H - 86, 4, 5, rgba8(220, 180, 50, 230));
3774
+ const goldStr = `${totalGold}g`;
3775
+ const goldMetrics = measureText(goldStr, 1);
3776
+ print(goldStr, 80, H - 90, rgba8(220, 180, 50, 230));
3777
+ print(`Floor: ${floor}`, 80 + goldMetrics.width + 12, H - 90, rgba8(150, 150, 170, 200));
3778
+ if (bossDefeated.size > 0) {
3779
+ printRight(`Bosses slain: ${bossDefeated.size}`, 560, H - 90, n64Palette.red);
3780
+ }
3781
+
3782
+ // Render stats debug info using get3DStats + getParticleStats + getRenderer
3783
+ const stats = get3DStats();
3784
+ if (stats) {
3785
+ let debugStr = `Tris:${stats.triangles || 0} Draws:${stats.drawCalls || 0} Meshes:${stats.meshes || 0}`;
3786
+ // Append particle stats if any systems are active
3787
+ if (particleSystems.length > 0) {
3788
+ const pStats = getParticleStats(particleSystems[0]);
3789
+ if (pStats) debugStr += ` Particles:${pStats.active}/${pStats.max}`;
3790
+ }
3791
+ debugStr += ` Frame:${frameCount}`;
3792
+ // Show renderer type from getRenderer()
3793
+ const renderer = getRenderer();
3794
+ if (renderer && renderer.info)
3795
+ debugStr += ` GL:${renderer.info.programs ? renderer.info.programs.length : '?'}prg`;
3796
+ // Show scene object count from getScene()
3797
+ const scene = getScene();
3798
+ if (scene && scene.children) debugStr += ` Obj:${scene.children.length}`;
3799
+ print(debugStr, 60, H - 55, rgba8(80, 80, 100, 120));
3800
+ }
3801
+ // Game stats from createGameStore
3802
+ if (gameStats) {
3803
+ const gs = gameStats.getState();
3804
+ print(`Steps:${gs.steps} Kills:${gs.kills}`, 60, H - 42, rgba8(70, 70, 90, 100));
3805
+ }
3806
+ // Restore font after inventory rendering
3807
+ if (prevFont) setFont(prevFont.name || 'default');
3808
+
3809
+ print('[S] Save Game', 60, H - 72, rgba8(100, 200, 100, 200));
3810
+ // Visual preset toggle hint
3811
+ const presetLabel = visualPreset ? `[V] Mode: ${visualPreset.toUpperCase()}` : '[V] Visual Mode';
3812
+ print(presetLabel, 250, H - 72, n64Palette.cyan);
3813
+ printCentered('Press I / TAB / ESC to close', 320, H - 55, rgba8(120, 120, 150, 180));
3814
+ }
3815
+
3816
+ function drawGameOver() {
3817
+ // Smooth fade-in using ease() for polished game over transition
3818
+ const fadeRaw = Math.min(1, stateElapsed() / 1.0);
3819
+ const fadeIn = ease(fadeRaw, 'easeOutCubic');
3820
+ const fade = Math.floor(fadeIn * 220);
3821
+ drawSkyGradient(rgba8(15, 0, 0, fade), rgba8(0, 0, 0, fade));
3822
+ // Pulsing red radial glow behind text
3823
+ drawRadialGradient(320, 140, 120, hslColor(0, 0.8, 0.2, 60), rgba8(0, 0, 0, 0));
3824
+ // Decorative ellipse frame behind title
3825
+ ellipse(320, 130, 160, 50, rgba8(120, 20, 20, Math.floor(fade * 0.3)), false);
3826
+ drawTextOutline('GAME OVER', 320, 120, hexColor(0xcc2828, 255), rgba8(80, 0, 0, 200), 3);
3827
+ // Skull polygon icon above text
3828
+ const skullAlpha = Math.floor(pulse(animTimer, 2) * 100 + 155);
3829
+ poly(
3830
+ [
3831
+ [310, 85],
3832
+ [320, 75],
3833
+ [330, 85],
3834
+ [325, 95],
3835
+ [315, 95],
3836
+ ],
3837
+ rgba8(200, 50, 50, skullAlpha),
3838
+ true
3839
+ );
3840
+ printCentered(`Your party fell on Floor ${floor}`, 320, 180, rgba8(180, 150, 130, 200));
3841
+ drawDiamond(270, 204, 4, 5, hexColor(0xc8b432, 200));
3842
+ printCentered(`${totalGold} Gold collected`, 320, 200, hexColor(0xc8b432, 200));
3843
+
3844
+ // Pulsing restart prompt using pulse() for smooth oscillation
3845
+ const restartAlpha = Math.floor(pulse(animTimer, 1.5) * 120 + 135);
3846
+ printCentered('Press SPACE to try again', 320, 280, rgba8(255, 255, 255, restartAlpha));
3847
+ // Interactive restart button (createButton/updateButton/drawButton)
3848
+ if (restartButton) {
3849
+ updateButton(restartButton);
3850
+ drawButton(restartButton);
3851
+ }
3852
+ }
3853
+
3854
+ function drawVictory() {
3855
+ drawGradient(0, 0, W, H, rgba8(10, 8, 2, 200), rgba8(2, 2, 10, 200));
3856
+
3857
+ // Victory crown polygon
3858
+ const crownColor = rgba8(
3859
+ 255,
3860
+ 220,
3861
+ 50,
3862
+ Math.floor(ease(Math.min(1, stateElapsed() / 1.5), 'easeOutBack') * 255)
3863
+ );
3864
+ poly(
3865
+ [
3866
+ [280, 55],
3867
+ [290, 35],
3868
+ [305, 50],
3869
+ [320, 25],
3870
+ [335, 50],
3871
+ [350, 35],
3872
+ [360, 55],
3873
+ ],
3874
+ crownColor,
3875
+ true
3876
+ );
3877
+ poly(
3878
+ [
3879
+ [280, 55],
3880
+ [290, 35],
3881
+ [305, 50],
3882
+ [320, 25],
3883
+ [335, 50],
3884
+ [350, 35],
3885
+ [360, 55],
3886
+ ],
3887
+ rgba8(180, 120, 0, 200),
3888
+ false
3889
+ );
3890
+
3891
+ // Celebratory starbursts using hslColor for rainbow cycling
3892
+ for (let i = 0; i < 5; i++) {
3893
+ const sx = 80 + i * 130;
3894
+ const sy = 50 + Math.sin(animTimer * 2 + i) * 15;
3895
+ const starColor = hslColor(animTimer * 60 + i * 72, 0.7, 0.5, 120);
3896
+ drawStarburst(sx, sy, 20, 8, 6, starColor);
3897
+ }
3898
+
3899
+ drawGlowTextCentered('VICTORY!', 320, 80, rgba8(255, 220, 50, 255), rgba8(180, 120, 0, 150), 3);
3900
+ printCentered('You conquered the Dark Tower!', 320, 140, rgba8(200, 200, 220, 255));
3901
+
3902
+ // Gold with diamond — use n64Palette for classic gold tone
3903
+ drawDiamond(290, 174, 4, 5, n64Palette.yellow);
3904
+ printCentered(`${totalGold} Gold`, 320, 170, n64Palette.yellow);
3905
+
3906
+ for (let i = 0; i < party.length; i++) {
3907
+ const m = party[i];
3908
+ const y = 200 + i * 16;
3909
+ // Use hexColor to convert CLASS_COLORS directly to rgba8
3910
+ printCentered(
3911
+ `${m.name} — Lv${m.level} ${m.class} ${m.alive ? '✓' : '☠'}`,
3912
+ 320,
3913
+ y,
3914
+ hexColor(CLASS_COLORS[m.class], 220)
3915
+ );
3916
+ }
3917
+
3918
+ // Pixel confetti sparkles (pset API)
3919
+ for (let i = 0; i < 30; i++) {
3920
+ const sx = (Math.sin(animTimer * 2 + i * 1.7) * 0.5 + 0.5) * W;
3921
+ const sy = (animTimer * 40 + i * 37) % H;
3922
+ pset(Math.floor(sx), Math.floor(sy), hslColor(i * 36 + animTimer * 60, 0.7, 0.6, 200));
3923
+ }
3924
+
3925
+ // Pulsing restart prompt using pulse() for smooth oscillation
3926
+ const replayAlpha = Math.floor(pulse(animTimer, 1.5) * 120 + 135);
3927
+ printCentered('Press SPACE to play again', 320, 300, rgba8(255, 255, 255, replayAlpha));
3928
+ }