Files
game/micro_demo.html
T
李鹏宇 5184fe260f feat: 微观世界建造系统完善 + 资源清理
建造系统:
- 新增「经营」页签,农田/牧场归入经营分类
- 建筑默认左向(_l.png)与素材方向统一,图标/预览/落地三处一致
- 建筑底宽对齐占地格边缘
- 修复:中键平移、建造态右键取消选择、登录态2x2被后端覆盖为3x3

资源清理(均为未引用文件):
- 删除 _ 临时脚本(_c/_check_inline/_chk/_del_assets/_mw_check/_proc_fence)
- 删除原始生成稿(_raw/、backgrounds/samples/、preview_clean/、设计说明图)
- 删除 default/ 下冗余待审稿(farmland_20260806/well_20260806/animals_3x3/style_reference)
- 删除 spring/_preview 预览图、assets/1.png、Keep_the_vast 草图
2026-08-06 14:44:26 +08:00

879 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>微观世界 Demo · 宇森 Yusen</title>
<link rel="stylesheet" href="assets/fonts/yusen-fonts.css" />
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;width:100%;overflow:hidden;background:#0a0e17;
font-family:"Noto Sans SC","Press Start 2P",system-ui,sans-serif;color:#e9edf6}
#game{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;
background:radial-gradient(ellipse at center, #0e1530 0%, #060912 100%)}
canvas{image-rendering:pixelated;image-rendering:crisp-edges;
box-shadow:0 0 40px rgba(139,92,246,.25), 0 0 0 1px rgba(139,92,246,.15);
cursor:crosshair;}
/* HUD */
.hud{position:absolute;top:14px;left:14px;display:flex;flex-direction:column;gap:8px;pointer-events:none;z-index:5}
.hud .panel{background:rgba(8,12,26,.78);border:1px solid rgba(139,92,246,.25);border-radius:10px;
padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px;color:#cdd8e6;
backdrop-filter:blur(6px);box-shadow:0 4px 14px rgba(0,0,0,.4)}
.hud .gold{color:#e7b85c;font-weight:600}
.hud .hp-bar{width:120px;height:8px;background:rgba(255,255,255,.08);border-radius:5px;overflow:hidden;position:relative}
.hud .hp-fill{height:100%;background:linear-gradient(90deg,#ef4444,#fbbf24);transition:width .3s;border-radius:5px}
.hud .hp-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;font-weight:600}
/* 右上时间 */
.clock{position:absolute;top:14px;right:14px;background:rgba(8,12,26,.78);border:1px solid rgba(139,92,246,.25);
border-radius:10px;padding:8px 14px;font-size:13px;color:#9aa6c4;backdrop-filter:blur(6px);z-index:5;pointer-events:none}
/* 控制提示 */
.controls{position:absolute;bottom:14px;left:14px;background:rgba(8,12,26,.78);border:1px solid rgba(139,92,246,.25);
border-radius:10px;padding:10px 14px;font-size:11px;color:#9aa6c4;z-index:5;
backdrop-filter:blur(6px);line-height:1.7;max-width:240px}
.controls kbd{background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.4);border-radius:4px;
padding:1px 6px;font-family:monospace;font-size:11px;color:#c4b5fd;margin:0 2px}
/* 状态栏(行动提示) */
.status-msg{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);
background:rgba(8,12,26,.85);border:1px solid rgba(76,201,240,.4);border-radius:20px;
padding:8px 20px;font-size:13px;color:#4cc9f0;backdrop-filter:blur(6px);
box-shadow:0 4px 18px rgba(0,0,0,.5);z-index:5;transition:opacity .35s;
pointer-events:none}
/* 对话/交易/战斗 Modal */
.modal{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(4,7,15,.55);backdrop-filter:blur(4px);z-index:50;padding:20px}
.modal.hidden{display:none}
.panel-box{background:linear-gradient(135deg,#1a1f3a,#0e1328);border:1px solid rgba(139,92,246,.4);
border-radius:16px;padding:24px 28px;max-width:560px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.6),
0 0 30px rgba(139,92,246,.2);position:relative}
.panel-title{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:26px;color:#fff;
letter-spacing:3px;margin-bottom:6px;text-shadow:0 0 16px rgba(139,92,246,.5)}
.panel-sub{font-size:13px;color:var(--cyan);letter-spacing:1px;margin-bottom:18px;
font-family:"Press Start 2P",monospace}
.panel-close{position:absolute;top:14px;right:18px;width:30px;height:30px;border-radius:50%;
background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#cdd8e6;
cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:.2s}
.panel-close:hover{background:rgba(239,68,68,.2);border-color:rgba(239,68,68,.4);color:#fff}
/* 对话 */
.dialog-text{font-size:15px;color:#d4dce8;line-height:1.85;margin-bottom:18px;
min-height:80px;padding:14px 16px;background:rgba(255,255,255,.03);
border-radius:10px;border-left:3px solid var(--violet,#8b5cf6)}
.dialog-choices{display:flex;flex-direction:column;gap:8px}
.dialog-choices button{background:rgba(255,255,255,.05);border:1px solid rgba(139,92,246,.3);
color:#cdd8e6;padding:10px 14px;border-radius:8px;font-size:14px;cursor:pointer;
text-align:left;transition:.18s;font-family:inherit}
.dialog-choices button:hover{background:rgba(139,92,246,.2);border-color:var(--violet,#8b5cf6);color:#fff}
.dialog-choices button::before{content:'› ';color:var(--gold,#e7b85c);margin-right:6px;font-weight:700}
/* 商店 */
.shop-list{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:14px}
.shop-item{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:10px;padding:12px;display:flex;justify-content:space-between;align-items:center;
gap:10px}
.shop-item .nm{font-size:14px;color:#fff;font-weight:500}
.shop-item .desc{font-size:12px;color:var(--text-dim,#9aa6c4);margin-top:3px}
.shop-item .price{color:var(--gold,#e7b85c);font-weight:600;font-size:14px;
font-family:"Press Start 2P",monospace}
.shop-item button{background:linear-gradient(120deg,var(--violet,#8b5cf6),#c77dff);
border:none;color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;
font-weight:600;transition:.18s;flex:0 0 auto}
.shop-item button:hover{filter:brightness(1.2)}
.shop-item button:disabled{background:rgba(255,255,255,.1);cursor:not-allowed;opacity:.5}
.shop-foot{font-size:13px;color:var(--text-dim,#9aa6c4);text-align:right;margin-top:10px}
/* 战斗 */
.battle{background:linear-gradient(135deg,#2a1015,#160810);border-color:rgba(239,68,68,.4)}
.battle-arena{display:flex;justify-content:space-around;align-items:center;
padding:24px 12px;background:rgba(0,0,0,.3);border-radius:10px;margin-bottom:18px;
min-height:140px;position:relative}
.battle-actor{text-align:center;flex:0 0 auto}
.battle-actor .ic{font-size:54px;margin-bottom:6px;line-height:1}
.battle-actor .nm{font-size:13px;color:#fff;font-weight:600;margin-bottom:4px}
.battle-actor .hp{font-size:11px;color:var(--text-dim,#9aa6c4);
font-family:"Press Start 2P",monospace}
.battle-actor .hp b{color:#ef4444}
.battle-arena .vs{font-size:14px;color:var(--magenta,#c77dff);font-weight:800;
font-family:"Press Start 2P",monospace;letter-spacing:2px}
.battle-log{font-size:13px;color:#e9edf6;background:rgba(0,0,0,.35);padding:10px 14px;
border-radius:8px;margin-bottom:14px;min-height:50px;line-height:1.65;
border-left:3px solid var(--magenta,#c77dff);font-family:monospace}
.battle-actions{display:flex;gap:10px;flex-wrap:wrap}
.battle-actions button{flex:1;padding:12px;border-radius:8px;font-size:14px;cursor:pointer;
border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.04);color:#cdd8e6;
transition:.18s;font-family:inherit}
.battle-actions button:hover{background:rgba(139,92,246,.18);border-color:var(--violet,#8b5cf6);color:#fff}
.battle-actions button.atk:hover{border-color:#ef4444;background:rgba(239,68,68,.15)}
.battle-actions button.flee:hover{border-color:var(--gold,#e7b85c);background:rgba(231,184,92,.15)}
.reward{font-size:14px;color:var(--gold,#e7b85c);text-align:center;padding:12px;
background:rgba(231,184,92,.1);border-radius:8px;margin-bottom:14px}
</style>
</head>
<body>
<div id="game">
<canvas id="world" width="800" height="560"></canvas>
<div class="hud">
<div class="panel">
<span>👤 探险者</span><span>·</span>
<span class="gold">💰 <span id="hudGold">100</span></span>
</div>
<div class="panel">
<span>❤️</span>
<div class="hp-bar"><div class="hp-fill" id="hudHp" style="width:100%"></div>
<div class="hp-text">HP <span id="hudHpNum">100</span>/100</div></div>
</div>
</div>
<div class="clock">⏰ <span id="clockTime">06:00</span> · Day <span id="clockDay">1</span></div>
<div class="controls">
移动:<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / 方向键<br>
互动:<kbd>E</kbd>(靠近后按)<br>
点击:移动 / 互动<br>
<kbd>Esc</kbd> 关闭对话框
</div>
<div class="status-msg" id="statusMsg" style="opacity:0"></div>
<!-- Modal -->
<div class="modal hidden" id="modal">
<div class="panel-box" id="modalBox"></div>
</div>
</div>
<script>
'use strict';
// =====================================================================
// 常量
// =====================================================================
const TILE = 20; // tile 尺寸(px)
const MAP_W = 40; // 地图宽
const MAP_H = 28; // 地图高
const MOVE_SPEED = 80; // px/s
// Tile 类型
const T = {
GRASS: 0, PATH: 1, WATER: 2, TREE: 3,
B_TOWN: 10, B_BLACK: 11, B_SHOP: 12, B_INN: 13,
NPC_FARMER: 20, NPC_SHOP: 21, NPC_GUARD: 22,
MONSTER: 30, ROCK: 40, FLOWER: 41,
};
// 是否可走
const WALKABLE = new Set([T.GRASS, T.PATH, T.FLOWER]);
// =====================================================================
// 地图(手绘 30×40,每行字符串 = 一行 tile)
// 字符 → tile 类型映射
// =====================================================================
const CHAR_MAP = {
'.': T.GRASS, ',': T.FLOWER, '#': T.PATH,
'T': T.TREE, 'W': T.WATER, 'R': T.ROCK,
'B': T.B_TOWN, // 议事厅
'K': T.B_BLACK, // 铁匠铺
'S': T.B_SHOP, // 杂货铺
'I': T.B_INN, // 客栈
'F': T.NPC_FARMER,
'M': T.NPC_SHOP,
'G': T.NPC_GUARD,
'X': T.MONSTER,
'P': T.PATH,
};
const MAP_RAW = [
"TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT",
"T......................................T",
"T..T..T.............,,,...........T....T",
"T..T..T..,,,,,,..TTT..TTT........T.....T",
"T..T..T..,,,,,,..T.T..T.T..,,,,,..T....T",
"T..T..T..,,,,,,..TTT..TTT..,,,,,..T....T",
"T..T..T...........................T....T",
"T..T..T.....T.....KK.............T.....T",
"T..T..T.....T.....KK..BB.........T.....T",
"T..T..T.....T.....KK..BB.........T.....T",
"T..T..T.....T.....KK..BB..SS.....T.....T",
"T..T..T.....T.....KK..BB..SS.....T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T.....T.....KK..BB..SS..II.T.....T",
"T..T..T......................GG..II.T...",
"T..T..T......................GG..II.T...",
"T..T..T.....,,,,,,,,,,,......FF.II.T....",
"T..T..T.....,,,,,,,,,,,..,,,,...II.T....",
"T..T..T.....,,,,,,,,,,,..,,,,...........",
"T..T..T.........................X.......",
"T..T..T..................XXXXXXX........",
"T..T..T.........................X.......",
];
// 构建 map 二维数组
const MAP = [];
for (let y=0; y<MAP_H; y++){
const row = [];
for (let x=0; x<MAP_W; x++){
row.push(CHAR_MAP[MAP_RAW[y][x]] || T.GRASS);
}
MAP.push(row);
}
// =====================================================================
// 玩家
// =====================================================================
const player = {
x: 20 * TILE + TILE/2,
y: 14 * TILE + TILE/2,
speed: MOVE_SPEED,
hp: 100, maxHp: 100,
gold: 100,
facing: 'down', // up/down/left/right
moving: false,
radius: 0,
};
// =====================================================================
// 实体(建筑互动点 / NPC / 怪物)
// =====================================================================
const ENTITIES = [
{ id:'town', type:'building', x:9 * TILE + TILE/2, y:13 * TILE + TILE/2, name:'议事厅',
icon:'🏛️', dialog:'lord', short:'议事厅' },
{ id:'black', type:'building', x:14 * TILE + TILE/2, y:11 * TILE + TILE/2, name:'铁匠铺',
icon:'⚒️', dialog:'black', short:'铁匠铺' },
{ id:'shop', type:'building', x:18 * TILE + TILE/2, y:14 * TILE + TILE/2, name:'杂货铺',
icon:'🏪', dialog:'shop', short:'杂货铺' },
{ id:'inn', type:'building', x:11 * TILE + TILE/2, y:16 * TILE + TILE/2, name:'客栈',
icon:'🏠', dialog:'inn', short:'客栈' },
{ id:'farmer', type:'npc', x:14 * TILE + TILE/2, y:17 * TILE + TILE/2, name:'农夫老李',
icon:'👨‍🌾', dialog:'farmer' },
{ id:'guard', type:'npc', x:15 * TILE + TILE/2, y:19 * TILE + TILE/2, name:'守夜人',
icon:'🗡️', dialog:'guard' },
{ id:'wolf', type:'monster', x:11 * TILE + TILE/2, y:21 * TILE + TILE/2, name:'暗影狼',
icon:'🐺', hp:60, maxHp:60, atk:8, def:2, gold:25 },
];
// =====================================================================
// 渲染:纯 Canvas 像素艺术
// =====================================================================
const canvas = document.getElementById('world');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const COLORS = {
grass1: '#7bb95c', grass2: '#6cb04f', grass3: '#82c45e',
path: '#c9a879', path2: '#b89766',
water: '#3f8ad0', water2: '#4f9ce0',
tree1: '#2f6c34', tree2: '#25632b', treeTrunk: '#6b4a2d',
rock: '#7c7c80', rock2: '#666',
flower: '#ec407a', flower2: '#fdd835',
townWall: '#b08968', townRoof: '#a13c3c',
blackWall: '#5a5a5e', blackRoof: '#3a3a3e',
shopWall: '#d4b06a', shopRoof: '#5c8a3e',
innWall: '#caa472', innRoof: '#7c3a3a',
};
function drawTile(x, y, t){
const px = x*TILE, py = y*TILE;
// 草地底色(随机深浅)
const seed = ((x*73 + y*131) % 100) / 100;
ctx.fillStyle = seed < 0.3 ? COLORS.grass2 : seed < 0.7 ? COLORS.grass1 : COLORS.grass3;
ctx.fillRect(px, py, TILE, TILE);
// 草地纹理点
if (seed < 0.2){
ctx.fillStyle = COLORS.grass2;
ctx.fillRect(px + 3, py + 6, 2, 2);
ctx.fillRect(px + 14, py + 12, 2, 2);
}
if (t === T.PATH){
ctx.fillStyle = COLORS.path; ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = COLORS.path2;
for (let i=0; i<3; i++){
ctx.fillRect(px + (i*6 + 2), py + 3, 3, 3);
ctx.fillRect(px + (i*6 + 8), py + 13, 3, 3);
}
} else if (t === T.FLOWER){
ctx.fillStyle = COLORS.flower;
ctx.fillRect(px + 6, py + 8, 3, 3);
ctx.fillRect(px + 12, py + 5, 3, 3);
ctx.fillRect(px + 9, py + 14, 3, 3);
ctx.fillStyle = COLORS.flower2;
ctx.fillRect(px + 8, py + 11, 3, 3);
} else if (t === T.WATER){
ctx.fillStyle = COLORS.water; ctx.fillRect(px, py, TILE, TILE);
ctx.fillStyle = COLORS.water2;
ctx.fillRect(px + 2, py + 4, 6, 2);
ctx.fillRect(px + 11, py + 12, 6, 2);
} else if (t === T.TREE){
ctx.fillStyle = COLORS.treeTrunk;
ctx.fillRect(px + 8, py + 12, 4, 8);
ctx.fillStyle = COLORS.tree1;
ctx.beginPath();
ctx.arc(px + 10, py + 8, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = COLORS.tree2;
ctx.beginPath();
ctx.arc(px + 7, py + 6, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath();
ctx.arc(px + 13, py + 9, 4, 0, Math.PI * 2); ctx.fill();
} else if (t === T.ROCK){
ctx.fillStyle = COLORS.rock;
ctx.beginPath(); ctx.arc(px + 10, py + 12, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = COLORS.rock2;
ctx.beginPath(); ctx.arc(px + 8, py + 10, 4, 0, Math.PI * 2); ctx.fill();
}
}
function drawBuilding(x, y, t){
const px = x*TILE, py = y*TILE;
let wall, roof;
if (t === T.B_TOWN){ wall = COLORS.townWall; roof = COLORS.townRoof; }
else if (t === T.B_BLACK){ wall = COLORS.blackWall; roof = COLORS.blackRoof; }
else if (t === T.B_SHOP){ wall = COLORS.shopWall; roof = COLORS.shopRoof; }
else if (t === T.B_INN){ wall = COLORS.innWall; roof = COLORS.innRoof; }
// 墙体(2x2 tile)
ctx.fillStyle = wall;
ctx.fillRect(px - TILE/2, py - TILE/2, TILE*2, TILE*2);
// 屋顶(三角)
ctx.fillStyle = roof;
ctx.beginPath();
ctx.moveTo(px - TILE/2 - 2, py - TILE/2);
ctx.lineTo(px + TILE*1.5 + 2, py - TILE/2);
ctx.lineTo(px + TILE/2, py - TILE/2 - 10);
ctx.closePath();
ctx.fill();
// 门
ctx.fillStyle = '#3a2818';
ctx.fillRect(px + TILE/2 - 4, py + TILE*1.2, 8, TILE*0.8);
ctx.fillStyle = '#f0d49a';
ctx.fillRect(px + TILE/2 - 1, py + TILE*1.5, 2, 2);
}
function drawPlayer(){
const px = Math.floor(player.x) - TILE/2;
const py = Math.floor(player.y) - TILE/2;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,.25)';
ctx.beginPath(); ctx.ellipse(player.x, player.y + 6, 6, 2.5, 0, 0, Math.PI*2); ctx.fill();
// 身体(白色小兔)
ctx.fillStyle = '#fff';
// 头
ctx.beginPath(); ctx.arc(player.x, py + 4, 5, 0, Math.PI*2); ctx.fill();
// 耳朵
ctx.fillRect(player.x - 3, py - 4, 2, 5);
ctx.fillRect(player.x + 1, py - 4, 2, 5);
// 身体
ctx.fillRect(player.x - 5, py + 6, 10, 8);
// 脚
ctx.fillStyle = '#ffd0d0';
ctx.fillRect(player.x - 5, py + 14, 3, 2);
ctx.fillRect(player.x + 2, py + 14, 3, 2);
// 眼睛
ctx.fillStyle = '#000';
ctx.fillRect(player.x - 2, py + 3, 1, 1);
ctx.fillRect(player.x + 1, py + 3, 1, 1);
}
function drawEntity(e){
const {x, y, type} = e;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,.25)';
ctx.beginPath(); ctx.ellipse(x, y + 8, 6, 2.5, 0, 0, Math.PI*2); ctx.fill();
// 名字
ctx.font = '10px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.fillStyle = type === 'monster' ? '#ef4444' : '#fde68a';
ctx.fillText(e.name, x, y - 14);
// 图标
ctx.font = '20px sans-serif';
ctx.fillText(e.icon || (type === 'monster' ? '👾' : '👤'), x, y);
// 怪物 HP 条
if (type === 'monster' && e.hp !== undefined){
ctx.fillStyle = 'rgba(0,0,0,.5)';
ctx.fillRect(x - 12, y + 12, 24, 3);
ctx.fillStyle = '#ef4444';
ctx.fillRect(x - 12, y + 12, 24 * (e.hp / e.maxHp), 3);
}
}
// =====================================================================
// 粒子系统(脚步扬尘、互动特效)
// =====================================================================
const particles = [];
function spawnDust(x, y){
for (let i=0; i<3; i++){
particles.push({
x: x + (Math.random()-0.5)*8,
y: y + 4,
vx: (Math.random()-0.5)*20,
vy: -10 - Math.random()*15,
life: 0.4 + Math.random()*0.3,
maxLife: 0.7,
size: 2 + Math.random()*2,
color: i < 2 ? 'rgba(220,210,180,.6)' : 'rgba(255,250,235,.7)',
});
}
}
function spawnSpark(x, y, color='#4cc9f0'){
for (let i=0; i<12; i++){
const a = Math.random() * Math.PI * 2;
const s = 30 + Math.random() * 60;
particles.push({
x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s,
life: 0.6, maxLife: 0.6,
size: 2, color, kind: 'spark',
});
}
}
function updateParticles(dt){
for (let i = particles.length-1; i>=0; i--){
const p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.kind !== 'spark') p.vy += 30 * dt; // gravity
else { p.vx *= 0.96; p.vy *= 0.96; }
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
}
function drawParticles(){
particles.forEach(p => {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.fillStyle = p.color.includes('rgba') ? p.color.replace(/\.?\d*\.?\d+\)$/, alpha + ')') : p.color;
ctx.globalAlpha = alpha;
ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size);
ctx.globalAlpha = 1;
});
}
// =====================================================================
// 输入
// =====================================================================
const keys = {};
let clickTarget = null;
window.addEventListener('keydown', e => {
keys[e.key.toLowerCase()] = true;
if (e.key === 'Escape') closeModal();
if (e.key === 'e' || e.key === 'E') tryInteract();
});
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
clickTarget = {x: mx, y: my};
// 直接点击附近实体:互动
const ent = findNearestEntity(mx, my, 25);
if (ent) tryInteractEntity(ent);
});
function tryInteract(){
const ent = findNearestEntity(player.x, player.y, 50);
if (ent) tryInteractEntity(ent);
}
function findNearestEntity(x, y, maxDist){
let best = null, bestD = maxDist;
ENTITIES.forEach(e => {
const d = Math.hypot(e.x - x, e.y - y);
if (d < bestD) { best = e; bestD = d; }
});
return best;
}
function tryInteractEntity(e){
if (e.type === 'building'){ openBuildingDialog(e); }
else if (e.type === 'npc'){ openNPCDialog(e); }
else if (e.type === 'monster'){ startBattle(e); }
}
// =====================================================================
// 对话/交易/战斗 Modal
// =====================================================================
const modal = document.getElementById('modal');
const modalBox = document.getElementById('modalBox');
function openModal(html){ modalBox.innerHTML = html; modal.classList.remove('hidden'); }
function closeModal(){ modal.classList.add('hidden'); modalBox.innerHTML = ''; }
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function openBuildingDialog(e){
const html = `
<button class="panel-close" onclick="closeModal()">✕</button>
<div class="panel-title">${e.icon} ${e.name}</div>
<div class="panel-sub">${e.short}</div>`;
if (e.dialog === 'shop'){ openShop(html); return; }
if (e.dialog === 'black'){ openBlacksmith(html); return; }
if (e.dialog === 'inn'){ openInn(html); return; }
// 议事厅 - 任务板
openModal(html + `
<div class="dialog-text">📜 今日议程:<br>
· 击退东林「暗影狼」三只,村中将给予赏金。<br>
· 西郊新发现矿脉,欢迎有识之士前往探查。
</div>
<div class="dialog-choices">
<button onclick="showToast('已接取赏金任务')">接受赏金任务</button>
<button onclick="showToast('记下了,有空去看看')">只是了解一下</button>
<button onclick="closeModal()">告辞</button>
</div>`);
}
function openNPCDialog(e){
const html = `
<button class="panel-close" onclick="closeModal()">✕</button>
<div class="panel-title">${e.icon} ${e.name}</div>
<div class="panel-sub">NPC</div>`;
if (e.dialog === 'farmer'){
openModal(html + `
<div class="dialog-text">老李擦着汗:<br>
「今年雨水不错,麦子长势好哇!要不要来一碗新米?我这米是今年刚收的,新鲜!」</div>
<div class="dialog-choices">
<button onclick="if(player.gold>=5){player.gold-=5;updateHud();showToast('买到一碗新米 🍚 +5 HP')}else{showToast('金币不足')}">买一碗新米(5 金币)</button>
<button onclick="showToast('老李嘿嘿笑了')">聊聊天</button>
<button onclick="closeModal()">告辞</button>
</div>`);
} else if (e.dialog === 'guard'){
openModal(html + `
<div class="dialog-text">守夜人低声说:<br>
「夜里不太平。村东林子里最近冒出一窝狼,按 <kbd>E</kbd> 试试身手,打了有赏。」</div>
<div class="dialog-choices">
<button onclick="closeModal();spawnSpark(player.x,player.y,'#fbbf24');showToast('获得情报 → 前往村东林子')">领任务前往</button>
<button onclick="closeModal()">记住了</button>
</div>`);
}
}
// 商店
const SHOP_ITEMS = [
{ id:'hp', name:'生命药水', desc:'恢复 40 HP', price:15, use: ()=> { player.hp = Math.min(player.maxHp, player.hp + 40); updateHud(); }},
{ id:'food', name:'烤鸡腿', desc:'恢复 25 HP', price:8, use: ()=> { player.hp = Math.min(player.maxHp, player.hp + 25); updateHud(); }},
{ id:'sword', name:'精铁剑', desc:'攻击力 +5(永久)', price:80, use: ()=> { player.atk = (player.atk||10) + 5; }},
{ id:'shield', name:'木盾', desc:'防御力 +3(永久)', price:50, use: ()=> { player.def = (player.def||2) + 3; }},
];
function openShop(preHtml){
player.atk = player.atk || 10; player.def = player.def || 2;
openModal(preHtml + `
<div class="shop-list">${SHOP_ITEMS.map(it => `
<div class="shop-item">
<div>
<div class="nm">${it.name}</div>
<div class="desc">${it.desc}</div>
</div>
<div class="price">💰${it.price}</div>
<button onclick="buyItem('${it.id}')" ${player.gold < it.price ? 'disabled' : ''}>购买</button>
</div>`).join('')}</div>
<div class="shop-foot">当前金币:<b style="color:var(--gold)">💰${player.gold}</b></div>`);
}
function buyItem(id){
const it = SHOP_ITEMS.find(x => x.id === id);
if (!it || player.gold < it.price) return;
player.gold -= it.price;
it.use();
updateHud();
showToast(`✓ 购买 ${it.name}`);
spawnSpark(player.x, player.y - 8, '#fde68a');
openShop(`<button class="panel-close" onclick="closeModal()">✕</button>
<div class="panel-title">🏪 杂货铺</div>
<div class="panel-sub">杂货铺</div>`); // 刷新按钮状态
}
function openBlacksmith(preHtml){
player.atk = player.atk || 10; player.def = player.def || 2;
openModal(preHtml + `
<div class="dialog-text">⚒️ 铁匠擦着汗,看着炉火:<br>
「你那把剑钝得都能切豆腐了。要磨还是要换?」</div>
<div class="dialog-choices">
<button onclick="if(player.gold>=20){player.gold-=20;spawnSpark(player.x,player.y,'#ef4444');showToast('武器已磨锋利 ✨');closeModal()}else{showToast('金币不足')}">磨剑(20 金币)</button>
<button onclick="if(player.gold>=60){player.gold-=60;player.atk+=8;spawnSpark(player.x,player.y,'#fbbf24');showToast('换上新武器 ⚔️ +8 ATK');closeModal();updateHud()}else{showToast('金币不足')}">买精铁剑(60 金币)</button>
<button onclick="closeModal()">下次再说</button>
</div>`);
}
function openInn(preHtml){
openModal(preHtml + `
<div class="dialog-text">🛏️ 客栈老板娘笑眯眯:<br>
「夜深了,楼上客房 8 金币一夜,包早餐。要休息一下恢复状态吗?」</div>
<div class="dialog-choices">
<button onclick="if(player.gold>=8){player.gold-=8;player.hp=player.maxHp;updateHud();spawnSpark(player.x,player.y,'#4cc9f0');showToast('美美睡了一觉 ❤️ HP 满');closeModal()}else{showToast('金币不足')}">住一晚(8 金币)</button>
<button onclick="closeModal()">不打扰了</button>
</div>`);
}
// =====================================================================
// 战斗系统(回合制)
// =====================================================================
let battle = null;
function startBattle(monster){
if (player.atk === undefined) player.atk = 10;
if (player.def === undefined) player.def = 2;
battle = {
monster: JSON.parse(JSON.stringify(monster)),
log: [`遭遇 ${monster.name}!`],
turn: 'player',
};
closeModal();
showBattleUI();
}
function showBattleUI(){
const b = battle;
openModal(`
<button class="panel-close" onclick="fleeBattle()">✕</button>
<div class="panel-title battle">⚔ 遭遇战</div>
<div class="panel-sub" style="color:#ef4444">COMBAT</div>
<div class="battle-arena">
<div class="battle-actor">
<div class="ic">👤</div>
<div class="nm">探险者</div>
<div class="hp">HP <b id="bhpP">${player.hp}</b>/${player.maxHp}</div>
</div>
<div class="vs">VS</div>
<div class="battle-actor">
<div class="ic">${b.monster.icon}</div>
<div class="nm">${b.monster.name}</div>
<div class="hp">HP <b id="bhpM">${b.monster.hp}</b>/${b.monster.maxHp}</div>
</div>
</div>
<div class="battle-log" id="battleLog">${b.log.join('<br>')}</div>
<div class="battle-actions" id="battleActions">
<button class="atk" onclick="playerAttack()">⚔ 攻击</button>
<button onclick="playerDefend()">🛡 防御</button>
<button class="flee" onclick="fleeBattle()">💨 逃跑</button>
</div>`);
}
function playerAttack(){
const b = battle;
const dmg = Math.max(1, (player.atk||10) - b.monster.def + Math.floor(Math.random()*4));
b.monster.hp -= dmg;
b.log.push(`你攻击了 ${b.monster.name},造成 <b style="color:#fbbf24">${dmg}</b> 点伤害`);
spawnSpark(b.monster.x, b.monster.y, '#fbbf24');
if (b.monster.hp <= 0){
b.log.push(`<b style="color:#22c55e">${b.monster.name} 被击败了!</b>`);
const gold = b.monster.gold;
player.gold += gold;
b.log.push(`获得 <b style="color:#fbbf24">💰 ${gold} 金币</b>`);
b.monster.hp = 0;
updateHud();
document.getElementById('battleActions').innerHTML =
`<button class="atk" style="flex:1" onclick="closeModal();spawnSpark(player.x,player.y,'#22c55e');showToast('战斗胜利!'+${gold}+' 金币')">✓ 离开</button>`;
document.getElementById('battleLog').innerHTML = b.log.join('<br>');
return;
}
setTimeout(()=> monsterAttack(), 700);
updateBattle();
}
function playerDefend(){
const b = battle;
b.log.push(`🛡 你摆好防御姿态,准备格挡下一击`);
setTimeout(()=> { monsterAttack(true); updateBattle(); }, 700);
updateBattle();
}
function monsterAttack(defending = false){
const b = battle;
const raw = Math.max(1, b.monster.atk - (player.def||2) + Math.floor(Math.random()*3));
const dmg = defending ? Math.floor(raw / 2) : raw;
player.hp -= dmg;
b.log.push(`${b.monster.icon} ${b.monster.name} 攻击了你,造成 <b style="color:#ef4444">${dmg}</b> 点伤害`);
spawnSpark(player.x, player.y, '#ef4444');
if (player.hp <= 0){
player.hp = 0;
b.log.push(`<b style="color:#ef4444">你被打败了,丧失 30 金币</b>`);
player.gold = Math.max(0, player.gold - 30);
updateHud();
document.getElementById('battleActions').innerHTML =
`<button style="flex:1" onclick="closeModal();player.hp=player.maxHp;showToast('你醒来在客栈,HP 恢复')">💀 倒下(送回客栈)</button>`;
document.getElementById('battleLog').innerHTML = b.log.join('<br>');
return;
}
updateHud();
updateBattle();
}
function fleeBattle(){
const b = battle;
b.log.push('💨 你转身逃跑了');
updateHud();
closeModal();
showToast('逃跑成功!');
battle = null;
}
function updateBattle(){
if (!battle) return;
document.getElementById('bhpP').textContent = player.hp;
document.getElementById('bhpM').textContent = battle.monster.hp;
document.getElementById('battleLog').innerHTML = battle.log.join('<br>');
}
// =====================================================================
// HUD
// =====================================================================
const hudGold = document.getElementById('hudGold');
const hudHp = document.getElementById('hudHp');
const hudHpNum = document.getElementById('hudHpNum');
function updateHud(){
hudGold.textContent = player.gold;
const pct = Math.max(0, player.hp / player.maxHp * 100);
hudHp.style.width = pct + '%';
hudHpNum.textContent = player.hp;
}
// 时间显示(每帧累加)
let gameTime = 6 * 60; // 6:00 AM 起步
let gameDay = 1;
const clockTime = document.getElementById('clockTime');
const clockDay = document.getElementById('clockDay');
function tickClock(dt){
gameTime += dt * 10; // 1 秒 = 10 分钟,加速跑
if (gameTime >= 24*60){ gameTime -= 24*60; gameDay++; }
const h = Math.floor(gameTime/60) % 24;
const m = Math.floor(gameTime % 60);
clockTime.textContent = String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0');
clockDay.textContent = gameDay;
}
// Toast
let statusTimer = null;
function showToast(msg){
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.style.opacity = '1';
clearTimeout(statusTimer);
statusTimer = setTimeout(()=> { el.style.opacity = '0'; }, 2500);
}
// =====================================================================
// 主循环
// =====================================================================
let lastTime = performance.now();
let dustTimer = 0;
function gameLoop(now){
const dt = Math.min(0.1, (now - lastTime) / 1000);
lastTime = now;
// 移动
let dx = 0, dy = 0;
if (keys['w'] || keys['arrowup']) { dy -= 1; player.facing = 'up'; }
if (keys['s'] || keys['arrowdown']) { dy += 1; player.facing = 'down'; }
if (keys['a'] || keys['arrowleft']) { dx -= 1; player.facing = 'left'; }
if (keys['d'] || keys['arrowright']) { dx += 1; player.facing = 'right'; }
// 点击移动(如果在 dialog 里则不响应)
if (clickTarget && modal.classList.contains('hidden')){
const tdx = clickTarget.x - player.x;
const tdy = clickTarget.y - player.y;
const dist = Math.hypot(tdx, tdy);
if (dist < 4) clickTarget = null;
else {
dx = tdx / dist;
dy = tdy / dist;
// 朝向
if (Math.abs(tdx) > Math.abs(tdy)) player.facing = tdx > 0 ? 'right' : 'left';
else player.facing = tdy > 0 ? 'down' : 'up';
}
}
// 归一化(对角线)
if (dx && dy){ const l = Math.hypot(dx,dy); dx/=l; dy/=l; }
if (dx || dy){
const newX = player.x + dx * player.speed * dt;
const newY = player.y + dy * player.speed * dt;
if (canMove(newX, player.y)) player.x = newX;
if (canMove(player.x, newY)) player.y = newY;
player.moving = true;
dustTimer += dt;
if (dustTimer > 0.15){ spawnDust(player.x, player.y); dustTimer = 0; }
} else {
player.moving = false;
}
// 摄像头跟随(简易:始终居中玩家)
const camX = player.x - canvas.width / 2;
const camY = player.y - canvas.height / 2;
// 渲染
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(-camX, -camY);
// 画 tile
const startX = Math.max(0, Math.floor(camX / TILE) - 1);
const endX = Math.min(MAP_W, Math.ceil((camX + canvas.width) / TILE) + 1);
const startY = Math.max(0, Math.floor(camY / TILE) - 1);
const endY = Math.min(MAP_H, Math.ceil((camY + canvas.height) / TILE) + 1);
for (let y = startY; y < endY; y++){
for (let x = startX; x < endX; x++){
const t = MAP[y][x];
drawTile(x, y, t);
// 建筑(2x2 tile)
if (t >= 10 && t <= 13 && x + 1 < MAP_W && MAP[y][x+1] === t){
drawBuilding(x, y, t);
}
}
}
// 实体
ENTITIES.forEach(drawEntity);
// 玩家
drawPlayer();
// 互动提示
const near = findNearestEntity(player.x, player.y, 50);
if (near){
ctx.font = '10px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#fde68a';
ctx.fillText('按 E 互动', player.x, player.y - 18);
}
// 粒子
updateParticles(dt);
drawParticles();
ctx.restore();
tickClock(dt);
updateHud();
requestAnimationFrame(gameLoop);
}
function canMove(x, y){
const tx = Math.floor(x / TILE), ty = Math.floor(y / TILE);
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
const t = MAP[ty][tx];
if (!WALKABLE.has(t)) return false;
// 实体碰撞
for (const e of ENTITIES){
if (e.type === 'monster' && e.hp <= 0) continue;
if (Math.hypot(e.x - x, e.y - y) < 12) return false;
}
return true;
}
// =====================================================================
// 启动
// =====================================================================
updateHud();
requestAnimationFrame(gameLoop);
// 友好提示
setTimeout(()=> showToast('欢迎来到微观世界 · WASD 移动 · E 互动'), 500);
setTimeout(()=> showToast('点击地面移动到目标位置'), 3500);
</script>
</body>
</html>