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