@aiyiran/myclaw 1.1.89 → 1.1.91

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.
@@ -0,0 +1,1756 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>魔幻探索地下城 Demo</title>
7
+ <style>
8
+ :root {
9
+ --bg: #101722;
10
+ --panel: rgba(17, 24, 39, 0.82);
11
+ --text: #f8f5e9;
12
+ }
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+ body {
17
+ margin: 0;
18
+ font-family: "Trebuchet MS", "Microsoft YaHei", sans-serif;
19
+ background: radial-gradient(
20
+ circle at top,
21
+ #1f3b2d 0%,
22
+ #101722 45%,
23
+ #091018 100%
24
+ );
25
+ color: var(--text);
26
+ min-height: 100vh;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ overflow: hidden;
31
+ }
32
+ .wrap {
33
+ display: flex;
34
+ gap: 18px;
35
+ align-items: flex-start;
36
+ padding: 18px;
37
+ flex-wrap: wrap;
38
+ justify-content: center;
39
+ }
40
+ canvas {
41
+ border: 3px solid rgba(246, 196, 83, 0.75);
42
+ border-radius: 14px;
43
+ box-shadow:
44
+ 0 14px 40px rgba(0, 0, 0, 0.35),
45
+ 0 0 25px rgba(246, 196, 83, 0.18);
46
+ background: #1b2a1d;
47
+ image-rendering: pixelated;
48
+ }
49
+ .panel {
50
+ width: 320px;
51
+ background: var(--panel);
52
+ border: 1px solid rgba(255, 255, 255, 0.08);
53
+ border-radius: 16px;
54
+ padding: 16px;
55
+ backdrop-filter: blur(8px);
56
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
57
+ }
58
+ h1 {
59
+ font-size: 22px;
60
+ margin: 0 0 10px;
61
+ color: #ffe7a3;
62
+ }
63
+ .tip,
64
+ .stat,
65
+ li {
66
+ line-height: 1.5;
67
+ }
68
+ .stat {
69
+ margin: 10px 0;
70
+ padding: 10px 12px;
71
+ border-radius: 10px;
72
+ background: rgba(255, 255, 255, 0.05);
73
+ }
74
+ ul {
75
+ margin: 8px 0 0;
76
+ padding-left: 18px;
77
+ }
78
+ .loot-log {
79
+ margin-top: 12px;
80
+ min-height: 90px;
81
+ padding: 10px 12px;
82
+ border-radius: 10px;
83
+ background: rgba(125, 211, 252, 0.08);
84
+ border: 1px solid rgba(125, 211, 252, 0.15);
85
+ color: #d9f4ff;
86
+ }
87
+ .hint {
88
+ color: #c7d2fe;
89
+ font-size: 14px;
90
+ }
91
+ .action-btn {
92
+ width: 100%;
93
+ margin-top: 12px;
94
+ border: 0;
95
+ border-radius: 12px;
96
+ padding: 12px 16px;
97
+ font-size: 15px;
98
+ cursor: pointer;
99
+ font-family: inherit;
100
+ font-weight: 700;
101
+ background: linear-gradient(180deg, #7dd3fc, #38bdf8);
102
+ color: #082f49;
103
+ box-shadow: 0 8px 20px rgba(56, 189, 248, 0.25);
104
+ }
105
+ .pause-mask {
106
+ position: absolute;
107
+ inset: 0;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ background: rgba(3, 6, 15, 0.55);
112
+ color: #fff1b5;
113
+ font-size: 28px;
114
+ font-weight: 800;
115
+ letter-spacing: 2px;
116
+ border-radius: 14px;
117
+ pointer-events: none;
118
+ }
119
+ .menu-overlay {
120
+ position: fixed;
121
+ inset: 0;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ background: rgba(3, 6, 15, 0.86);
126
+ z-index: 20;
127
+ padding: 24px;
128
+ }
129
+ .menu-card {
130
+ width: min(920px, 96vw);
131
+ background: rgba(17, 24, 39, 0.96);
132
+ border: 1px solid rgba(255, 255, 255, 0.08);
133
+ border-radius: 20px;
134
+ overflow: hidden;
135
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
136
+ }
137
+ .menu-video {
138
+ width: 100%;
139
+ display: block;
140
+ background: #000;
141
+ max-height: 58vh;
142
+ object-fit: cover;
143
+ }
144
+ .menu-content {
145
+ padding: 18px;
146
+ }
147
+ .menu-title {
148
+ margin: 0 0 10px;
149
+ color: #ffe7a3;
150
+ font-size: 28px;
151
+ }
152
+ .menu-actions {
153
+ display: flex;
154
+ gap: 12px;
155
+ flex-wrap: wrap;
156
+ margin-top: 14px;
157
+ }
158
+ .menu-btn {
159
+ border: 0;
160
+ border-radius: 12px;
161
+ padding: 12px 18px;
162
+ font-size: 16px;
163
+ cursor: pointer;
164
+ font-family: inherit;
165
+ font-weight: 700;
166
+ }
167
+ .menu-btn.primary {
168
+ background: linear-gradient(180deg, #f6c453, #d89a2b);
169
+ color: #23180a;
170
+ }
171
+ .menu-btn.secondary {
172
+ background: rgba(125, 211, 252, 0.12);
173
+ color: #d9f4ff;
174
+ border: 1px solid rgba(125, 211, 252, 0.25);
175
+ }
176
+ .menu-help {
177
+ margin-top: 14px;
178
+ padding: 12px;
179
+ border-radius: 12px;
180
+ background: rgba(255, 255, 255, 0.04);
181
+ line-height: 1.6;
182
+ color: #dbeafe;
183
+ display: none;
184
+ }
185
+ .hidden {
186
+ display: none !important;
187
+ }
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <div id="menuOverlay" class="menu-overlay">
192
+ <div class="menu-card">
193
+ <video
194
+ id="introVideo"
195
+ class="menu-video"
196
+ src="./intro.mp4"
197
+ autoplay
198
+ muted
199
+ loop
200
+ playsinline
201
+ controls
202
+ ></video>
203
+ <div class="menu-content">
204
+ <h2 class="menu-title">魔幻探索地下城</h2>
205
+ <div>在森林与地下城中探索、开箱、战斗,并一步步走向更深层。</div>
206
+ <div class="menu-actions">
207
+ <button id="startGameBtn" class="menu-btn primary">开始游戏</button>
208
+ <button id="showHelpBtn" class="menu-btn secondary">
209
+ 游戏说明
210
+ </button>
211
+ </div>
212
+ <div id="menuHelp" class="menu-help">
213
+ <div>1. 用 WASD 或方向键移动。</div>
214
+ <div>2. 按 E / 空格 互动,开宝箱、进门、上下楼梯。</div>
215
+ <div>3. 按 J 攻击敌人。</div>
216
+ <div>4. 地图比屏幕大,镜头会跟着你移动。</div>
217
+ <div>5. 进入地下城后,每层都要找到向下楼梯才能继续深入。</div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ <div class="wrap">
224
+ <div style="position: relative">
225
+ <canvas id="game" width="960" height="720"></canvas>
226
+ <div id="pauseMask" class="pause-mask hidden">
227
+ 图签查看中,游戏已暂停
228
+ </div>
229
+ </div>
230
+ <aside class="panel">
231
+ <h1>魔幻探索地下城 Demo</h1>
232
+ <div class="tip">
233
+ 先在地表找到地下城入口。进入后,每一层都要找到楼梯才能继续前进。
234
+ </div>
235
+ <div class="stat">地点:<span id="floorLabel">地表森林</span></div>
236
+ <div class="stat">金币:<span id="gold">0</span></div>
237
+ <div class="stat">
238
+ 生命:<span id="hp">100</span> / <span id="maxHp">100</span>
239
+ </div>
240
+ <div class="stat">攻击力:<span id="attack">12</span></div>
241
+ <div class="stat">连斩数:<span id="combo">0</span></div>
242
+ <div class="stat">
243
+ 已收集物品:
244
+ <ul id="inventory"></ul>
245
+ </div>
246
+ <div class="stat">
247
+ 已收集龙卡:
248
+ <ul id="cardInventory"></ul>
249
+ </div>
250
+ <div class="loot-log" id="lootLog">
251
+ 欢迎来到森林遗迹,找到发光的地下城入口吧。
252
+ </div>
253
+ <button id="viewCodexBtn" class="action-btn">查看图签</button>
254
+ <p class="hint">操作:WASD / 方向键移动,E / 空格互动,J 攻击</p>
255
+ </aside>
256
+ </div>
257
+
258
+ <script>
259
+ const menuOverlayEl = document.getElementById("menuOverlay");
260
+ const introVideoEl = document.getElementById("introVideo");
261
+ const startGameBtn = document.getElementById("startGameBtn");
262
+ const showHelpBtn = document.getElementById("showHelpBtn");
263
+ const menuHelpEl = document.getElementById("menuHelp");
264
+ const canvas = document.getElementById("game");
265
+ const ctx = canvas.getContext("2d");
266
+ const goldEl = document.getElementById("gold");
267
+ const hpEl = document.getElementById("hp");
268
+ const maxHpEl = document.getElementById("maxHp");
269
+ const attackEl = document.getElementById("attack");
270
+ const comboEl = document.getElementById("combo");
271
+ const floorLabelEl = document.getElementById("floorLabel");
272
+ const inventoryEl = document.getElementById("inventory");
273
+ const cardInventoryEl = document.getElementById("cardInventory");
274
+ const lootLogEl = document.getElementById("lootLog");
275
+ const viewCodexBtn = document.getElementById("viewCodexBtn");
276
+ const pauseMask = document.getElementById("pauseMask");
277
+
278
+ const tileSize = 48;
279
+ const viewportCols = 20;
280
+ const viewportRows = 15;
281
+ const keys = new Set();
282
+
283
+ const SAVE_KEY = "dragonDungeonSave";
284
+ const lootTable = [
285
+ {
286
+ name: "金币",
287
+ type: "gold",
288
+ min: 12,
289
+ max: 32,
290
+ color: "#f6c453",
291
+ weight: 4,
292
+ },
293
+ {
294
+ name: "生命药水",
295
+ type: "heal",
296
+ value: 30,
297
+ color: "#ef4444",
298
+ weight: 3,
299
+ },
300
+ {
301
+ name: "骑士护符",
302
+ type: "maxHp",
303
+ value: 18,
304
+ color: "#fb7185",
305
+ weight: 2,
306
+ },
307
+ {
308
+ name: "锋利符文",
309
+ type: "attack",
310
+ value: 5,
311
+ color: "#a78bfa",
312
+ weight: 2,
313
+ },
314
+ { name: "宝石", type: "item", color: "#22d3ee", weight: 1 },
315
+ { name: "稀有遗物", type: "item", color: "#f59e0b", weight: 1 },
316
+ ];
317
+ const cardLootTable = [
318
+ { name: "晨曦龙", rarity: "N", color: "#d1d5db", weight: 34 },
319
+ { name: "曙光龙", rarity: "R", color: "#60a5fa", weight: 24 },
320
+ { name: "焚焰龙", rarity: "R", color: "#f97316", weight: 20 },
321
+ { name: "星尘龙", rarity: "SR", color: "#c084fc", weight: 12 },
322
+ { name: "梦境龙", rarity: "SR", color: "#67e8f9", weight: 7 },
323
+ { name: "灾厄龙", rarity: "SSR", color: "#fbbf24", weight: 3 },
324
+ ];
325
+ const chestDropConfig = {
326
+ cardDropRate: 0.2,
327
+ };
328
+
329
+ const inventory = new Map();
330
+ const cardInventory = new Map();
331
+ const particles = [];
332
+ const popups = [];
333
+ const enemies = [];
334
+ const groundLoots = [];
335
+
336
+ let gold = 0;
337
+ let messageTimer = 0;
338
+ let currentMessage = "欢迎来到森林遗迹,找到发光的地下城入口吧。";
339
+ let spawnTimer = 1.2;
340
+ let attackCooldown = 0;
341
+ let hurtCooldown = 0;
342
+ let gameTime = 0;
343
+ let combo = 0;
344
+ let comboTimer = 0;
345
+ let currentFloor = 0;
346
+ let scene = "surface";
347
+ let interactionLock = 0;
348
+ let isPaused = false;
349
+ const saveData = createDefaultSave();
350
+
351
+ const player = {
352
+ x: tileSize * 2.5,
353
+ y: tileSize * 2.5,
354
+ size: 26,
355
+ speed: 170,
356
+ dirX: 0,
357
+ dirY: 1,
358
+ hp: 130,
359
+ maxHp: 130,
360
+ attack: 18,
361
+ attackArc: 0,
362
+ lifeSteal: 2,
363
+ };
364
+
365
+ const world = {
366
+ map: [],
367
+ cols: viewportCols,
368
+ rows: viewportRows,
369
+ chests: [],
370
+ stairsUp: null,
371
+ stairsDown: null,
372
+ entrance: null,
373
+ };
374
+
375
+ function getCamera() {
376
+ const worldWidth = world.cols * tileSize;
377
+ const worldHeight = world.rows * tileSize;
378
+ const cameraX = Math.max(
379
+ 0,
380
+ Math.min(player.x - canvas.width / 2, worldWidth - canvas.width),
381
+ );
382
+ const cameraY = Math.max(
383
+ 0,
384
+ Math.min(player.y - canvas.height / 2, worldHeight - canvas.height),
385
+ );
386
+ return { x: cameraX, y: cameraY };
387
+ }
388
+
389
+ function setMessage(text) {
390
+ currentMessage = text;
391
+ lootLogEl.textContent = text;
392
+ messageTimer = 3;
393
+ }
394
+
395
+ function setPaused(paused) {
396
+ isPaused = paused;
397
+ pauseMask.classList.toggle("hidden", !paused);
398
+ if (!paused) {
399
+ setMessage("你回到了地下城,继续冒险吧。");
400
+ }
401
+ }
402
+
403
+ function createDefaultSave() {
404
+ return {
405
+ version: 1,
406
+ player: {
407
+ gold: 0,
408
+ hp: 130,
409
+ maxHp: 130,
410
+ attack: 18,
411
+ combo: 0,
412
+ lifeSteal: 2,
413
+ },
414
+ progress: {
415
+ scene: "surface",
416
+ currentFloor: 0,
417
+ openedChestIds: [],
418
+ lastSavedAt: Date.now(),
419
+ },
420
+ inventory: { items: {}, cards: {} },
421
+ cardBook: { unlocked: [], seen: [] },
422
+ stats: {
423
+ totalChestsOpened: 0,
424
+ totalCardsCollected: 0,
425
+ totalGoldCollected: 0,
426
+ },
427
+ };
428
+ }
429
+
430
+ function updateStats() {
431
+ goldEl.textContent = gold;
432
+ hpEl.textContent = Math.max(0, Math.round(player.hp));
433
+ maxHpEl.textContent = Math.round(player.maxHp);
434
+ attackEl.textContent = Math.round(player.attack);
435
+ comboEl.textContent = combo;
436
+ floorLabelEl.textContent =
437
+ scene === "surface" ? "地表森林" : `地下城 ${currentFloor} 层`;
438
+ }
439
+
440
+ function syncMapsToSave() {
441
+ saveData.inventory.items = Object.fromEntries(inventory.entries());
442
+ saveData.inventory.cards = Object.fromEntries(cardInventory.entries());
443
+ saveData.player.gold = gold;
444
+ saveData.player.hp = Math.round(player.hp);
445
+ saveData.player.maxHp = Math.round(player.maxHp);
446
+ saveData.player.attack = Math.round(player.attack);
447
+ saveData.player.combo = combo;
448
+ saveData.player.lifeSteal = player.lifeSteal;
449
+ saveData.progress.scene = scene;
450
+ saveData.progress.currentFloor = currentFloor;
451
+ saveData.progress.lastSavedAt = Date.now();
452
+ }
453
+
454
+ function saveGame() {
455
+ syncMapsToSave();
456
+ localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
457
+ }
458
+
459
+ function loadGame() {
460
+ try {
461
+ const raw = localStorage.getItem(SAVE_KEY);
462
+ if (!raw) return;
463
+ const parsed = JSON.parse(raw);
464
+ if (!parsed || typeof parsed !== "object") return;
465
+ const defaults = createDefaultSave();
466
+ saveData.version = parsed.version || defaults.version;
467
+ saveData.player = { ...defaults.player, ...(parsed.player || {}) };
468
+ saveData.progress = {
469
+ ...defaults.progress,
470
+ ...(parsed.progress || {}),
471
+ };
472
+ saveData.inventory = {
473
+ items: { ...(parsed.inventory?.items || {}) },
474
+ cards: { ...(parsed.inventory?.cards || {}) },
475
+ };
476
+ saveData.cardBook = {
477
+ unlocked: [...(parsed.cardBook?.unlocked || [])],
478
+ seen: [...(parsed.cardBook?.seen || [])],
479
+ };
480
+ saveData.stats = { ...defaults.stats, ...(parsed.stats || {}) };
481
+
482
+ gold = saveData.player.gold || 0;
483
+ player.hp = saveData.player.hp || 130;
484
+ player.maxHp = saveData.player.maxHp || 130;
485
+ player.attack = saveData.player.attack || 18;
486
+ player.lifeSteal = saveData.player.lifeSteal || 2;
487
+ combo = saveData.player.combo || 0;
488
+ scene = saveData.progress.scene || "surface";
489
+ currentFloor = saveData.progress.currentFloor || 0;
490
+
491
+ inventory.clear();
492
+ for (const [name, count] of Object.entries(saveData.inventory.items))
493
+ inventory.set(name, count);
494
+ cardInventory.clear();
495
+ for (const [name, data] of Object.entries(saveData.inventory.cards))
496
+ cardInventory.set(name, data);
497
+ } catch (err) {
498
+ console.warn("读取存档失败", err);
499
+ }
500
+ }
501
+
502
+ function addInventory(name) {
503
+ inventory.set(name, (inventory.get(name) || 0) + 1);
504
+ renderInventory();
505
+ saveGame();
506
+ }
507
+
508
+ function addCard(name, rarity, source = "chest") {
509
+ const existing = cardInventory.get(name);
510
+ if (existing) {
511
+ existing.count += 1;
512
+ } else {
513
+ cardInventory.set(name, {
514
+ count: 1,
515
+ firstGetAt: Date.now(),
516
+ rarity,
517
+ source,
518
+ });
519
+ }
520
+ if (!saveData.cardBook.unlocked.includes(name))
521
+ saveData.cardBook.unlocked.push(name);
522
+ if (!saveData.cardBook.seen.includes(name))
523
+ saveData.cardBook.seen.push(name);
524
+ saveData.stats.totalCardsCollected += 1;
525
+ renderCardInventory();
526
+ saveGame();
527
+ }
528
+
529
+ function renderInventory() {
530
+ inventoryEl.innerHTML = "";
531
+ if (inventory.size === 0) {
532
+ const li = document.createElement("li");
533
+ li.textContent = "还没有物品";
534
+ inventoryEl.appendChild(li);
535
+ return;
536
+ }
537
+ for (const [name, count] of inventory.entries()) {
538
+ const li = document.createElement("li");
539
+ li.textContent = `${name} x${count}`;
540
+ inventoryEl.appendChild(li);
541
+ }
542
+ }
543
+
544
+ function renderCardInventory() {
545
+ cardInventoryEl.innerHTML = "";
546
+ if (cardInventory.size === 0) {
547
+ const li = document.createElement("li");
548
+ li.textContent = "还没有龙卡";
549
+ cardInventoryEl.appendChild(li);
550
+ return;
551
+ }
552
+ for (const [name, data] of cardInventory.entries()) {
553
+ const li = document.createElement("li");
554
+ li.textContent = `[${data.rarity}] ${name} x${data.count}`;
555
+ cardInventoryEl.appendChild(li);
556
+ }
557
+ }
558
+
559
+ function clearFloorEntities() {
560
+ enemies.length = 0;
561
+ groundLoots.length = 0;
562
+ particles.length = 0;
563
+ popups.length = 0;
564
+ combo = 0;
565
+ comboTimer = 0;
566
+ spawnTimer = 0.6;
567
+ }
568
+
569
+ function randomFloorTile() {
570
+ const r = Math.random();
571
+ if (r < 0.08) return "crystal";
572
+ if (r < 0.16) return "moss";
573
+ return "floor";
574
+ }
575
+
576
+ function generateSurface() {
577
+ const map = [];
578
+ const cols = 30;
579
+ const rows = 22;
580
+ for (let y = 0; y < rows; y++) {
581
+ const row = [];
582
+ for (let x = 0; x < cols; x++) {
583
+ let tile = "grass";
584
+ if (x === 0 || y === 0 || x === cols - 1 || y === rows - 1)
585
+ tile = "tree";
586
+ if (
587
+ (x > 4 && x < cols - 5 && y === Math.floor(rows / 2)) ||
588
+ (y > 4 && y < rows - 4 && x === Math.floor(cols / 2))
589
+ )
590
+ tile = "path";
591
+ if (
592
+ (x === 7 && y === 7) ||
593
+ (x === cols - 8 && y === rows - 7) ||
594
+ (x === 10 && y === rows - 5) ||
595
+ (x === cols - 10 && y === 6)
596
+ )
597
+ tile = "rock";
598
+ if (
599
+ (x === 5 && y === rows - 6) ||
600
+ (x === Math.floor(cols / 2) + 2 && y === 5) ||
601
+ (x === cols - 6 && y === Math.floor(rows / 2) + 1)
602
+ )
603
+ tile = "flower";
604
+ row.push(tile);
605
+ }
606
+ map.push(row);
607
+ }
608
+ world.map = map;
609
+ world.cols = cols;
610
+ world.rows = rows;
611
+ world.chests = [
612
+ {
613
+ id: "surface-4-4",
614
+ x: 4,
615
+ y: 4,
616
+ opened: false,
617
+ glow: Math.random() * Math.PI * 2,
618
+ },
619
+ {
620
+ id: `surface-${cols - 5}-4`,
621
+ x: cols - 5,
622
+ y: 4,
623
+ opened: false,
624
+ glow: Math.random() * Math.PI * 2,
625
+ },
626
+ {
627
+ id: `surface-5-${rows - 5}`,
628
+ x: 5,
629
+ y: rows - 5,
630
+ opened: false,
631
+ glow: Math.random() * Math.PI * 2,
632
+ },
633
+ {
634
+ id: `surface-${cols - 6}-${rows - 6}`,
635
+ x: cols - 6,
636
+ y: rows - 6,
637
+ opened: false,
638
+ glow: Math.random() * Math.PI * 2,
639
+ },
640
+ {
641
+ id: `surface-${Math.floor(cols / 2) + 3}-${Math.floor(rows / 2) - 3}`,
642
+ x: Math.floor(cols / 2) + 3,
643
+ y: Math.floor(rows / 2) - 3,
644
+ opened: false,
645
+ glow: Math.random() * Math.PI * 2,
646
+ },
647
+ ];
648
+ for (const chest of world.chests)
649
+ chest.opened = saveData.progress.openedChestIds.includes(chest.id);
650
+ world.entrance = {
651
+ x: Math.floor(cols / 2),
652
+ y: Math.floor(rows / 2) + 2,
653
+ glow: Math.random() * Math.PI * 2,
654
+ };
655
+ world.stairsUp = null;
656
+ world.stairsDown = null;
657
+ }
658
+
659
+ function carveRoom(map, x, y, w, h) {
660
+ for (let yy = y; yy < y + h; yy++) {
661
+ for (let xx = x; xx < x + w; xx++) {
662
+ if (
663
+ xx > 0 &&
664
+ yy > 0 &&
665
+ xx < world.cols - 1 &&
666
+ yy < world.rows - 1
667
+ ) {
668
+ map[yy][xx] = randomFloorTile();
669
+ }
670
+ }
671
+ }
672
+ }
673
+
674
+ function carveHall(map, x1, y1, x2, y2) {
675
+ let x = x1;
676
+ let y = y1;
677
+ while (x !== x2) {
678
+ map[y][x] = "floor";
679
+ x += x < x2 ? 1 : -1;
680
+ }
681
+ while (y !== y2) {
682
+ map[y][x] = "floor";
683
+ y += y < y2 ? 1 : -1;
684
+ }
685
+ map[y][x] = "floor";
686
+ }
687
+
688
+ function randomOpenTile(map) {
689
+ for (let i = 0; i < 200; i++) {
690
+ const x = 1 + Math.floor(Math.random() * (world.cols - 2));
691
+ const y = 1 + Math.floor(Math.random() * (world.rows - 2));
692
+ if (["floor", "moss", "crystal"].includes(map[y][x])) return { x, y };
693
+ }
694
+ return { x: 2, y: 2 };
695
+ }
696
+
697
+ function generateDungeonFloor(floor, fromDirection = "down") {
698
+ world.cols = 34;
699
+ world.rows = 26;
700
+ const map = Array.from({ length: world.rows }, () =>
701
+ Array.from({ length: world.cols }, () => "wall"),
702
+ );
703
+ const rooms = [];
704
+ const roomCount = 6 + Math.floor(Math.random() * 4);
705
+
706
+ for (let i = 0; i < roomCount; i++) {
707
+ const w = 3 + Math.floor(Math.random() * 4);
708
+ const h = 3 + Math.floor(Math.random() * 4);
709
+ const x = 1 + Math.floor(Math.random() * (world.cols - w - 2));
710
+ const y = 1 + Math.floor(Math.random() * (world.rows - h - 2));
711
+ carveRoom(map, x, y, w, h);
712
+ rooms.push({
713
+ x,
714
+ y,
715
+ w,
716
+ h,
717
+ cx: Math.floor(x + w / 2),
718
+ cy: Math.floor(y + h / 2),
719
+ });
720
+ }
721
+
722
+ for (let i = 1; i < rooms.length; i++) {
723
+ carveHall(
724
+ map,
725
+ rooms[i - 1].cx,
726
+ rooms[i - 1].cy,
727
+ rooms[i].cx,
728
+ rooms[i].cy,
729
+ );
730
+ }
731
+
732
+ const upRoom = rooms[0] || { cx: 2, cy: 2 };
733
+ const downRoom = rooms[rooms.length - 1] || {
734
+ cx: world.cols - 3,
735
+ cy: world.rows - 3,
736
+ };
737
+ const stairsUp = {
738
+ x: upRoom.cx,
739
+ y: upRoom.cy,
740
+ glow: Math.random() * Math.PI * 2,
741
+ };
742
+ const stairsDown = {
743
+ x: downRoom.cx,
744
+ y: downRoom.cy,
745
+ glow: Math.random() * Math.PI * 2,
746
+ };
747
+
748
+ if (stairsUp.x === stairsDown.x && stairsUp.y === stairsDown.y) {
749
+ const other = randomOpenTile(map);
750
+ stairsDown.x = other.x;
751
+ stairsDown.y = other.y;
752
+ }
753
+
754
+ const chests = [];
755
+ for (let i = 0; i < 4; i++) {
756
+ const spot = randomOpenTile(map);
757
+ if (
758
+ (spot.x === stairsUp.x && spot.y === stairsUp.y) ||
759
+ (spot.x === stairsDown.x && spot.y === stairsDown.y)
760
+ )
761
+ continue;
762
+ const chestId = `floor${floor}-${spot.x}-${spot.y}-${i}`;
763
+ chests.push({
764
+ id: chestId,
765
+ x: spot.x,
766
+ y: spot.y,
767
+ opened: false,
768
+ glow: Math.random() * Math.PI * 2,
769
+ });
770
+ }
771
+
772
+ world.map = map;
773
+ world.chests = chests;
774
+ world.entrance = null;
775
+ world.stairsUp =
776
+ floor === 1 && fromDirection === "down"
777
+ ? {
778
+ x: stairsUp.x,
779
+ y: stairsUp.y,
780
+ glow: stairsUp.glow,
781
+ exitToSurface: true,
782
+ }
783
+ : stairsUp;
784
+ world.stairsDown = stairsDown;
785
+ for (const chest of world.chests)
786
+ chest.opened = saveData.progress.openedChestIds.includes(chest.id);
787
+ }
788
+
789
+ function isBlocked(tileX, tileY) {
790
+ if (
791
+ tileX < 0 ||
792
+ tileY < 0 ||
793
+ tileX >= world.cols ||
794
+ tileY >= world.rows
795
+ )
796
+ return true;
797
+ const tile = world.map[tileY][tileX];
798
+ return ["tree", "rock", "wall", "void"].includes(tile);
799
+ }
800
+
801
+ function burstParticles(x, y, color, count = 18) {
802
+ for (let i = 0; i < count; i++) {
803
+ particles.push({
804
+ x,
805
+ y,
806
+ vx: (Math.random() - 0.5) * 120,
807
+ vy: (Math.random() - 0.8) * 120,
808
+ life: 0.9 + Math.random() * 0.4,
809
+ color,
810
+ size: 2 + Math.random() * 4,
811
+ });
812
+ }
813
+ }
814
+
815
+ function pickWeighted(table) {
816
+ const totalWeight = table.reduce(
817
+ (sum, item) => sum + (item.weight || 1),
818
+ 0,
819
+ );
820
+ let roll = Math.random() * totalWeight;
821
+ for (const item of table) {
822
+ roll -= item.weight || 1;
823
+ if (roll <= 0) return item;
824
+ }
825
+ return table[0];
826
+ }
827
+
828
+ function pickLoot() {
829
+ return pickWeighted(lootTable);
830
+ }
831
+
832
+ function pickCardLoot() {
833
+ return pickWeighted(cardLootTable);
834
+ }
835
+
836
+ function tryOpenChest() {
837
+ const playerTileX = Math.floor(player.x / tileSize);
838
+ const playerTileY = Math.floor(player.y / tileSize);
839
+ const chest = world.chests.find(
840
+ (c) =>
841
+ !c.opened &&
842
+ Math.abs(c.x - playerTileX) + Math.abs(c.y - playerTileY) <= 1,
843
+ );
844
+ if (!chest) {
845
+ setMessage("附近没有能互动的宝箱。");
846
+ return;
847
+ }
848
+ chest.opened = true;
849
+ if (chest.id && !saveData.progress.openedChestIds.includes(chest.id))
850
+ saveData.progress.openedChestIds.push(chest.id);
851
+ saveData.stats.totalChestsOpened += 1;
852
+
853
+ if (Math.random() < chestDropConfig.cardDropRate) {
854
+ const card = pickCardLoot();
855
+ addCard(card.name, card.rarity, "chest");
856
+ setMessage(`你打开宝箱,获得了龙卡【${card.name}】!`);
857
+ popups.push({
858
+ x: chest.x * tileSize + 24,
859
+ y: chest.y * tileSize + 10,
860
+ text: `[${card.rarity}] ${card.name}`,
861
+ color: card.color,
862
+ life: 1.8,
863
+ });
864
+ updateStats();
865
+ burstParticles(
866
+ chest.x * tileSize + 24,
867
+ chest.y * tileSize + 24,
868
+ card.color,
869
+ 24,
870
+ );
871
+ saveGame();
872
+ return;
873
+ }
874
+
875
+ const loot = pickLoot();
876
+ if (loot.type === "gold") {
877
+ const amount =
878
+ Math.floor(Math.random() * (loot.max - loot.min + 1)) + loot.min;
879
+ gold += amount;
880
+ saveData.stats.totalGoldCollected += amount;
881
+ setMessage(`你打开宝箱,获得了 ${amount} 枚金币!`);
882
+ popups.push({
883
+ x: chest.x * tileSize + 24,
884
+ y: chest.y * tileSize + 10,
885
+ text: `+${amount} 金币`,
886
+ color: loot.color,
887
+ life: 1.4,
888
+ });
889
+ } else if (loot.type === "heal") {
890
+ player.hp = Math.min(player.maxHp, player.hp + loot.value);
891
+ addInventory(loot.name);
892
+ setMessage(`你打开宝箱,恢复了 ${loot.value} 点生命!`);
893
+ popups.push({
894
+ x: chest.x * tileSize + 24,
895
+ y: chest.y * tileSize + 10,
896
+ text: `+${loot.value} 生命`,
897
+ color: loot.color,
898
+ life: 1.6,
899
+ });
900
+ } else if (loot.type === "maxHp") {
901
+ player.maxHp += loot.value;
902
+ player.hp = Math.min(player.maxHp, player.hp + loot.value + 8);
903
+ addInventory(loot.name);
904
+ setMessage(`你获得 ${loot.name},最大生命 +${loot.value}!`);
905
+ popups.push({
906
+ x: chest.x * tileSize + 24,
907
+ y: chest.y * tileSize + 10,
908
+ text: `生命上限 +${loot.value}`,
909
+ color: loot.color,
910
+ life: 1.6,
911
+ });
912
+ } else if (loot.type === "attack") {
913
+ player.attack += loot.value;
914
+ addInventory(loot.name);
915
+ setMessage(`你获得 ${loot.name},攻击力 +${loot.value}!`);
916
+ popups.push({
917
+ x: chest.x * tileSize + 24,
918
+ y: chest.y * tileSize + 10,
919
+ text: `攻击 +${loot.value}`,
920
+ color: loot.color,
921
+ life: 1.6,
922
+ });
923
+ } else {
924
+ addInventory(loot.name);
925
+ setMessage(`你打开宝箱,找到了 ${loot.name}!`);
926
+ popups.push({
927
+ x: chest.x * tileSize + 24,
928
+ y: chest.y * tileSize + 10,
929
+ text: loot.name,
930
+ color: loot.color,
931
+ life: 1.6,
932
+ });
933
+ }
934
+ updateStats();
935
+ burstParticles(
936
+ chest.x * tileSize + 24,
937
+ chest.y * tileSize + 24,
938
+ loot.color,
939
+ 18,
940
+ );
941
+ saveGame();
942
+ }
943
+
944
+ function makeEnemyType() {
945
+ const roll = Math.random();
946
+ if (roll < 0.55)
947
+ return {
948
+ name: "小史莱姆",
949
+ color: "#7c3aed",
950
+ eye: "#fca5a5",
951
+ hpMul: 1,
952
+ speed: 1.05,
953
+ damage: 1,
954
+ reward: 1,
955
+ vision: 1,
956
+ };
957
+ if (roll < 0.85)
958
+ return {
959
+ name: "野狼",
960
+ color: "#ef4444",
961
+ eye: "#fee2e2",
962
+ hpMul: 1.5,
963
+ speed: 1.18,
964
+ damage: 2,
965
+ reward: 1.5,
966
+ vision: 1.08,
967
+ };
968
+ return {
969
+ name: "重甲魔物",
970
+ color: "#f59e0b",
971
+ eye: "#fff7ed",
972
+ hpMul: 2.3,
973
+ speed: 0.82,
974
+ damage: 4,
975
+ reward: 2.4,
976
+ vision: 0.92,
977
+ };
978
+ }
979
+
980
+ function spawnEnemy() {
981
+ if (enemies.length >= 16) return;
982
+ let tries = 0;
983
+ while (tries < 30) {
984
+ tries++;
985
+ const tx = 1 + Math.floor(Math.random() * (world.cols - 2));
986
+ const ty = 1 + Math.floor(Math.random() * (world.rows - 2));
987
+ if (isBlocked(tx, ty)) continue;
988
+ const px = tx * tileSize + tileSize / 2;
989
+ const py = ty * tileSize + tileSize / 2;
990
+ if (Math.hypot(px - player.x, py - player.y) < 220) continue;
991
+ const stageBoost = Math.min(
992
+ 18,
993
+ Math.floor((gameTime + currentFloor * 12) / 28) * 2,
994
+ );
995
+ const type = makeEnemyType();
996
+ const baseHp = 18 + stageBoost;
997
+ enemies.push({
998
+ x: px,
999
+ y: py,
1000
+ hp: Math.round(baseHp * type.hpMul),
1001
+ maxHp: Math.round(baseHp * type.hpMul),
1002
+ speed: (72 + Math.random() * 22) * type.speed,
1003
+ vision: (scene === "surface" ? 210 : 185) * type.vision,
1004
+ damage: 4 + Math.floor(stageBoost / 5) + type.damage,
1005
+ attackCooldown: 0,
1006
+ wander: Math.random() * Math.PI * 2,
1007
+ reward: Math.round((10 + Math.floor(stageBoost / 2)) * type.reward),
1008
+ color: type.color,
1009
+ eye: type.eye,
1010
+ name: type.name,
1011
+ potionDropRate:
1012
+ type.name === "重甲魔物"
1013
+ ? 0.8
1014
+ : type.name === "野狼"
1015
+ ? 0.45
1016
+ : 0.22,
1017
+ });
1018
+ return;
1019
+ }
1020
+ }
1021
+
1022
+ function dropPotion(x, y, enemy) {
1023
+ if (Math.random() > enemy.potionDropRate) return;
1024
+ groundLoots.push({
1025
+ x,
1026
+ y,
1027
+ type: "potion",
1028
+ heal:
1029
+ enemy.name === "重甲魔物" ? 34 : enemy.name === "野狼" ? 24 : 18,
1030
+ color: "#ef4444",
1031
+ glow: Math.random() * Math.PI * 2,
1032
+ });
1033
+ popups.push({
1034
+ x,
1035
+ y: y - 18,
1036
+ text: "掉了血药!",
1037
+ color: "#fecaca",
1038
+ life: 1.0,
1039
+ });
1040
+ }
1041
+
1042
+ function tryAttack() {
1043
+ if (attackCooldown > 0 || player.hp <= 0) return;
1044
+ attackCooldown = 0.2;
1045
+ player.attackArc = 0.24;
1046
+ let hit = false;
1047
+ for (let i = enemies.length - 1; i >= 0; i--) {
1048
+ const enemy = enemies[i];
1049
+ const dx = enemy.x - player.x;
1050
+ const dy = enemy.y - player.y;
1051
+ const dist = Math.hypot(dx, dy);
1052
+ const dirDot =
1053
+ dist === 0
1054
+ ? 1
1055
+ : (dx / dist) * player.dirX + (dy / dist) * player.dirY;
1056
+ if (dist < 88 && dirDot > -0.55) {
1057
+ hit = true;
1058
+ const comboBonus = Math.min(18, combo * 0.8);
1059
+ const damage = Math.round(player.attack + comboBonus);
1060
+ enemy.hp -= damage;
1061
+ popups.push({
1062
+ x: enemy.x,
1063
+ y: enemy.y - 12,
1064
+ text: `-${damage}`,
1065
+ color: "#fca5a5",
1066
+ life: 0.9,
1067
+ });
1068
+ burstParticles(enemy.x, enemy.y, "#ef4444", 10);
1069
+ if (enemy.hp <= 0) {
1070
+ enemies.splice(i, 1);
1071
+ gold += enemy.reward;
1072
+ saveData.stats.totalGoldCollected += enemy.reward;
1073
+ combo += 1;
1074
+ comboTimer = 2.8;
1075
+ player.hp = Math.min(
1076
+ player.maxHp,
1077
+ player.hp + player.lifeSteal + Math.floor(combo / 4),
1078
+ );
1079
+ popups.push({
1080
+ x: enemy.x,
1081
+ y: enemy.y - 24,
1082
+ text: `+${enemy.reward} 金币`,
1083
+ color: "#f6c453",
1084
+ life: 1.0,
1085
+ });
1086
+ popups.push({
1087
+ x: enemy.x,
1088
+ y: enemy.y - 42,
1089
+ text: `${combo} 连斩!`,
1090
+ color: "#fde68a",
1091
+ life: 1.0,
1092
+ });
1093
+ burstParticles(enemy.x, enemy.y, "#f6c453", 16);
1094
+ dropPotion(enemy.x, enemy.y, enemy);
1095
+ }
1096
+ }
1097
+ }
1098
+ setMessage(hit ? "你挥剑击中了敌人!" : "你挥了一下剑。");
1099
+ updateStats();
1100
+ }
1101
+
1102
+ function tryUseStairsOrEntrance() {
1103
+ const tileX = Math.floor(player.x / tileSize);
1104
+ const tileY = Math.floor(player.y / tileSize);
1105
+
1106
+ if (
1107
+ scene === "surface" &&
1108
+ world.entrance &&
1109
+ tileX === world.entrance.x &&
1110
+ tileY === world.entrance.y
1111
+ ) {
1112
+ currentFloor = 1;
1113
+ scene = "dungeon";
1114
+ generateDungeonFloor(currentFloor, "down");
1115
+ clearFloorEntities();
1116
+ player.x = world.stairsUp.x * tileSize + tileSize / 2;
1117
+ player.y = world.stairsUp.y * tileSize + tileSize / 2;
1118
+ setMessage("你进入了地下城 1 层,去寻找向下的楼梯吧。");
1119
+ updateStats();
1120
+ return true;
1121
+ }
1122
+
1123
+ if (
1124
+ scene === "dungeon" &&
1125
+ world.stairsDown &&
1126
+ tileX === world.stairsDown.x &&
1127
+ tileY === world.stairsDown.y
1128
+ ) {
1129
+ currentFloor += 1;
1130
+ generateDungeonFloor(currentFloor, "down");
1131
+ clearFloorEntities();
1132
+ player.x = world.stairsUp.x * tileSize + tileSize / 2;
1133
+ player.y = world.stairsUp.y * tileSize + tileSize / 2;
1134
+ setMessage(`你进入了地下城 ${currentFloor} 层,这一层又变了。`);
1135
+ updateStats();
1136
+ return true;
1137
+ }
1138
+
1139
+ if (
1140
+ scene === "dungeon" &&
1141
+ world.stairsUp &&
1142
+ tileX === world.stairsUp.x &&
1143
+ tileY === world.stairsUp.y
1144
+ ) {
1145
+ if (world.stairsUp.exitToSurface) {
1146
+ scene = "surface";
1147
+ currentFloor = 0;
1148
+ generateSurface();
1149
+ clearFloorEntities();
1150
+ player.x = world.entrance.x * tileSize + tileSize / 2;
1151
+ player.y = (world.entrance.y - 1) * tileSize + tileSize / 2;
1152
+ setMessage("你回到了地表森林。");
1153
+ updateStats();
1154
+ return true;
1155
+ }
1156
+ currentFloor = Math.max(1, currentFloor - 1);
1157
+ generateDungeonFloor(currentFloor, "up");
1158
+ clearFloorEntities();
1159
+ player.x = world.stairsDown.x * tileSize + tileSize / 2;
1160
+ player.y = world.stairsDown.y * tileSize + tileSize / 2;
1161
+ setMessage(`你回到了地下城 ${currentFloor} 层。`);
1162
+ updateStats();
1163
+ return true;
1164
+ }
1165
+ return false;
1166
+ }
1167
+
1168
+ function drawTile(x, y, type) {
1169
+ const px = x * tileSize;
1170
+ const py = y * tileSize;
1171
+ if (type === "grass" || type === "flower") {
1172
+ const g = ctx.createLinearGradient(px, py, px, py + tileSize);
1173
+ g.addColorStop(0, "#3d8f43");
1174
+ g.addColorStop(1, "#245b2d");
1175
+ ctx.fillStyle = g;
1176
+ ctx.fillRect(px, py, tileSize, tileSize);
1177
+ ctx.fillStyle = "rgba(255,255,255,0.06)";
1178
+ for (let i = 0; i < 4; i++)
1179
+ ctx.fillRect(px + 6 + i * 10, py + 8 + (i % 2) * 10, 2, 10);
1180
+ if (type === "flower") {
1181
+ ctx.fillStyle = "#f472b6";
1182
+ ctx.beginPath();
1183
+ ctx.arc(px + 22, py + 20, 4, 0, Math.PI * 2);
1184
+ ctx.fill();
1185
+ ctx.fillStyle = "#fde68a";
1186
+ ctx.beginPath();
1187
+ ctx.arc(px + 26, py + 28, 4, 0, Math.PI * 2);
1188
+ ctx.fill();
1189
+ }
1190
+ } else if (type === "path") {
1191
+ const g = ctx.createLinearGradient(px, py, px, py + tileSize);
1192
+ g.addColorStop(0, "#8e8a7a");
1193
+ g.addColorStop(1, "#666253");
1194
+ ctx.fillStyle = g;
1195
+ ctx.fillRect(px, py, tileSize, tileSize);
1196
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
1197
+ ctx.fillRect(px + 4, py + 5, tileSize - 10, 9);
1198
+ ctx.fillRect(px + 10, py + 23, tileSize - 18, 8);
1199
+ } else if (type === "tree") {
1200
+ ctx.fillStyle = "#234a2b";
1201
+ ctx.fillRect(px, py, tileSize, tileSize);
1202
+ ctx.fillStyle = "#6b4f2b";
1203
+ ctx.fillRect(px + 20, py + 26, 8, 16);
1204
+ ctx.fillStyle = "#2f8f46";
1205
+ ctx.beginPath();
1206
+ ctx.arc(px + 24, py + 18, 16, 0, Math.PI * 2);
1207
+ ctx.fill();
1208
+ ctx.beginPath();
1209
+ ctx.arc(px + 14, py + 22, 10, 0, Math.PI * 2);
1210
+ ctx.fill();
1211
+ ctx.beginPath();
1212
+ ctx.arc(px + 34, py + 22, 10, 0, Math.PI * 2);
1213
+ ctx.fill();
1214
+ } else if (type === "rock") {
1215
+ const g = ctx.createLinearGradient(px, py, px, py + tileSize);
1216
+ g.addColorStop(0, "#3f6d3d");
1217
+ g.addColorStop(1, "#274c2d");
1218
+ ctx.fillStyle = g;
1219
+ ctx.fillRect(px, py, tileSize, tileSize);
1220
+ ctx.fillStyle = "#7a7f87";
1221
+ ctx.beginPath();
1222
+ ctx.moveTo(px + 12, py + 34);
1223
+ ctx.lineTo(px + 18, py + 18);
1224
+ ctx.lineTo(px + 32, py + 14);
1225
+ ctx.lineTo(px + 38, py + 30);
1226
+ ctx.lineTo(px + 28, py + 38);
1227
+ ctx.closePath();
1228
+ ctx.fill();
1229
+ } else if (type === "wall") {
1230
+ const g = ctx.createLinearGradient(px, py, px, py + tileSize);
1231
+ g.addColorStop(0, "#3b3b48");
1232
+ g.addColorStop(1, "#1d1d28");
1233
+ ctx.fillStyle = g;
1234
+ ctx.fillRect(px, py, tileSize, tileSize);
1235
+ ctx.fillStyle = "rgba(255,255,255,0.05)";
1236
+ ctx.fillRect(px + 4, py + 6, tileSize - 8, 6);
1237
+ } else if (type === "floor" || type === "moss" || type === "crystal") {
1238
+ const g = ctx.createLinearGradient(px, py, px, py + tileSize);
1239
+ g.addColorStop(0, "#6b6556");
1240
+ g.addColorStop(1, "#40392f");
1241
+ ctx.fillStyle = g;
1242
+ ctx.fillRect(px, py, tileSize, tileSize);
1243
+ ctx.fillStyle = "rgba(255,255,255,0.06)";
1244
+ ctx.fillRect(px + 6, py + 8, 14, 8);
1245
+ ctx.fillRect(px + 24, py + 22, 12, 8);
1246
+ if (type === "moss") {
1247
+ ctx.fillStyle = "#3f7f4b";
1248
+ ctx.fillRect(px + 12, py + 28, 14, 7);
1249
+ }
1250
+ if (type === "crystal") {
1251
+ ctx.fillStyle = "#7dd3fc";
1252
+ ctx.beginPath();
1253
+ ctx.moveTo(px + 24, py + 12);
1254
+ ctx.lineTo(px + 30, py + 26);
1255
+ ctx.lineTo(px + 24, py + 34);
1256
+ ctx.lineTo(px + 18, py + 26);
1257
+ ctx.closePath();
1258
+ ctx.fill();
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ function drawChest(chest, time) {
1264
+ const px = chest.x * tileSize;
1265
+ const py = chest.y * tileSize;
1266
+ const glow = 0.5 + Math.sin(time * 0.004 + chest.glow) * 0.5;
1267
+ if (!chest.opened) {
1268
+ ctx.fillStyle = `rgba(246, 196, 83, ${0.1 + glow * 0.12})`;
1269
+ ctx.beginPath();
1270
+ ctx.arc(px + 24, py + 24, 24 + glow * 5, 0, Math.PI * 2);
1271
+ ctx.fill();
1272
+ }
1273
+ ctx.fillStyle = chest.opened ? "#8b6b3e" : "#9a6a32";
1274
+ ctx.fillRect(px + 10, py + 20, 28, 18);
1275
+ ctx.fillStyle = chest.opened ? "#a98c58" : "#c79b3a";
1276
+ ctx.fillRect(px + 10, py + 16, 28, 8);
1277
+ ctx.fillStyle = "#f7d36a";
1278
+ ctx.fillRect(px + 22, py + 20, 4, 10);
1279
+ if (chest.opened) {
1280
+ ctx.strokeStyle = "#d6b15a";
1281
+ ctx.lineWidth = 3;
1282
+ ctx.beginPath();
1283
+ ctx.moveTo(px + 10, py + 17);
1284
+ ctx.lineTo(px + 4, py + 10);
1285
+ ctx.stroke();
1286
+ }
1287
+ }
1288
+
1289
+ function drawStairs(stairs, kind, time) {
1290
+ if (!stairs) return;
1291
+ const px = stairs.x * tileSize;
1292
+ const py = stairs.y * tileSize;
1293
+ const glow = 0.5 + Math.sin(time * 0.004 + stairs.glow) * 0.5;
1294
+ const color = kind === "down" ? "#60a5fa" : "#c084fc";
1295
+ ctx.fillStyle = `rgba(${kind === "down" ? "96,165,250" : "192,132,252"}, ${0.16 + glow * 0.12})`;
1296
+ ctx.beginPath();
1297
+ ctx.arc(px + 24, py + 24, 20 + glow * 4, 0, Math.PI * 2);
1298
+ ctx.fill();
1299
+ ctx.fillStyle = "#5b5347";
1300
+ ctx.fillRect(px + 8, py + 12, 32, 24);
1301
+ ctx.fillStyle = color;
1302
+ for (let i = 0; i < 4; i++)
1303
+ ctx.fillRect(px + 12, py + 14 + i * 5, 24, 2);
1304
+ ctx.fillStyle = "#eef2ff";
1305
+ ctx.font = "bold 18px sans-serif";
1306
+ ctx.fillText(kind === "down" ? "↓" : "↑", px + 18, py + 31);
1307
+ }
1308
+
1309
+ function drawEntrance(time) {
1310
+ if (!world.entrance) return;
1311
+ const px = world.entrance.x * tileSize;
1312
+ const py = world.entrance.y * tileSize;
1313
+ const glow = 0.5 + Math.sin(time * 0.004 + world.entrance.glow) * 0.5;
1314
+ ctx.fillStyle = `rgba(99, 102, 241, ${0.15 + glow * 0.16})`;
1315
+ ctx.beginPath();
1316
+ ctx.arc(px + 24, py + 24, 22 + glow * 6, 0, Math.PI * 2);
1317
+ ctx.fill();
1318
+ ctx.fillStyle = "#312e81";
1319
+ ctx.fillRect(px + 10, py + 8, 28, 30);
1320
+ ctx.fillStyle = "#818cf8";
1321
+ ctx.fillRect(px + 15, py + 12, 18, 20);
1322
+ ctx.fillStyle = "#eef2ff";
1323
+ ctx.font = "bold 18px sans-serif";
1324
+ ctx.fillText("门", px + 15, py + 31);
1325
+ }
1326
+
1327
+ function drawPlayer() {
1328
+ const x = player.x;
1329
+ const y = player.y;
1330
+ ctx.fillStyle = "rgba(0,0,0,0.25)";
1331
+ ctx.beginPath();
1332
+ ctx.ellipse(x, y + 16, 13, 7, 0, 0, Math.PI * 2);
1333
+ ctx.fill();
1334
+ ctx.fillStyle = hurtCooldown > 0 ? "#f87171" : "#3b82f6";
1335
+ ctx.fillRect(x - 9, y - 8, 18, 20);
1336
+ ctx.fillStyle = "#dbeafe";
1337
+ ctx.fillRect(x - 6, y - 14, 12, 10);
1338
+ ctx.fillStyle = "#f0c9a4";
1339
+ ctx.beginPath();
1340
+ ctx.arc(x, y - 16, 8, 0, Math.PI * 2);
1341
+ ctx.fill();
1342
+ ctx.fillStyle = "#f6c453";
1343
+ ctx.fillRect(x - 8, y - 24, 16, 5);
1344
+ ctx.fillStyle = "#1f2937";
1345
+ ctx.fillRect(x - 11, y + 12, 7, 12);
1346
+ ctx.fillRect(x + 4, y + 12, 7, 12);
1347
+ ctx.fillStyle = "#93c5fd";
1348
+ ctx.fillRect(x + 10, y - 5, 4, 16);
1349
+ if (player.attackArc > 0) {
1350
+ ctx.strokeStyle = "#fde68a";
1351
+ ctx.lineWidth = 5;
1352
+ ctx.beginPath();
1353
+ ctx.arc(
1354
+ x + player.dirX * 20,
1355
+ y + player.dirY * 10,
1356
+ 26,
1357
+ -0.8 + Math.atan2(player.dirY, player.dirX),
1358
+ 0.8 + Math.atan2(player.dirY, player.dirX),
1359
+ );
1360
+ ctx.stroke();
1361
+ }
1362
+ }
1363
+
1364
+ function drawEnemy(enemy) {
1365
+ ctx.fillStyle = "rgba(0,0,0,0.22)";
1366
+ ctx.beginPath();
1367
+ ctx.ellipse(enemy.x, enemy.y + 12, 12, 6, 0, 0, Math.PI * 2);
1368
+ ctx.fill();
1369
+ ctx.fillStyle = enemy.color;
1370
+ ctx.beginPath();
1371
+ ctx.arc(
1372
+ enemy.x,
1373
+ enemy.y,
1374
+ enemy.name === "重甲魔物" ? 17 : enemy.name === "野狼" ? 13 : 14,
1375
+ 0,
1376
+ Math.PI * 2,
1377
+ );
1378
+ ctx.fill();
1379
+ ctx.fillStyle = enemy.eye;
1380
+ ctx.beginPath();
1381
+ ctx.arc(enemy.x - 4, enemy.y - 2, 2.3, 0, Math.PI * 2);
1382
+ ctx.fill();
1383
+ ctx.beginPath();
1384
+ ctx.arc(enemy.x + 4, enemy.y - 2, 2.3, 0, Math.PI * 2);
1385
+ ctx.fill();
1386
+ if (enemy.name === "重甲魔物") {
1387
+ ctx.strokeStyle = "#fff7ed";
1388
+ ctx.lineWidth = 2;
1389
+ ctx.strokeRect(enemy.x - 10, enemy.y - 10, 20, 20);
1390
+ }
1391
+ ctx.fillStyle = "#1f2937";
1392
+ ctx.fillRect(enemy.x - 10, enemy.y - 22, 20, 4);
1393
+ ctx.fillStyle = "#ef4444";
1394
+ ctx.fillRect(
1395
+ enemy.x - 10,
1396
+ enemy.y - 22,
1397
+ Math.max(0, 20 * (enemy.hp / enemy.maxHp)),
1398
+ 4,
1399
+ );
1400
+ }
1401
+
1402
+ function drawGroundLoot(loot, time) {
1403
+ const bob = Math.sin(time * 0.005 + loot.glow) * 4;
1404
+ ctx.fillStyle = "rgba(239,68,68,0.18)";
1405
+ ctx.beginPath();
1406
+ ctx.arc(loot.x, loot.y, 16, 0, Math.PI * 2);
1407
+ ctx.fill();
1408
+ ctx.fillStyle = "#ef4444";
1409
+ ctx.fillRect(loot.x - 7, loot.y - 10 + bob, 14, 20);
1410
+ ctx.fillStyle = "#fee2e2";
1411
+ ctx.fillRect(loot.x - 3, loot.y - 6 + bob, 6, 12);
1412
+ ctx.fillStyle = "#fff1f2";
1413
+ ctx.fillRect(loot.x - 1, loot.y - 2 + bob, 2, 4);
1414
+ }
1415
+
1416
+ function drawFog(camera) {
1417
+ const radius = scene === "surface" ? 280 : 220;
1418
+ const px = player.x - camera.x;
1419
+ const py = player.y - camera.y;
1420
+ const grad = ctx.createRadialGradient(px, py, 70, px, py, radius);
1421
+ grad.addColorStop(0, "rgba(0,0,0,0)");
1422
+ grad.addColorStop(0.55, "rgba(0,0,0,0.18)");
1423
+ grad.addColorStop(
1424
+ 1,
1425
+ scene === "surface" ? "rgba(3, 6, 15, 0.74)" : "rgba(2, 4, 12, 0.82)",
1426
+ );
1427
+ ctx.fillStyle = grad;
1428
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1429
+ }
1430
+
1431
+ function update(dt) {
1432
+ if (isPaused) return;
1433
+ gameTime += dt;
1434
+ if (player.hp <= 0) return;
1435
+
1436
+ let dx = 0;
1437
+ let dy = 0;
1438
+ if (keys.has("ArrowLeft") || keys.has("a") || keys.has("A")) dx -= 1;
1439
+ if (keys.has("ArrowRight") || keys.has("d") || keys.has("D")) dx += 1;
1440
+ if (keys.has("ArrowUp") || keys.has("w") || keys.has("W")) dy -= 1;
1441
+ if (keys.has("ArrowDown") || keys.has("s") || keys.has("S")) dy += 1;
1442
+
1443
+ if (dx || dy) {
1444
+ const len = Math.hypot(dx, dy) || 1;
1445
+ dx /= len;
1446
+ dy /= len;
1447
+ player.dirX = dx;
1448
+ player.dirY = dy;
1449
+ const nextX = player.x + dx * player.speed * dt;
1450
+ const nextY = player.y + dy * player.speed * dt;
1451
+ const tileX = Math.floor(nextX / tileSize);
1452
+ const tileY = Math.floor(player.y / tileSize);
1453
+ if (!isBlocked(tileX, tileY)) player.x = nextX;
1454
+ const tileX2 = Math.floor(player.x / tileSize);
1455
+ const tileY2 = Math.floor(nextY / tileSize);
1456
+ if (!isBlocked(tileX2, tileY2)) player.y = nextY;
1457
+ }
1458
+
1459
+ spawnTimer += dt;
1460
+ const spawnInterval =
1461
+ scene === "surface"
1462
+ ? gameTime < 40
1463
+ ? 1.6
1464
+ : gameTime < 90
1465
+ ? 1.3
1466
+ : 1.1
1467
+ : gameTime < 40
1468
+ ? 1.3
1469
+ : gameTime < 90
1470
+ ? 1.0
1471
+ : 0.85;
1472
+ if (spawnTimer > spawnInterval) {
1473
+ spawnTimer = 0;
1474
+ spawnEnemy();
1475
+ }
1476
+
1477
+ if (attackCooldown > 0) attackCooldown -= dt;
1478
+ if (hurtCooldown > 0) hurtCooldown -= dt;
1479
+ if (interactionLock > 0) interactionLock -= dt;
1480
+ if (player.attackArc > 0) player.attackArc -= dt;
1481
+ if (comboTimer > 0) {
1482
+ comboTimer -= dt;
1483
+ if (comboTimer <= 0) combo = 0;
1484
+ }
1485
+
1486
+ for (let i = groundLoots.length - 1; i >= 0; i--) {
1487
+ const loot = groundLoots[i];
1488
+ loot.glow += dt * 6;
1489
+ if (Math.hypot(loot.x - player.x, loot.y - player.y) < 28) {
1490
+ player.hp = Math.min(player.maxHp, player.hp + loot.heal);
1491
+ popups.push({
1492
+ x: loot.x,
1493
+ y: loot.y - 16,
1494
+ text: `+${loot.heal} 生命`,
1495
+ color: "#fecaca",
1496
+ life: 1.0,
1497
+ });
1498
+ burstParticles(loot.x, loot.y, "#ef4444", 12);
1499
+ groundLoots.splice(i, 1);
1500
+ setMessage("你捡到了补血药!");
1501
+ updateStats();
1502
+ saveGame();
1503
+ }
1504
+ }
1505
+
1506
+ for (const enemy of enemies) {
1507
+ const dxToPlayer = player.x - enemy.x;
1508
+ const dyToPlayer = player.y - enemy.y;
1509
+ const dist = Math.hypot(dxToPlayer, dyToPlayer);
1510
+ enemy.attackCooldown -= dt;
1511
+ let moveX = 0;
1512
+ let moveY = 0;
1513
+ if (dist < enemy.vision) {
1514
+ moveX = dxToPlayer / (dist || 1);
1515
+ moveY = dyToPlayer / (dist || 1);
1516
+ } else {
1517
+ enemy.wander += dt;
1518
+ moveX = Math.cos(enemy.wander) * 0.35;
1519
+ moveY = Math.sin(enemy.wander * 0.8) * 0.35;
1520
+ }
1521
+ const nextX = enemy.x + moveX * enemy.speed * dt;
1522
+ const nextY = enemy.y + moveY * enemy.speed * dt;
1523
+ const tileX = Math.floor(nextX / tileSize);
1524
+ const tileY = Math.floor(enemy.y / tileSize);
1525
+ if (!isBlocked(tileX, tileY)) enemy.x = nextX;
1526
+ const tileX2 = Math.floor(enemy.x / tileSize);
1527
+ const tileY2 = Math.floor(nextY / tileSize);
1528
+ if (!isBlocked(tileX2, tileY2)) enemy.y = nextY;
1529
+
1530
+ if (dist < 40 && enemy.attackCooldown <= 0) {
1531
+ enemy.attackCooldown = 1.7;
1532
+ player.hp -= enemy.damage;
1533
+ hurtCooldown = 0.25;
1534
+ popups.push({
1535
+ x: player.x,
1536
+ y: player.y - 20,
1537
+ text: `-${enemy.damage}`,
1538
+ color: "#fb7185",
1539
+ life: 0.8,
1540
+ });
1541
+ burstParticles(player.x, player.y, "#fb7185", 8);
1542
+ combo = 0;
1543
+ comboTimer = 0;
1544
+ if (player.hp <= 0) {
1545
+ player.hp = 0;
1546
+ setMessage("你被敌人击倒了,刷新页面可以重新开始。");
1547
+ } else {
1548
+ setMessage("敌人打中了你,小心一点!");
1549
+ }
1550
+ updateStats();
1551
+ saveGame();
1552
+ }
1553
+ }
1554
+
1555
+ for (let i = particles.length - 1; i >= 0; i--) {
1556
+ const p = particles[i];
1557
+ p.life -= dt;
1558
+ p.x += p.vx * dt;
1559
+ p.y += p.vy * dt;
1560
+ p.vy += 180 * dt;
1561
+ if (p.life <= 0) particles.splice(i, 1);
1562
+ }
1563
+
1564
+ for (let i = popups.length - 1; i >= 0; i--) {
1565
+ const p = popups[i];
1566
+ p.life -= dt;
1567
+ p.y -= 24 * dt;
1568
+ if (p.life <= 0) popups.splice(i, 1);
1569
+ }
1570
+
1571
+ if (messageTimer > 0) {
1572
+ messageTimer -= dt;
1573
+ if (messageTimer <= 0) lootLogEl.textContent = currentMessage;
1574
+ }
1575
+ }
1576
+
1577
+ function render(time) {
1578
+ const camera = getCamera();
1579
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1580
+ ctx.save();
1581
+ ctx.translate(-camera.x, -camera.y);
1582
+ for (let y = 0; y < world.rows; y++) {
1583
+ for (let x = 0; x < world.cols; x++) drawTile(x, y, world.map[y][x]);
1584
+ }
1585
+
1586
+ ctx.fillStyle = "rgba(255,255,255,0.07)";
1587
+ for (let x = 0; x <= world.cols; x++)
1588
+ ctx.fillRect(x * tileSize, 0, 1, world.rows * tileSize);
1589
+ for (let y = 0; y <= world.rows; y++)
1590
+ ctx.fillRect(0, y * tileSize, world.cols * tileSize, 1);
1591
+
1592
+ for (const chest of world.chests) drawChest(chest, time);
1593
+ drawEntrance(time);
1594
+ drawStairs(world.stairsUp, "up", time);
1595
+ drawStairs(world.stairsDown, "down", time);
1596
+ for (const loot of groundLoots) drawGroundLoot(loot, time);
1597
+ for (const enemy of enemies) drawEnemy(enemy);
1598
+ drawPlayer();
1599
+ ctx.restore();
1600
+
1601
+ for (const p of particles) {
1602
+ ctx.globalAlpha = Math.max(p.life, 0);
1603
+ ctx.fillStyle = p.color;
1604
+ ctx.fillRect(p.x, p.y, p.size, p.size);
1605
+ }
1606
+ ctx.globalAlpha = 1;
1607
+
1608
+ for (const popup of popups) {
1609
+ ctx.globalAlpha = Math.max(popup.life / 1.6, 0);
1610
+ ctx.fillStyle = popup.color;
1611
+ ctx.font = "bold 20px sans-serif";
1612
+ ctx.fillText(popup.text, popup.x - 18, popup.y);
1613
+ }
1614
+ ctx.globalAlpha = 1;
1615
+
1616
+ drawFog(camera);
1617
+
1618
+ const tileX = Math.floor(player.x / tileSize);
1619
+ const tileY = Math.floor(player.y / tileSize);
1620
+ const nearChest = world.chests.find(
1621
+ (c) =>
1622
+ !c.opened && Math.abs(c.x - tileX) + Math.abs(c.y - tileY) <= 1,
1623
+ );
1624
+ const onEntrance =
1625
+ world.entrance &&
1626
+ tileX === world.entrance.x &&
1627
+ tileY === world.entrance.y;
1628
+ const onStairsUp =
1629
+ world.stairsUp &&
1630
+ tileX === world.stairsUp.x &&
1631
+ tileY === world.stairsUp.y;
1632
+ const onStairsDown =
1633
+ world.stairsDown &&
1634
+ tileX === world.stairsDown.x &&
1635
+ tileY === world.stairsDown.y;
1636
+
1637
+ if (nearChest || onEntrance || onStairsUp || onStairsDown) {
1638
+ ctx.fillStyle = "rgba(17,24,39,0.75)";
1639
+ ctx.fillRect(300, 18, 360, 36);
1640
+ ctx.fillStyle = "#fff1b5";
1641
+ ctx.font = "bold 18px sans-serif";
1642
+ let text = "按 E 或 空格 互动";
1643
+ if (nearChest) text = "按 E 或 空格 打开宝箱";
1644
+ if (onEntrance) text = "按 E 或 空格 进入地下城";
1645
+ if (onStairsDown) text = "按 E 或 空格 前往下一层";
1646
+ if (onStairsUp)
1647
+ text = world.stairsUp.exitToSurface
1648
+ ? "按 E 或 空格 返回地表"
1649
+ : "按 E 或 空格 返回上一层";
1650
+ ctx.fillText(text, 332, 42);
1651
+ }
1652
+
1653
+ if (player.hp <= 0) {
1654
+ ctx.fillStyle = "rgba(0,0,0,0.55)";
1655
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1656
+ ctx.fillStyle = "#fff1b5";
1657
+ ctx.font = "bold 38px sans-serif";
1658
+ ctx.fillText("你倒下了", 390, 320);
1659
+ ctx.font = "20px sans-serif";
1660
+ ctx.fillText("刷新页面可以重新开始", 365, 360);
1661
+ }
1662
+ }
1663
+
1664
+ function loop(now) {
1665
+ const dt = Math.min((now - loop.last) / 1000, 0.033);
1666
+ loop.last = now;
1667
+ update(dt);
1668
+ render(now);
1669
+ requestAnimationFrame(loop);
1670
+ }
1671
+ loop.last = performance.now();
1672
+
1673
+ function initGameWorld() {
1674
+ loadGame();
1675
+ if (scene === "dungeon" && currentFloor > 0) {
1676
+ generateDungeonFloor(currentFloor, "down");
1677
+ player.x = world.stairsUp.x * tileSize + tileSize / 2;
1678
+ player.y = world.stairsUp.y * tileSize + tileSize / 2;
1679
+ } else {
1680
+ scene = "surface";
1681
+ currentFloor = 0;
1682
+ generateSurface();
1683
+ player.x = tileSize * 3.5;
1684
+ player.y = tileSize * 3.5;
1685
+ }
1686
+ renderInventory();
1687
+ renderCardInventory();
1688
+ updateStats();
1689
+ saveGame();
1690
+ }
1691
+
1692
+ function startGame() {
1693
+ menuOverlayEl.classList.add("hidden");
1694
+ introVideoEl.pause();
1695
+ requestAnimationFrame(loop);
1696
+ }
1697
+
1698
+ showHelpBtn.addEventListener("click", () => {
1699
+ const visible = menuHelpEl.style.display === "block";
1700
+ menuHelpEl.style.display = visible ? "none" : "block";
1701
+ showHelpBtn.textContent = visible ? "游戏说明" : "收起说明";
1702
+ });
1703
+
1704
+ startGameBtn.addEventListener("click", startGame);
1705
+
1706
+ viewCodexBtn.addEventListener("click", () => {
1707
+ saveGame();
1708
+ setPaused(true);
1709
+ setMessage("正在打开图签页面...");
1710
+ const codexWin = window.open(
1711
+ "../remix/万物图签:我的AI卡牌宇宙_1508/index.html",
1712
+ "_blank",
1713
+ );
1714
+ if (!codexWin) {
1715
+ setMessage("图签没有打开,可能被浏览器拦截了弹窗。");
1716
+ setPaused(false);
1717
+ return;
1718
+ }
1719
+ codexWin.focus();
1720
+ setMessage("图签已打开,返回游戏后按任意键继续。");
1721
+ const watcher = setInterval(() => {
1722
+ if (codexWin.closed) {
1723
+ clearInterval(watcher);
1724
+ setPaused(false);
1725
+ }
1726
+ }, 500);
1727
+ });
1728
+
1729
+ window.addEventListener("keydown", (e) => {
1730
+ if (!menuOverlayEl.classList.contains("hidden")) {
1731
+ if (e.key === "Enter") startGame();
1732
+ return;
1733
+ }
1734
+ if (isPaused) {
1735
+ e.preventDefault();
1736
+ setPaused(false);
1737
+ return;
1738
+ }
1739
+ keys.add(e.key);
1740
+ if (e.key === " " || e.key === "e" || e.key === "E") {
1741
+ e.preventDefault();
1742
+ if (interactionLock > 0) return;
1743
+ interactionLock = 0.2;
1744
+ if (!tryUseStairsOrEntrance()) tryOpenChest();
1745
+ }
1746
+ if (e.key === "j" || e.key === "J") {
1747
+ e.preventDefault();
1748
+ tryAttack();
1749
+ }
1750
+ });
1751
+ window.addEventListener("keyup", (e) => keys.delete(e.key));
1752
+
1753
+ initGameWorld();
1754
+ </script>
1755
+ </body>
1756
+ </html>