Files
game/creature_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

500 lines
15 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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>生物移动 Demo — 6帧行走</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#1a1a2e; color:#e0e0e0; font-family:'Segoe UI',system-ui,sans-serif; display:flex; flex-direction:column; align-items:center; min-height:100vh; }
h1 { margin:14px 0 4px; font-size:16px; color:#8ec6eb; }
.info { font-size:12px; color:#6a7a8a; margin-bottom:10px; }
#wrap { position:relative; }
canvas { display:block; background:#2a2a3e; border:2px solid #3a3a5e; border-radius:8px; image-rendering:pixelated; image-rendering:crisp-edges; }
.controls { margin:10px 0; display:flex; gap:12px; align-items:center; }
.controls button { background:#2a3a5e; color:#cde; border:1px solid #4a5a7e; padding:6px 16px; border-radius:6px; cursor:pointer; font-size:12px; }
.controls button:hover { background:#3a4a6e; }
.controls label { font-size:12px; color:#8a9aaa; }
#log { font-size:11px; color:#5a6a7a; margin-top:6px; max-width:600px; text-align:center; }
</style>
</head>
<body>
<h1>生物移动 Demo — 6帧行走 + 自动漫游</h1>
<p class="info">像素风小人 · 6帧步循环 · 拖拽=指定目的地 · 空格=暂停 · R=重置</p>
<div id="wrap"><canvas id="cv" width="640" height="360"></canvas></div>
<div class="controls">
<button id="btnPause">暂停</button>
<button id="btnReset">重置</button>
<label><input type="checkbox" id="chkPath" checked> 显示路径</label>
<label><input type="checkbox" id="chkGrid" checked> 显示网格</label>
<label>速度 <input type="range" id="rngSpeed" min="20" max="150" value="70" style="width:100px"></label>
</div>
<p id="log"></p>
<script>
'use strict';
// ============================================================
// 6帧像素角色 —— 全程序化绘制,无外部素材
// ============================================================
// 每帧是一个函数(ctx, facing),facing = 1(右) / -1(左)
// 像素格子 PX=4,角色实际宽 ~9 格、高 ~12 格
const PX = 4; // 单像素大小
// 调色板
const C = {
skin: '#f0c090', skinD: '#c89868',
hair: '#3a2818', hairD: '#1a0e08',
shirt: '#4a7abf', shirtD:'#2a5a9f',
pants: '#3a3a4e', pantsD:'#2a2a3e',
shoe: '#1a1a2e', shoeD: '#0a0a1e',
eye: '#1a1a2e',
outline:'#1a1a2e',
};
// 9x12 像素角色,6帧步行动画
// 每帧定义角色身体的像素布局(相对坐标 0-8 x, 0-11 y)
// frame 数据:[ [x,y,color], ... ]
// 简化方式:用 fillRect 画 1x1 像素格
function px(ctx, x, y, color, facing, ox=0, oy=0) {
const fx = facing < 0 ? (8 - x) : x; // 水平镜像
ctx.fillStyle = color;
ctx.fillRect((fx + ox) * PX, (y + oy) * PX, PX, PX);
}
// 画一个完整的角色帧
// frameIndex: 0-5
function drawCreature(ctx, frameIndex, facing, ox, oy) {
ctx.save();
ctx.translate(ox, oy);
// 6帧步态:0,1,2,3,4,5 循环
// 腿部偏移(左右腿交替前后)
// frame: 0 1 2 3 4 5
// legL: 0 +1 0 -1 0 +1 (左腿上下)
// legR: 0 -1 0 +1 0 -1 (右腿上下)
// armL: 0 -1 0 +1 0 -1
// armR: 0 +1 0 -1 0 +1
// bodyBob: 0 0 +1 0 +1 0 (身体上下浮动)
const leg = [
{ l: 0, r: 0, aL: 0, aR: 0, bob: 0 },
{ l: 1, r:-1, aL:-1, aR: 1, bob: 0 },
{ l: 0, r: 0, aL: 0, aR: 0, bob: 1 },
{ l:-1, r: 1, aL: 1, aR:-1, bob: 0 },
{ l: 0, r: 0, aL: 0, aR: 0, bob: 1 },
{ l: 1, r:-1, aL:-1, aR: 1, bob: 0 },
];
const f = leg[frameIndex];
const bob = f.bob;
// ---- 头部 (y: 0-3) ----
// 头发顶
for (let x=2; x<=6; x++) px(ctx, x, 0, C.hairD, facing);
for (let x=2; x<=6; x++) px(ctx, x, 1, C.hair, facing);
// 脸
px(ctx, 2, 2, C.skinD, facing);
for (let x=3; x<=5; x++) px(ctx, x, 2, C.skin, facing);
px(ctx, 6, 2, C.skinD, facing);
// 眼睛
px(ctx, 3, 2, C.eye, facing);
px(ctx, 5, 2, C.eye, facing);
// 头发帘
px(ctx, 2, 2, C.hair, facing);
px(ctx, 6, 2, C.hair, facing);
// 下巴
px(ctx, 3, 3, C.skinD, facing);
px(ctx, 4, 3, C.skin, facing);
px(ctx, 5, 3, C.skinD, facing);
// ---- 身体/衣服 (y: 4-6) ----
const bodyY = 4 + bob;
// 左臂
px(ctx, 1, bodyY + f.aL, C.shirtD, facing);
px(ctx, 1, bodyY+1 + f.aL, C.shirtD, facing);
// 右臂
px(ctx, 7, bodyY + f.aR, C.shirtD, facing);
px(ctx, 7, bodyY+1 + f.aR, C.shirtD, facing);
// 躯干
for (let x=2; x<=6; x++) px(ctx, x, bodyY, C.shirt, facing);
for (let x=2; x<=6; x++) px(ctx, x, bodyY+1, C.shirt, facing);
px(ctx, 2, bodyY+1, C.shirtD, facing);
px(ctx, 6, bodyY+1, C.shirtD, facing);
// 腰带
for (let x=2; x<=6; x++) px(ctx, x, bodyY+2, C.pantsD, facing);
// ---- 腿 (y: 7-9) ----
const legY = 7 + bob;
// 左腿
px(ctx, 3, legY, C.pantsD, facing);
px(ctx, 3, legY+1, C.pants, facing);
px(ctx, 3, legY+2 + Math.max(0, f.l), C.shoe, facing);
// 右腿
px(ctx, 5, legY, C.pantsD, facing);
px(ctx, 5, legY+1, C.pants, facing);
px(ctx, 5, legY+2 + Math.max(0, f.r), C.shoe, facing);
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath();
ctx.ellipse(4.5 * PX, (legY + 4) * PX, PX * 3.5, PX * 1.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// ============================================================
// 场景 —— 草地 + 树 + 石头 + 水
// ============================================================
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
ctx.imageSmoothingEnabled = false;
const VW = cv.width, VH = cv.height;
const TS = 32; // 场景格子大小
const COLS = Math.floor(VW / TS), ROWS = Math.floor(VH / TS);
// 场景类型:grass / tree / rock / water / path
const scene = [];
function genScene() {
scene.length = 0;
for (let r = 0; r < ROWS; r++) {
scene.push([]);
for (let c = 0; c < COLS; c++) {
let t = 'grass';
// 边缘水
if (c < 1 || c >= COLS-1 || r < 1 || r >= ROWS-1) t = 'water';
scene[r].push({ type: t });
}
}
// 随机放树
let placed = 0;
while (placed < 12) {
const r = 2 + Math.floor(Math.random() * (ROWS-4));
const c = 2 + Math.floor(Math.random() * (COLS-4));
if (scene[r][c].type === 'grass') { scene[r][c].type = 'tree'; placed++; }
}
// 随机放石头
placed = 0;
while (placed < 6) {
const r = 2 + Math.floor(Math.random() * (ROWS-4));
const c = 2 + Math.floor(Math.random() * (COLS-4));
if (scene[r][c].type === 'grass') { scene[r][c].type = 'rock'; placed++; }
}
// 放一条小路
const pathR = Math.floor(ROWS/2);
for (let c = 1; c < COLS-1; c++) {
if (scene[pathR][c].type === 'grass') scene[pathR][c].type = 'path';
}
}
genScene();
// 画场景格子
function drawScene() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = scene[r][c].type;
let x = c * TS, y = r * TS;
if (t === 'water') {
ctx.fillStyle = '#2a4a7e';
ctx.fillRect(x, y, TS, TS);
ctx.fillStyle = '#3a5a8e';
ctx.fillRect(x+4, y+TS/2-2, TS-8, 4);
ctx.fillStyle = '#4a6aae';
ctx.fillRect(x+TS/3, y+2, 2, 2);
ctx.fillRect(x+TS*2/3, y+TS-6, 2, 2);
} else if (t === 'tree') {
drawGrassTile(x, y);
drawTree(x, y);
} else if (t === 'rock') {
drawGrassTile(x, y);
drawRock(x, y);
} else if (t === 'path') {
ctx.fillStyle = '#8a7a5e';
ctx.fillRect(x, y, TS, TS);
ctx.fillStyle = '#9a8a6e';
ctx.fillRect(x+2, y+2, TS-4, TS-4);
} else {
drawGrassTile(x, y);
}
}
}
// 网格
if (document.getElementById('chkGrid').checked) {
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
for (let c = 0; c <= COLS; c++) { ctx.beginPath(); ctx.moveTo(c*TS,0); ctx.lineTo(c*TS,VH); ctx.stroke(); }
for (let r = 0; r <= ROWS; r++) { ctx.beginPath(); ctx.moveTo(0,r*TS); ctx.lineTo(VW,r*TS); ctx.stroke(); }
}
}
function drawGrassTile(x, y) {
ctx.fillStyle = '#2a5e2e';
ctx.fillRect(x, y, TS, TS);
ctx.fillStyle = '#3a6e3e';
ctx.fillRect(x+2, y+2, TS-4, TS-4);
ctx.fillStyle = '#4a7e4e';
ctx.fillRect(x+6, y+8, 2, 2);
ctx.fillRect(x+20, y+16, 2, 2);
ctx.fillRect(x+14, y+22, 2, 2);
}
function drawTree(x, y) {
// 树干
ctx.fillStyle = '#4a3a1e';
ctx.fillRect(x+TS/2-3, y+TS-12, 6, 12);
ctx.fillStyle = '#3a2a0e';
ctx.fillRect(x+TS/2-3, y+TS-12, 2, 12);
// 树冠
ctx.fillStyle = '#1a4e1e';
ctx.beginPath();
ctx.arc(x+TS/2, y+TS/2-4, 14, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#2a6e2e';
ctx.beginPath();
ctx.arc(x+TS/2-2, y+TS/2-6, 10, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#3a8e3e';
ctx.beginPath();
ctx.arc(x+TS/2-4, y+TS/2-8, 5, 0, Math.PI*2);
ctx.fill();
}
function drawRock(x, y) {
ctx.fillStyle = '#5a5a6e';
ctx.beginPath();
ctx.ellipse(x+TS/2, y+TS-6, 12, 8, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#6a6a7e';
ctx.beginPath();
ctx.ellipse(x+TS/2-2, y+TS-8, 8, 5, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#7a7a8e';
ctx.beginPath();
ctx.ellipse(x+TS/2-4, y+TS-10, 4, 3, 0, 0, Math.PI*2);
ctx.fill();
}
// ============================================================
// 生物状态机
// ============================================================
const creature = {
x: VW/2, y: VH/2, // 像素坐标
fx: VW/2, fy: VH/2, // 浮点位置
facing: 1, // 1=右 -1=左
state: 'walk', // idle / walk
frame: 0, // 0-5
frameT: 0, // 帧计时
frameDur: 0.12, // 每帧 120ms
targetX: 0, targetY: 0, // 目的地
speed: 70, // 像素/秒
idleT: 0, // 待机计时
idleDur: 1.0, // 待机时长
trail: [], // 轨迹
};
function pickNewTarget() {
// 随机选一个可达格子(非水/非树/非石)
let tries = 50;
while (tries-- > 0) {
const c = 1 + Math.floor(Math.random() * (COLS - 2));
const r = 1 + Math.floor(Math.random() * (ROWS - 2));
const t = scene[r][c].type;
if (t === 'grass' || t === 'path') {
creature.targetX = c * TS + TS/2;
creature.targetY = r * TS + TS/2;
creature.state = 'walk';
return;
}
}
// fallback
creature.targetX = VW/2;
creature.targetY = VH/2;
creature.state = 'walk';
}
function isWalkable(c, r) {
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return false;
const t = scene[r][c].type;
return t === 'grass' || t === 'path';
}
// 简易直线移动(不做 A*,碰撞=换目标)
function updateCreature(dt) {
if (creature.state === 'idle') {
creature.idleT += dt;
if (creature.idleT >= creature.idleDur) {
pickNewTarget();
creature.idleT = 0;
}
// idle 也轻微呼吸(用 frame 0)
creature.frame = 0;
return;
}
// walk
const dx = creature.targetX - creature.fx;
const dy = creature.targetY - creature.fy;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 4) {
// 到达
creature.state = 'idle';
creature.idleT = 0;
creature.idleDur = 0.5 + Math.random() * 1.5;
creature.frame = 0;
return;
}
// 朝目标移动
const vx = (dx / dist) * creature.speed;
const vy = (dy / dist) * creature.speed;
const newX = creature.fx + vx * dt;
const newY = creature.fy + vy * dt;
// 碰撞检测:检查下一个格子
const nc = Math.floor(newX / TS);
const nr = Math.floor(newY / TS);
if (isWalkable(nc, nr)) {
creature.fx = newX;
creature.fy = newY;
} else {
// 碰到障碍,换目标
pickNewTarget();
return;
}
// 朝向
if (Math.abs(dx) > 2) creature.facing = dx > 0 ? 1 : -1;
// 动画帧推进
creature.frameT += dt;
if (creature.frameT >= creature.frameDur) {
creature.frameT = 0;
creature.frame = (creature.frame + 1) % 6;
}
// 轨迹
creature.trail.push({ x: creature.fx, y: creature.fy });
if (creature.trail.length > 80) creature.trail.shift();
}
function drawCreatureOnScene() {
const sx = creature.fx - 4.5 * PX; // 居中(角色 9 像素宽)
const sy = creature.fy - 6 * PX; // 脚底居中(角色 12 像素高,脚在 y≈10)
// 路径
if (document.getElementById('chkPath').checked && creature.trail.length > 1) {
ctx.strokeStyle = 'rgba(100,200,255,0.15)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(creature.trail[0].x, creature.trail[0].y);
for (let i = 1; i < creature.trail.length; i++) {
ctx.lineTo(creature.trail[i].x, creature.trail[i].y);
}
ctx.stroke();
// 目标点
ctx.fillStyle = 'rgba(100,200,255,0.4)';
ctx.beginPath();
ctx.arc(creature.targetX, creature.targetY, 4, 0, Math.PI*2);
ctx.fill();
}
// 角色
drawCreature(ctx, creature.frame, creature.facing, sx, sy);
}
// ============================================================
// 主循环
// ============================================================
let lastT = performance.now();
let paused = false;
let fps = 0, fpsT = 0, fpsCount = 0;
function loop(now) {
const dt = Math.min((now - lastT) / 1000, 0.05); // 上限 50ms
lastT = now;
// FPS
fpsT += dt;
fpsCount++;
if (fpsT >= 0.5) {
fps = Math.round(fpsCount / fpsT);
fpsT = 0; fpsCount = 0;
}
if (!paused) {
updateCreature(dt);
}
// 渲染
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, VW, VH);
drawScene();
drawCreatureOnScene();
// HUD
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
const stateTxt = creature.state === 'idle' ? '待机' : '行走';
ctx.fillText(`FPS:${fps} 状态:${stateTxt} 帧:${creature.frame}/5 朝向:${creature.facing>0?'右':'左'} ${paused?'[暂停]':''}`, 8, 14);
requestAnimationFrame(loop);
}
// 初始化
pickNewTarget();
requestAnimationFrame(loop);
// ============================================================
// 交互
// ============================================================
cv.addEventListener('click', (e) => {
const rect = cv.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (VW / rect.width);
const my = (e.clientY - rect.top) * (VH / rect.height);
const c = Math.floor(mx / TS);
const r = Math.floor(my / TS);
if (isWalkable(c, r)) {
creature.targetX = c * TS + TS/2;
creature.targetY = r * TS + TS/2;
creature.state = 'walk';
log(`点击格子(${c},${r}) → 移动到`);
} else {
log(`格子(${c},${r}) 不可通行`);
}
});
document.getElementById('btnPause').addEventListener('click', () => {
paused = !paused;
document.getElementById('btnPause').textContent = paused ? '继续' : '暂停';
});
document.getElementById('btnReset').addEventListener('click', () => {
genScene();
creature.fx = VW/2; creature.fy = VH/2;
creature.trail = [];
pickNewTarget();
log('场景已重置');
});
document.getElementById('rngSpeed').addEventListener('input', (e) => {
creature.speed = parseInt(e.target.value);
});
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); paused = !paused; document.getElementById('btnPause').textContent = paused ? '继续' : '暂停'; }
if (e.code === 'KeyR') { document.getElementById('btnReset').click(); }
});
function log(msg) {
document.getElementById('log').textContent = msg;
}
</script>
</body>
</html>