建造系统: - 新增「经营」页签,农田/牧场归入经营分类 - 建筑默认左向(_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 草图
879 lines
35 KiB
HTML
879 lines
35 KiB
HTML
<!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> |