nova64 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/bin/nova64.js +165 -0
- package/dist/assets/console-CY_kygm3.js +14 -0
- package/dist/assets/console-CY_kygm3.js.map +1 -0
- package/dist/assets/main-l0sNRNKZ.js.map +1 -0
- package/dist/assets/sky/studio/nx.png +0 -0
- package/dist/assets/sky/studio/ny.png +0 -0
- package/dist/assets/sky/studio/nz.png +0 -0
- package/dist/assets/sky/studio/px.png +0 -0
- package/dist/assets/sky/studio/py.png +0 -0
- package/dist/assets/sky/studio/pz.png +0 -0
- package/dist/assets/vanilla-Dcuy32gi.js +2 -0
- package/dist/assets/vanilla-Dcuy32gi.js.map +1 -0
- package/dist/console.html +899 -0
- package/dist/docs/BENCHMARK.md +77 -0
- package/dist/docs/CHEATSHEET.md +255 -0
- package/dist/docs/EFFECTS_API_GUIDE.md +577 -0
- package/dist/docs/EFFECTS_QUICK_REFERENCE.md +331 -0
- package/dist/docs/FONT_CHARACTER_REFERENCE.md +219 -0
- package/dist/docs/FREE_GLB_ASSETS.md +330 -0
- package/dist/docs/FULLSCREEN_BUTTON_FEATURE.md +296 -0
- package/dist/docs/GAMEPAD_SUPPORT.md +348 -0
- package/dist/docs/GAME_IMPROVEMENTS.md +278 -0
- package/dist/docs/GAME_QUALITY_STATUS.md +300 -0
- package/dist/docs/MIGRATION_GUIDE.md +553 -0
- package/dist/docs/NOVA64_3D_API.md +356 -0
- package/dist/docs/NOVA64_API_REFERENCE.md +1406 -0
- package/dist/docs/NOVA64_UI_API.md +503 -0
- package/dist/docs/UI_SYSTEM_SUMMARY.md +445 -0
- package/dist/docs/VOXEL_ENGINE_GUIDE.md +662 -0
- package/dist/docs/VOXEL_QUICK_REFERENCE.md +386 -0
- package/dist/docs/api-3d.html +750 -0
- package/dist/docs/api-effects.html +385 -0
- package/dist/docs/api-improvements.md +121 -0
- package/dist/docs/api-skybox.html +407 -0
- package/dist/docs/api-sprites.html +321 -0
- package/dist/docs/api-voxel.html +337 -0
- package/dist/docs/api.html +543 -0
- package/dist/docs/assets.html +306 -0
- package/dist/docs/audio.html +340 -0
- package/dist/docs/blogs.html +286 -0
- package/dist/docs/collision.html +316 -0
- package/dist/docs/console.html +247 -0
- package/dist/docs/editor.html +297 -0
- package/dist/docs/font.html +247 -0
- package/dist/docs/framebuffer.html +247 -0
- package/dist/docs/fullscreen-button.html +297 -0
- package/dist/docs/gpu-systems.html +247 -0
- package/dist/docs/index.html +580 -0
- package/dist/docs/input.html +491 -0
- package/dist/docs/physics.html +311 -0
- package/dist/docs/screens.html +311 -0
- package/dist/docs/storage.html +311 -0
- package/dist/docs/textinput.html +332 -0
- package/dist/docs/ui.html +488 -0
- package/dist/examples/3d-advanced/code.js +695 -0
- package/dist/examples/adventure-comic-3d/code.js +342 -0
- package/dist/examples/audio-lab/code.js +150 -0
- package/dist/examples/boids-flocking/code.js +270 -0
- package/dist/examples/crystal-cathedral-3d/code.js +706 -0
- package/dist/examples/cyberpunk-city-3d/code.js +1383 -0
- package/dist/examples/demoscene/README.md +192 -0
- package/dist/examples/demoscene/code.js +1081 -0
- package/dist/examples/demoscene/meta.json +21 -0
- package/dist/examples/dungeon-crawler-3d/code.js +1117 -0
- package/dist/examples/f-zero-nova-3d/code.js +865 -0
- package/dist/examples/f-zero-nova-3d/code_old.js +1555 -0
- package/dist/examples/fps-demo-3d/code.js +744 -0
- package/dist/examples/game-of-life-3d/code.js +338 -0
- package/dist/examples/generative-art/code.js +632 -0
- package/dist/examples/hello-3d/code.js +325 -0
- package/dist/examples/hello-skybox/code.js +183 -0
- package/dist/examples/hello-world/code.js +19 -0
- package/dist/examples/input-showcase/code.js +109 -0
- package/dist/examples/instancing-demo/code.js +315 -0
- package/dist/examples/minecraft-demo/code.js +387 -0
- package/dist/examples/model-viewer-3d/code.js +114 -0
- package/dist/examples/mystical-realm-3d/code.js +1203 -0
- package/dist/examples/nature-explorer-3d/code.js +1318 -0
- package/dist/examples/particles-demo/code.js +522 -0
- package/dist/examples/pbr-showcase/code.js +140 -0
- package/dist/examples/physics-demo-3d/code.js +948 -0
- package/dist/examples/screen-demo/code.js +267 -0
- package/dist/examples/shooter-demo-3d/code.js +1286 -0
- package/dist/examples/space-combat-3d/IMPLEMENTATION_SUMMARY.md +109 -0
- package/dist/examples/space-combat-3d/README.md +135 -0
- package/dist/examples/space-combat-3d/code.js +1332 -0
- package/dist/examples/space-harrier-3d/code.js +923 -0
- package/dist/examples/star-fox-nova-3d/code.js +1116 -0
- package/dist/examples/star-fox-nova-3d/code_backup.js +410 -0
- package/dist/examples/star-fox-nova-3d/code_broken.js +1821 -0
- package/dist/examples/storage-quest/code.js +209 -0
- package/dist/examples/strider-demo-3d/IMPROVEMENT_OPTIONS.md +285 -0
- package/dist/examples/strider-demo-3d/cache-test.html +132 -0
- package/dist/examples/strider-demo-3d/code-fixed.js +582 -0
- package/dist/examples/strider-demo-3d/code-old.js +1537 -0
- package/dist/examples/strider-demo-3d/code.js +1462 -0
- package/dist/examples/strider-demo-3d/code.js.bak2 +1169 -0
- package/dist/examples/strider-demo-3d/fix-game.sh +53 -0
- package/dist/examples/super-plumber-64/README.md +128 -0
- package/dist/examples/super-plumber-64/code.js +1185 -0
- package/dist/examples/super-plumber-64/index.html +88 -0
- package/dist/examples/test-2d-overlay/code.js +32 -0
- package/dist/examples/test-font/code.js +51 -0
- package/dist/examples/test-minimal/code.js +21 -0
- package/dist/examples/ui-demo/code.js +306 -0
- package/dist/examples/wing-commander-space/README.md +180 -0
- package/dist/examples/wing-commander-space/code.js +1285 -0
- package/dist/examples/wizardry-3d/CHANGELOG.md +366 -0
- package/dist/examples/wizardry-3d/code.js +3928 -0
- package/dist/index.html +666 -0
- package/dist/os9-shell/assets/index-DIHfrTaW.css +1 -0
- package/dist/os9-shell/assets/index-KchE_ngx.js +483 -0
- package/dist/os9-shell/assets/index-KchE_ngx.js.map +1 -0
- package/dist/os9-shell/index.html +23 -0
- package/dist/os9-shell/nova-icon.svg +12 -0
- package/index.html +6 -1
- package/package.json +37 -32
- package/public/assets/sky/studio/nx.png +0 -0
- package/public/assets/sky/studio/ny.png +0 -0
- package/public/assets/sky/studio/nz.png +0 -0
- package/public/assets/sky/studio/px.png +0 -0
- package/public/assets/sky/studio/py.png +0 -0
- package/public/assets/sky/studio/pz.png +0 -0
- package/public/os9-shell/assets/index-KchE_ngx.js +483 -0
- package/public/os9-shell/assets/index-KchE_ngx.js.map +1 -0
- package/public/os9-shell/index.html +10 -1
- package/runtime/api-2d.js +301 -21
- package/runtime/api-3d/pbr.js +45 -1
- package/runtime/api-3d.js +1 -0
- package/runtime/api-effects.js +90 -3
- package/runtime/api-gameutils.js +476 -0
- package/runtime/api-generative.js +610 -0
- package/runtime/api-skybox.js +54 -0
- package/runtime/api-voxel.js +139 -28
- package/runtime/gpu-threejs.js +13 -9
- package/runtime/ui.js +2 -2
- package/src/main.js +20 -0
- package/public/os9-shell/assets/index-B1Uvacma.js +0 -32825
- 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
|
+
}
|