nova64 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +24 -1
- package/public/os9-shell/assets/index-B1Uvacma.js +0 -32825
- package/public/os9-shell/assets/index-B1Uvacma.js.map +0 -1
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
// DUNGEON DELVE 3D — Ultimate Roguelike Dungeon Crawler
|
|
2
|
+
// Procedurally generated dungeons with themed floors, boss fights, traps,
|
|
3
|
+
// particle effects, screen shake, and atmospheric lighting.
|
|
4
|
+
|
|
5
|
+
const TILE_SIZE = 2;
|
|
6
|
+
const MAP_W = 30;
|
|
7
|
+
const MAP_H = 30;
|
|
8
|
+
const TILE = { WALL: 0, FLOOR: 1, DOOR: 2, STAIRS: 3, CHEST: 4, TRAP: 5, TORCH: 6 };
|
|
9
|
+
|
|
10
|
+
// ── Floor themes ────────────────────────────────────────────────────────────
|
|
11
|
+
const THEMES = [
|
|
12
|
+
{
|
|
13
|
+
name: 'Stone Crypt',
|
|
14
|
+
wall: 0x444466,
|
|
15
|
+
floor: 0x2a2a3a,
|
|
16
|
+
fog: 0x0a0a18,
|
|
17
|
+
accent: 0x6666aa,
|
|
18
|
+
ambient: 0xccccee,
|
|
19
|
+
torch: 0xff8833,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Mossy Sewer',
|
|
23
|
+
wall: 0x335544,
|
|
24
|
+
floor: 0x223322,
|
|
25
|
+
fog: 0x081008,
|
|
26
|
+
accent: 0x44aa66,
|
|
27
|
+
ambient: 0x88ddaa,
|
|
28
|
+
torch: 0x66ff88,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'Bone Catacombs',
|
|
32
|
+
wall: 0x665544,
|
|
33
|
+
floor: 0x332b22,
|
|
34
|
+
fog: 0x100c08,
|
|
35
|
+
accent: 0xccaa88,
|
|
36
|
+
ambient: 0xeeddcc,
|
|
37
|
+
torch: 0xffaa44,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'Frozen Depths',
|
|
41
|
+
wall: 0x556688,
|
|
42
|
+
floor: 0x334455,
|
|
43
|
+
fog: 0x0a1020,
|
|
44
|
+
accent: 0x88bbff,
|
|
45
|
+
ambient: 0xaaccff,
|
|
46
|
+
torch: 0x44ccff,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'Inferno Pits',
|
|
50
|
+
wall: 0x663322,
|
|
51
|
+
floor: 0x331a10,
|
|
52
|
+
fog: 0x180808,
|
|
53
|
+
accent: 0xff4422,
|
|
54
|
+
ambient: 0xff8866,
|
|
55
|
+
torch: 0xff3300,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'The Void',
|
|
59
|
+
wall: 0x332244,
|
|
60
|
+
floor: 0x1a1128,
|
|
61
|
+
fog: 0x08041a,
|
|
62
|
+
accent: 0xaa44ff,
|
|
63
|
+
ambient: 0xcc88ff,
|
|
64
|
+
torch: 0xdd55ff,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
69
|
+
let gameState = 'start';
|
|
70
|
+
let time = 0;
|
|
71
|
+
let floor = 1;
|
|
72
|
+
let map = [];
|
|
73
|
+
let rooms = [];
|
|
74
|
+
let mapMeshes = [];
|
|
75
|
+
let torchLights = [];
|
|
76
|
+
|
|
77
|
+
let hero = {
|
|
78
|
+
x: 0,
|
|
79
|
+
y: 0,
|
|
80
|
+
hp: 25,
|
|
81
|
+
maxHp: 25,
|
|
82
|
+
atk: 5,
|
|
83
|
+
def: 2,
|
|
84
|
+
xp: 0,
|
|
85
|
+
xpNext: 15,
|
|
86
|
+
level: 1,
|
|
87
|
+
gold: 0,
|
|
88
|
+
potions: 2,
|
|
89
|
+
weapon: 'Rusty Sword',
|
|
90
|
+
weaponBonus: 0,
|
|
91
|
+
kills: 0,
|
|
92
|
+
totalDmg: 0,
|
|
93
|
+
};
|
|
94
|
+
let heroMesh = null;
|
|
95
|
+
let heroGlow = null;
|
|
96
|
+
|
|
97
|
+
let enemies = [];
|
|
98
|
+
let enemyMeshes = new Map();
|
|
99
|
+
|
|
100
|
+
let items = [];
|
|
101
|
+
let itemMeshes = new Map();
|
|
102
|
+
|
|
103
|
+
let messages = [];
|
|
104
|
+
let floatingTexts;
|
|
105
|
+
let shake;
|
|
106
|
+
let potionCd;
|
|
107
|
+
|
|
108
|
+
let camCurrent = { x: 0, y: 0 };
|
|
109
|
+
let moveTimer = 0;
|
|
110
|
+
const MOVE_DELAY = 0.12;
|
|
111
|
+
|
|
112
|
+
let screenFlash = 0;
|
|
113
|
+
let screenFlashColor = [255, 50, 50];
|
|
114
|
+
let minimap = null;
|
|
115
|
+
let discoveredTiles = null;
|
|
116
|
+
|
|
117
|
+
// ── Theme helper ────────────────────────────────────────────────────────────
|
|
118
|
+
function getTheme() {
|
|
119
|
+
return THEMES[(floor - 1) % THEMES.length];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Enemy templates ─────────────────────────────────────────────────────────
|
|
123
|
+
function getEnemyTypes() {
|
|
124
|
+
const f = floor;
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
name: 'Slime',
|
|
128
|
+
hp: 6 + f * 2,
|
|
129
|
+
atk: 2 + f,
|
|
130
|
+
def: 0,
|
|
131
|
+
xp: 5 + f,
|
|
132
|
+
color: 0x44ff44,
|
|
133
|
+
shape: 'slime',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'Skeleton',
|
|
137
|
+
hp: 10 + f * 2,
|
|
138
|
+
atk: 4 + f,
|
|
139
|
+
def: 1,
|
|
140
|
+
xp: 10 + f * 2,
|
|
141
|
+
color: 0xddddaa,
|
|
142
|
+
shape: 'skeleton',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'Demon',
|
|
146
|
+
hp: 14 + f * 3,
|
|
147
|
+
atk: 6 + f,
|
|
148
|
+
def: 2,
|
|
149
|
+
xp: 20 + f * 3,
|
|
150
|
+
color: 0xff3333,
|
|
151
|
+
shape: 'demon',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'Ghost',
|
|
155
|
+
hp: 8 + f * 2,
|
|
156
|
+
atk: 5 + f,
|
|
157
|
+
def: 0,
|
|
158
|
+
xp: 15 + f * 2,
|
|
159
|
+
color: 0x8888ff,
|
|
160
|
+
shape: 'ghost',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'Lich',
|
|
164
|
+
hp: 18 + f * 4,
|
|
165
|
+
atk: 8 + f,
|
|
166
|
+
def: 3,
|
|
167
|
+
xp: 30 + f * 4,
|
|
168
|
+
color: 0xaa44ff,
|
|
169
|
+
shape: 'lich',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'Dragon Whelp',
|
|
173
|
+
hp: 25 + f * 5,
|
|
174
|
+
atk: 10 + f,
|
|
175
|
+
def: 4,
|
|
176
|
+
xp: 50 + f * 5,
|
|
177
|
+
color: 0xff8800,
|
|
178
|
+
shape: 'dragon',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getBossTemplate() {
|
|
184
|
+
const f = floor;
|
|
185
|
+
const bosses = [
|
|
186
|
+
{
|
|
187
|
+
name: 'Goblin King',
|
|
188
|
+
hp: 40 + f * 8,
|
|
189
|
+
atk: 8 + f * 2,
|
|
190
|
+
def: 3,
|
|
191
|
+
xp: 80 + f * 10,
|
|
192
|
+
color: 0x88ff44,
|
|
193
|
+
shape: 'boss',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'Shadow Lord',
|
|
197
|
+
hp: 60 + f * 10,
|
|
198
|
+
atk: 10 + f * 2,
|
|
199
|
+
def: 4,
|
|
200
|
+
xp: 120 + f * 10,
|
|
201
|
+
color: 0x6622cc,
|
|
202
|
+
shape: 'boss',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'Bone Dragon',
|
|
206
|
+
hp: 80 + f * 12,
|
|
207
|
+
atk: 12 + f * 3,
|
|
208
|
+
def: 5,
|
|
209
|
+
xp: 200 + f * 15,
|
|
210
|
+
color: 0xffcc44,
|
|
211
|
+
shape: 'boss',
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
return bosses[Math.floor(floor / 3) % bosses.length];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Create enemy mesh ───────────────────────────────────────────────────────
|
|
218
|
+
function createEnemyMesh(e) {
|
|
219
|
+
const wx = e.x * TILE_SIZE;
|
|
220
|
+
const wz = e.y * TILE_SIZE;
|
|
221
|
+
let m;
|
|
222
|
+
const opts = { material: 'emissive', emissive: e.color };
|
|
223
|
+
switch (e.shape) {
|
|
224
|
+
case 'slime':
|
|
225
|
+
m = createSphere(0.45, e.color, [wx, 0.45, wz], 8, opts);
|
|
226
|
+
setScale(m, 1.2, 0.7, 1.2);
|
|
227
|
+
break;
|
|
228
|
+
case 'skeleton':
|
|
229
|
+
m = createCapsule(0.25, 0.9, e.color, [wx, 0.7, wz], opts);
|
|
230
|
+
break;
|
|
231
|
+
case 'demon':
|
|
232
|
+
m = createCone(0.4, 1.2, e.color, [wx, 0.6, wz], opts);
|
|
233
|
+
break;
|
|
234
|
+
case 'ghost':
|
|
235
|
+
m = createSphere(0.35, e.color, [wx, 0.8, wz], 8, {
|
|
236
|
+
material: 'emissive',
|
|
237
|
+
emissive: e.color,
|
|
238
|
+
});
|
|
239
|
+
break;
|
|
240
|
+
case 'lich':
|
|
241
|
+
m = createCylinder(0.15, 0.35, 1.2, e.color, [wx, 0.6, wz], opts);
|
|
242
|
+
break;
|
|
243
|
+
case 'dragon':
|
|
244
|
+
m = createCube(0.9, e.color, [wx, 0.6, wz], opts);
|
|
245
|
+
setScale(m, 1.2, 0.8, 1.5);
|
|
246
|
+
break;
|
|
247
|
+
case 'boss':
|
|
248
|
+
m = createCube(1.2, e.color, [wx, 0.9, wz], { material: 'emissive', emissive: e.color });
|
|
249
|
+
setScale(m, 1.5, 1.5, 1.5);
|
|
250
|
+
break;
|
|
251
|
+
default:
|
|
252
|
+
m = createSphere(0.4, e.color, [wx, 0.5, wz], 8, opts);
|
|
253
|
+
}
|
|
254
|
+
return m;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Dungeon generation ──────────────────────────────────────────────────────
|
|
258
|
+
function generateDungeon() {
|
|
259
|
+
for (const m of mapMeshes) destroyMesh(m);
|
|
260
|
+
mapMeshes = [];
|
|
261
|
+
for (const [, m] of enemyMeshes) destroyMesh(m);
|
|
262
|
+
enemyMeshes.clear();
|
|
263
|
+
for (const [, m] of itemMeshes) destroyMesh(m);
|
|
264
|
+
itemMeshes.clear();
|
|
265
|
+
for (const l of torchLights) destroyMesh(l);
|
|
266
|
+
torchLights = [];
|
|
267
|
+
if (heroMesh) {
|
|
268
|
+
destroyMesh(heroMesh);
|
|
269
|
+
heroMesh = null;
|
|
270
|
+
}
|
|
271
|
+
if (heroGlow) {
|
|
272
|
+
destroyMesh(heroGlow);
|
|
273
|
+
heroGlow = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
map = [];
|
|
277
|
+
for (let y = 0; y < MAP_H; y++) {
|
|
278
|
+
map[y] = [];
|
|
279
|
+
for (let x = 0; x < MAP_W; x++) map[y][x] = TILE.WALL;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
discoveredTiles = [];
|
|
283
|
+
for (let y = 0; y < MAP_H; y++) {
|
|
284
|
+
discoveredTiles[y] = [];
|
|
285
|
+
for (let x = 0; x < MAP_W; x++) discoveredTiles[y][x] = false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Generate rooms
|
|
289
|
+
rooms = [];
|
|
290
|
+
const maxRooms = 8 + Math.min(floor, 6);
|
|
291
|
+
for (let i = 0; i < maxRooms * 4; i++) {
|
|
292
|
+
const w = 3 + Math.floor(Math.random() * 5);
|
|
293
|
+
const h = 3 + Math.floor(Math.random() * 5);
|
|
294
|
+
const rx = 1 + Math.floor(Math.random() * (MAP_W - w - 2));
|
|
295
|
+
const ry = 1 + Math.floor(Math.random() * (MAP_H - h - 2));
|
|
296
|
+
let overlap = false;
|
|
297
|
+
for (const r of rooms) {
|
|
298
|
+
if (rx - 1 < r.x + r.w && rx + w + 1 > r.x && ry - 1 < r.y + r.h && ry + h + 1 > r.y) {
|
|
299
|
+
overlap = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (overlap) continue;
|
|
304
|
+
rooms.push({ x: rx, y: ry, w, h, cx: Math.floor(rx + w / 2), cy: Math.floor(ry + h / 2) });
|
|
305
|
+
for (let dy = 0; dy < h; dy++)
|
|
306
|
+
for (let dx = 0; dx < w; dx++) map[ry + dy][rx + dx] = TILE.FLOOR;
|
|
307
|
+
if (rooms.length >= maxRooms) break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Corridors with doors
|
|
311
|
+
for (let i = 1; i < rooms.length; i++) {
|
|
312
|
+
const a = rooms[i - 1],
|
|
313
|
+
b = rooms[i];
|
|
314
|
+
let x = a.cx,
|
|
315
|
+
y = a.cy;
|
|
316
|
+
let placedDoor = false;
|
|
317
|
+
while (x !== b.cx) {
|
|
318
|
+
if (!placedDoor && map[y][x] === TILE.WALL) {
|
|
319
|
+
map[y][x] = TILE.DOOR;
|
|
320
|
+
placedDoor = true;
|
|
321
|
+
} else if (map[y][x] === TILE.WALL) map[y][x] = TILE.FLOOR;
|
|
322
|
+
x += x < b.cx ? 1 : -1;
|
|
323
|
+
}
|
|
324
|
+
placedDoor = false;
|
|
325
|
+
while (y !== b.cy) {
|
|
326
|
+
if (!placedDoor && map[y][x] === TILE.WALL) {
|
|
327
|
+
map[y][x] = TILE.DOOR;
|
|
328
|
+
placedDoor = true;
|
|
329
|
+
} else if (map[y][x] === TILE.WALL) map[y][x] = TILE.FLOOR;
|
|
330
|
+
y += y < b.cy ? 1 : -1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Stairs in last room
|
|
335
|
+
const lastRoom = rooms[rooms.length - 1];
|
|
336
|
+
map[lastRoom.cy][lastRoom.cx] = TILE.STAIRS;
|
|
337
|
+
|
|
338
|
+
// Chests
|
|
339
|
+
for (let i = 2; i < rooms.length - 1; i++) {
|
|
340
|
+
if (Math.random() < 0.45) {
|
|
341
|
+
const r = rooms[i];
|
|
342
|
+
const cx = r.x + 1 + Math.floor(Math.random() * Math.max(1, r.w - 2));
|
|
343
|
+
const cy = r.y + 1 + Math.floor(Math.random() * Math.max(1, r.h - 2));
|
|
344
|
+
if (map[cy][cx] === TILE.FLOOR) map[cy][cx] = TILE.CHEST;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Traps (floor 2+)
|
|
349
|
+
if (floor >= 2) {
|
|
350
|
+
for (let i = 1; i < rooms.length; i++) {
|
|
351
|
+
if (Math.random() < 0.3) {
|
|
352
|
+
const r = rooms[i];
|
|
353
|
+
const tx = r.x + 1 + Math.floor(Math.random() * Math.max(1, r.w - 2));
|
|
354
|
+
const ty = r.y + 1 + Math.floor(Math.random() * Math.max(1, r.h - 2));
|
|
355
|
+
if (map[ty][tx] === TILE.FLOOR) map[ty][tx] = TILE.TRAP;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Torches at room edges
|
|
361
|
+
for (let i = 0; i < rooms.length; i++) {
|
|
362
|
+
const r = rooms[i];
|
|
363
|
+
// Try corners adjacent to walls
|
|
364
|
+
const corners = [
|
|
365
|
+
[r.x - 1, r.y - 1],
|
|
366
|
+
[r.x + r.w, r.y - 1],
|
|
367
|
+
[r.x - 1, r.y + r.h],
|
|
368
|
+
[r.x + r.w, r.y + r.h],
|
|
369
|
+
];
|
|
370
|
+
for (const [tx, ty] of corners) {
|
|
371
|
+
if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H && map[ty][tx] === TILE.WALL) {
|
|
372
|
+
map[ty][tx] = TILE.TORCH;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Player in first room
|
|
378
|
+
hero.x = rooms[0].cx;
|
|
379
|
+
hero.y = rooms[0].cy;
|
|
380
|
+
|
|
381
|
+
// Enemies
|
|
382
|
+
enemies = [];
|
|
383
|
+
const types = getEnemyTypes();
|
|
384
|
+
const isBossFloor = floor % 3 === 0;
|
|
385
|
+
|
|
386
|
+
for (let i = 1; i < rooms.length; i++) {
|
|
387
|
+
const r = rooms[i];
|
|
388
|
+
if (isBossFloor && i === rooms.length - 1) {
|
|
389
|
+
const boss = getBossTemplate();
|
|
390
|
+
enemies.push({
|
|
391
|
+
...boss,
|
|
392
|
+
x: r.cx,
|
|
393
|
+
y: r.cy + (r.h > 3 ? 1 : 0),
|
|
394
|
+
maxHp: boss.hp,
|
|
395
|
+
alive: true,
|
|
396
|
+
isBoss: true,
|
|
397
|
+
});
|
|
398
|
+
map[lastRoom.cy][lastRoom.cx] = TILE.FLOOR;
|
|
399
|
+
const sy = lastRoom.y + 1,
|
|
400
|
+
sx = lastRoom.x + 1;
|
|
401
|
+
if (sy < MAP_H && sx < MAP_W) map[sy][sx] = TILE.STAIRS;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const numEnemies = 1 + Math.floor(Math.random() * (1 + Math.floor(floor / 2)));
|
|
405
|
+
for (let e = 0; e < numEnemies; e++) {
|
|
406
|
+
const ex = r.x + 1 + Math.floor(Math.random() * Math.max(1, r.w - 2));
|
|
407
|
+
const ey = r.y + 1 + Math.floor(Math.random() * Math.max(1, r.h - 2));
|
|
408
|
+
if (map[ey][ex] !== TILE.FLOOR) continue;
|
|
409
|
+
const maxIdx = Math.min(Math.floor(1 + floor * 0.6), types.length - 1);
|
|
410
|
+
const t = types[Math.floor(Math.random() * (maxIdx + 1))];
|
|
411
|
+
enemies.push({ ...t, x: ex, y: ey, maxHp: t.hp, alive: true, isBoss: false });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Items
|
|
416
|
+
items = [];
|
|
417
|
+
for (let i = 1; i < rooms.length; i++) {
|
|
418
|
+
if (Math.random() < 0.4) {
|
|
419
|
+
const r = rooms[i];
|
|
420
|
+
const ix = r.x + Math.floor(Math.random() * r.w);
|
|
421
|
+
const iy = r.y + Math.floor(Math.random() * r.h);
|
|
422
|
+
if (map[iy][ix] === TILE.FLOOR) {
|
|
423
|
+
items.push({
|
|
424
|
+
x: ix,
|
|
425
|
+
y: iy,
|
|
426
|
+
type: Math.random() < 0.55 ? 'gold' : 'potion',
|
|
427
|
+
amount: Math.random() < 0.55 ? 5 + Math.floor(Math.random() * 10 * floor) : 1,
|
|
428
|
+
collected: false,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
buildMapMeshes();
|
|
435
|
+
revealAround(hero.x, hero.y, 4);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Fog of war ──────────────────────────────────────────────────────────────
|
|
439
|
+
function revealAround(cx, cy, radius) {
|
|
440
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
441
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
442
|
+
const nx = cx + dx,
|
|
443
|
+
ny = cy + dy;
|
|
444
|
+
if (
|
|
445
|
+
nx >= 0 &&
|
|
446
|
+
nx < MAP_W &&
|
|
447
|
+
ny >= 0 &&
|
|
448
|
+
ny < MAP_H &&
|
|
449
|
+
Math.abs(dx) + Math.abs(dy) <= radius + 1
|
|
450
|
+
)
|
|
451
|
+
discoveredTiles[ny][nx] = true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Build 3D meshes ─────────────────────────────────────────────────────────
|
|
457
|
+
function buildMapMeshes() {
|
|
458
|
+
const theme = getTheme();
|
|
459
|
+
|
|
460
|
+
setAmbientLight(theme.ambient, 0.12);
|
|
461
|
+
setLightDirection(0, -1, 0.2);
|
|
462
|
+
setLightColor(theme.accent);
|
|
463
|
+
setFog(theme.fog, 4, 20);
|
|
464
|
+
enableBloom(0.6, 0.3, 0.4);
|
|
465
|
+
enableVignette(1.2, 0.9);
|
|
466
|
+
|
|
467
|
+
for (let y = 0; y < MAP_H; y++) {
|
|
468
|
+
for (let x = 0; x < MAP_W; x++) {
|
|
469
|
+
const wx = x * TILE_SIZE;
|
|
470
|
+
const wz = y * TILE_SIZE;
|
|
471
|
+
const tile = map[y][x];
|
|
472
|
+
|
|
473
|
+
if (tile === TILE.WALL || tile === TILE.TORCH) {
|
|
474
|
+
let adjacent = false;
|
|
475
|
+
for (let dy2 = -1; dy2 <= 1; dy2++) {
|
|
476
|
+
for (let dx2 = -1; dx2 <= 1; dx2++) {
|
|
477
|
+
const ny2 = y + dy2,
|
|
478
|
+
nx2 = x + dx2;
|
|
479
|
+
if (
|
|
480
|
+
ny2 >= 0 &&
|
|
481
|
+
ny2 < MAP_H &&
|
|
482
|
+
nx2 >= 0 &&
|
|
483
|
+
nx2 < MAP_W &&
|
|
484
|
+
map[ny2][nx2] !== TILE.WALL &&
|
|
485
|
+
map[ny2][nx2] !== TILE.TORCH
|
|
486
|
+
)
|
|
487
|
+
adjacent = true;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (adjacent) {
|
|
491
|
+
const wallMesh = createCube(TILE_SIZE, theme.wall, [wx, TILE_SIZE * 0.75, wz], {
|
|
492
|
+
material: 'standard',
|
|
493
|
+
roughness: 0.9,
|
|
494
|
+
});
|
|
495
|
+
setScale(wallMesh, 1, 1.5, 1);
|
|
496
|
+
mapMeshes.push(wallMesh);
|
|
497
|
+
if (tile === TILE.TORCH) {
|
|
498
|
+
const torchMesh = createCube(0.15, theme.torch, [wx, TILE_SIZE * 1.6, wz], {
|
|
499
|
+
material: 'emissive',
|
|
500
|
+
emissive: theme.torch,
|
|
501
|
+
});
|
|
502
|
+
mapMeshes.push(torchMesh);
|
|
503
|
+
const light = createPointLight(theme.torch, 1.5, 8, wx, TILE_SIZE * 1.6, wz);
|
|
504
|
+
torchLights.push(light);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
let floorColor = theme.floor;
|
|
509
|
+
if (tile === TILE.STAIRS) floorColor = 0xffdd44;
|
|
510
|
+
else if (tile === TILE.CHEST) floorColor = 0x886622;
|
|
511
|
+
else if (tile === TILE.DOOR) floorColor = 0x553311;
|
|
512
|
+
|
|
513
|
+
const floorMesh = createCube(TILE_SIZE, floorColor, [wx, -0.1, wz], {
|
|
514
|
+
material: 'standard',
|
|
515
|
+
roughness: 1.0,
|
|
516
|
+
});
|
|
517
|
+
setScale(floorMesh, 1, 0.1, 1);
|
|
518
|
+
mapMeshes.push(floorMesh);
|
|
519
|
+
|
|
520
|
+
if (tile === TILE.STAIRS) {
|
|
521
|
+
const stairGlow = createCylinder(0.15, 0.15, 2.5, 0xffdd44, [wx, 1.25, wz], {
|
|
522
|
+
material: 'emissive',
|
|
523
|
+
emissive: 0xffdd44,
|
|
524
|
+
});
|
|
525
|
+
mapMeshes.push(stairGlow);
|
|
526
|
+
const sl = createPointLight(0xffdd44, 2, 8, wx, 2, wz);
|
|
527
|
+
torchLights.push(sl);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (tile === TILE.CHEST) {
|
|
531
|
+
const chest = createCube(0.7, 0xcc8833, [wx, 0.35, wz], {
|
|
532
|
+
material: 'standard',
|
|
533
|
+
roughness: 0.5,
|
|
534
|
+
});
|
|
535
|
+
setScale(chest, 1, 0.7, 0.7);
|
|
536
|
+
mapMeshes.push(chest);
|
|
537
|
+
const lid = createCube(0.7, 0xffcc44, [wx, 0.6, wz], {
|
|
538
|
+
material: 'emissive',
|
|
539
|
+
emissive: 0xffcc44,
|
|
540
|
+
});
|
|
541
|
+
setScale(lid, 1.05, 0.15, 0.75);
|
|
542
|
+
mapMeshes.push(lid);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (tile === TILE.DOOR) {
|
|
546
|
+
const doorL = createCube(0.2, 0x553311, [wx - 0.7, 0.7, wz], {
|
|
547
|
+
material: 'standard',
|
|
548
|
+
roughness: 0.7,
|
|
549
|
+
});
|
|
550
|
+
setScale(doorL, 1, 7, 1);
|
|
551
|
+
mapMeshes.push(doorL);
|
|
552
|
+
const doorR = createCube(0.2, 0x553311, [wx + 0.7, 0.7, wz], {
|
|
553
|
+
material: 'standard',
|
|
554
|
+
roughness: 0.7,
|
|
555
|
+
});
|
|
556
|
+
setScale(doorR, 1, 7, 1);
|
|
557
|
+
mapMeshes.push(doorR);
|
|
558
|
+
const doorTop = createCube(0.2, 0x664422, [wx, 1.5, wz], {
|
|
559
|
+
material: 'standard',
|
|
560
|
+
roughness: 0.7,
|
|
561
|
+
});
|
|
562
|
+
setScale(doorTop, 8, 1, 1);
|
|
563
|
+
mapMeshes.push(doorTop);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Hero
|
|
570
|
+
heroMesh = createCapsule(0.3, 0.8, 0x4488ff, [hero.x * TILE_SIZE, 0.7, hero.y * TILE_SIZE], {
|
|
571
|
+
material: 'standard',
|
|
572
|
+
roughness: 0.4,
|
|
573
|
+
});
|
|
574
|
+
heroGlow = createPointLight(0x4488ff, 1.0, 6, hero.x * TILE_SIZE, 1.5, hero.y * TILE_SIZE);
|
|
575
|
+
torchLights.push(heroGlow);
|
|
576
|
+
|
|
577
|
+
for (const e of enemies) enemyMeshes.set(e, createEnemyMesh(e));
|
|
578
|
+
|
|
579
|
+
for (const item of items) {
|
|
580
|
+
const color = item.type === 'gold' ? 0xffdd00 : 0xff4488;
|
|
581
|
+
itemMeshes.set(
|
|
582
|
+
item,
|
|
583
|
+
createSphere(0.2, color, [item.x * TILE_SIZE, 0.3, item.y * TILE_SIZE], 8, {
|
|
584
|
+
material: 'emissive',
|
|
585
|
+
emissive: color,
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
minimap = createMinimap({
|
|
591
|
+
x: 540,
|
|
592
|
+
y: 10,
|
|
593
|
+
width: 90,
|
|
594
|
+
height: 90,
|
|
595
|
+
shape: 'rect',
|
|
596
|
+
worldW: MAP_W,
|
|
597
|
+
worldH: MAP_H,
|
|
598
|
+
bgColor: rgba8(0, 0, 0, 200),
|
|
599
|
+
borderLight: rgba8(100, 100, 140, 255),
|
|
600
|
+
borderDark: rgba8(40, 40, 60, 255),
|
|
601
|
+
fogOfWar: 5,
|
|
602
|
+
tiles: (tx, ty) => {
|
|
603
|
+
if (!discoveredTiles[ty] || !discoveredTiles[ty][tx]) return null;
|
|
604
|
+
const t = map[ty][tx];
|
|
605
|
+
if (t === TILE.WALL || t === TILE.TORCH) return rgba8(60, 60, 80);
|
|
606
|
+
if (t === TILE.STAIRS) return rgba8(255, 255, 100);
|
|
607
|
+
if (t === TILE.CHEST) return rgba8(200, 150, 50);
|
|
608
|
+
if (t === TILE.DOOR) return rgba8(100, 70, 30);
|
|
609
|
+
return rgba8(40, 40, 55);
|
|
610
|
+
},
|
|
611
|
+
tileW: MAP_W,
|
|
612
|
+
tileH: MAP_H,
|
|
613
|
+
player: { x: hero.x, y: hero.y, color: rgba8(80, 160, 255), blink: true },
|
|
614
|
+
entities: [],
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ── Combat ──────────────────────────────────────────────────────────────────
|
|
619
|
+
function rollDmg(attacker, defender) {
|
|
620
|
+
return Math.max(1, attacker.atk - defender.def + Math.floor(Math.random() * 3) - 1);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function tryMove(dx, dy) {
|
|
624
|
+
const nx = hero.x + dx;
|
|
625
|
+
const ny = hero.y + dy;
|
|
626
|
+
if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H) return;
|
|
627
|
+
if (map[ny][nx] === TILE.WALL || map[ny][nx] === TILE.TORCH) return;
|
|
628
|
+
|
|
629
|
+
const enemy = enemies.find(e => e.alive && e.x === nx && e.y === ny);
|
|
630
|
+
if (enemy) {
|
|
631
|
+
const dmg = rollDmg(hero, enemy);
|
|
632
|
+
enemy.hp -= dmg;
|
|
633
|
+
hero.totalDmg += dmg;
|
|
634
|
+
triggerShake(shake, enemy.isBoss ? 6 : 3);
|
|
635
|
+
screenFlash = 0.12;
|
|
636
|
+
screenFlashColor = [255, 180, 50];
|
|
637
|
+
addMessage(`Hit ${enemy.name} for ${dmg}!`, 0xffaa44);
|
|
638
|
+
floatingTexts.spawn(`-${dmg}`, enemy.x * TILE_SIZE, 2.5, {
|
|
639
|
+
z: enemy.y * TILE_SIZE,
|
|
640
|
+
duration: 0.8,
|
|
641
|
+
color: 0xff8844,
|
|
642
|
+
});
|
|
643
|
+
sfx('hit');
|
|
644
|
+
if (enemy.hp <= 0) {
|
|
645
|
+
enemy.alive = false;
|
|
646
|
+
hero.xp += enemy.xp;
|
|
647
|
+
hero.kills++;
|
|
648
|
+
addMessage(`${enemy.name} slain! +${enemy.xp}XP`, enemy.isBoss ? 0xffdd44 : 0x44ff44);
|
|
649
|
+
if (enemyMeshes.has(enemy)) {
|
|
650
|
+
destroyMesh(enemyMeshes.get(enemy));
|
|
651
|
+
enemyMeshes.delete(enemy);
|
|
652
|
+
}
|
|
653
|
+
sfx('explosion');
|
|
654
|
+
if (Math.random() < 0.35) {
|
|
655
|
+
const drop = {
|
|
656
|
+
x: nx,
|
|
657
|
+
y: ny,
|
|
658
|
+
type: Math.random() < 0.5 ? 'gold' : 'potion',
|
|
659
|
+
amount: Math.random() < 0.5 ? 5 + Math.floor(Math.random() * 8 * floor) : 1,
|
|
660
|
+
collected: false,
|
|
661
|
+
};
|
|
662
|
+
items.push(drop);
|
|
663
|
+
const color = drop.type === 'gold' ? 0xffdd00 : 0xff4488;
|
|
664
|
+
itemMeshes.set(
|
|
665
|
+
drop,
|
|
666
|
+
createSphere(0.2, color, [drop.x * TILE_SIZE, 0.3, drop.y * TILE_SIZE], 8, {
|
|
667
|
+
material: 'emissive',
|
|
668
|
+
emissive: color,
|
|
669
|
+
})
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
checkLevelUp();
|
|
673
|
+
}
|
|
674
|
+
enemyTurn();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
hero.x = nx;
|
|
679
|
+
hero.y = ny;
|
|
680
|
+
setPosition(heroMesh, nx * TILE_SIZE, 0.7, ny * TILE_SIZE);
|
|
681
|
+
setPosition(heroGlow, nx * TILE_SIZE, 1.5, ny * TILE_SIZE);
|
|
682
|
+
revealAround(nx, ny, 4);
|
|
683
|
+
|
|
684
|
+
for (const item of items) {
|
|
685
|
+
if (!item.collected && item.x === nx && item.y === ny) {
|
|
686
|
+
item.collected = true;
|
|
687
|
+
if (item.type === 'gold') {
|
|
688
|
+
hero.gold += item.amount;
|
|
689
|
+
addMessage(`+${item.amount} gold`, 0xffdd00);
|
|
690
|
+
} else {
|
|
691
|
+
hero.potions += item.amount;
|
|
692
|
+
addMessage('+1 Potion', 0xff4488);
|
|
693
|
+
}
|
|
694
|
+
sfx('coin');
|
|
695
|
+
if (itemMeshes.has(item)) {
|
|
696
|
+
destroyMesh(itemMeshes.get(item));
|
|
697
|
+
itemMeshes.delete(item);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (map[ny][nx] === TILE.CHEST) {
|
|
703
|
+
const loot = Math.floor(Math.random() * 25 * floor) + 10;
|
|
704
|
+
hero.gold += loot;
|
|
705
|
+
addMessage(`Chest! +${loot} gold`, 0xffaa00);
|
|
706
|
+
sfx('coin');
|
|
707
|
+
map[ny][nx] = TILE.FLOOR;
|
|
708
|
+
if (Math.random() < 0.35) {
|
|
709
|
+
hero.weaponBonus++;
|
|
710
|
+
const weapons = [
|
|
711
|
+
'Iron Sword',
|
|
712
|
+
'Steel Blade',
|
|
713
|
+
'Flame Sword',
|
|
714
|
+
'Shadow Katana',
|
|
715
|
+
'Dragon Fang',
|
|
716
|
+
'Void Cleaver',
|
|
717
|
+
];
|
|
718
|
+
hero.weapon = weapons[Math.min(hero.weaponBonus - 1, weapons.length - 1)];
|
|
719
|
+
hero.atk += 2;
|
|
720
|
+
addMessage(`Found ${hero.weapon}! ATK+2`, 0xff8844);
|
|
721
|
+
sfx('powerup');
|
|
722
|
+
screenFlash = 0.2;
|
|
723
|
+
screenFlashColor = [255, 200, 50];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (map[ny][nx] === TILE.TRAP) {
|
|
728
|
+
const trapDmg = 3 + Math.floor(floor * 1.5);
|
|
729
|
+
hero.hp -= trapDmg;
|
|
730
|
+
addMessage(`TRAP! -${trapDmg} HP`, 0xff2222);
|
|
731
|
+
screenFlash = 0.15;
|
|
732
|
+
screenFlashColor = [255, 50, 50];
|
|
733
|
+
triggerShake(shake, 4);
|
|
734
|
+
sfx('hit');
|
|
735
|
+
map[ny][nx] = TILE.FLOOR;
|
|
736
|
+
if (hero.hp <= 0) {
|
|
737
|
+
gameState = 'dead';
|
|
738
|
+
addMessage('Killed by a trap!', 0xff0000);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (map[ny][nx] === TILE.STAIRS) {
|
|
744
|
+
floor++;
|
|
745
|
+
addMessage(`Descending to Floor ${floor}...`, 0x8888ff);
|
|
746
|
+
hero.hp = Math.min(hero.hp + 8, hero.maxHp);
|
|
747
|
+
sfx('powerup');
|
|
748
|
+
generateDungeon();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
enemyTurn();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function enemyTurn() {
|
|
756
|
+
for (const e of enemies) {
|
|
757
|
+
if (!e.alive) continue;
|
|
758
|
+
const dx = hero.x - e.x;
|
|
759
|
+
const dy = hero.y - e.y;
|
|
760
|
+
const dist = Math.abs(dx) + Math.abs(dy);
|
|
761
|
+
if (dist <= 1) {
|
|
762
|
+
const dmg = rollDmg(e, hero);
|
|
763
|
+
hero.hp -= dmg;
|
|
764
|
+
screenFlash = 0.1;
|
|
765
|
+
screenFlashColor = [255, 50, 50];
|
|
766
|
+
triggerShake(shake, e.isBoss ? 5 : 2);
|
|
767
|
+
addMessage(`${e.name} hits you for ${dmg}!`, 0xff4444);
|
|
768
|
+
floatingTexts.spawn(`-${dmg}`, hero.x * TILE_SIZE, 2.5, {
|
|
769
|
+
z: hero.y * TILE_SIZE,
|
|
770
|
+
duration: 0.8,
|
|
771
|
+
color: 0xff4444,
|
|
772
|
+
});
|
|
773
|
+
sfx('hit');
|
|
774
|
+
if (hero.hp <= 0) {
|
|
775
|
+
gameState = 'dead';
|
|
776
|
+
addMessage('You have been slain!', 0xff0000);
|
|
777
|
+
sfx('explosion');
|
|
778
|
+
}
|
|
779
|
+
} else if (dist < 10) {
|
|
780
|
+
let mx = 0,
|
|
781
|
+
my = 0;
|
|
782
|
+
if (Math.abs(dx) > Math.abs(dy)) mx = dx > 0 ? 1 : -1;
|
|
783
|
+
else my = dy > 0 ? 1 : -1;
|
|
784
|
+
const enx = e.x + mx,
|
|
785
|
+
eny = e.y + my;
|
|
786
|
+
if (
|
|
787
|
+
enx >= 0 &&
|
|
788
|
+
enx < MAP_W &&
|
|
789
|
+
eny >= 0 &&
|
|
790
|
+
eny < MAP_H &&
|
|
791
|
+
map[eny][enx] !== TILE.WALL &&
|
|
792
|
+
map[eny][enx] !== TILE.TORCH
|
|
793
|
+
) {
|
|
794
|
+
const blocked = enemies.some(o => o !== e && o.alive && o.x === enx && o.y === eny);
|
|
795
|
+
if (!blocked && !(enx === hero.x && eny === hero.y)) {
|
|
796
|
+
e.x = enx;
|
|
797
|
+
e.y = eny;
|
|
798
|
+
if (enemyMeshes.has(e))
|
|
799
|
+
setPosition(enemyMeshes.get(e), e.x * TILE_SIZE, e.isBoss ? 0.9 : 0.5, e.y * TILE_SIZE);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function checkLevelUp() {
|
|
807
|
+
while (hero.xp >= hero.xpNext) {
|
|
808
|
+
hero.xp -= hero.xpNext;
|
|
809
|
+
hero.level++;
|
|
810
|
+
hero.maxHp += 5;
|
|
811
|
+
hero.hp = hero.maxHp;
|
|
812
|
+
hero.atk += 1;
|
|
813
|
+
hero.def += 1;
|
|
814
|
+
hero.xpNext = Math.floor(hero.xpNext * 1.5);
|
|
815
|
+
addMessage(`LEVEL UP! Lv.${hero.level}!`, 0xffff00);
|
|
816
|
+
screenFlash = 0.3;
|
|
817
|
+
screenFlashColor = [255, 255, 100];
|
|
818
|
+
sfx('powerup');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function addMessage(text, color) {
|
|
823
|
+
messages.unshift({ text, color, timer: 4 });
|
|
824
|
+
if (messages.length > 8) messages.pop();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function usePotion() {
|
|
828
|
+
if (hero.potions > 0 && hero.hp < hero.maxHp) {
|
|
829
|
+
hero.potions--;
|
|
830
|
+
const heal = 10 + hero.level * 3;
|
|
831
|
+
hero.hp = Math.min(hero.hp + heal, hero.maxHp);
|
|
832
|
+
addMessage(`Healed ${heal} HP!`, 0xff88ff);
|
|
833
|
+
screenFlash = 0.1;
|
|
834
|
+
screenFlashColor = [100, 255, 150];
|
|
835
|
+
sfx('powerup');
|
|
836
|
+
enemyTurn();
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ── Init ────────────────────────────────────────────────────────────────────
|
|
841
|
+
export function init() {
|
|
842
|
+
hero = {
|
|
843
|
+
x: 0,
|
|
844
|
+
y: 0,
|
|
845
|
+
hp: 25,
|
|
846
|
+
maxHp: 25,
|
|
847
|
+
atk: 5,
|
|
848
|
+
def: 2,
|
|
849
|
+
xp: 0,
|
|
850
|
+
xpNext: 15,
|
|
851
|
+
level: 1,
|
|
852
|
+
gold: 0,
|
|
853
|
+
potions: 2,
|
|
854
|
+
weapon: 'Rusty Sword',
|
|
855
|
+
weaponBonus: 0,
|
|
856
|
+
kills: 0,
|
|
857
|
+
totalDmg: 0,
|
|
858
|
+
};
|
|
859
|
+
floor = 1;
|
|
860
|
+
messages = [];
|
|
861
|
+
floatingTexts = createFloatingTextSystem();
|
|
862
|
+
shake = createShake({ decay: 0.85, maxMag: 8 });
|
|
863
|
+
potionCd = createCooldown(0.5);
|
|
864
|
+
enemies = [];
|
|
865
|
+
items = [];
|
|
866
|
+
mapMeshes = [];
|
|
867
|
+
torchLights = [];
|
|
868
|
+
time = 0;
|
|
869
|
+
gameState = 'start';
|
|
870
|
+
|
|
871
|
+
enableFXAA();
|
|
872
|
+
generateDungeon();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── Update ──────────────────────────────────────────────────────────────────
|
|
876
|
+
export function update(dt) {
|
|
877
|
+
time += dt;
|
|
878
|
+
updateShake(shake, dt);
|
|
879
|
+
updateCooldown(potionCd, dt);
|
|
880
|
+
|
|
881
|
+
if (gameState === 'start') {
|
|
882
|
+
if (keyp('Space') || keyp('Enter')) {
|
|
883
|
+
gameState = 'playing';
|
|
884
|
+
sfx('confirm');
|
|
885
|
+
}
|
|
886
|
+
updateCamera(dt);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (gameState === 'dead') {
|
|
891
|
+
if (keyp('Space') || keyp('Enter')) init();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
moveTimer -= dt;
|
|
896
|
+
if (moveTimer <= 0) {
|
|
897
|
+
let moved = false;
|
|
898
|
+
const held = moveTimer < -0.3;
|
|
899
|
+
if (keyp('ArrowUp') || keyp('KeyW') || (held && (key('ArrowUp') || key('KeyW')))) {
|
|
900
|
+
tryMove(0, -1);
|
|
901
|
+
moved = true;
|
|
902
|
+
} else if (keyp('ArrowDown') || keyp('KeyS') || (held && (key('ArrowDown') || key('KeyS')))) {
|
|
903
|
+
tryMove(0, 1);
|
|
904
|
+
moved = true;
|
|
905
|
+
} else if (keyp('ArrowLeft') || keyp('KeyA') || (held && (key('ArrowLeft') || key('KeyA')))) {
|
|
906
|
+
tryMove(-1, 0);
|
|
907
|
+
moved = true;
|
|
908
|
+
} else if (keyp('ArrowRight') || keyp('KeyD') || (held && (key('ArrowRight') || key('KeyD')))) {
|
|
909
|
+
tryMove(1, 0);
|
|
910
|
+
moved = true;
|
|
911
|
+
}
|
|
912
|
+
if (moved) moveTimer = MOVE_DELAY;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if ((keyp('KeyP') || keyp('KeyQ')) && useCooldown(potionCd)) usePotion();
|
|
916
|
+
|
|
917
|
+
if (keyp('Space')) {
|
|
918
|
+
enemyTurn();
|
|
919
|
+
addMessage('Waiting...', 0x888899);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (screenFlash > 0) screenFlash -= dt;
|
|
923
|
+
|
|
924
|
+
for (const m of messages) m.timer -= dt;
|
|
925
|
+
messages = messages.filter(m => m.timer > 0);
|
|
926
|
+
floatingTexts.update(dt);
|
|
927
|
+
|
|
928
|
+
for (const e of enemies) {
|
|
929
|
+
if (!e.alive) continue;
|
|
930
|
+
const mesh = enemyMeshes.get(e);
|
|
931
|
+
if (mesh) {
|
|
932
|
+
const bob = Math.sin(time * 3 + e.x * 2 + e.y * 3) * 0.12;
|
|
933
|
+
setPosition(mesh, e.x * TILE_SIZE, (e.isBoss ? 0.9 : 0.5) + bob, e.y * TILE_SIZE);
|
|
934
|
+
rotateMesh(mesh, 0, dt * (e.isBoss ? 0.5 : 1.5), 0);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (heroMesh) {
|
|
939
|
+
const bob = Math.sin(time * 4) * 0.04;
|
|
940
|
+
setPosition(heroMesh, hero.x * TILE_SIZE, 0.7 + bob, hero.y * TILE_SIZE);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (minimap) {
|
|
944
|
+
minimap.player.x = hero.x;
|
|
945
|
+
minimap.player.y = hero.y;
|
|
946
|
+
minimap.entities = enemies
|
|
947
|
+
.filter(e => e.alive && Math.abs(e.x - hero.x) + Math.abs(e.y - hero.y) <= 8)
|
|
948
|
+
.map(e => ({
|
|
949
|
+
x: e.x,
|
|
950
|
+
y: e.y,
|
|
951
|
+
color: e.isBoss ? rgba8(255, 220, 50) : rgba8(255, 60, 60),
|
|
952
|
+
size: e.isBoss ? 3 : 2,
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
updateCamera(dt);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function updateCamera(dt) {
|
|
960
|
+
const tx = hero.x * TILE_SIZE;
|
|
961
|
+
const tz = hero.y * TILE_SIZE;
|
|
962
|
+
camCurrent.x += (tx - camCurrent.x) * 0.12;
|
|
963
|
+
camCurrent.y += (tz - camCurrent.y) * 0.12;
|
|
964
|
+
const sx = shake.offsetX || 0;
|
|
965
|
+
const sy = shake.offsetY || 0;
|
|
966
|
+
setCameraPosition(camCurrent.x + 1.5 + sx * 0.05, 14, camCurrent.y + 11 + sy * 0.05);
|
|
967
|
+
setCameraTarget(camCurrent.x + sx * 0.02, 0, camCurrent.y + sy * 0.02);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── Draw HUD ────────────────────────────────────────────────────────────────
|
|
971
|
+
export function draw() {
|
|
972
|
+
const theme = getTheme();
|
|
973
|
+
|
|
974
|
+
if (gameState === 'start') {
|
|
975
|
+
rectfill(0, 0, 640, 360, rgba8(0, 0, 0, 200));
|
|
976
|
+
drawGlowText('DUNGEON DELVE', 210, 50, rgba8(255, 200, 100), rgba8(200, 120, 40));
|
|
977
|
+
printCentered('A Roguelike Adventure', 320, 85, rgba8(180, 170, 160));
|
|
978
|
+
printCentered(
|
|
979
|
+
'Descend the dungeon. Slay monsters. Find treasure.',
|
|
980
|
+
320,
|
|
981
|
+
130,
|
|
982
|
+
rgba8(180, 180, 200)
|
|
983
|
+
);
|
|
984
|
+
printCentered('Permadeath! When you die, you start over.', 320, 150, rgba8(255, 100, 100));
|
|
985
|
+
rectfill(160, 180, 320, 70, rgba8(20, 20, 30, 200));
|
|
986
|
+
rect(160, 180, 320, 70, rgba8(100, 100, 150), false);
|
|
987
|
+
printCentered('WASD / Arrows = Move & Attack', 320, 190, rgba8(160, 160, 200));
|
|
988
|
+
printCentered('P / Q = Use Potion', 320, 206, rgba8(160, 160, 200));
|
|
989
|
+
printCentered('SPACE = Wait (skip turn)', 320, 222, rgba8(160, 160, 200));
|
|
990
|
+
printCentered('Walk into enemies to attack!', 320, 238, rgba8(200, 200, 160));
|
|
991
|
+
const pulse = Math.sin(time * 3) * 0.5 + 0.5;
|
|
992
|
+
printCentered(
|
|
993
|
+
'PRESS SPACE TO DELVE',
|
|
994
|
+
320,
|
|
995
|
+
280,
|
|
996
|
+
rgba8(255, 255, 100, Math.floor(100 + pulse * 155))
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (gameState === 'dead') {
|
|
1002
|
+
rectfill(0, 0, 640, 360, rgba8(60, 0, 0, 220));
|
|
1003
|
+
drawGlowText('YOU HAVE PERISHED', 185, 60, rgba8(255, 50, 50), rgba8(180, 0, 0));
|
|
1004
|
+
printCentered(
|
|
1005
|
+
`Floor ${floor} | Level ${hero.level} | ${hero.kills} Kills | ${hero.gold} Gold`,
|
|
1006
|
+
320,
|
|
1007
|
+
110,
|
|
1008
|
+
rgba8(200, 200, 200)
|
|
1009
|
+
);
|
|
1010
|
+
printCentered(`Total Damage Dealt: ${hero.totalDmg}`, 320, 130, rgba8(180, 180, 180));
|
|
1011
|
+
const rating =
|
|
1012
|
+
hero.kills > 30
|
|
1013
|
+
? 'LEGENDARY'
|
|
1014
|
+
: hero.kills > 20
|
|
1015
|
+
? 'HEROIC'
|
|
1016
|
+
: hero.kills > 10
|
|
1017
|
+
? 'CHAMPION'
|
|
1018
|
+
: hero.kills > 5
|
|
1019
|
+
? 'BRAVE'
|
|
1020
|
+
: 'NOVICE';
|
|
1021
|
+
drawGlowText(rating, 260, 170, rgba8(255, 215, 0), rgba8(180, 150, 0));
|
|
1022
|
+
const pulse = Math.sin(time * 2) * 0.5 + 0.5;
|
|
1023
|
+
printCentered(
|
|
1024
|
+
'PRESS SPACE TO TRY AGAIN',
|
|
1025
|
+
320,
|
|
1026
|
+
240,
|
|
1027
|
+
rgba8(200, 150, 150, Math.floor(120 + pulse * 135))
|
|
1028
|
+
);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Screen flash
|
|
1033
|
+
if (screenFlash > 0) {
|
|
1034
|
+
const a = Math.floor(screenFlash * 500);
|
|
1035
|
+
rectfill(
|
|
1036
|
+
0,
|
|
1037
|
+
0,
|
|
1038
|
+
640,
|
|
1039
|
+
360,
|
|
1040
|
+
rgba8(screenFlashColor[0], screenFlashColor[1], screenFlashColor[2], Math.min(a, 200))
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Stats panel
|
|
1045
|
+
rectfill(6, 6, 170, 108, rgba8(10, 10, 18, 220));
|
|
1046
|
+
rect(6, 6, 170, 108, rgba8(80, 80, 120, 200), false);
|
|
1047
|
+
drawPixelBorder(6, 6, 170, 108, rgba8(90, 90, 130), rgba8(30, 30, 50));
|
|
1048
|
+
print(`FLOOR ${floor} Lv.${hero.level}`, 14, 14, rgba8(255, 200, 100));
|
|
1049
|
+
print(
|
|
1050
|
+
`${theme.name}`,
|
|
1051
|
+
14,
|
|
1052
|
+
26,
|
|
1053
|
+
rgba8((theme.accent >> 16) & 0xff, (theme.accent >> 8) & 0xff, theme.accent & 0xff, 200)
|
|
1054
|
+
);
|
|
1055
|
+
print('HP', 14, 40, rgba8(220, 220, 220));
|
|
1056
|
+
drawHealthBar(36, 40, 128, 8, hero.hp, hero.maxHp);
|
|
1057
|
+
print(`${hero.hp}/${hero.maxHp}`, 80, 40, rgba8(255, 255, 255));
|
|
1058
|
+
print('XP', 14, 54, rgba8(220, 220, 220));
|
|
1059
|
+
drawProgressBar(
|
|
1060
|
+
36,
|
|
1061
|
+
54,
|
|
1062
|
+
128,
|
|
1063
|
+
8,
|
|
1064
|
+
hero.xp / hero.xpNext,
|
|
1065
|
+
rgba8(100, 100, 255),
|
|
1066
|
+
rgba8(30, 30, 50),
|
|
1067
|
+
rgba8(80, 80, 150)
|
|
1068
|
+
);
|
|
1069
|
+
print(`ATK:${hero.atk} DEF:${hero.def}`, 14, 68, rgba8(180, 180, 210));
|
|
1070
|
+
print(`${hero.weapon}`, 14, 80, rgba8(255, 180, 100));
|
|
1071
|
+
print(`Gold:${hero.gold} Pot:${hero.potions}`, 14, 94, rgba8(255, 220, 80));
|
|
1072
|
+
|
|
1073
|
+
// Messages
|
|
1074
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1075
|
+
const m = messages[i];
|
|
1076
|
+
const alpha = Math.min(255, Math.floor(m.timer * 85));
|
|
1077
|
+
const r = (m.color >> 16) & 0xff,
|
|
1078
|
+
g = (m.color >> 8) & 0xff,
|
|
1079
|
+
b = m.color & 0xff;
|
|
1080
|
+
rectfill(188, 318 - i * 14, m.text.length * 8 + 8, 13, rgba8(0, 0, 0, Math.floor(alpha * 0.6)));
|
|
1081
|
+
print(m.text, 192, 320 - i * 14, rgba8(r, g, b, alpha));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Minimap
|
|
1085
|
+
if (minimap) drawMinimap(minimap, time);
|
|
1086
|
+
|
|
1087
|
+
// Enemy HP bars
|
|
1088
|
+
for (const e of enemies) {
|
|
1089
|
+
if (!e.alive) continue;
|
|
1090
|
+
const dist = Math.abs(e.x - hero.x) + Math.abs(e.y - hero.y);
|
|
1091
|
+
if (dist > 6) continue;
|
|
1092
|
+
const sx = 320 + (e.x * TILE_SIZE - hero.x * TILE_SIZE) * 8;
|
|
1093
|
+
const sy = 150 - (e.y * TILE_SIZE - hero.y * TILE_SIZE) * 4;
|
|
1094
|
+
if (sx < 20 || sx > 620 || sy < 20 || sy > 340) continue;
|
|
1095
|
+
const barW = e.isBoss ? 50 : 30;
|
|
1096
|
+
const hpPct = e.hp / e.maxHp;
|
|
1097
|
+
rectfill(sx - barW / 2, sy - 14, barW, 4, rgba8(40, 40, 40, 180));
|
|
1098
|
+
const hpCol =
|
|
1099
|
+
hpPct > 0.5 ? rgba8(50, 200, 50) : hpPct > 0.25 ? rgba8(220, 200, 50) : rgba8(220, 50, 50);
|
|
1100
|
+
rectfill(sx - barW / 2, sy - 14, Math.floor(barW * hpPct), 4, hpCol);
|
|
1101
|
+
print(e.name, sx - e.name.length * 4, sy - 22, rgba8(255, 255, 255, 180));
|
|
1102
|
+
if (e.isBoss) print('BOSS', sx - 16, sy - 30, rgba8(255, 220, 50));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
drawFloatingTexts3D(floatingTexts, (x, y, z) => [
|
|
1106
|
+
Math.floor(320 + (x - hero.x * TILE_SIZE) * 8),
|
|
1107
|
+
Math.floor(180 - y * 6 - (z - hero.y * TILE_SIZE) * 4),
|
|
1108
|
+
]);
|
|
1109
|
+
|
|
1110
|
+
rectfill(0, 348, 400, 12, rgba8(0, 0, 0, 120));
|
|
1111
|
+
print(
|
|
1112
|
+
'WASD=Move P=Potion SPACE=Wait Walk into enemies to attack!',
|
|
1113
|
+
8,
|
|
1114
|
+
348,
|
|
1115
|
+
rgba8(140, 140, 170, 200)
|
|
1116
|
+
);
|
|
1117
|
+
}
|