Canvas-六边形小球实验
对比主流模型的物理世界理解
Post to social channels, or use Markdown and badges for GitHub/README.
[](/en/r/M714uZcUyZ)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D 卡通房屋建造动画</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1a2e;
}
canvas {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W, H, dpr;
let groundY, cx;
let wallWidth, wallHeight, wallTopY;
let roofWidth, roofHeight, roofTopY;
let foundationWidth, foundationHeight;
let depthWidth; // 侧面深度
let houseScale;
// 粒子系统(烟)
let smokeParticles = [];
let lastSmokeTime = 0;
// 阶段定义
const phases = [
{ name: '打地基', start: 0, duration: 3.5 },
{ name: '砌墙', start: 4.0, duration: 3.0 },
{ name: '填墙', start: 7.5, duration: 2.5 },
{ name: '盖屋顶', start: 10.5, duration: 4.0 },
{ name: '安门', start: 15.0, duration: 2.0 },
{ name: '安窗', start: 17.5, duration: 2.0 },
{ name: '添细节', start: 20.0, duration: 3.5 },
];
const totalCycle = 24.0; // 秒
const pauseDuration = 0.5;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
// 房屋参数
groundY = H * 0.68;
cx = W / 2;
houseScale = Math.min(W / 800, H / 600, 1.3);
houseScale = Math.max(houseScale, 0.55);
wallWidth = Math.round(280 * houseScale);
wallHeight = Math.round(220 * houseScale);
wallTopY = groundY - wallHeight;
foundationWidth = Math.round(320 * houseScale);
foundationHeight = Math.round(22 * houseScale);
roofWidth = Math.round(360 * houseScale);
roofHeight = Math.round(135 * houseScale);
roofTopY = wallTopY - roofHeight;
depthWidth = Math.round(45 * houseScale);
}
window.addEventListener('resize', resize);
resize();
// 缓动函数
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; }
function easeOutBack(t) { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }
function easeOutBounce(t) {
const n1 = 7.5625, d1 = 2.75;
if (t < 1/d1) return n1*t*t;
else if (t < 2/d1) return n1*(t-=1.5/d1)*t+.75;
else if (t < 2.5/d1) return n1*(t-=2.25/d1)*t+.9375;
else return n1*(t-=2.625/d1)*t+.984375;
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
// 获取阶段进度
function getPhaseProgress(globalTime, phase) {
const localTime = globalTime - phase.start;
if (localTime <= 0) return 0;
if (localTime >= phase.duration) return 1;
return localTime / phase.duration;
}
// 获取当前活跃阶段索引和进度
function getCurrentPhase(globalTime) {
for (let i = phases.length - 1; i >= 0; i--) {
if (globalTime >= phases[i].start) {
const progress = getPhaseProgress(globalTime, phases[i]);
if (progress > 0 && progress < 1) {
return { index: i, progress, isPause: false };
}
if (progress >= 1) {
// 检查是否在停顿中
const nextStart = i < phases.length - 1 ? phases[i + 1].start : totalCycle;
if (globalTime < nextStart) {
return { index: i, progress: 1, isPause: true };
}
}
}
}
// 检查是否在第一个阶段之前的停顿
if (globalTime < phases[0].start) {
return { index: -1, progress: 0, isPause: true };
}
return { index: phases.length - 1, progress: 1, isPause: true };
}
// 检查阶段是否已完成
function isPhaseCompleted(globalTime, phaseIndex) {
if (phaseIndex < 0 || phaseIndex >= phases.length) return false;
return globalTime >= phases[phaseIndex].start + phases[phaseIndex].duration;
}
// 获取字幕
function getSubtitle(globalTime) {
const current = getCurrentPhase(globalTime);
if (current.index >= 0 && current.index < phases.length) {
return phases[current.index].name;
}
// 在第一个阶段之前的停顿
if (globalTime < phases[0].start) return phases[0].name;
return phases[phases.length - 1].name;
}
// ========== 绘制函数 ==========
function drawSky(ctx) {
const grad = ctx.createLinearGradient(0, 0, 0, groundY);
grad.addColorStop(0, '#b8dff0');
grad.addColorStop(0.5, '#c9e6f5');
grad.addColorStop(1, '#dcedf7');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, groundY);
// 几朵静态云
drawCloud(ctx, W * 0.12, H * 0.08, 55 * houseScale);
drawCloud(ctx, W * 0.55, H * 0.05, 65 * houseScale);
drawCloud(ctx, W * 0.78, H * 0.12, 48 * houseScale);
}
function drawCloud(ctx, x, y, size) {
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath();
ctx.arc(x, y, size * 0.5, 0, Math.PI * 2);
ctx.arc(x + size * 0.35, y - size * 0.1, size * 0.4, 0, Math.PI * 2);
ctx.arc(x + size * 0.7, y, size * 0.45, 0, Math.PI * 2);
ctx.arc(x + size * 0.35, y + size * 0.15, size * 0.38, 0, Math.PI * 2);
ctx.fill();
}
function drawGround(ctx) {
// 地面
const grad = ctx.createLinearGradient(0, groundY, 0, H);
grad.addColorStop(0, '#5a9e4b');
grad.addColorStop(0.15, '#4d8c3f');
grad.addColorStop(0.5, '#3d7a32');
grad.addColorStop(1, '#2d5a24');
ctx.fillStyle = grad;
ctx.fillRect(0, groundY, W, H - groundY);
// 地面线高光
ctx.strokeStyle = '#6bbf5a';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(W, groundY);
ctx.stroke();
// 一些草
ctx.fillStyle = '#3d7a32';
for (let i = 0; i < 60; i++) {
const gx = (i * 47 + 13) % W;
const gy = groundY + 3 + ((i * 31) % (H - groundY - 5));
const gh = 4 + (i % 6);
ctx.fillRect(gx, gy, 2, gh);
}
}
function drawFoundation(ctx, progress) {
if (progress <= 0) return;
const p = easeOutCubic(clamp(progress, 0, 1));
// 地基从下方升起
const currentTop = lerp(groundY + foundationHeight + 20, groundY, p);
const currentBottom = currentTop + foundationHeight;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.15)';
roundRect(ctx, cx - foundationWidth / 2 + 3, currentTop + 3, foundationWidth, foundationHeight, 4 * houseScale);
ctx.fill();
// 地基主体
const grad = ctx.createLinearGradient(0, currentTop, 0, currentBottom);
grad.addColorStop(0, '#b0b0b0');
grad.addColorStop(0.3, '#9e9e9e');
grad.addColorStop(0.7, '#888888');
grad.addColorStop(1, '#757575');
ctx.fillStyle = grad;
roundRect(ctx, cx - foundationWidth / 2, currentTop, foundationWidth, foundationHeight, 4 * houseScale);
ctx.fill();
// 顶部高光线
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx - foundationWidth / 2 + 8, currentTop + 1.5);
ctx.lineTo(cx + foundationWidth / 2 - 8, currentTop + 1.5);
ctx.stroke();
}
function roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function drawWallFrames(ctx, progress) {
if (progress <= 0) return;
const p = easeOutBack(clamp(progress, 0, 1));
const fullHeight = wallHeight;
const currentHeight = fullHeight * p;
const fl = cx - wallWidth / 2; // 正面左
const fr = cx + wallWidth / 2; // 正面右
const sl = cx - wallWidth / 2 - depthWidth; // 侧面左后
const sr = cx - wallWidth / 2; // 侧面右前(与正面左重合)
const wallThickness = Math.round(8 * houseScale);
const color = '#e0d5c5';
const darkColor = '#c4b8a5';
// 前左柱
const flTop = groundY - currentHeight;
ctx.fillStyle = color;
ctx.fillRect(fl - wallThickness / 2, flTop, wallThickness, currentHeight);
ctx.fillStyle = darkColor;
ctx.fillRect(fl - wallThickness / 2, flTop, wallThickness * 0.4, currentHeight);
// 前右柱
ctx.fillStyle = color;
ctx.fillRect(fr - wallThickness / 2, flTop, wallThickness, currentHeight);
ctx.fillStyle = darkColor;
ctx.fillRect(fr - wallThickness / 2, flTop, wallThickness * 0.4, currentHeight);
// 后左柱(侧面后方)
const slTop = groundY - currentHeight;
ctx.fillStyle = '#d5c8b5';
ctx.fillRect(sl - wallThickness / 2, slTop, wallThickness, currentHeight);
// 侧面前柱(与正面左重合,但侧面边缘)
// 在侧面墙的右边缘(sr处),实际上在2.5D中,侧面墙的前右边缘与正面左边缘重合
// 我们已经在fl处画了前左柱,它同时是侧面的前边缘
}
function drawSideWall(ctx, progress) {
if (progress <= 0) return;
const p = easeOutCubic(clamp(progress, 0, 1));
const currentHeight = wallHeight * p;
// 侧面墙梯形(从左后到前左)
const fl = cx - wallWidth / 2;
const topFront = fl;
const topBack = fl - depthWidth;
const botFront = fl;
const botBack = fl - depthWidth;
const topY = groundY - currentHeight;
ctx.fillStyle = '#e8dcc8';
ctx.beginPath();
ctx.moveTo(botFront, groundY);
ctx.lineTo(botBack, groundY);
ctx.lineTo(topBack, topY);
ctx.lineTo(topFront, topY);
ctx.closePath();
ctx.fill();
// 侧面墙高光
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(topFront, topY);
ctx.lineTo(topBack, topY);
ctx.stroke();
}
function drawFilledWalls(ctx, progress) {
if (progress <= 0) return;
const p = easeInOutCubic(clamp(progress, 0, 1));
const fillTop = lerp(groundY, wallTopY, p);
const fl = cx - wallWidth / 2;
const fr = cx + wallWidth / 2;
// 正面墙填充
const grad = ctx.createLinearGradient(0, groundY, 0, wallTopY);
grad.addColorStop(0, '#fdf3e3');
grad.addColorStop(0.5, '#fff8e8');
grad.addColorStop(1, '#fbe9d4');
ctx.fillStyle = grad;
ctx.fillRect(fl, fillTop, wallWidth, groundY - fillTop);
// 正面墙纹理线
ctx.strokeStyle = 'rgba(210,190,160,0.3)';
ctx.lineWidth = 0.8;
for (let y = fillTop + 15; y < groundY; y += 18) {
if (y > fillTop) {
ctx.beginPath();
ctx.moveTo(fl + 5, y);
ctx.lineTo(fr - 5, y);
ctx.stroke();
}
}
// 侧面墙填充(梯形)
const topFront = fl;
const topBack = fl - depthWidth;
const topY = fillTop;
ctx.fillStyle = '#f0e4d0';
ctx.beginPath();
ctx.moveTo(fl, groundY);
ctx.lineTo(fl - depthWidth, groundY);
ctx.lineTo(topBack, topY);
ctx.lineTo(topFront, topY);
ctx.closePath();
ctx.fill();
}
function drawRoofSlope(ctx, slopeType, progress) {
// slopeType: 'left' 或 'right'
if (progress <= 0) return;
const p = easeOutCubic(clamp(progress, 0, 1));
const roofSlideOffset = lerp(-180, 0, p) * houseScale;
// 屋顶三角形参数
const peakX = cx;
const peakY = roofTopY;
const leftBaseX = cx - roofWidth / 2;
const rightBaseX = cx + roofWidth / 2;
const baseY = wallTopY;
ctx.save();
let triPath;
if (slopeType === 'left') {
// 左坡:从屋脊到左底角
const baseX = leftBaseX;
const offsetY = roofSlideOffset;
triPath = new Path2D();
triPath.moveTo(peakX, peakY);
triPath.lineTo(baseX, baseY);
triPath.lineTo(peakX, baseY);
triPath.closePath();
// 应用偏移
ctx.translate(0, offsetY);
} else {
// 右坡:从屋脊到右底角
const baseX = rightBaseX;
const offsetY = roofSlideOffset;
triPath = new Path2D();
triPath.moveTo(peakX, peakY);
triPath.lineTo(baseX, baseY);
triPath.lineTo(peakX, baseY);
triPath.closePath();
ctx.translate(0, offsetY);
}
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.12)';
ctx.fill(triPath);
ctx.fill(triPath); // 双重阴影
// 主体颜色
const grad = ctx.createLinearGradient(peakX, peakY, peakX, baseY);
if (slopeType === 'left') {
grad.addColorStop(0, '#ef5350');
grad.addColorStop(0.6, '#e53935');
grad.addColorStop(1, '#c62828');
} else {
grad.addColorStop(0, '#ef5350');
grad.addColorStop(0.6, '#e53935');
grad.addColorStop(1, '#c62828');
}
ctx.fillStyle = grad;
ctx.fill(triPath);
// 边框
ctx.strokeStyle = '#b71c1c';
ctx.lineWidth = 2;
ctx.stroke(triPath);
ctx.restore();
}
function drawRoofTiles(ctx, progress) {
if (progress <= 0) return;
const p = clamp(progress, 0, 1);
const peakX = cx;
const peakY = roofTopY;
const leftBaseX = cx - roofWidth / 2;
const rightBaseX = cx + roofWidth / 2;
const baseY = wallTopY;
const totalRows = Math.round(12 * houseScale);
const rowHeight = roofHeight / totalRows;
const rowsToShow = Math.floor(p * totalRows);
const partialProgress = (p * totalRows) - rowsToShow;
ctx.save();
// 定义屋顶大三角形裁剪区域
ctx.beginPath();
ctx.moveTo(peakX, peakY);
ctx.lineTo(leftBaseX, baseY);
ctx.lineTo(rightBaseX, baseY);
ctx.closePath();
ctx.clip();
// 绘制瓦片行
const tileW = Math.round(20 * houseScale);
const tileH = Math.round(7 * houseScale);
const tileGap = Math.round(3 * houseScale);
for (let row = 0; row < totalRows; row++) {
const rowBottom = baseY - row * rowHeight;
const rowTop = baseY - (row + 1) * rowHeight;
// 计算该行在三角形内的左右边界
const t = (row + 0.5) / totalRows;
const rowCenterX = peakX;
const halfWidthAtRow = (roofWidth / 2) * t;
const rowLeft = peakX - halfWidthAtRow;
const rowRight = peakX + halfWidthAtRow;
if (row > rowsToShow && row > rowsToShow + 1) continue;
let rowOpacity = 1;
if (row === rowsToShow && rowsToShow < totalRows - 1) {
rowOpacity = partialProgress;
}
if (row > rowsToShow) continue;
// 奇数行偏移
const offset = (row % 2 === 0) ? 0 : tileW / 2;
const startX = rowLeft + offset;
const endX = rowRight;
ctx.globalAlpha = rowOpacity;
for (let tx = startX; tx < endX; tx += tileW + tileGap) {
const tileX = tx;
const tileY = rowTop;
const tw = Math.min(tileW, endX - tx - tileGap);
if (tw <= 0) continue;
// 瓦片颜色微变
const hueShift = (row * 7 + Math.floor(tx / tileW) * 3) % 5;
const shade = hueShift < 2 ? '#d32f2f' : hueShift < 4 ? '#c62828' : '#b71c1c';
ctx.fillStyle = shade;
roundRect(ctx, tileX, tileY, tw, tileH, 2);
ctx.fill();
// 瓦片高光
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(tileX + 2, tileY + 1, tw - 4, tileH * 0.4);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
function drawDoor(ctx, progress) {
if (progress <= 0) return;
const p = easeInOutCubic(clamp(progress, 0, 1));
const doorW = Math.round(58 * houseScale);
const doorH = Math.round(105 * houseScale);
const doorX = cx - doorW / 2;
const doorY = groundY - doorH - Math.round(5 * houseScale);
ctx.globalAlpha = p;
// 门框阴影
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(doorX + 3, doorY + 3, doorW, doorH);
// 门板
const doorGrad = ctx.createLinearGradient(doorX, doorY, doorX + doorW, doorY);
doorGrad.addColorStop(0, '#8d6e63');
doorGrad.addColorStop(0.3, '#a1887f');
doorGrad.addColorStop(0.7, '#795548');
doorGrad.addColorStop(1, '#5d4037');
ctx.fillStyle = doorGrad;
roundRect(ctx, doorX, doorY, doorW, doorH, 3 * houseScale);
ctx.fill();
// 门板边框
ctx.strokeStyle = '#4e342e';
ctx.lineWidth = 2;
roundRect(ctx, doorX, doorY, doorW, doorH, 3 * houseScale);
ctx.stroke();
// 门板中间装饰线
ctx.strokeStyle = 'rgba(90,60,40,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(doorX + 6, doorY + 8);
ctx.lineTo(doorX + doorW - 6, doorY + 8);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(doorX + 6, doorY + doorH - 8);
ctx.lineTo(doorX + doorW - 6, doorY + doorH - 8);
ctx.stroke();
// 门把手
const handleX = doorX + doorW - Math.round(14 * houseScale);
const handleY = doorY + doorH / 2;
const handleR = Math.round(5 * houseScale);
// 把手底座
ctx.fillStyle = '#5d4037';
ctx.beginPath();
ctx.arc(handleX, handleY, handleR + 2, 0, Math.PI * 2);
ctx.fill();
// 把手主体
const hGrad = ctx.createRadialGradient(handleX - 1, handleY - 1, 0, handleX, handleY, handleR);
hGrad.addColorStop(0, '#ffe082');
hGrad.addColorStop(0.5, '#ffc107');
hGrad.addColorStop(1, '#c79100');
ctx.fillStyle = hGrad;
ctx.beginPath();
ctx.arc(handleX, handleY, handleR, 0, Math.PI * 2);
ctx.fill();
// 把手高光
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath();
ctx.arc(handleX - 1.5, handleY - 1.5, handleR * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
function drawWindows(ctx, progress) {
if (progress <= 0) return;
const p = easeOutBack(clamp(progress, 0, 1));
const winW = Math.round(48 * houseScale);
const winH = Math.round(58 * houseScale);
const doorW = Math.round(58 * houseScale);
const doorH = Math.round(105 * houseScale);
const doorX = cx - doorW / 2;
const doorY = groundY - doorH - Math.round(5 * houseScale);
const gap = Math.round(16 * houseScale);
const leftWinX = doorX - gap - winW;
const rightWinX = doorX + doorW + gap;
const winY = doorY + Math.round(5 * houseScale);
ctx.globalAlpha = p;
[leftWinX, rightWinX].forEach((wx, wi) => {
// 窗户阴影
ctx.fillStyle = 'rgba(0,0,0,0.2)';
roundRect(ctx, wx + 3, winY + 3, winW, winH, 4 * houseScale);
ctx.fill();
// 窗框
ctx.fillStyle = '#5d4037';
roundRect(ctx, wx, winY, winW, winH, 4 * houseScale);
ctx.fill();
// 玻璃
const glassGrad = ctx.createLinearGradient(wx, winY, wx, winY + winH);
glassGrad.addColorStop(0, '#64b5f6');
glassGrad.addColorStop(0.5, '#42a5f5');
glassGrad.addColorStop(1, '#1e88e5');
ctx.fillStyle = glassGrad;
roundRect(ctx, wx + 3, winY + 3, winW - 6, winH - 6, 2 * houseScale);
ctx.fill();
// 玻璃高光
ctx.fillStyle = 'rgba(255,255,255,0.3)';
roundRect(ctx, wx + 5, winY + 5, winW - 14, winH * 0.3, 2);
ctx.fill();
// 十字窗框
ctx.strokeStyle = '#4e342e';
ctx.lineWidth = Math.round(3 * houseScale);
// 垂直
ctx.beginPath();
ctx.moveTo(wx + winW / 2, winY + 4);
ctx.lineTo(wx + winW / 2, winY + winH - 4);
ctx.stroke();
// 水平
ctx.beginPath();
ctx.moveTo(wx + 4, winY + winH / 2);
ctx.lineTo(wx + winW - 4, winY + winH / 2);
ctx.stroke();
// 窗框外边框
ctx.strokeStyle = '#3e2723';
ctx.lineWidth = 2.5;
roundRect(ctx, wx, winY, winW, winH, 4 * houseScale);
ctx.stroke();
});
ctx.globalAlpha = 1;
}
function drawChimney(ctx, progress) {
if (progress <= 0) return;
const p = easeOutBack(clamp(progress, 0, 1));
const chimW = Math.round(22 * houseScale);
const chimH = Math.round(42 * houseScale);
const chimX = cx + Math.round(85 * houseScale);
// 计算屋顶表面y
const dx = chimX - cx;
const t = dx / (roofWidth / 2);
const roofSurfY = lerp(roofTopY, wallTopY, Math.abs(t));
const chimTopY = roofSurfY - chimH * p;
const chimBotY = roofSurfY;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.18)';
roundRect(ctx, chimX - chimW / 2 + 3, chimBotY - chimH * p + 3, chimW, chimH * p, 3);
ctx.fill();
// 烟囱主体
const chimGrad = ctx.createLinearGradient(chimX - chimW / 2, 0, chimX + chimW / 2, 0);
chimGrad.addColorStop(0, '#8d6e63');
chimGrad.addColorStop(0.4, '#a1887f');
chimGrad.addColorStop(0.6, '#795548');
chimGrad.addColorStop(1, '#5d4037');
ctx.fillStyle = chimGrad;
roundRect(ctx, chimX - chimW / 2, chimBotY - chimH * p, chimW, chimH * p, 3 * houseScale);
ctx.fill();
// 烟囱顶部边缘
ctx.fillStyle = '#6d4c41';
roundRect(ctx, chimX - chimW / 2 - 2, chimBotY - chimH * p - 2, chimW + 4, 6 * houseScale, 2);
ctx.fill();
return { chimX, chimTopY: chimBotY - chimH * p };
}
function drawPathStones(ctx, progress) {
if (progress <= 0) return;
const p = easeOutCubic(clamp(progress, 0, 1));
const doorW = Math.round(58 * houseScale);
const doorX = cx - doorW / 2;
const doorBotY = groundY - Math.round(3 * houseScale);
const stonePositions = [
{ x: cx - 2, y: H - 45, w: 28, h: 12 },
{ x: cx - 18, y: H - 80, w: 24, h: 10 },
{ x: cx + 12, y: H - 115, w: 26, h: 11 },
{ x: cx - 8, y: H - 148, w: 22, h: 9 },
{ x: cx + 5, y: H - 175, w: 20, h: 8 },
];
stonePositions.forEach((s, i) => {
const sp = clamp(p * 1.5 - i * 0.2, 0, 1);
if (sp <= 0) return;
ctx.globalAlpha = sp;
// 石头阴影
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(s.x + 2, s.y + 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
// 石头主体
const stoneGrad = ctx.createRadialGradient(s.x - 2, s.y - 2, 0, s.x, s.y, s.w / 2);
stoneGrad.addColorStop(0, '#9e9e9e');
stoneGrad.addColorStop(0.6, '#757575');
stoneGrad.addColorStop(1, '#616161');
ctx.fillStyle = stoneGrad;
ctx.beginPath();
ctx.ellipse(s.x, s.y, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
}
function drawTree(ctx, progress) {
if (progress <= 0) return;
const p = easeOutCubic(clamp(progress, 0, 1));
const treeX = cx - wallWidth / 2 - depthWidth - Math.round(35 * houseScale);
const treeBaseY = groundY;
const trunkW = Math.round(14 * houseScale);
const trunkH = Math.round(55 * houseScale) * p;
const crownR = Math.round(38 * houseScale) * Math.min(p * 1.3, 1);
if (trunkH < 1) return;
const trunkTopY = treeBaseY - trunkH;
// 树干阴影
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(treeX - trunkW / 2 + 2, trunkTopY + 2, trunkW, trunkH);
// 树干
const trunkGrad = ctx.createLinearGradient(treeX - trunkW / 2, 0, treeX + trunkW / 2, 0);
trunkGrad.addColorStop(0, '#5d4037');
trunkGrad.addColorStop(0.5, '#795548');
trunkGrad.addColorStop(1, '#4e342e');
ctx.fillStyle = trunkGrad;
ctx.fillRect(treeX - trunkW / 2, trunkTopY, trunkW, trunkH);
// 树冠
if (crownR > 2) {
const crownY = trunkTopY - crownR * 0.3;
// 树冠阴影
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.beginPath();
ctx.arc(treeX + 2, crownY + 2, crownR, 0, Math.PI * 2);
ctx.fill();
// 树冠主体
const crownGrad = ctx.createRadialGradient(treeX - crownR * 0.25, crownY - crownR * 0.3, 0, treeX, crownY, crownR);
crownGrad.addColorStop(0, '#66bb6a');
crownGrad.addColorStop(0.5, '#43a047');
crownGrad.addColorStop(1, '#2e7d32');
ctx.fillStyle = crownGrad;
ctx.beginPath();
ctx.arc(treeX, crownY, crownR, 0, Math.PI * 2);
ctx.fill();
// 树冠高光
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.arc(treeX - crownR * 0.3, crownY - crownR * 0.35, crownR * 0.45, 0, Math.PI * 2);
ctx.fill();
}
}
function drawSmoke(ctx, chimX, chimTopY, dt) {
// 生成新粒子
if (chimX !== undefined && chimTopY !== undefined) {
const now = performance.now() / 1000;
if (now - lastSmokeTime > 0.22) {
smokeParticles.push({
x: chimX + (Math.random() - 0.5) * 8 * houseScale,
y: chimTopY,
vx: (Math.random() - 0.5) * 12 * houseScale,
vy: -(30 + Math.random() * 20) * houseScale,
radius: (3 + Math.random() * 3) * houseScale,
opacity: 0.5 + Math.random() * 0.3,
life: 1.8 + Math.random() * 1.2,
maxLife: 1.8 + Math.random() * 1.2,
});
lastSmokeTime = now;
}
}
// 更新和绘制粒子
const alive = [];
smokeParticles.forEach(p => {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy *= 0.995;
p.radius += dt * 6 * houseScale;
p.life -= dt;
if (p.life > 0) {
const alpha = p.opacity * clamp(p.life / p.maxLife, 0, 1);
ctx.fillStyle = `rgba(220,220,220,${alpha})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
alive.push(p);
}
});
smokeParticles = alive;
}
function drawSubtitle(ctx, subtitle, globalTime) {
if (!subtitle) return;
const fontSize = Math.round(28 * houseScale);
ctx.font = `bold ${fontSize}px "Arial", "PingFang SC", "Microsoft YaHei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const textY = groundY - wallHeight - roofHeight - Math.round(50 * houseScale);
const clampedY = Math.max(20, textY);
// 背景
const metrics = ctx.measureText(subtitle);
const paddingX = Math.round(16 * houseScale);
const paddingY = Math.round(8 * houseScale);
const bgW = metrics.width + paddingX * 2;
const bgH = fontSize + paddingY * 2;
const bgX = cx - bgW / 2;
const bgY = clampedY - paddingY;
ctx.fillStyle = 'rgba(30,30,50,0.7)';
ctx.beginPath();
ctx.roundRect(bgX, bgY, bgW, bgH, Math.round(10 * houseScale));
ctx.fill();
// 文字
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
ctx.fillText(subtitle, cx, clampedY);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
}
// ========== 主循环 ==========
let lastTimestamp = 0;
let cycleStartTime = null;
function animate(timestamp) {
if (!cycleStartTime) cycleStartTime = timestamp;
const elapsed = (timestamp - cycleStartTime) / 1000;
const globalTime = elapsed % totalCycle;
// 清除画布
ctx.clearRect(0, 0, W, H);
// 绘制天空和地面(始终存在)
drawSky(ctx);
drawGround(ctx);
// 计算各阶段进度
const f0 = getPhaseProgress(globalTime, phases[0]); // 地基
const f1 = getPhaseProgress(globalTime, phases[1]); // 砌墙
const f2 = getPhaseProgress(globalTime, phases[2]); // 填墙
const f3 = getPhaseProgress(globalTime, phases[3]); // 屋顶
const f4 = getPhaseProgress(globalTime, phases[4]); // 门
const f5 = getPhaseProgress(globalTime, phases[5]); // 窗
const f6 = getPhaseProgress(globalTime, phases[6]); // 细节
// 检查阶段是否已完成
const c0 = globalTime >= phases[0].start + phases[0].duration;
const c1 = globalTime >= phases[1].start + phases[1].duration;
const c2 = globalTime >= phases[2].start + phases[2].duration;
const c3 = globalTime >= phases[3].start + phases[3].duration;
const c4 = globalTime >= phases[4].start + phases[4].duration;
const c5 = globalTime >= phases[5].start + phases[5].duration;
// 绘制地基
drawFoundation(ctx, c0 ? 1 : f0);
// 绘制侧面墙(砌墙阶段开始后可见,填墙阶段填充)
if (f1 > 0 || c1) {
const sideProgress = c2 ? 1 : (f1 > 0 ? f1 : 0);
drawSideWall(ctx, sideProgress);
// 填充侧面墙
if (f2 > 0 || c2) {
// 侧面墙填充在drawFilledWalls中处理
}
}
// 绘制墙框(砌墙阶段)
if (f1 > 0 || c1) {
drawWallFrames(ctx, c1 ? 1 : f1);
}
// 绘制填充墙(填墙阶段)
if (f2 > 0 || c2) {
drawFilledWalls(ctx, c2 ? 1 : f2);
}
// 绘制屋顶滑入
if (f3 > 0 || c3) {
const roofSlideDuration = 1.8; // 前1.8秒滑入
const roofSlideLocal = globalTime - phases[3].start;
const roofSlideProgress = clamp(roofSlideLocal / roofSlideDuration, 0, 1);
const roofEased = easeOutCubic(roofSlideProgress);
drawRoofSlope(ctx, 'left', roofEased);
drawRoofSlope(ctx, 'right', roofEased);
// 瓦片纹理(滑入完成后逐行出现)
if (roofEased > 0.85) {
const tileStartProgress = clamp((roofSlideLocal - roofSlideDuration * 0.75) / (phases[3].duration - roofSlideDuration * 0.75), 0, 1);
drawRoofTiles(ctx, tileStartProgress);
} else if (c3) {
drawRoofTiles(ctx, 1);
}
} else if (c3) {
// 已完成的屋顶
drawRoofSlope(ctx, 'left', 1);
drawRoofSlope(ctx, 'right', 1);
drawRoofTiles(ctx, 1);
}
// 绘制门
if (f4 > 0 || c4) {
drawDoor(ctx, c4 ? 1 : f4);
}
// 绘制窗户
if (f5 > 0 || c5) {
drawWindows(ctx, c5 ? 1 : f5);
}
// 绘制烟囱(细节阶段)
if (f6 > 0 || c6) {
const chimResult = drawChimney(ctx, c6 ? 1 : f6);
// 烟(在烟囱升起后开始冒)
if (chimResult && f6 > 0.3) {
const dt = Math.min(0.05, (timestamp - lastTimestamp) / 1000);
drawSmoke(ctx, chimResult.chimX, chimResult.chimTopY, dt);
}
}
// 绘制踏脚石
if (f6 > 0 || c6) {
drawPathStones(ctx, c6 ? 1 : f6);
}
// 绘制小树
if (f6 > 0 || c6) {
drawTree(ctx, c6 ? 1 : f6);
}
// 绘制字幕
const subtitle = getSubtitle(globalTime);
drawSubtitle(ctx, subtitle, globalTime);
// 循环重置处理
if (globalTime < 0.3 && elapsed > 0.5) {
// 刚进入新循环,清除粒子
smokeParticles = [];
lastSmokeTime = performance.now() / 1000;
}
lastTimestamp = timestamp;
requestAnimationFrame(animate);
}
// 启动
// 初始化烟时间
lastSmokeTime = performance.now() / 1000;
lastTimestamp = performance.now();
// 设置初始周期开始时间
cycleStartTime = performance.now();
requestAnimationFrame(animate);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>卡通房屋建造动画</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
overflow: hidden;
}
body {
width: 100vw;
height: 100vh;
background: #000;
}
canvas {
display: block;
position: absolute;
top: 0;
left: 0;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(function() {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H, dpr;
let houseCX, houseW, houseH, groundY, wallTop, roofPeakY, foundationThick;
let houseLeft, houseRight, roofHeight;
let smokeParticles = [];
let lastLoopTime = -1;
const TOTAL_CYCLE = 25;
const STAGES = [
{ name: '打地基', start: 0, dur: 3 },
{ name: '砌墙', start: 3.5, dur: 3 },
{ name: '填墙', start: 7, dur: 3 },
{ name: '盖屋顶', start: 10.5, dur: 3 },
{ name: '安门', start: 14, dur: 3 },
{ name: '装窗', start: 17.5, dur: 3 },
{ name: '完工啦', start: 21, dur: 3 },
];
function resize() {
dpr = window.devicePixelRatio || 1;
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
houseW = Math.min(W * 0.24, 220);
houseH = houseW * 0.88;
houseCX = W * 0.42;
groundY = H * 0.64;
wallTop = groundY - houseH;
roofHeight = houseW * 0.5;
roofPeakY = wallTop - roofHeight;
foundationThick = Math.max(houseW * 0.09, 12);
houseLeft = houseCX - houseW / 2;
houseRight = houseCX + houseW / 2;
}
function easeOutBack(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function easeOutElastic(t) {
if (t === 0 || t === 1) return t;
return Math.pow(2, -10 * t) * Math.sin((t - 1) * (2 * Math.PI) / 0.4) + 1;
}
function getStageProgress(timeInCycle, stageIndex) {
const s = STAGES[stageIndex];
if (timeInCycle < s.start) return 0;
if (timeInCycle > s.start + s.dur) return 1;
return (timeInCycle - s.start) / s.dur;
}
function getActiveStageIndex(timeInCycle) {
for (let i = STAGES.length - 1; i >= 0; i--) {
if (timeInCycle >= STAGES[i].start) return i;
}
return 0;
}
function getSubtitle(timeInCycle) {
for (let i = STAGES.length - 1; i >= 0; i--) {
if (timeInCycle >= STAGES[i].start) {
return STAGES[i].name;
}
}
return STAGES[0].name;
}
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, groundY);
grad.addColorStop(0, '#c9e8f7');
grad.addColorStop(0.7, '#dff0f9');
grad.addColorStop(1, '#e8f4fb');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, groundY);
}
function drawGround() {
const grad = ctx.createLinearGradient(0, groundY, 0, H);
grad.addColorStop(0, '#7cba5f');
grad.addColorStop(0.03, '#6aaf4d');
grad.addColorStop(0.25, '#5d9d3f');
grad.addColorStop(1, '#4a8032');
ctx.fillStyle = grad;
ctx.fillRect(0, groundY, W, H - groundY);
ctx.fillStyle = '#558d36';
ctx.fillRect(0, groundY, W, 3);
ctx.fillStyle = '#6db84a';
ctx.fillRect(0, groundY + 1, W, 2);
}
function drawClouds(timeInCycle) {
const clouds = [
{ x: W * 0.15, y: H * 0.1, s: 0.8, speed: 0.12 },
{ x: W * 0.5, y: H * 0.07, s: 1.1, speed: 0.08 },
{ x: W * 0.78, y: H * 0.14, s: 0.65, speed: 0.15 },
];
clouds.forEach(c => {
const cx = ((c.x + timeInCycle * c.speed * W / TOTAL_CYCLE) % (W + 200)) - 100;
const cy = c.y;
const s = c.s;
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.beginPath();
ctx.arc(cx, cy, 30 * s, 0, Math.PI * 2);
ctx.arc(cx + 25 * s, cy - 12 * s, 22 * s, 0, Math.PI * 2);
ctx.arc(cx + 50 * s, cy - 2 * s, 26 * s, 0, Math.PI * 2);
ctx.arc(cx + 28 * s, cy + 8 * s, 20 * s, 0, Math.PI * 2);
ctx.arc(cx + 45 * s, cy + 5 * s, 18 * s, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.beginPath();
ctx.arc(cx + 55 * s, cy - 6 * s, 15 * s, 0, Math.PI * 2);
ctx.fill();
});
}
function drawFoundation(progress) {
if (progress <= 0) return;
const p = easeOutBack(Math.min(1, progress));
const fTop = groundY;
const fBottom = groundY + foundationThick;
const fWidth = houseW + foundationThick * 1.2;
const fLeft = houseCX - fWidth / 2;
const hiddenOffset = foundationThick + 20;
const currentTop = fTop + hiddenOffset * (1 - p);
ctx.save();
ctx.beginPath();
ctx.rect(fLeft, currentTop, fWidth, fBottom - currentTop);
ctx.clip();
const grad = ctx.createLinearGradient(0, fTop - 5, 0, fBottom + 5);
grad.addColorStop(0, '#c4c4c4');
grad.addColorStop(0.3, '#b0b0b0');
grad.addColorStop(0.6, '#9a9a9a');
grad.addColorStop(1, '#7d7d7d');
ctx.fillStyle = grad;
ctx.fillRect(fLeft, fTop - 1, fWidth, fBottom - fTop + 2);
ctx.fillStyle = '#d5d5d5';
ctx.fillRect(fLeft, currentTop, fWidth, 3);
ctx.fillStyle = '#8a8a8a';
ctx.fillRect(fLeft, fBottom - 2, fWidth, 2);
ctx.restore();
}
function drawWallPosts(progress) {
if (progress <= 0) return;
const p = easeOutCubic(Math.min(1, progress));
const postW = houseW * 0.06;
const posts = [
{ x: houseLeft + postW / 2, h: houseH },
{ x: houseRight - postW / 2, h: houseH },
{ x: houseLeft + houseW * 0.28, h: houseH },
{ x: houseRight - houseW * 0.28, h: houseH },
];
posts.forEach(post => {
const currentH = post.h * p;
const postBottom = groundY;
const postTop = postBottom - currentH;
const grad = ctx.createLinearGradient(post.x - postW / 2, 0, post.x + postW / 2, 0);
grad.addColorStop(0, '#d4c0a8');
grad.addColorStop(0.3, '#e0cfb8');
grad.addColorStop(0.7, '#c8b090');
grad.addColorStop(1, '#b09878');
ctx.fillStyle = grad;
ctx.fillRect(post.x - postW / 2, postTop, postW, currentH);
ctx.fillStyle = 'rgba(0,0,0,0.08)';
ctx.fillRect(post.x - postW / 2, postTop, postW, 2);
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(post.x - postW / 2, postTop, 2, currentH);
});
}
function drawWallFill(progress) {
if (progress <= 0) return;
const p = easeInOutCubic(Math.min(1, progress));
const fillTop = wallTop;
const fillBottom = groundY;
const fullH = fillBottom - fillTop;
const currentH = fullH * p;
const currentTop = fillBottom - currentH;
ctx.save();
ctx.beginPath();
ctx.rect(houseLeft + 2, currentTop, houseW - 4, currentH);
ctx.clip();
const grad = ctx.createLinearGradient(0, fillTop, 0, fillBottom);
grad.addColorStop(0, '#f5e6d3');
grad.addColorStop(0.3, '#fdf2e4');
grad.addColorStop(0.7, '#f0ddc4');
grad.addColorStop(1, '#e8d0b0');
ctx.fillStyle = grad;
ctx.fillRect(houseLeft + 2, fillTop, houseW - 4, fullH);
for (let row = 0; row < Math.floor(fullH / 16); row++) {
const ry = fillTop + row * 16 + 2;
if (ry < currentTop) continue;
ctx.fillStyle = 'rgba(0,0,0,0.03)';
ctx.fillRect(houseLeft + 4, ry, houseW - 8, 1);
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(houseLeft + 4, ry + 1, houseW - 8, 1);
}
ctx.restore();
}
function drawRoof(progress) {
if (progress <= 0) return;
const slideProgress = Math.min(1, progress / 0.55);
const tileProgress = Math.max(0, Math.min(1, (progress - 0.45) / 0.55));
const slideP = easeOutBack(Math.min(1, slideProgress));
const slideOffset = roofHeight * 1.3 * (1 - slideP);
const peakX = houseCX;
const peakY = roofPeakY;
const leftX = houseLeft - houseW * 0.04;
const rightX = houseRight + houseW * 0.04;
const baseY = wallTop;
const currentPeakY = peakY + slideOffset;
ctx.save();
ctx.beginPath();
ctx.moveTo(peakX, currentPeakY);
ctx.lineTo(leftX, baseY + slideOffset);
ctx.lineTo(peakX, baseY + slideOffset);
ctx.closePath();
ctx.clip();
const gradL = ctx.createLinearGradient(0, currentPeakY, 0, baseY + slideOffset);
gradL.addColorStop(0, '#d4453b');
gradL.addColorStop(0.5, '#c0392b');
gradL.addColorStop(1, '#a93226');
ctx.fillStyle = gradL;
ctx.fill();
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.moveTo(peakX, currentPeakY);
ctx.lineTo(rightX, baseY + slideOffset);
ctx.lineTo(peakX, baseY + slideOffset);
ctx.closePath();
ctx.clip();
const gradR = ctx.createLinearGradient(0, currentPeakY, 0, baseY + slideOffset);
gradR.addColorStop(0, '#e0554b');
gradR.addColorStop(0.5, '#cd4035');
gradR.addColorStop(1, '#b5352a');
ctx.fillStyle = gradR;
ctx.fill();
ctx.restore();
if (tileProgress > 0 && slideProgress >= 0.95) {
const tp = easeOutCubic(tileProgress);
const totalTileRows = Math.floor(roofHeight / 10);
const visibleRows = Math.floor(totalTileRows * tp);
for (let row = 0; row < visibleRows; row++) {
const ry = roofPeakY + row * 10 + 5;
if (ry >= wallTop) continue;
const frac = (ry - roofPeakY) / roofHeight;
const halfSpread = (houseW / 2 + houseW * 0.04) * frac;
const lx = peakX - halfSpread;
const rx = peakX + halfSpread;
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.fillRect(lx + 2, ry, (rx - lx - 4), 2);
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(lx + 2, ry + 3, (rx - lx - 4), 1);
if (row > 0 && row % 2 === 0) {
ctx.fillStyle = 'rgba(0,0,0,0.08)';
const notchX = lx + 8 + (row * 7) % (halfSpread * 2 - 16);
ctx.fillRect(notchX, ry - 1, 10, 2);
}
}
}
}
function drawDoor(progress) {
if (progress <= 0) return;
const p = Math.min(1, progress);
const alpha = easeOutCubic(p);
const doorW = houseW * 0.22;
const doorH = houseH * 0.6;
const doorX = houseCX - doorW / 2;
const doorY = groundY - doorH;
const doorTopRound = doorW * 0.5;
ctx.globalAlpha = alpha;
ctx.save();
ctx.beginPath();
ctx.moveTo(doorX, groundY);
ctx.lineTo(doorX, doorY + doorTopRound);
ctx.arcTo(doorX, doorY, doorX + doorW / 2, doorY, doorTopRound);
ctx.arcTo(doorX + doorW, doorY, doorX + doorW, doorY + doorTopRound, doorTopRound);
ctx.lineTo(doorX + doorW, groundY);
ctx.closePath();
ctx.clip();
const grad = ctx.createLinearGradient(doorX, 0, doorX + doorW, 0);
grad.addColorStop(0, '#7d4a2f');
grad.addColorStop(0.3, '#9b5d3b');
grad.addColorStop(0.6, '#8b5033');
grad.addColorStop(1, '#5c3420');
ctx.fillStyle = grad;
ctx.fillRect(doorX, doorY, doorW, doorH + doorTopRound);
for (let i = 0; i < 4; i++) {
const px = doorX + doorW * 0.1 + i * doorW * 0.22;
ctx.fillStyle = 'rgba(0,0,0,0.12)';
ctx.fillRect(px, doorY + 4, doorW * 0.13, doorH + doorTopRound - 8);
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(px + 1, doorY + 4, doorW * 0.11, doorH + doorTopRound - 8);
}
ctx.fillStyle = 'rgba(0,0,0,0.1)';
ctx.fillRect(doorX, doorY, doorW, doorH + doorTopRound);
ctx.restore();
const knobX = doorX + doorW * 0.75;
const knobY = doorY + doorH * 0.55;
const knobR = doorW * 0.08;
const knobGlow = ctx.createRadialGradient(knobX - knobR * 0.3, knobY - knobR * 0.3, knobR * 0.1, knobX, knobY,
knobR);
knobGlow.addColorStop(0, '#fce38a');
knobGlow.addColorStop(0.4, '#e8b730');
knobGlow.addColorStop(0.8, '#c4901a');
knobGlow.addColorStop(1, '#8a5f0c');
ctx.fillStyle = knobGlow;
ctx.beginPath();
ctx.arc(knobX, knobY, knobR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath();
ctx.arc(knobX - knobR * 0.25, knobY - knobR * 0.3, knobR * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
function drawWindows(progress) {
if (progress <= 0) return;
const p = Math.min(1, progress);
const alpha = easeOutCubic(p);
const winW = houseW * 0.16;
const winH = winW * 1.1;
const winY = wallTop + houseH * 0.25;
const leftWinX = houseLeft + houseW * 0.1;
const rightWinX = houseRight - houseW * 0.1 - winW;
const windows = [
{ x: leftWinX, y: winY },
{ x: rightWinX, y: winY },
];
ctx.globalAlpha = alpha;
windows.forEach(w => {
const sx = w.x;
const sy = w.y;
ctx.fillStyle = '#d5e8f5';
ctx.fillRect(sx - 3, sy - 3, winW + 6, winH + 6);
ctx.fillStyle = '#f5f0e8';
ctx.fillRect(sx - 2, sy - 2, winW + 4, winH + 4);
const glassGrad = ctx.createLinearGradient(sx, sy, sx + winW, sy + winH);
glassGrad.addColorStop(0, '#a8d8f0');
glassGrad.addColorStop(0.3, '#7cc8e8');
glassGrad.addColorStop(0.6, '#5db8dd');
glassGrad.addColorStop(1, '#3da0cc');
ctx.fillStyle = glassGrad;
ctx.fillRect(sx, sy, winW, winH);
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillRect(sx + 2, sy + 2, winW * 0.35, winH * 0.35);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(sx + winW * 0.55, sy + winH * 0.55, winW * 0.35, winH * 0.35);
ctx.fillStyle = '#f5f0e8';
ctx.fillRect(sx + winW / 2 - 2.5, sy, 5, winH);
ctx.fillRect(sx, sy + winH / 2 - 2.5, winW, 5);
ctx.fillStyle = '#e8ddd0';
ctx.fillRect(sx + winW / 2 - 1.5, sy, 3, winH);
ctx.fillRect(sx, sy + winH / 2 - 1.5, winW, 3);
});
ctx.globalAlpha = 1;
}
function drawChimney(progress) {
if (progress <= 0) return;
const p = easeOutBack(Math.min(1, progress));
const chimW = houseW * 0.1;
const chimFullH = houseW * 0.35;
const chimX = houseCX + houseW * 0.22;
const roofSlopeY = roofPeakY + (wallTop - roofPeakY) * ((chimX - houseCX) / (houseW / 2 + houseW * 0.04));
const chimBaseY = roofSlopeY;
const chimTopY = chimBaseY - chimFullH;
const currentH = chimFullH * p;
const currentTop = chimBaseY - currentH;
const grad = ctx.createLinearGradient(chimX - chimW / 2, 0, chimX + chimW / 2, 0);
grad.addColorStop(0, '#c4785a');
grad.addColorStop(0.3, '#d4896b');
grad.addColorStop(0.6, '#b8684d');
grad.addColorStop(1, '#8c4532');
ctx.fillStyle = grad;
ctx.fillRect(chimX - chimW / 2, currentTop, chimW, currentH);
ctx.fillStyle = '#a0553d';
ctx.fillRect(chimX - chimW / 2 - 3, currentTop, chimW + 6, 7);
ctx.fillStyle = '#d4896b';
ctx.fillRect(chimX - chimW / 2 - 2, currentTop + 1, chimW + 4, 5);
for (let i = 0; i < Math.floor(currentH / 14); i++) {
const by = currentTop + i * 14 + 3;
ctx.fillStyle = 'rgba(0,0,0,0.1)';
ctx.fillRect(chimX - chimW / 2 + 1, by, chimW - 2, 1.5);
ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(chimX - chimW / 2 + 1, by + 2, chimW - 2, 1);
}
}
function updateAndDrawSmoke(timeInCycle, chimneyProgress) {
const chimW = houseW * 0.1;
const chimX = houseCX + houseW * 0.22;
const roofSlopeY = roofPeakY + (wallTop - roofPeakY) * ((chimX - houseCX) / (houseW / 2 + houseW * 0.04));
const chimFullH = houseW * 0.35;
const chimTopY = roofSlopeY - chimFullH;
if (chimneyProgress >= 0.7 && timeInCycle >= 21 && timeInCycle < 24.3) {
if (Math.random() < 0.25) {
smokeParticles.push({
x: chimX + (Math.random() - 0.5) * chimW * 0.7,
y: chimTopY - 2,
vx: (Math.random() - 0.5) * 0.6,
vy: -1.2 - Math.random() * 1.8,
life: 1,
size: 4 + Math.random() * 8,
decay: 0.006 + Math.random() * 0.014,
});
}
}
for (let i = smokeParticles.length - 1; i >= 0; i--) {
const sp = smokeParticles[i];
sp.x += sp.vx;
sp.y += sp.vy;
sp.life -= sp.decay;
if (sp.life <= 0) {
smokeParticles.splice(i, 1);
continue;
}
sp.vx += (Math.random() - 0.5) * 0.08;
sp.size += 0.06;
const alpha = sp.life * 0.55;
const sz = sp.size;
ctx.fillStyle = `rgba(190,190,195,${alpha})`;
ctx.beginPath();
ctx.arc(sp.x, sp.y, sz, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(210,210,215,${alpha * 0.7})`;
ctx.beginPath();
ctx.arc(sp.x - sz * 0.25, sp.y - sz * 0.2, sz * 0.6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawSteppingStones(progress) {
if (progress <= 0) return;
const p = Math.min(1, progress);
const stones = [
{ x: houseCX, y: groundY + 16, w: 22, h: 9 },
{ x: houseCX - 10, y: groundY + 34, w: 24, h: 10 },
{ x: houseCX + 8, y: groundY + 54, w: 20, h: 8 },
{ x: houseCX - 3, y: groundY + 72, w: 26, h: 10 },
{ x: houseCX + 12, y: groundY + 92, w: 22, h: 9 },
{ x: houseCX - 8, y: groundY + 110, w: 25, h: 10 },
];
const totalStones = stones.length;
const stonesToShow = Math.floor(p * totalStones + 0.01);
for (let i = 0; i < Math.min(stonesToShow, totalStones); i++) {
const stoneProgress = Math.min(1, (p * totalStones - i) / 0.8);
const sp = easeOutCubic(Math.max(0, stoneProgress));
const s = stones[i];
const alpha = sp;
const sy = s.y + (1 - sp) * 8;
ctx.fillStyle = `rgba(165,155,145,${alpha})`;
ctx.beginPath();
ctx.ellipse(s.x, sy, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(185,175,165,${alpha})`;
ctx.beginPath();
ctx.ellipse(s.x, sy - 1, s.w / 2 - 2, s.h / 3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(140,130,120,${alpha * 0.6})`;
ctx.beginPath();
ctx.ellipse(s.x, sy + s.h / 4, s.w / 2 - 1, s.h / 4, 0, 0, Math.PI * 2);
ctx.fill();
}
}
function drawTree(progress) {
if (progress <= 0) return;
const p = easeOutElastic(Math.min(1, progress));
const treeX = houseRight + houseW * 0.35;
const treeBaseY = groundY;
const trunkW = houseW * 0.07;
const trunkH = houseH * 0.5;
const trunkTopY = treeBaseY - trunkH;
const currentTrunkH = trunkH * p;
const currentTrunkTop = treeBaseY - currentTrunkH;
const scaleP = p;
ctx.save();
ctx.fillStyle = '#8b6914';
ctx.fillRect(treeX - trunkW / 2, currentTrunkTop, trunkW, currentTrunkH);
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fillRect(treeX - trunkW / 2, currentTrunkTop, trunkW * 0.3, currentTrunkH);
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect(treeX + trunkW * 0.15, currentTrunkTop, trunkW * 0.25, currentTrunkH);
if (p > 0.5) {
const crownP = Math.min(1, (p - 0.5) / 0.5);
const cp = easeOutBack(crownP);
const crownBaseY = trunkTopY;
const crownCX = treeX;
const crownCY = crownBaseY - houseW * 0.2;
const crownR = houseW * 0.18;
ctx.globalAlpha = cp;
const circles = [
{ dx: 0, dy: -crownR * 0.5, r: crownR * 0.85 },
{ dx: -crownR * 0.55, dy: crownR * 0.05, r: crownR * 0.7 },
{ dx: crownR * 0.5, dy: crownR * 0.1, r: crownR * 0.72 },
{ dx: -crownR * 0.2, dy: -crownR * 0.85, r: crownR * 0.6 },
{ dx: crownR * 0.15, dy: -crownR * 0.75, r: crownR * 0.55 },
{ dx: 0, dy: crownR * 0.3, r: crownR * 0.65 },
];
circles.forEach(c => {
const grad = ctx.createRadialGradient(crownCX + c.dx - c.r * 0.25, crownCY + c.dy - c
.r * 0.3, c.r * 0.15, crownCX + c.dx, crownCY + c.dy, c.r);
grad.addColorStop(0, '#7cc850');
grad.addColorStop(0.5, '#5daa35');
grad.addColorStop(0.85, '#3d8020');
grad.addColorStop(1, '#2d6018');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(crownCX + c.dx, crownCY + c.dy, c.r, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
}
ctx.restore();
}
function drawSubtitleText(timeInCycle) {
const subtitle = getSubtitle(timeInCycle);
const fontSize = Math.max(22, Math.min(W * 0.04, 36));
ctx.font = `bold ${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC",sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const tx = W / 2;
const ty = H * 0.04;
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillText(subtitle, tx + 2, ty + 3);
ctx.fillStyle = '#2c3e50';
ctx.fillText(subtitle, tx, ty + 1);
ctx.fillStyle = '#ffffff';
ctx.fillText(subtitle, tx - 1, ty);
ctx.fillStyle = '#34495e';
ctx.fillText(subtitle, tx, ty);
}
function drawSun() {
const sunX = W * 0.82;
const sunY = H * 0.13;
const sunR = Math.min(W * 0.06, 45);
const glowR = sunR * 2.5;
const glow = ctx.createRadialGradient(sunX, sunY, sunR * 0.5, sunX, sunY, glowR);
glow.addColorStop(0, 'rgba(255,250,230,0.9)');
glow.addColorStop(0.3, 'rgba(255,240,200,0.5)');
glow.addColorStop(0.6, 'rgba(255,220,150,0.12)');
glow.addColorStop(1, 'rgba(255,200,100,0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(sunX, sunY, glowR, 0, Math.PI * 2);
ctx.fill();
const sunGlow = ctx.createRadialGradient(sunX, sunY, sunR * 0.2, sunX, sunY, sunR);
sunGlow.addColorStop(0, '#fffef5');
sunGlow.addColorStop(0.5, '#fff8d0');
sunGlow.addColorStop(1, '#ffe8a0');
ctx.fillStyle = sunGlow;
ctx.beginPath();
ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
ctx.fill();
}
function drawScene(timeInCycle) {
ctx.clearRect(0, 0, W, H);
drawSky();
drawSun();
drawClouds(timeInCycle);
drawGround();
const fp = getStageProgress(timeInCycle, 0);
const wp = getStageProgress(timeInCycle, 1);
const wfp = getStageProgress(timeInCycle, 2);
const rp = getStageProgress(timeInCycle, 3);
const dp = getStageProgress(timeInCycle, 4);
const winp = getStageProgress(timeInCycle, 5);
const detp = getStageProgress(timeInCycle, 6);
drawFoundation(fp);
drawWallPosts(wp);
drawWallFill(wfp);
drawRoof(rp);
drawDoor(dp);
drawWindows(winp);
drawChimney(detp);
updateAndDrawSmoke(timeInCycle, detp);
drawSteppingStones(detp);
drawTree(detp);
drawSubtitleText(timeInCycle);
if (detp >= 1 && timeInCycle > 24 && timeInCycle < 25) {
const extraSmokeTime = timeInCycle - 24;
if (extraSmokeTime < 0.8 && Math.random() < 0.3) {
const chimW = houseW * 0.1;
const chimX = houseCX + houseW * 0.22;
const roofSlopeY = roofPeakY + (wallTop - roofPeakY) * ((chimX - houseCX) / (houseW / 2 + houseW *
0.04));
const chimFullH = houseW * 0.35;
const chimTopY = roofSlopeY - chimFullH;
smokeParticles.push({
x: chimX + (Math.random() - 0.5) * chimW * 0.6,
y: chimTopY - 2,
vx: (Math.random() - 0.5) * 0.5,
vy: -0.8 - Math.random() * 1.5,
life: 0.7 + Math.random() * 0.3,
size: 3 + Math.random() * 6,
decay: 0.01 + Math.random() * 0.02,
});
}
}
}
function animate(timestamp) {
const timeInSeconds = (timestamp / 1000) % TOTAL_CYCLE;
if (lastLoopTime > timeInSeconds + TOTAL_CYCLE - 1) {
smokeParticles = [];
}
lastLoopTime = timeInSeconds;
if (timeInSeconds < 0.05) {
smokeParticles = [];
}
drawScene(timeInSeconds);
requestAnimationFrame(animate);
}
window.addEventListener('resize', () => {
resize();
smokeParticles = [];
});
resize();
smokeParticles = [];
lastLoopTime = -1;
requestAnimationFrame(animate);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D 卡通房屋建造动画</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #8ecae6;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #b0d4f1;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
(function() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W, H, scale;
let groundY;
let house = {};
// 颜色定义
const COLORS = {
sky: '#b0d4f1',
ground: '#7ccd7c',
groundDark: '#5fb85f',
foundation: '#9a9a9a',
foundationDark: '#7a7a7a',
wallFrame: '#b5b5b5',
wallFrameDark: '#8a8a8a',
wallFill: '#f0e0c0',
wallFillDark: '#e0d0b0',
roofRed: '#c0392b',
roofRedLight: '#e74c3c',
roofLine: '#922b21',
door: '#8b5a2b',
doorDark: '#6b3f1a',
doorKnob: '#f1c40f',
windowFrame: '#ffffff',
windowGlass: '#a8d8ea',
windowGlassLight: '#c8e8f8',
windowCross: '#5a7a8a',
chimney: '#a0522d',
chimneyDark: '#7a3a1d',
smoke: 'rgba(220,220,220,0.7)',
pathStone: '#c0b090',
pathStoneDark: '#a09070',
treeTrunk: '#6d4c2a',
treeLeaf: '#4a9e4a',
treeLeafLight: '#6abe6a',
textBg: 'rgba(0,0,0,0.55)',
textColor: '#ffffff',
};
// 阶段名称
const PHASE_NAMES = [
'打地基',
'砌墙',
'填墙',
'盖屋顶',
'装门',
'安窗',
'细节装饰'
];
const TOTAL_PHASES = 7;
const PHASE_DUR = 3.0; // 每个阶段动画时长(秒)
const PAUSE_DUR = 0.5; // 停顿时长(秒)
const CYCLE_DUR = TOTAL_PHASES * (PHASE_DUR + PAUSE_DUR); // 24.5秒
const LOOP_DUR = CYCLE_DUR + 0.5; // 25秒循环
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
scale = Math.min(W, H) / 700;
groundY = H * 0.72;
const hw = W * 0.40;
const hh = hw * 0.72;
const fw = hw * 1.08;
const fh = hw * 0.07;
const wallW = hw * 0.035;
const roofH = hh * 0.46;
const doorW = hw * 0.19;
const doorH = hh * 0.57;
const winW = hw * 0.17;
const winH = hh * 0.30;
house = {
cx: W / 2,
groundY: groundY,
hw: hw,
hh: hh,
fw: fw,
fh: fh,
wallW: wallW,
roofH: roofH,
doorW: doorW,
doorH: doorH,
winW: winW,
winH: winH,
// 四段墙柱 x 位置 (从左到右)
wallX: [
W / 2 - hw / 2,
W / 2 - hw / 6,
W / 2 + hw / 6,
W / 2 + hw / 2 - wallW
],
chimneyW: hw * 0.055,
chimneyH: roofH * 0.55,
treeH: hh * 0.85,
treeTrunkW: hw * 0.025,
};
// 调整墙柱位置,确保对称
const halfW = hw / 2;
const cx = W / 2;
const ww = wallW;
house.wallX = [
cx - halfW,
cx - halfW * 0.33 - ww / 2,
cx + halfW * 0.33 - ww / 2,
cx + halfW - ww
];
}
// 缓动函数
function easeInOut(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeIn(t) {
return t * t * t;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
// 获取当前阶段信息
function getPhaseInfo(time) {
const t = time % LOOP_DUR;
const total = PHASE_DUR + PAUSE_DUR;
const phaseIndex = Math.floor(t / total);
const phase = Math.min(phaseIndex, TOTAL_PHASES - 1);
const localTime = t - phase * total;
const progress = clamp(localTime / PHASE_DUR, 0, 1);
const isPause = localTime > PHASE_DUR;
return {
phase: phase,
progress: progress,
isPause: isPause,
localTime: localTime,
globalT: t,
// 每个阶段的完成度:已完成=1,当前=progress,未完成=0
phaseProgress: (idx) => {
if (idx < phase) return 1;
if (idx === phase) return progress;
return 0;
}
};
}
// ---------- 绘制函数 ----------
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, groundY);
grad.addColorStop(0, '#b8ddf8');
grad.addColorStop(0.6, '#d4ecf7');
grad.addColorStop(1, '#e8f4f8');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, groundY + 2);
}
function drawGround() {
const grad = ctx.createLinearGradient(0, groundY, 0, H);
grad.addColorStop(0, COLORS.ground);
grad.addColorStop(0.15, COLORS.groundDark);
grad.addColorStop(1, '#4a8a4a');
ctx.fillStyle = grad;
ctx.fillRect(0, groundY, W, H - groundY);
// 地面线
ctx.strokeStyle = '#5a9a5a';
ctx.lineWidth = 2.5 * scale;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(W, groundY);
ctx.stroke();
// 远处小草
ctx.strokeStyle = '#6aaa6a';
ctx.lineWidth = 1.5 * scale;
for (let i = 0; i < 60; i++) {
const x = (i * 53 + 17) % W;
const y = groundY + 2 + (i * 7) % 12;
const h = 6 + (i * 3) % 10;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - 2 * scale, y - h, x + 1 * scale, y - h - 2);
ctx.stroke();
}
}
function drawFoundation(progress) {
const p = easeOut(progress);
const fy = groundY - house.fh * p;
const fw = house.fw;
const fh = house.fh;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.1)';
ctx.fillRect(house.cx - fw / 2 + 4 * scale, fy + 4 * scale, fw, fh);
// 主体
const grad = ctx.createLinearGradient(house.cx - fw / 2, fy, house.cx - fw / 2, fy + fh);
grad.addColorStop(0, '#b0b0b0');
grad.addColorStop(0.3, '#c8c8c8');
grad.addColorStop(1, '#8a8a8a');
ctx.fillStyle = grad;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 8 * scale;
ctx.fillRect(house.cx - fw / 2, fy, fw, fh);
ctx.shadowBlur = 0;
// 顶边线
ctx.strokeStyle = '#c0c0c0';
ctx.lineWidth = 1.5 * scale;
ctx.beginPath();
ctx.moveTo(house.cx - fw / 2, fy);
ctx.lineTo(house.cx + fw / 2, fy);
ctx.stroke();
// 底边线
ctx.strokeStyle = '#707070';
ctx.lineWidth = 1.5 * scale;
ctx.beginPath();
ctx.moveTo(house.cx - fw / 2, fy + fh);
ctx.lineTo(house.cx + fw / 2, fy + fh);
ctx.stroke();
// 水平线纹理
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 0.5 * scale;
for (let i = 1; i < 4; i++) {
const y = fy + fh * i / 4;
ctx.beginPath();
ctx.moveTo(house.cx - fw / 2 + 2, y);
ctx.lineTo(house.cx + fw / 2 - 2, y);
ctx.stroke();
}
}
function drawWallFrame(progress) {
const p = easeOut(progress);
const wallH = house.hh * p;
const baseY = groundY - house.fh;
for (let i = 0; i < 4; i++) {
const x = house.wallX[i];
const w = house.wallW;
const y = baseY - wallH;
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.08)';
ctx.fillRect(x + 2 * scale, y + 2 * scale, w, wallH);
// 立柱
const grad = ctx.createLinearGradient(x, y, x + w, y);
grad.addColorStop(0, '#cccccc');
grad.addColorStop(0.4, '#e0e0e0');
grad.addColorStop(0.6, '#d0d0d0');
grad.addColorStop(1, '#a0a0a0');
ctx.fillStyle = grad;
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 4 * scale;
ctx.fillRect(x, y, w, wallH);
ctx.shadowBlur = 0;
// 边缘高光
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1 * scale;
ctx.beginPath();
ctx.moveTo(x + 1, y + 2);
ctx.lineTo(x + 1, y + wallH - 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.beginPath();
ctx.moveTo(x + w - 1, y + 2);
ctx.lineTo(x + w - 1, y + wallH - 2);
ctx.stroke();
// 顶部装饰
ctx.fillStyle = '#c8c8c8';
ctx.fillRect(x - 1 * scale, y - 2 * scale, w + 2 * scale, 3 * scale);
}
// 顶部横梁 (随着墙体延伸)
if (p > 0.3) {
const beamP = clamp((p - 0.3) / 0.7, 0, 1);
const topY = baseY - house.hh * beamP;
ctx.fillStyle = '#b8b8b8';
ctx.shadowColor = 'rgba(0,0,0,0.08)';
ctx.shadowBlur = 3 * scale;
const beamH = 4 * scale;
ctx.fillRect(house.wallX[0] - 2 * scale, topY - beamH, house.wallX[3] + house.wallW - house.wallX[0] + 4 *
scale, beamH);
ctx.shadowBlur = 0;
}
}
function drawWallFill(progress) {
const p = easeOut(progress);
const wallH = house.hh;
const baseY = groundY - house.fh;
// 三个填充区域
const segments = [
[0, 1],
[1, 2],
[2, 3]
];
const colors = ['#f5e6c8', '#f0e0c0', '#f5e6c8'];
for (let s = 0; s < segments.length; s++) {
const leftIdx = segments[s][0];
const rightIdx = segments[s][1];
const x1 = house.wallX[leftIdx] + house.wallW;
const x2 = house.wallX[rightIdx];
const w = x2 - x1;
const fillH = wallH * p;
const y = baseY - fillH;
if (w <= 0 || fillH <= 0) continue;
// 墙板填充
const grad = ctx.createLinearGradient(x1, y, x1, baseY);
const c = colors[s];
grad.addColorStop(0, c);
grad.addColorStop(0.5, '#f8ecd4');
grad.addColorStop(1, '#e8d8b8');
ctx.fillStyle = grad;
ctx.shadowColor = 'rgba(0,0,0,0.05)';
ctx.shadowBlur = 2 * scale;
ctx.fillRect(x1, y, w, fillH);
ctx.shadowBlur = 0;
// 水平木板纹理
ctx.strokeStyle = 'rgba(180,160,130,0.25)';
ctx.lineWidth = 0.8 * scale;
const rows = Math.floor(fillH / (14 * scale));
for (let r = 0; r < rows; r++) {
const ly = baseY - (r + 0.5) * fillH / rows;
if (ly > y) {
ctx.beginPath();
ctx.moveTo(x1 + 2, ly);
ctx.lineTo(x2 - 2, ly);
ctx.stroke();
}
}
// 垂直拼缝
ctx.strokeStyle = 'rgba(160,140,110,0.15)';
ctx.lineWidth = 0.5 * scale;
const vcount = Math.floor(w / (25 * scale));
for (let c2 = 1; c2 < vcount; c2++) {
const vx = x1 + c2 * w / vcount;
ctx.beginPath();
ctx.moveTo(vx, y + 2);
ctx.lineTo(vx, baseY - 2);
ctx.stroke();
}
// 边缘装饰线
ctx.strokeStyle = 'rgba(200,180,160,0.3)';
ctx.lineWidth = 1 * scale;
ctx.beginPath();
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
ctx.stroke();
}
}
function drawRoof(progress) {
const p = easeOut(progress);
const cx = house.cx;
const baseY = groundY - house.fh - house.hh;
const roofH = house.roofH;
const hw = house.hw;
// 两个三角形滑入
const slide = p;
const leftX = lerp(cx - hw * 1.5, cx - hw, slide);
const rightX = lerp(cx + hw * 1.5, cx + hw, slide);
// 左半屋顶
ctx.beginPath();
ctx.moveTo(leftX, baseY);
ctx.lineTo(cx, baseY - roofH * slide);
ctx.lineTo(cx, baseY);
ctx.closePath();
const gradL = ctx.createLinearGradient(leftX, baseY, cx, baseY - roofH);
gradL.addColorStop(0, '#d44a3a');
gradL.addColorStop(0.5, '#c0392b');
gradL.addColorStop(1, '#a93226');
ctx.fillStyle = gradL;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 10 * scale;
ctx.fill();
ctx.shadowBlur = 0;
// 右半屋顶
ctx.beginPath();
ctx.moveTo(rightX, baseY);
ctx.lineTo(cx, baseY - roofH * slide);
ctx.lineTo(cx, baseY);
ctx.closePath();
const gradR = ctx.createLinearGradient(rightX, baseY, cx, baseY - roofH);
gradR.addColorStop(0, '#d44a3a');
gradR.addColorStop(0.5, '#c0392b');
gradR.addColorStop(1, '#a93226');
ctx.fillStyle = gradR;
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 10 * scale;
ctx.fill();
ctx.shadowBlur = 0;
// 屋脊
if (slide > 0.3) {
const ridgeP = clamp((slide - 0.3) / 0.7, 0, 1);
const ridgeY = baseY - roofH * slide;
ctx.strokeStyle = '#7a2a1a';
ctx.lineWidth = 3 * scale;
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 4 * scale;
ctx.beginPath();
ctx.moveTo(cx, ridgeY);
ctx.lineTo(cx, baseY);
ctx.stroke();
ctx.shadowBlur = 0;
}
// 红色瓦片纹理 逐行出现
if (p > 0.2) {
const tileP = clamp((p - 0.2) / 0.8, 0, 1);
const rows = 12;
const totalRows = Math.floor(rows * tileP);
for (let r = 0; r < totalRows; r++) {
const t = (r + 0.5) / rows;
const yPos = baseY - roofH * slide * t;
const halfWidth = (hw * slide) * (1 - t);
if (halfWidth < 2) continue;
// 瓦片线 - 左
ctx.strokeStyle = 'rgba(200,80,60,0.5)';
ctx.lineWidth = 1.2 * scale;
ctx.beginPath();
ctx.moveTo(cx - halfWidth, yPos);
ctx.lineTo(cx, yPos - roofH * slide * 0.06);
ctx.stroke();
// 瓦片线 - 右
ctx.beginPath();
ctx.moveTo(cx + halfWidth, yPos);
ctx.lineTo(cx, yPos - roofH * slide * 0.06);
ctx.stroke();
// 瓦片弧线
ctx.strokeStyle = 'rgba(180,70,50,0.3)';
ctx.lineWidth = 0.8 * scale;
const arcCount = Math.floor(halfWidth / (12 * scale));
for (let a = 0; a < arcCount; a++) {
const ax = cx - halfWidth + (a + 0.5) * 2 * halfWidth / arcCount;
ctx.beginPath();
ctx.arc(ax, yPos + 2 * scale, 4 * scale, Math.PI, 0);
ctx.stroke();
}
}
// 额外瓦片行填充颜色
for (let r = 0; r < totalRows; r++) {
const t = (r + 0.5) / rows;
const yPos = baseY - roofH * slide * t;
const halfWidth = (hw * slide) * (1 - t);
if (halfWidth < 3) continue;
ctx.fillStyle = `rgba(200,70,50,${0.15 + 0.1 * (r / rows)})`;
ctx.beginPath();
ctx.moveTo(cx - halfWidth, yPos - roofH * slide * 0.03);
ctx.lineTo(cx, yPos - roofH * slide * 0.09);
ctx.lineTo(cx + halfWidth, yPos - roofH * slide * 0.03);
ctx.lineTo(cx + halfWidth, yPos + 1);
ctx.lineTo(cx - halfWidth, yPos + 1);
ctx.closePath();
ctx.fill();
}
}
// 屋檐装饰线
ctx.strokeStyle = '#7a3a2a';
ctx.lineWidth = 2 * scale;
ctx.beginPath();
ctx.moveTo(leftX, baseY);
ctx.lineTo(rightX, baseY);
ctx.stroke();
}
function drawDoor(progress) {
const p = easeOut(progress);
const cx = house.cx;
const baseY = groundY - house.fh;
const dw = house.doorW;
const dh = house.doorH * p;
const dy = baseY - dh;
const dx = cx - dw / 2;
// 门框阴影
ctx.shadowColor = 'rgba(0,0,0,0.15)';
ctx.shadowBlur = 6 * scale;
// 门体
const grad = ctx.createLinearGradient(dx, dy, dx + dw, dy);
grad.addColorStop(0, '#7a4a2a');
grad.addColorStop(0.2, '#9a6a4a');
grad.addColorStop(0.5, '#8a5a3a');
grad.addColorStop(0.8, '#9a6a4a');
grad.addColorStop(1, '#6a3a1a');
ctx.fillStyle = grad;
const radius = 4 * scale;
ctx.beginPath();
ctx.moveTo(dx + radius, dy);
ctx.lineTo(dx + dw - radius, dy);
ctx.quadraticCurveTo(dx + dw, dy, dx + dw, dy + radius);
ctx.lineTo(dx + dw, dy + dh);
ctx.lineTo(dx, dy + dh);
ctx.lineTo(dx, dy + radius);
ctx.quadraticCurveTo(dx, dy, dx + radius, dy);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
// 门板纹理
ctx.strokeStyle = 'rgba(60,30,10,0.2)';
ctx.lineWidth = 1 * scale;
const panels = 4;
for (let i = 1; i < panels; i++) {
const px = dx + dw * i / panels;
ctx.beginPath();
ctx.moveTo(px, dy + 4 * scale);
ctx.lineTo(px, dy + dh - 4 * scale);
ctx.stroke();
}
// 水平纹理
for (let i = 1; i < 3; i++) {
const py = dy + dh * i / 3;
ctx.beginPath();
ctx.moveTo(dx + 4 * scale, py);
ctx.lineTo(dx + dw - 4 * scale, py);
ctx.stroke();
}
// 门框
ctx.strokeStyle = '#5a3a1a';
ctx.lineWidth = 2 * scale;
ctx.strokeRect(dx, dy, dw, dh);
// 门把手 (金色)
if (p > 0.7) {
const knobP = clamp((p - 0.7) / 0.3, 0, 1);
const knobR = 4 * scale * knobP;
const knobX = dx + dw * 0.78;
const knobY = dy + dh * 0.5;
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 4 * scale;
const gradK = ctx.createRadialGradient(knobX - 1, knobY - 1, 0, knobX, knobY, knobR);
gradK.addColorStop(0, '#ffe87c');
gradK.addColorStop(0.5, '#f1c40f');
gradK.addColorStop(1, '#c9a000');
ctx.fillStyle = gradK;
ctx.beginPath();
ctx.arc(knobX, knobY, knobR, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// 高光
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.arc(knobX - knobR * 0.25, knobY - knobR * 0.25, knobR * 0.3, 0, Math.PI * 2);
ctx.fill();
}
}
function drawWindows(progress) {
const p = easeOut(progress);
const baseY = groundY - house.fh;
const wallH = house.hh;
const winW = house.winW;
const winH = house.winH;
const winY = baseY - wallH * 0.65;
// 左右窗位置
const leftWinX = house.cx - house.hw * 0.32 - winW / 2;
const rightWinX = house.cx + house.hw * 0.32 - winW / 2;
const windows = [
{ x: leftWinX, label: 'L' },
{ x: rightWinX, label: 'R' }
];
for (const win of windows) {
const wx = win.x;
const wy = winY;
const scaleP = p;
// 窗口出现动画 (从中心扩展)
const cxW = wx + winW / 2;
const cyW = wy + winH / 2;
const w = winW * scaleP;
const h = winH * scaleP;
const drawX = cxW - w / 2;
const drawY = cyW - h / 2;
if (w < 1 || h < 1) continue;
// 窗框阴影
ctx.shadowColor = 'rgba(0,0,0,0.12)';
ctx.shadowBlur = 6 * scale;
// 窗框背景 (白色)
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(drawX - 2 * scale, drawY - 2 * scale, w + 4 * scale, h + 4 * scale);
ctx.shadowBlur = 0;
// 蓝色玻璃
const gradG = ctx.createLinearGradient(drawX, drawY, drawX + w, drawY + h);
gradG.addColorStop(0, '#b8e0f0');
gradG.addColorStop(0.4, '#8ec8e0');
gradG.addColorStop(0.7, '#7ab8d0');
gradG.addColorStop(1, '#a0d0e8');
ctx.fillStyle = gradG;
ctx.fillRect(drawX, drawY, w, h);
// 玻璃反光
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.beginPath();
ctx.moveTo(drawX + 2, drawY + 2);
ctx.lineTo(drawX + w * 0.3, drawY + 2);
ctx.lineTo(drawX + 2, drawY + h * 0.3);
ctx.closePath();
ctx.fill();
// 十字窗框
ctx.strokeStyle = '#d0d8e0';
ctx.lineWidth = 2 * scale;
ctx.shadowBlur = 0;
// 竖框
ctx.beginPath();
ctx.moveTo(cxW, drawY + 2);
ctx.lineTo(cxW, drawY + h - 2);
ctx.stroke();
// 横框
ctx.beginPath();
ctx.moveTo(drawX + 2, cyW);
ctx.lineTo(drawX + w - 2, cyW);
ctx.stroke();
// 外窗框
ctx.strokeStyle = '#c0c8d0';
ctx.lineWidth = 2.5 * scale;
ctx.strokeRect(drawX, drawY, w, h);
// 窗台
ctx.fillStyle = '#d8d8d8';
ctx.fillRect(drawX - 3 * scale, drawY + h - 1 * scale, w + 6 * scale, 4 * scale);
ctx.strokeStyle = '#b0b0b0';
ctx.lineWidth = 1 * scale;
ctx.strokeRect(drawX - 3 * scale, drawY + h - 1 * scale, w + 6 * scale, 4 * scale);
// 窗顶装饰
ctx.fillStyle = '#e0e0e0';
ctx.fillRect(drawX - 2 * scale, drawY - 3 * scale, w + 4 * scale, 3 * scale);
}
}
function drawDetails(progress) {
const p = easeOut(progress);
const baseY = groundY - house.fh;
const wallH = house.hh;
const roofBaseY = baseY - wallH;
const roofH = house.roofH;
// ===== 烟囱 =====
const chimP = clamp(p * 1.5, 0, 1);
const chimW = house.chimneyW;
const chimH = house.chimneyH * chimP;
const chimX = house.cx + house.hw * 0.28;
const chimY = roofBaseY - roofH + 2 * scale - chimH;
if (chimP > 0) {
// 烟囱阴影
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 6 * scale;
// 烟囱主体
const gradC = ctx.createLinearGradient(chimX, chimY, chimX + chimW, chimY);
gradC.addColorStop(0, '#8a4a2a');
gradC.addColorStop(0.3, '#b06040');
gradC.addColorStop(0.7, '#a05838');
gradC.addColorStop(1, '#7a3a1a');
ctx.fillStyle = gradC;
ctx.fillRect(chimX, chimY, chimW, chimH);
// 烟囱顶帽
ctx.fillStyle = '#6a3a1a';
ctx.fillRect(chimX - 3 * scale, chimY - 2 * scale, chimW + 6 * scale, 4 * scale);
ctx.shadowBlur = 0;
// 砖缝纹理
ctx.strokeStyle = 'rgba(60,30,10,0.2)';
ctx.lineWidth = 0.6 * scale;
const brickRows = Math.floor(chimH / (8 * scale));
for (let r = 0; r < brickRows; r++) {
const by = chimY + chimH * (r / brickRows);
ctx.beginPath();
ctx.moveTo(chimX + 1, by);
ctx.lineTo(chimX + chimW - 1, by);
ctx.stroke();
}
for (let r = 0; r < brickRows; r++) {
const by = chimY + chimH * ((r + 0.5) / brickRows);
const offset = (r % 2) * chimW * 0.5;
ctx.beginPath();
ctx.moveTo(chimX + chimW * 0.5 + offset * 0.5, by - 2);
ctx.lineTo(chimX + chimW * 0.5 + offset * 0.5, by + 2);
ctx.stroke();
}
}
// ===== 烟雾 =====
if (chimP > 0.5) {
const smokeP = clamp((p - 0.5) / 0.5, 0, 1);
const smokeCount = 8;
for (let i = 0; i < smokeCount; i++) {
const t = (i / smokeCount + smokeP * 0.6) % 1.0;
const alpha = 0.6 * (1 - t);
if (alpha < 0.05) continue;
const r = (4 + t * 12) * scale * (1 + 0.3 * Math.sin(i * 2.7));
const sx = chimX + chimW / 2 + Math.sin(t * 5 + i) * 8 * scale;
const sy = chimY - 4 * scale - t * 35 * scale;
ctx.fillStyle = `rgba(210,210,210,${alpha})`;
ctx.shadowColor = 'rgba(200,200,200,0.1)';
ctx.shadowBlur = 15 * scale;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
}
// ===== 踏脚石小径 =====
const pathP = clamp((p - 0.1) / 0.7, 0, 1);
const stoneCount = 7;
for (let i = 0; i < stoneCount; i++) {
const t = (i + 0.5) / stoneCount;
const show = pathP > t - 0.08;
if (!show) continue;
const stoneAlpha = clamp((pathP - t + 0.08) / 0.08, 0, 1);
const sx = house.cx + Math.sin(i * 1.3 + 0.5) * 6 * scale;
const sy = groundY + 4 * scale + t * (H - groundY - 10 * scale) * 0.85;
const sw = (12 + Math.sin(i * 0.7) * 4) * scale;
const sh = (6 + Math.cos(i * 0.9) * 2) * scale;
ctx.globalAlpha = stoneAlpha;
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 3 * scale;
const gradS = ctx.createRadialGradient(sx - sw * 0.1, sy - sh * 0.1, 0, sx, sy, sw);
gradS.addColorStop(0, '#d0c8b0');
gradS.addColorStop(0.6, '#b8a890');
gradS.addColorStop(1, '#9a8870');
ctx.fillStyle = gradS;
ctx.beginPath();
ctx.ellipse(sx, sy, sw / 2, sh / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// 石头纹理
ctx.strokeStyle = 'rgba(140,120,100,0.2)';
ctx.lineWidth = 0.5 * scale;
ctx.beginPath();
ctx.ellipse(sx + sw * 0.1, sy - sh * 0.1, sw * 0.2, sh * 0.15, 0.3, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.globalAlpha = 1;
// ===== 小树 =====
const treeP = clamp((p - 0.2) / 0.6, 0, 1);
if (treeP > 0) {
const treeX = house.cx - house.hw * 0.6;
const treeY = groundY;
const trunkH = house.treeH * 0.45 * treeP;
const trunkW = house.treeTrunkW;
// 树干
const gradT = ctx.createLinearGradient(treeX - trunkW, treeY, treeX + trunkW, treeY);
gradT.addColorStop(0, '#5a3a1a');
gradT.addColorStop(0.5, '#7a5a3a');
gradT.addColorStop(1, '#4a2a0a');
ctx.fillStyle = gradT;
ctx.shadowColor = 'rgba(0,0,0,0.08)';
ctx.shadowBlur = 4 * scale;
ctx.fillRect(treeX - trunkW / 2, treeY - trunkH, trunkW, trunkH);
ctx.shadowBlur = 0;
// 树枝
ctx.strokeStyle = '#5a3a1a';
ctx.lineWidth = 1.5 * scale;
for (let i = 0; i < 3; i++) {
const angle = (i - 1) * 0.6 + 0.2;
const bx = treeX + Math.cos(angle) * trunkH * 0.25;
const by = treeY - trunkH * (0.5 + i * 0.18);
ctx.beginPath();
ctx.moveTo(treeX, by);
ctx.lineTo(bx, by - 10 * scale);
ctx.stroke();
}
// 树冠 (多个圆形)
const leafSize = (16 + 8 * treeP) * scale;
const leafPositions = [
{ dx: 0, dy: -trunkH * 0.7, r: leafSize * 1.3 },
{ dx: -leafSize * 0.7, dy: -trunkH * 0.55, r: leafSize * 1.0 },
{ dx: leafSize * 0.7, dy: -trunkH * 0.55, r: leafSize * 1.0 },
{ dx: -leafSize * 0.5, dy: -trunkH * 0.8, r: leafSize * 0.9 },
{ dx: leafSize * 0.5, dy: -trunkH * 0.8, r: leafSize * 0.9 },
{ dx: 0, dy: -trunkH * 0.9, r: leafSize * 0.85 },
];
for (const leaf of leafPositions) {
const lx = treeX + leaf.dx;
const ly = treeY + leaf.dy;
const lr = leaf.r * treeP;
if (lr < 1) continue;
const gradL = ctx.createRadialGradient(lx - lr * 0.2, ly - lr * 0.2, 0, lx, ly, lr);
gradL.addColorStop(0, '#7ece7e');
gradL.addColorStop(0.5, '#5aae5a');
gradL.addColorStop(1, '#3a8a3a');
ctx.fillStyle = gradL;
ctx.shadowColor = 'rgba(0,0,0,0.06)';
ctx.shadowBlur = 6 * scale;
ctx.beginPath();
ctx.arc(lx, ly, lr, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// 高光
ctx.fillStyle = 'rgba(180,240,180,0.2)';
ctx.beginPath();
ctx.arc(lx - lr * 0.25, ly - lr * 0.25, lr * 0.3, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ---------- 主绘制循环 ----------
function drawPhase(time) {
const info = getPhaseInfo(time);
const phase = info.phase;
const p = info.progress;
// 清空画布
ctx.clearRect(0, 0, W, H);
// 天空
drawSky();
// 地面
drawGround();
// 获取各阶段完成度
const f0 = info.phaseProgress(0);
const f1 = info.phaseProgress(1);
const f2 = info.phaseProgress(2);
const f3 = info.phaseProgress(3);
const f4 = info.phaseProgress(4);
const f5 = info.phaseProgress(5);
const f6 = info.phaseProgress(6);
// ---- 按顺序绘制 ----
// 阶段0: 地基
if (f0 > 0) {
drawFoundation(f0);
}
// 阶段1: 墙体 (四段竖直墙)
if (f1 > 0) {
drawWallFrame(f1);
}
// 阶段2: 填墙
if (f2 > 0) {
drawWallFill(f2);
}
// 阶段3: 屋顶
if (f3 > 0) {
drawRoof(f3);
}
// 阶段4: 门
if (f4 > 0) {
drawDoor(f4);
}
// 阶段5: 窗
if (f5 > 0) {
drawWindows(f5);
}
// 阶段6: 细节
if (f6 > 0) {
drawDetails(f6);
}
// ---- 显示字幕 ----
const currentPhaseName = PHASE_NAMES[phase];
const textSize = 28 * scale;
ctx.font = `bold ${textSize}px "Microsoft YaHei", "PingFang SC", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// 字幕背景
const textW = ctx.measureText(currentPhaseName).width + 60 * scale;
const textH = textSize + 24 * scale;
const textX = W / 2 - textW / 2;
const textY = 20 * scale;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 10 * scale;
ctx.beginPath();
const r = 12 * scale;
ctx.moveTo(textX + r, textY);
ctx.lineTo(textX + textW - r, textY);
ctx.quadraticCurveTo(textX + textW, textY, textX + textW, textY + r);
ctx.lineTo(textX + textW, textY + textH - r);
ctx.quadraticCurveTo(textX + textW, textY + textH, textX + textW - r, textY + textH);
ctx.lineTo(textX + r, textY + textH);
ctx.quadraticCurveTo(textX, textY + textH, textX, textY + textH - r);
ctx.lineTo(textX, textY + r);
ctx.quadraticCurveTo(textX, textY, textX + r, textY);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
// 字幕文字
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 4 * scale;
ctx.fillText(currentPhaseName, W / 2, textY + 12 * scale);
ctx.shadowBlur = 0;
// 进度指示 (小点)
const dotR = 5 * scale;
const dotSpacing = 18 * scale;
const dotsStartX = W / 2 - (TOTAL_PHASES - 1) * dotSpacing / 2;
const dotsY = textY + textH + 12 * scale;
for (let i = 0; i < TOTAL_PHASES; i++) {
const dx = dotsStartX + i * dotSpacing;
const isActive = i === phase;
const isDone = i < phase;
ctx.fillStyle = isActive ? '#ffdd44' : (isDone ? '#88cc88' : 'rgba(255,255,255,0.3)');
ctx.shadowColor = isActive ? 'rgba(255,220,60,0.4)' : 'rgba(0,0,0,0.1)';
ctx.shadowBlur = isActive ? 10 * scale : 2 * scale;
ctx.beginPath();
ctx.arc(dx, dotsY, isActive ? dotR * 1.3 : dotR, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
// 在底部显示小提示
ctx.font = `${12 * scale}px "Microsoft YaHei", sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('🏠 房屋建造过程 · 自动循环', W / 2, H - 10 * scale);
}
// ---------- 动画循环 ----------
let startTime = 0;
function animate(timestamp) {
if (startTime === 0) startTime = timestamp;
const elapsed = (timestamp - startTime) / 1000;
drawPhase(elapsed);
requestAnimationFrame(animate);
}
// ---------- 窗口自适应 ----------
window.addEventListener('resize', () => {
resize();
});
// 初始化
resize();
requestAnimationFrame(animate);
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>房屋建造动画</title>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H, groundY, cx, fw, fh, wallH, houseX, centerX, wallTopY, roofH, oh, roofTopY;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
groundY = H * 0.72;
cx = W / 2;
fw = Math.min(280, W * 0.5);
fh = 24;
wallH = 150;
houseX = cx - fw / 2;
centerX = cx;
wallTopY = groundY - fh - wallH;
roofH = 90;
oh = 25;
roofTopY = wallTopY - roofH;
}
window.addEventListener('resize', resize);
resize();
const stageDurs = [2.5, 3, 2, 4.5, 2.5, 3, 4.5];
const pause = 0.5;
const stageNames = ['打地基', '砌墙', '填外墙', '盖屋顶', '装门', '装窗', '加细节'];
const stages = [];
for (let i = 0; i < stageDurs.length; i++) {
const start = i === 0 ? 0 : stages[i - 1].end + pause;
stages.push({ name: stageNames[i], start, dur: stageDurs[i], end: start + stageDurs[i], idx: i });
}
const total = stages[stages.length - 1].end;
let smoke = [];
let lastSmokeSpawn = -99;
let startT = performance.now();
let lastT = startT;
function clamp(v) { return Math.max(0, Math.min(1, v)); }
function easeOutQuad(t) { t = clamp(t); return 1 - (1 - t) * (1 - t); }
function drawSky() {
const g = ctx.createLinearGradient(0, 0, 0, groundY);
g.addColorStop(0, '#B3E5FC');
g.addColorStop(1, '#E1F5FE');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, groundY);
}
function drawGround() {
ctx.fillStyle = '#81C784';
ctx.fillRect(0, groundY, W, H - groundY);
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(W, groundY);
ctx.stroke();
}
function drawSubtitle(text) {
if (!text) return;
ctx.save();
ctx.font = 'bold 28px "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = '#ffffff';
ctx.shadowColor = 'rgba(0,0,0,0.35)';
ctx.shadowBlur = 6;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText(text, W / 2, 30);
ctx.restore();
}
function drawFoundation(p) {
const e = easeOutQuad(p);
const y = groundY + fh - 2 * fh * e;
const x = houseX - 20;
const w = fw + 40;
ctx.fillStyle = '#9E9E9E';
ctx.fillRect(x, y, w, fh);
ctx.strokeStyle = '#757575';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, fh);
}
function drawWalls(p) {
const e = easeOutQuad(p);
const h = wallH * e;
const bottom = groundY - fh;
const studs = [houseX, houseX + fw * 0.25, houseX + fw * 0.75, houseX + fw - 10];
const sw = 10;
ctx.fillStyle = '#8D6E63';
for (const sx of studs) {
ctx.fillRect(sx, bottom - h, sw, h);
}
}
function drawFill(p) {
const e = clamp(p);
const bottom = groundY - fh;
const top = wallTopY;
const studs = [houseX, houseX + fw * 0.25, houseX + fw * 0.75, houseX + fw - 10];
const sw = 10;
ctx.save();
ctx.globalAlpha = e;
ctx.fillStyle = '#F5DEB3';
for (let i = 0; i < studs.length - 1; i++) {
const x1 = studs[i] + sw;
const x2 = studs[i + 1];
ctx.fillRect(x1, top, x2 - x1, bottom - top);
}
ctx.strokeStyle = '#8D6E63';
ctx.lineWidth = 2;
ctx.strokeRect(studs[0] + sw, top, studs[studs.length - 1] - studs[0] - sw, bottom - top);
ctx.restore();
}
function drawRoofHalf(ox, oy, isLeft, tileReveal) {
const path = isLeft ? [
{ x: centerX + ox, y: roofTopY + oy },
{ x: houseX - oh + ox, y: wallTopY + oy },
{ x: centerX + ox, y: wallTopY + oy }
] : [
{ x: centerX + ox, y: roofTopY + oy },
{ x: centerX + ox, y: wallTopY + oy },
{ x: houseX + fw + oh + ox, y: wallTopY + oy }
];
ctx.save();
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
ctx.lineTo(path[1].x, path[1].y);
ctx.lineTo(path[2].x, path[2].y);
ctx.closePath();
ctx.fillStyle = '#E53935';
ctx.fill();
ctx.clip();
const top = path[0].y;
const bottom = path[1].y;
const totalH = bottom - top;
const revealLocal = top + totalH * clamp(tileReveal);
const rowH = 14;
const minX = Math.min(path[0].x, path[1].x, path[2].x);
const maxX = Math.max(path[0].x, path[1].x, path[2].x);
for (let y = top; y < bottom; y += rowH) {
if (y + rowH > revealLocal) break;
ctx.fillStyle = '#C62828';
ctx.fillRect(minX, y, maxX - minX, rowH - 2);
const rowIndex = Math.round((y - top) / rowH);
const offset = (rowIndex % 2) * rowH * 0.5;
ctx.strokeStyle = '#FFCDD2';
ctx.lineWidth = 1;
ctx.beginPath();
for (let tx = minX + offset; tx < maxX; tx += rowH) {
ctx.moveTo(tx, y);
ctx.lineTo(tx, y + rowH - 2);
}
ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
ctx.lineTo(path[1].x, path[1].y);
ctx.lineTo(path[2].x, path[2].y);
ctx.closePath();
ctx.strokeStyle = '#B71C1C';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
function drawRoof(p) {
const slideT = clamp(p / 0.55);
const tileT = clamp((p - 0.3) / 0.7);
const e = easeOutQuad(slideT);
const lStartX = -(fw / 2 + oh + 40);
const lStartY = -(roofH + 60);
const rStartX = (fw / 2 + oh + 40);
const rStartY = -(roofH + 60);
const lOffX = lStartX * (1 - e);
const lOffY = lStartY * (1 - e);
const rOffX = rStartX * (1 - e);
const rOffY = rStartY * (1 - e);
drawRoofHalf(lOffX, lOffY, true, tileT);
drawRoofHalf(rOffX, rOffY, false, tileT);
ctx.strokeStyle = '#B71C1C';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX, roofTopY);
ctx.lineTo(centerX, wallTopY);
ctx.stroke();
}
function drawDoor(p) {
const e = clamp(p);
const w = 44;
const h = 80;
const x = centerX - w / 2;
const y = groundY - fh - h;
ctx.save();
ctx.globalAlpha = e;
ctx.fillStyle = '#5D4037';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#3E2723';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#FFD700';
ctx.beginPath();
ctx.arc(x + w - 10, y + h / 2, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawWindow(p, wx, wy) {
const e = clamp(p);
const s = 34;
const x = wx - s / 2;
const y = wy - s / 2;
ctx.save();
ctx.globalAlpha = e;
ctx.fillStyle = '#64B5F6';
ctx.fillRect(x, y, s, s);
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 3;
ctx.strokeRect(x, y, s, s);
ctx.beginPath();
ctx.moveTo(x + s / 2, y);
ctx.lineTo(x + s / 2, y + s);
ctx.moveTo(x, y + s / 2);
ctx.lineTo(x + s, y + s / 2);
ctx.stroke();
ctx.restore();
}
function drawChimney(p) {
const e = clamp(p);
if (e <= 0) return;
const w = 34;
const h = 60;
const bottom = wallTopY + 15;
const top = bottom - h * e;
const x = houseX + fw - 55;
ctx.fillStyle = '#795548';
ctx.fillRect(x, top, w, bottom - top);
ctx.strokeStyle = '#5D4037';
ctx.lineWidth = 2;
ctx.strokeRect(x, top, w, bottom - top);
}
function drawPath(p) {
const stones = 7;
for (let i = 0; i < stones; i++) {
const e = clamp((p - i * 0.08) / 0.25);
if (e <= 0) continue;
const ratio = (i + 1) / (stones + 1);
const sx = centerX + Math.sin(i * 1.3) * 12;
const sy = H - 30 - (H - 30 - (groundY - fh)) * ratio;
const rx = 14 * e;
const ry = 6 * e;
ctx.save();
ctx.translate(sx, sy);
ctx.scale(e, e);
ctx.fillStyle = '#BDBDBD';
ctx.beginPath();
ctx.ellipse(0, 0, 14, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#9E9E9E';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
}
function drawTree(p) {
const e = easeOutQuad(clamp((p - 0.1) / 0.9));
const x = houseX - 80;
const bottom = groundY - fh;
const h = 70 * e;
ctx.fillStyle = '#795548';
ctx.fillRect(x - 5, bottom - h, 10, h);
const cy = bottom - h;
const r = 35 * e;
ctx.fillStyle = '#66BB6A';
ctx.beginPath();
ctx.arc(x, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#388E3C';
ctx.lineWidth = 2;
ctx.stroke();
}
function updateSmoke(dt, p) {
if (p > 0.3 && p <= 1) {
const nowSec = performance.now() / 1000;
if (nowSec - lastSmokeSpawn > 0.25) {
const e = clamp(p);
const chimneyX = houseX + fw - 55 + 17;
const chimneyTop = (wallTopY + 15) - 60 * e;
smoke.push({
x: chimneyX,
y: chimneyTop,
vx: (Math.random() - 0.5) * 15,
vy: -25 - Math.random() * 15,
life: 0,
maxLife: 2.5 + Math.random(),
r: 4 + Math.random() * 3
});
lastSmokeSpawn = nowSec;
}
}
for (let i = smoke.length - 1; i >= 0; i--) {
const s = smoke[i];
s.life += dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.vy *= 0.99;
if (s.life >= s.maxLife) smoke.splice(i, 1);
}
}
function drawSmoke() {
for (const s of smoke) {
const a = 1 - s.life / s.maxLife;
ctx.save();
ctx.globalAlpha = Math.max(0, a * 0.6);
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function frame(now) {
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
const elapsed = (now - startT) / 1000;
const cycle = elapsed % total;
let idx = -1;
let progress = 0;
let subtitle = '';
for (let i = 0; i < stages.length; i++) {
const s = stages[i];
if (cycle < s.end) {
idx = i;
progress = (cycle - s.start) / s.dur;
subtitle = s.name;
break;
}
if (i < stages.length - 1 && cycle < stages[i + 1].start) {
idx = i;
progress = 1;
subtitle = stages[i + 1].name;
break;
}
}
if (idx === -1) {
idx = stages.length - 1;
progress = 1;
subtitle = stages[0].name;
}
if (idx < 6) {
smoke = [];
lastSmokeSpawn = -99;
}
ctx.clearRect(0, 0, W, H);
drawSky();
drawGround();
drawFoundation(idx >= 0 ? (idx === 0 ? progress : 1) : 0);
drawWalls(idx >= 1 ? (idx === 1 ? progress : 1) : 0);
drawFill(idx >= 2 ? (idx === 2 ? progress : 1) : 0);
drawRoof(idx >= 3 ? (idx === 3 ? progress : 1) : 0);
drawDoor(idx >= 4 ? (idx === 4 ? progress : 1) : 0);
const winY = groundY - fh - 110;
if (idx >= 5) {
const studs = [houseX, houseX + fw * 0.25, houseX + fw * 0.75, houseX + fw - 10];
const sw = 10;
const leftCx = (studs[0] + sw + studs[1]) / 2;
const rightCx = (studs[2] + sw + studs[3]) / 2;
drawWindow(idx === 5 ? progress : 1, leftCx, winY);
drawWindow(idx === 5 ? progress : 1, rightCx, winY);
}
const detailP = idx >= 6 ? (idx === 6 ? progress : 1) : 0;
drawChimney(detailP);
updateSmoke(dt, detailP);
drawSmoke();
drawPath(detailP);
drawTree(detailP);
drawSubtitle(subtitle);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
<think>The user wants a single-file HTML with a full-page canvas animation showing a 2D cartoon house being built in 7 stages over ~25 seconds, then loops. Let me plan this out:
- Foundation - gray rectangle rises from underground
- Walls - four vertical walls extend up from corners of foundation
- Fill walls - beige/light brown exterior panels fill between wall frames
- Roof - two triangular roof panels slide in from above and meet at ridge, red tile texture appears row by row
- Door - brown door fades in on front wall with small gold doorknob
- Windows - windows on either side of door with cross frames and blue glass
- Details - chimney rises from roof, smoke appears, stepping stone path to door, small tree grows beside house
Each stage has a subtitle at top. 0.5s pause between stages. Total ~25 seconds, then loops.
Total time: ~25 seconds for 7 stages
- Stage 1: ~3s
- Stage 2: ~3s
- Stage 3: ~3s
- Stage 4: ~4s (slide in + tile rows)
- Stage 5: ~2s
- Stage 6: ~3s
- Stage 7: ~4s
- Pause: 0.5s × 6 = 3s
- Total: ~25s ✓
Let me design the layout:
- Canvas full page
- Light blue sky background
- Green ground line
- House roughly centered
House dimensions:
- Foundation: 300w × 30h, positioned around (canvas.w/2 - 150, ground - 30)
- Walls: height ~200, width ~30 each
- House body: 300w × 200h
- Roof: triangles meeting at peak
Let me code this up. I'll use requestAnimationFrame and manage time-based animation states.
Let me set up the structure:
const stages = [
{ name: '打地基', duration: 3000 },
{ name: '砌墙', duration: 3000 },
{ name: '填墙', duration: 3000 },
{ name: '盖屋顶', duration: 4000 },
{ name: '装门', duration: 2000 },
{ name: '装窗户', duration: 3000 },
{ name: '添细节', duration: 4000 }
];
const pauseDuration = 500;
For each stage, I'll have an object that tracks the current state (progress 0-1) and update it each frame.
Let me think about the drawing:
Sky: light blue #B3E5FC or similar Ground: green line near bottom Foundation: dark gray rectangle #888, with darker outline Walls: vertical lines/rects in darker brown/gray for frame Wall fill: beige/light brown #E8D8B0 or similar Roof: triangles in red/brown Roof tiles: red rows Door: brown #8B4513 rectangle with gold knob Windows: blue glass #87CEEB with white frame and cross Chimney: brick rectangle Smoke: gray circles fading Path: stone shapes Tree: brown trunk + green canopy
Let me set canvas size based on window, redraw on resize.
For the animation, I'll track total elapsed time and compute current stage + progress within stage.
Let me write this out:
let startTime = null;
const stageSequence = [
{ name: '打地基', duration: 3000, type: 'foundation' },
{ name: '砌墙', duration: 3000, type: 'walls' },
{ name: '填墙', duration: 3000, type: 'fillWalls' },
{ name: '盖屋顶', duration: 4000, type: 'roof' },
{ name: '装门', duration: 2000, type: 'door' },
{ name: '装窗户', duration: 3000, type: 'windows' },
{ name: '添细节', duration: 4000, type: 'details' }
];
const pauseBetween = 500;
const totalCycleTime = stageSequence.reduce((s, x) => s + x.duration, 0) + pauseBetween * (stageSequence.length - 1);
For each stage, I need to render only what's been built so far. Let me track built elements:
- foundation: { y, alpha }
- walls: { leftHeight, rightHeight, frontWallProgress, ... }
- fill: { progress }
- roof: { leftSlide, rightSlide, tileRows }
- door: { alpha }
- windows: { leftAlpha, rightAlpha }
- details: { chimneyHeight, smokeProgress, pathProgress, treeHeight }
Actually, let me simplify by computing "what should be visible" based on which stages have completed and progress within current stage.
Let me structure as:
progress= overall progress through cycle (0 to 1)- Find current stage based on time
Or better, track currentStageIndex and stageProgress (0-1).
Let me think about each stage's animation:
Stage 1 (Foundation, 3s):
- Gray rectangle starts below ground (y = canvas.height) and rises up to its final position
- Final position: y = groundY - foundationHeight
- Use ease-out
Stage 2 (Walls, 3s):
- Four vertical walls extend upward from foundation corners
- Actually, "四段竖直墙从地基四角向上延伸" means 4 vertical walls from 4 corners
- So: front-left, front-right, back-left, back-right? But it's 2D, so we see front wall and maybe a side wall
- In 2D, we'd see: front-left vertical line, front-right vertical line, and maybe the corners...
- Let me interpret: 4 vertical wall sections at corners meaning the wall frame at left, middle-left, middle-right, right? Or just the outer wall frame?
- I'll go with: a wall frame consists of 2 vertical posts (left and right) and these "rise" from foundation. The "four walls" could mean the four corner pillars.
- Actually let me re-read: "四段竖直墙从地基四角向上延伸" - four vertical walls extend from the four corners of the foundation upward. So at each corner of the foundation, a vertical wall segment rises. In 2D view, we'd see the two front corners (and the back ones hidden behind).
- Hmm, but with 4 corners we'd see 2 in front and 2 partially hidden. Let me make it 4 visible vertical posts in front view: leftmost, left-of-center, right-of-center, rightmost. These are the wall posts/frame.
- Actually the simplest interpretation: front face shows left wall, right wall, and the wall fill between them. So "four walls" might mean the four sides of a 3D box. But for 2D, let me draw: a left vertical wall edge and right vertical wall edge, plus maybe a slight indication of depth (a back wall slightly offset).
- I'll go with: 4 vertical posts visible in front view at left, 1/3, 2/3, right positions, representing the wall framing. These grow from bottom to top.
- Final: 4 dark brown/gray vertical rectangles
Actually, looking again: "墙体——四段竖直墙从地基四角向上延伸". So at each of the four corners of the foundation (which is a rectangle), a vertical wall section extends upward. In 2D side/front view, we'd see 2 in front and 2 in back. To keep 2D simple, I'll just show:
- 2 front corner posts (left and right edges of front wall)
- 2 back corner posts slightly visible at top edges (showing depth)
Or more cleanly: just show the front wall as having two vertical edge posts (left and right), and the back two corners visible as small dark triangles at the top (to suggest 3D box). Actually, this might be overcomplicating.
Let me just go with: front wall frame has 2 vertical posts (left and right edges), and back wall has 2 vertical posts visible behind/slightly offset. So 4 vertical lines/rects total.
Hmm, actually let me just make it simple 2D: 4 vertical wall posts evenly distributed across the width (like at 0%, 33%, 66%, 100%) representing wall studs/framing.
No wait, simplest interpretation: it's a 2D house, "四段竖直墙" = 4 vertical wall sections = the front wall, back wall, left wall, right wall. In 2D side projection we see front wall, in 2D front projection we see front wall. Hmm.
OK let me just do this: 4 vertical posts at the corners (and perhaps 2 visible front, 2 visible behind). For 2D simplicity, I'll draw:
- 2 front vertical posts at left and right of house body
- 2 slightly offset back vertical posts visible behind, with darker shade This creates a slight 3D effect.
Actually, let me re-read once more: "四段竖直墙从地基四角向上延伸". OK 4 vertical walls from foundation's 4 corners. The house has 4 corners at the base. Each corner has a wall extending up. So the walls form the 4 sides of the house box. In 2D front view, we see the front face's left and right corners (2 posts). The back face's left and right corners are behind (hidden).
To show this, I'll draw:
- 2 front corner posts (left and right of front wall) - dark brown
- 2 back corner posts (slightly offset behind, darker shade, suggesting depth)
This gives the 3D box feel.
Actually you know what, let me simplify and just show it as a flat 2D house with corner posts at the 4 visible corners in 2D... no, in 2D a house has 2 visible corners (top-left and top-right of front face).
OK let me just go with: 4 vertical posts representing the four walls. In 2D I'll show the front face's left and right wall edges (the corners of the front face), and add 2 small inner posts showing where the back wall would peek. This is getting complex.
Let me just do the simplest 2D interpretation: 4 vertical wall posts at evenly spaced positions across the width. Like a stud frame: leftmost, 1/3, 2/3, rightmost. These grow up from the foundation.
No actually, the most natural 2D interpretation: a house in 2D front view. The "4 walls" are conceptual (it's a 3D box). In 2D we draw the front wall (a rectangle). So the "walls rising" would be: the outline of the front face rectangle growing from bottom to top. We could show it as: left edge growing up, right edge growing up, and once both are full, the top edge connects.
Or, we draw 4 separate vertical "wall segments": left wall, right wall, plus maybe two interior dividers. Hmm.
Let me just commit to: 2 vertical front wall edges (left and right) plus 2 small inner vertical studs (showing wall framing). They grow upward simultaneously.
OK enough deliberation. Let me code it as:
- 4 vertical posts: at positions x=0, 0.33, 0.66, 1.0 (as fractions of house width), each being a dark brown rectangle ~10px wide
- These grow from foundation upward over 3 seconds
Then stage 3 fills the spaces between them with beige panels.
Stage 3 (Fill walls, 3s):
- Beige panels fill in between the 4 wall posts
- Could be: 3 panels (between posts 1-2, 2-3, 3-4)
- Or: fill animation goes left-to-right or reveals panel by panel
- Simple: 3 beige rectangles that fade/scale in between the dark posts
Stage 4 (Roof, 4s):
- Two triangular roof panels slide in from above
- They meet at the ridge (top center)
- Then red tile rows appear from bottom of roof upward
- Triangle: from base (house top corners) to peak (above center)
- Left triangle slides from upper-left, right from upper-right
- Then tiles: horizontal red bands on the triangles, appearing row by row
Stage 5 (Door, 2s):
- Brown door fades in at center of front wall
- Gold doorknob appears
- Final position: centered horizontally on front wall, vertical from ground (foundation top) to maybe 70% of wall height
Stage 6 (Windows, 3s):
- 2 windows appear, one on each side of door
- Each has cross frame and blue glass
- Could fade in or scale up
- Final positions: left window at ~25% width, right window at ~75% width, vertically centered on upper wall portion
Stage 7 (Details, 4s):
- Chimney: brick rectangle rises from roof (left or right side of roof)
- Smoke: gray puffs emerge from chimney top
- Path: stepping stones appear from house base toward bottom of canvas
- Tree: small tree grows beside house (right side perhaps)
Let me think about timing within each stage. I'll use easing for smooth motion.
OK let me start coding:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
For drawing functions, I'll use helper functions. The house dimensions will be relative to canvas size.
Let me define house position:
- groundY = canvas.height * 0.75 (where ground is)
- houseCenterX = canvas.width / 2
- houseW = min(canvas.width * 0.4, 400)
- houseH = houseW * 0.7 (proportion)
- foundationH = 25
- wallBottomY = groundY - foundationH
- wallTopY = wallBottomY - houseH
- roofPeakY = wallTopY - houseH * 0.4
- houseLeftX = houseCenterX - houseW / 2
- houseRightX = houseCenterX + houseW / 2
Let me code it up:
const stages = [
{ name: '打地基', duration: 3000 },
{ name: '砌墙', duration: 3000 },
{ name: '填墙', duration: 3000 },
{ name: '盖屋顶', duration: 4000 },
{ name: '装门', duration: 2000 },
{ name: '装窗户', duration: 3000 },
{ name: '添细节', duration: 4000 }
];
const pauseMs = 500;
function getStageAt(t) {
// t is ms since start
let elapsed = 0;
for (let i = 0; i < stages.length; i++) {
const stageDur = stages[i].duration;
if (t < elapsed + stageDur) {
return { index: i, progress: (t - elapsed) / stageDur };
}
elapsed += stageDur;
if (i < stages.length - 1) {
if (t < elapsed + pauseMs) {
return { index: i, progress: 1, pause: true };
}
elapsed += pauseMs;
}
}
return { index: stages.length - 1, progress: 1, done: true };
}
Then drawing logic based on current stage index and progress.
Let me write the full code:
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2; }
function easeIn(t) { return t*t*t; }
Let me now write the complete draw function:
function draw(timestamp) {
if (!startTime) startTime = timestamp;
const t = (timestamp - startTime) % totalCycleTime;
const stage = getStageAt(t);
// Clear and draw background
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Sky
ctx.fillStyle = '#B3E5FC';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Some clouds maybe
// Ground
const groundY = canvas.height * 0.78;
ctx.fillStyle = '#7CB342';
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
// Darker line at top of ground
ctx.fillStyle = '#558B2F';
ctx.fillRect(0, groundY, canvas.width, 3);
// House dimensions
const houseW = Math.min(canvas.width * 0.35, 360);
const houseH = houseW * 0.7;
const foundationH = 25;
const cx = canvas.width / 2;
const houseLeftX = cx - houseW / 2;
const houseRightX = cx + houseW / 2;
const foundationTopY = groundY - foundationH;
const wallTopY = foundationTopY - houseH;
const roofHeight = houseW * 0.45;
const roofPeakY = wallTopY - roofHeight;
// Draw elements based on stages completed
const completedUpTo = stage.pause ? stage.index + 1 : (stage.done ? stages.length : stage.index);
const currentStageIdx = stage.done ? stages.length - 1 : stage.index;
const currentProgress = stage.done ? 1 : stage.progress;
// Stage 1: Foundation
let foundationVisible = false;
let foundationY = foundationTopY;
if (completedUpTo >= 0) {
// Foundation is fully shown if stage 1 done
if (currentStageIdx > 0) {
foundationVisible = true;
foundationY = foundationTopY;
} else if (currentStageIdx === 0) {
// Animate rising from below ground
foundationVisible = true;
const ease = easeOut(currentProgress);
const startY = groundY + 30;
foundationY = startY + (foundationTopY - startY) * ease;
}
}
if (foundationVisible) {
ctx.fillStyle = '#757575';
ctx.fillRect(houseLeftX - 15, foundationY, houseW + 30, foundationH);
ctx.fillStyle = '#616161';
ctx.fillRect(houseLeftX - 15, foundationY + foundationH - 5, houseW + 30, 5);
ctx.strokeStyle = '#424242';
ctx.lineWidth = 2;
ctx.strokeRect(houseLeftX - 15, foundationY, houseW + 30, foundationH);
}
// Stage 2: Walls (4 vertical posts)
// ... etc
}
Let me think more carefully about the wall animation. I want 4 vertical posts that grow from foundation. In 2D, I'll interpret these as the front face's 2 corner posts and 2 back face corner posts visible behind.
Actually, let me just have:
- 2 visible front corner posts (left and right edges of front face)
- 2 back corner posts slightly offset to create depth perception (the back-left post is slightly to the right and darker, back-right similarly)
Hmm this is complex. Let me simplify:
- 4 vertical posts at corners of the foundation, growing up
- 2 visible at front (left edge and right edge of front wall)
- 2 visible at back (slightly offset, darker, suggesting they are behind)
- Actually in 2D front view, the back corners would be hidden by the front face... unless we show a slight isometric/3D effect.
You know what, let me just do 4 vertical posts evenly spaced:
- post 1 at houseLeftX (left edge of front wall)
- post 2 at 1/3 width
- post 3 at 2/3 width
- post 4 at houseRightX (right edge of front wall)
These represent wall framing. Between them (in stage 3) we fill with beige panels.
Actually let me commit to this and move on. 4 posts, evenly spaced. They could represent:
- The 2 outer edges + 2 inner studs (wall framing)
OK final plan, let me code:
// Wall posts (stage 2)
const postWidth = 12;
const postPositions = [
houseLeftX,
houseLeftX + houseW * 0.33,
houseLeftX + houseW * 0.67,
houseRightX - postWidth // adjust for width
];
// Actually for simplicity:
// Post 1: houseLeftX, post 2: 1/3, post 3: 2/3, post 4: houseRightX - postWidth
// These divide the wall into 3 sections that will be filled with beige panels
const postXs = [
houseLeftX,
houseLeftX + (houseW - postWidth) / 3,
houseLeftX + 2 * (houseW - postWidth) / 3,
houseLeftX + (houseW - postWidth)
];
Hmm let me just use 4 posts at positions 0%, 33%, 67%, 100%:
postXs[0] = houseLeftX;
postXs[1] = houseLeftX + (houseW - postWidth) * 1/3;
postXs[2] = houseLeftX + (houseW - postWidth) * 2/3;
postXs[3] = houseLeftX + (houseW - postWidth);
That places them evenly.
For wall fill (stage 3), 3 panels between them:
- Panel 1: between post 1 and post 2
- Panel 2: between post 2 and post 3
- Panel 3: between post 3 and post 4
Each panel fills with beige color. They could appear sequentially or together.
Let me animate stage 3 as: panels fade/scale in sequentially over the 3 seconds (1s each).
OK let me start writing the actual code now. I'll just write it all out:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>建造小房子</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
canvas { display: block; width: 100vw; height: 100vh; background: #B3E5FC; }
</style>
</head>
<body>
<canvas id="cv"></canvas>
<script>
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
const stages = [
{ name: '打地基', duration: 2800 },
{ name: '砌墙', duration: 2800 },
{ name: '填墙', duration: 2800 },
{ name: '盖屋顶', duration: 4000 },
{ name: '装门', duration: 2000 },
{ name: '装窗户', duration: 2800 },
{ name: '添细节', duration: 4000 }
];
const pauseMs = 500;
const totalMs = stages.reduce((s,x)=>s+x.duration,0) + pauseMs*(stages.length-1);
function easeOut(t) { return 1 - Math.pow(1-t, 3); }
function easeIn(t) { return t*t*t; }
function easeIO(t) { return t<0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2; }
function getStage(t) {
let acc = 0;
for (let i = 0; i < stages.length; i++) {
if (t < acc + stages[i].duration) {
return { idx: i, p: (t-acc)/stages[i].duration };
}
acc += stages[i].duration;
if (i < stages.length - 1) {
if (t < acc + pauseMs) return { idx: i, p: 1, pause: true };
acc += pauseMs;
}
}
return { idx: stages.length-1, p: 1, done: true };
}
let startTime = null;
function draw(ts) {
if (startTime === null) startTime = ts;
const t = (ts - startTime) % totalMs;
const stage = getStage(t);
// Background
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, canvas.height);
skyGrad.addColorStop(0, '#B3E5FC');
skyGrad.addColorStop(1, '#E1F5FE');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Sun
const sunX = canvas.width * 0.85;
const sunY = canvas.height * 0.15;
const sunR = 35;
ctx.fillStyle = '#FFEB3B';
ctx.beginPath();
ctx.arc(sunX, sunY, sunR, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 235, 59, 0.3)';
ctx.beginPath();
ctx.arc(sunX, sunY, sunR*1.5, 0, Math.PI*2);
ctx.fill();
// Clouds (subtle)
drawCloud(canvas.width*0.15, canvas.height*0.2, 40);
drawCloud(canvas.width*0.7, canvas.height*0.1, 30);
// Ground
const groundY = canvas.height * 0.78;
const groundGrad = ctx.createLinearGradient(0, groundY, 0, canvas.height);
groundGrad.addColorStop(0, '#8BC34A');
groundGrad.addColorStop(1, '#558B2F');
ctx.fillStyle = groundGrad;
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
ctx.fillStyle = '#7CB342';
ctx.fillRect(0, groundY, canvas.width, 4);
// House dimensions
const houseW = Math.min(canvas.width * 0.32, 360);
const houseH = houseW * 0.72;
const cx = canvas.width / 2;
const houseLeftX = cx - houseW / 2;
const houseRightX = cx + houseW / 2;
const foundationH = 22;
const foundationTopY = groundY - foundationH;
const wallTopY = foundationTopY - houseH;
const roofH = houseW * 0.5;
const roofPeakY = wallTopY - roofH;
const postW = 14;
// Determine what's drawn based on stage
let foundationY = foundationTopY, foundationAlpha = 1;
let wallsProgress = 0; // 0 = not started, 1 = full height
let fillProgress = 0; // 0 to 1, how much of fill done
let roofLeftProgress = 0, roofRightProgress = 0;
let tileRows = 0;
let doorAlpha = 0;
let winLeftAlpha = 0, winRightAlpha = 0;
let chimneyProgress = 0;
let smokeProgress = 0;
let pathProgress = 0;
let treeProgress = 0;
if (stage.idx >= 0) {
if (stage.idx === 0) {
const p = easeOut(stage.p);
const startY = groundY + 40;
foundationY = startY + (foundationTopY - startY) * p;
}
}
if (stage.idx >= 1 || (stage.idx === 0 && stage.p === 1)) {
wallsProgress = 1;
} else if (stage.idx === 1) {
wallsProgress = easeOut(stage.p);
}
if (stage.idx >= 2 || (stage.idx === 1 && stage.p === 1)) {
fillProgress = 1;
} else if (stage.idx === 2) {
fillProgress = easeOut(stage.p);
}
// Stage 3: roof
if (stage.idx >= 3 || (stage.idx === 2 && stage.p === 1)) {
roofLeftProgress = 1;
roofRightProgress = 1;
tileRows = 1;
} else if (stage.idx === 3) {
// First half: triangles slide in
// Second half: tiles appear
if (stage.p < 0.5) {
const p = easeOut(stage.p / 0.5);
roofLeftProgress = p;
roofRightProgress = p;
} else {
roofLeftProgress = 1;
roofRightProgress = 1;
tileRows = (stage.p - 0.5) / 0.5;
}
}
// Stage 4: door
if (stage.idx >= 4 || (stage.idx === 3 && stage.p === 1)) {
doorAlpha = 1;
} else if (stage.idx === 4) {
doorAlpha = easeOut(stage.p);
}
// Stage 5: windows
if (stage.idx >= 5 || (stage.idx === 4 && stage.p === 1)) {
winLeftAlpha = 1;
winRightAlpha = 1;
} else if (stage.idx === 5) {
if (stage.p < 0.5) {
winLeftAlpha = easeOut(stage.p / 0.5);
} else {
winLeftAlpha = 1;
winRightAlpha = easeOut((stage.p - 0.5) / 0.5);
}
}
// Stage 6: details
if (stage.idx >= 6 || (stage.idx === 5 && stage.p === 1)) {
chimneyProgress = 1;
smokeProgress = 1;
pathProgress = 1;
treeProgress = 1;
} else if (stage.idx === 6) {
if (stage.p < 0.25) {
chimneyProgress = easeOut(stage.p / 0.25);
} else if (stage.p < 0.5) {
chimneyProgress = 1;
smokeProgress = easeOut((stage.p - 0.25) / 0.25);
} else if (stage.p < 0.75) {
chimneyProgress = 1;
smokeProgress = 1;
pathProgress = easeOut((stage.p - 0.5) / 0.25);
} else {
chimneyProgress = 1;
smokeProgress = 1;
pathProgress = 1;
treeProgress = easeOut((stage.p - 0.75) / 0.25);
}
}
// Draw elements in order
drawFoundation(houseLeftX, houseRightX, foundationY, foundationH);
if (wallsProgress > 0) {
drawWalls(houseLeftX, houseRightX, wallTopY, foundationTopY, postW, houseW, wallsProgress);
}
if (fillProgress > 0) {
drawFillWalls(houseLeftX, houseRightX, wallTopY, foundationTopY, postW, fillProgress);
}
if (roofLeftProgress > 0 || roofRightProgress > 0) {
drawRoof(houseLeftX, houseRightX, wallTopY, roofPeakY, roofLeftProgress, roofRightProgress, tileRows);
}
if (doorAlpha > 0) {
drawDoor(houseLeftX, houseW, foundationTopY, houseH, doorAlpha);
}
if (winLeftAlpha > 0 || winRightAlpha > 0) {
drawWindows(houseLeftX, houseW, foundationTopY, houseH, winLeftAlpha, winRightAlpha);
}
if (chimneyProgress > 0) {
drawChimney(houseLeftX, houseW, roofPeakY, wallTopY, chimneyProgress);
}
if (pathProgress > 0) {
drawPath(cx, foundationTopY, houseW, groundY, pathProgress);
}
if (treeProgress > 0) {
drawTree(houseRightX, groundY, treeProgress);
}
// Smoke on top (animated continuously if shown)
if (smokeProgress > 0) {
drawSmoke(houseLeftX, houseW, roofPeakY, wallTopY, chimneyProgress, smokeProgress, t);
}
// Subtitle
drawSubtitle(stage, t);
requestAnimationFrame(draw);
}
function drawCloud(x, y, r) {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI*2);
ctx.arc(x+r*0.8, y-r*0.2, r*0.7, 0, Math.PI*2);
ctx.arc(x-r*0.7, y-r*0.1, r*0.6, 0, Math.PI*2);
ctx.arc(x+r*0.3, y-r*0.5, r*0.5, 0, Math.PI*2);
ctx.fill();
}
function drawFoundation(leftX, rightX, y, h) {
const w = rightX - leftX + 30;
const x = leftX - 15;
ctx.fillStyle = '#9E9E9E';
ctx.fillRect(x, y, w, h);
ctx.fillStyle = '#757575';
ctx.fillRect(x, y, w, 6);
ctx.fillStyle = '#616161';
ctx.fillRect(x, y + h - 4, w, 4);
ctx.strokeStyle = '#424242';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
}
function drawWalls(leftX, rightX, topY, botY, postW, totalW, progress) {
const houseW = rightX - leftX;
const wallH = (botY - topY) * progress;
const currentTopY = botY - wallH;
const xs = [
leftX,
leftX + (houseW - postW) / 3,
leftX + 2 * (houseW - postW) / 3,
leftX + (houseW - postW)
];
for (let i = 0; i < 4; i++) {
// Slight depth shading: outer posts darker
const isCorner = (i === 0 || i === 3);
ctx.fillStyle = isCorner ? '#5D4037' : '#6D4C41';
ctx.fillRect(xs[i], currentTopY, postW, wallH);
ctx.strokeStyle = '#3E2723';
ctx.lineWidth = 1;
ctx.strokeRect(xs[i], currentTopY, postW, wallH);
}
}
function drawFillWalls(leftX, rightX, topY, botY, postW, progress) {
const houseW = rightX - leftX;
const xs = [
leftX,
leftX + (houseW - postW) / 3,
leftX + 2 * (houseW - postW) / 3,
leftX + (houseW - postW)
];
// 3 panels
// Animate by panel: each panel takes 1/3 of progress
for (let i = 0; i < 3; i++) {
const panelStart = i / 3;
const panelEnd = (i + 1) / 3;
let panelP = (progress - panelStart) / (panelEnd - panelStart);
panelP = Math.max(0, Math.min(1, panelP));
panelP = easeOut(panelP);
const px = xs[i] + postW;
const pw = xs[i+1] - xs[i] - postW;
const ph = (botY - topY) * panelP;
const py = botY - ph;
// Beige panel
const panelGrad = ctx.createLinearGradient(px, 0, px + pw, 0);
panelGrad.addColorStop(0, '#E8D8B0');
panelGrad.addColorStop(0.5, '#D7C496');
panelGrad.addColorStop(1, '#C9B07F');
ctx.fillStyle = panelGrad;
ctx.fillRect(px, py, pw, ph);
ctx.strokeStyle = '#8B7355';
ctx.lineWidth = 1;
ctx.strokeRect(px, py, pw, ph);
}
}
function drawRoof(leftX, rightX, wallTop, peakY, leftP, rightP, tileP) {
const midX = (leftX + rightX) / 2;
// Left triangle: (leftX, wallTop) - (midX, peakY) - and we need base at rightX?
// Actually a roof has two triangles meeting at the ridge.
// Left half: vertices (leftX, wallTop), (midX, wallTop), (midX, peakY)
// Right half: vertices (midX, wallTop), (rightX, wallTop), (midX, peakY)
// Animate by sliding in from above
// For left triangle: starts at (leftX - dx, wallTop - dy) to (midX - dx, wallTop - dy) to (midX, peakY - dy)
// Simpler: animate by sliding the triangle vertically downward from above the canvas
const slideDist = 200;
// Left triangle
{
const offsetY = -slideDist * (1 - leftP);
ctx.save();
ctx.beginPath();
ctx.moveTo(leftX, wallTop + offsetY);
ctx.lineTo(midX, wallTop + offsetY);
ctx.lineTo(midX, peakY + offsetY);
ctx.closePath();
ctx.fillStyle = '#B71C1C';
ctx.fill();
ctx.strokeStyle = '#7F0000';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// Right triangle
{
const offsetY = -slideDist * (1 - rightP);
ctx.save();
ctx.beginPath();
ctx.moveTo(midX, wallTop + offsetY);
ctx.lineTo(rightX, wallTop + offsetY);
ctx.lineTo(midX, peakY + offsetY);
ctx.closePath();
ctx.fillStyle = '#B71C1C';
ctx.fill();
ctx.strokeStyle = '#7F0000';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// Roof tiles - draw when both triangles are in place
if (tileP > 0) {
drawRoofTiles(leftX, rightX, wallTop, peakY, midX, tileP);
}
}
function drawRoofTiles(leftX, rightX, wallTop, peakY, midX, tileP) {
const roofHeight = wallTop - peakY;
const numRows = Math.max(2, Math.floor(roofHeight / 12));
const visibleRows = Math.floor(numRows * tileP);
for (let i = 0; i < visibleRows; i++) {
// Progress of row from 0 to 1 for fade-in effect
let rowP = Math.min(1, (numRows * tileP - i));
const yTop = wallTop - (i+1) * (roofHeight / numRows);
const yBot = wallTop - i * (roofHeight / numRows);
// Compute the x range at yTop and yBot for the triangle profile
// For the left triangle at height h from peak (0=peak, roofHeight=base):
// xLeft = midX - (midX - leftX) * h / roofHeight
// xRight = midX
// For right triangle:
// xLeft = midX
// xRight = midX + (rightX - midX) * h / roofHeight
function xLeftAt(y) {
const h = wallTop - y;
return midX - (midX - leftX) * h / roofHeight;
}
function xRightAt(y) {
const h = wallTop - y;
return midX + (rightX - midX) * h / roofHeight;
}
const xl1 = xLeftAt(yBot), xr1 = xRightAt(yBot);
const xl2 = xLeftAt(yTop), xr2 = xRightAt(yTop);
ctx.globalAlpha = rowP;
// Left triangle tile row
ctx.fillStyle = '#C62828';
ctx.beginPath();
ctx.moveTo(xl1, yBot);
ctx.lineTo(midX, yBot);
ctx.lineTo(midX, yTop);
ctx.lineTo(xl2, yTop);
ctx.closePath();
ctx.fill();
// Right triangle tile row
ctx.beginPath();
ctx.moveTo(midX, yBot);
ctx.lineTo(xr1, yBot);
ctx.lineTo(xr2, yTop);
ctx.lineTo(midX, yTop);
ctx.closePath();
ctx.fill();
// Tile lines (horizontal lines between rows)
ctx.strokeStyle = '#7F0000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xl1, yBot);
ctx.lineTo(xr1, yBot);
ctx.stroke();
// Individual tile divisions
const tileW = 15;
const numTilesL = Math.max(1, Math.floor((midX - xl1) / tileW));
const numTilesR = Math.max(1, Math.floor((xr1 - midX) / tileW));
// Vertical tile divisions on left
for (let j = 1; j < numTilesL; j++) {
const t = j / numTilesL;
const xBottom = xl1 + (midX - xl1) * t;
const xTop = xl2 + (midX - xl2) * t;
ctx.beginPath();
ctx.moveTo(xBottom, yBot);
ctx.lineTo(xTop, yTop);
ctx.stroke();
}
for (let j = 1; j < numTilesR; j++) {
const t = j / numTilesR;
const xBottom = midX + (xr1 - midX) * t;
const xTop = midX + (xr2 - midX) * t;
ctx.beginPath();
ctx.moveTo(xBottom, yBot);
ctx.lineTo(xTop, yTop);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
}
function drawDoor(leftX, houseW, botY, wallH, alpha) {
const doorW = houseW * 0.18;
const doorH = wallH * 0.55;
const dx = leftX + houseW/2 - doorW/2;
const dy = botY - doorH;
ctx.globalAlpha = alpha;
// Door frame
ctx.fillStyle = '#5D4037';
ctx.fillRect(dx - 3, dy - 3, doorW + 6, doorH + 3);
// Door
const doorGrad = ctx.createLinearGradient(dx, 0, dx + doorW, 0);
doorGrad.addColorStop(0, '#8B4513');
doorGrad.addColorStop(1, '#6D3510');
ctx.fillStyle = doorGrad;
ctx.fillRect(dx, dy, doorW, doorH);
// Panels
ctx.strokeStyle = '#4E2A0E';
ctx.lineWidth = 1.5;
ctx.strokeRect(dx + 4, dy + 6, doorW/2 - 6, doorH/2 - 8);
ctx.strokeRect(dx + doorW/2 + 2, dy + 6, doorW/2 - 6, doorH/2 - 8);
ctx.strokeRect(dx + 4, dy + doorH/2 + 2, doorW/2 - 6, doorH/2 - 8);
ctx.strokeRect(dx + doorW/2 + 2, dy + doorH/2 + 2, doorW/2 - 6, doorH/2 - 8);
// Doorknob
ctx.fillStyle = '#FFD700';
ctx.beginPath();
ctx.arc(dx + doorW - 10, dy + doorH/2 + 5, 3.5, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = '#B8860B';
ctx.lineWidth = 1;
ctx.stroke();
ctx.globalAlpha = 1;
}
function drawWindows(leftX, houseW, botY, wallH, leftAlpha, rightAlpha) {
const winW = houseW * 0.16;
const winH = wallH * 0.28;
const winY = botY - wallH * 0.7;
const leftXWin = leftX + houseW * 0.16;
const rightXWin = leftX + houseW * 0.84 - winW;
if (leftAlpha > 0) {
drawWindow(leftXWin, winY, winW, winH, leftAlpha);
}
if (rightAlpha > 0) {
drawWindow(rightXWin, winY, winW, winH, rightAlpha);
}
}
function drawWindow(x, y, w, h, alpha) {
ctx.globalAlpha = alpha;
// Frame
ctx.fillStyle = '#FAFAFA';
ctx.fillRect(x - 5, y - 5, w + 10, h + 10);
// Glass
ctx.fillStyle = '#4FC3F7';
ctx.fillRect(x, y, w, h);
// Glass shine
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(x + 4, y + 4, w * 0.3, h * 0.3);
// Cross frame
ctx.strokeStyle = '#FAFAFA';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(x + w/2, y);
ctx.lineTo(x + w/2, y + h);
ctx.moveTo(x, y + h/2);
ctx.lineTo(x + w, y + h/2);
ctx.stroke();
// Outer border
ctx.strokeStyle = '#424242';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
ctx.globalAlpha = 1;
}
function drawChimney(leftX, houseW, peakY, wallTop, progress) {
// Chimney on right side of roof
const cx = leftX + houseW * 0.7;
const baseY = wallTop - houseW * 0.1; // somewhere on right slope of roof
// Actually let's place it on the roof, slightly to the right
// Compute y at this x on the right triangle slope
const midX = leftX + houseW / 2;
const rightX = leftX + houseW;
const roofH = wallTop - peakY;
// y = wallTop - roofH * (cx - midX) / (rightX - midX)
// Wait: at midX (center), y = peakY. At rightX, y = wallTop. So y = peakY + (wallTop - peakY) * (cx - midX) / (rightX - midX)
const baseYChimney = peakY + (wallTop - peakY) * (cx - midX) / (rightX - midX);
const chimW = 28;
const chimH = 80 * progress;
const chimTopY = baseYChimney - chimH;
ctx.fillStyle = '#A1887F';
ctx.fillRect(cx - chimW/2, chimTopY, chimW, chimH);
ctx.fillStyle = '#8D6E63';
ctx.fillRect(cx - chimW/2, chimTopY, chimW, 8);
// Brick lines
ctx.strokeStyle = '#5D4037';
ctx.lineWidth = 1;
const rows = Math.floor(chimH / 10);
for (let i = 0; i < rows; i++) {
const y = chimTopY + i * 10 + 8;
if (y > chimTopY + chimH) break;
ctx.beginPath();
ctx.moveTo(cx - chimW/2, y);
ctx.lineTo(cx + chimW/2, y);
ctx.stroke();
}
ctx.strokeStyle = '#3E2723';
ctx.lineWidth = 2;
ctx.strokeRect(cx - chimW/2, chimTopY, chimW, chimH);
}
function drawSmoke(leftX, houseW, peakY, wallTop, chimneyP, smokeP, t) {
if (chimneyP < 1) return;
const midX = leftX + houseW / 2;
const rightX = leftX + houseW;
const cx = leftX + houseW * 0.7;
const baseYChimney = peakY + (wallTop - peakY) * (cx - midX) / (rightX - midX);
const chimTopY = baseYChimney - 80;
// Smoke puffs at different stages
for (let i = 0; i < 5; i++) {
const phase = (t / 1000 + i * 0.4) % 2;
const yOffset = -phase * 80;
const xJitter = Math.sin(phase * 3 + i) * 10;
const r = 8 + phase * 12;
const alpha = (1 - phase / 2) * smokeP * 0.6;
ctx.globalAlpha = alpha;
ctx.fillStyle = '#ECEFF1';
ctx.beginPath();
ctx.arc(cx + xJitter, chimTopY + yOffset, r, 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawPath(cx, botY, houseW, groundY, progress) {
// Stepping stones from door bottom to bottom of canvas
const doorW = houseW * 0.18;
const startX = cx - doorW/2;
const startY = botY;
const numStones = 5;
const maxDist = canvas.height - startY;
for (let i = 0; i < numStones; i++) {
const stoneP = (progress * numStones) - i;
if (stoneP <= 0) continue;
stoneP = Math.min(1, stoneP);
const eased = easeOut(stoneP);
const distFromDoor = 20 + i * 30;
const sy = startY + distFromDoor;
const sx = startX + doorW/2 + (i % 2 === 0 ? 0 : 5);
const sw = 35 * eased;
const sh = 18 * eased;
ctx.fillStyle = '#9E9E9E';
ctx.beginPath();
ctx.ellipse(sx, sy, sw/2, sh/2, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#757575';
ctx.beginPath();
ctx.ellipse(sx, sy - sh/4, sw/2 * 0.9, sh/2 * 0.9, 0, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = '#424242';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(sx, sy, sw/2, sh/2, 0, 0, Math.PI*2);
ctx.stroke();
}
}
function drawTree(rightX, groundY, progress) {
const tx = rightX + 60;
const ty = groundY;
const trunkH = 70 * progress;
const trunkW = 18 * Math.min(1, progress * 2);
// Trunk
ctx.fillStyle = '#6D4C41';
ctx.fillRect(tx - trunkW/2, ty - trunkH, trunkW, trunkH);
ctx.strokeStyle = '#4E342E';
ctx.lineWidth = 1.5;
ctx.strokeRect(tx - trunkW/2, ty - trunkH, trunkW, trunkH);
// Canopy
if (progress > 0.3) {
const canopyP = (progress - 0.3) / 0.7;
const canopyR = 40 * canopyP;
ctx.fillStyle = '#388E3C';
ctx.beginPath();
ctx.arc(tx, ty - trunkH - canopyR * 0.5, canopyR, 0, Math.PI*2);
ctx.arc(tx - canopyR * 0.6, ty - trunkH - canopyR * 0.3, canopyR * 0.8, 0, Math.PI*2);
ctx.arc(tx + canopyR * 0.6, ty - trunkH - canopyR * 0.3, canopyR * 0.8, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = '#1B5E20';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(tx, ty - trunkH - canopyR * 0.5, canopyR, 0, Math.PI*2);
ctx.stroke();
}
}
function drawSubtitle(stage, t) {
let label;
if (stage.done) label = '完成!';
else label = stages[stage.idx].name;
ctx.save();
// Background pill
ctx.font = 'bold 32px sans-serif';
const text = label;
const metrics = ctx.measureText(text);
const padX = 24, padY = 12;
const boxW = metrics.width + padX * 2;
const boxH = 60;
const boxX = (canvas.width - boxW) / 2;
const boxY = 30;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
// Rounded rectangle
const r = 12;
ctx.beginPath();
ctx.moveTo(boxX + r, boxY);
ctx.lineTo(boxX + boxW - r, boxY);
ctx.quadraticCurveTo(boxX + boxW, boxY, boxX + boxW, boxY + r);
ctx.lineTo(boxX + boxW, boxY + boxH - r);
ctx.quadraticCurveTo(boxX + boxW, boxY + boxH, boxX + boxW - r, boxY + boxH);
ctx.lineTo(boxX + r, boxY + boxH);
ctx.quadraticCurveTo(boxX, boxY + boxH, boxX, boxY + boxH - r);
ctx.lineTo(boxX, boxY + r);
ctx.quadraticCurveTo(boxX, boxY, boxX + r, boxY);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, boxY + boxH / 2);
ctx.restore();
}
requestAnimationFrame(draw);
</script>
</body>
</html>
OK that's a lot. Let me review and refine:
-
The wall fill animation: I have 3 panels each with their own timing within stage 3's 3 seconds. Each panel takes 1s. Good.
-
Roof tiles: I'm drawing tile rows. The horizontal lines between rows and vertical divisions create the tile look.
-
Chimney position calculation needs to be consistent between drawChimney and drawSmoke.
-
The path: stepping stones from door base going down.
-
Tree: right of house.
Let me also check the stage transitions. When stage transitions, the previous stage should be complete and the next begins.
Let me also think about the foundation going back underground at end of cycle. Actually no, the loop just restarts from stage 1 so the foundation rises again.
Wait, if I loop, do I want the house to disappear first? Or just snap back? The user says "持续循环" (continuously loop). The simplest is just to snap back to nothing and start over. But maybe a smoother transition would be: at end, house fades out or stays. Let me keep it simple: at the start of next cycle, stage 1 starts with foundation underground (rising), so the rest naturally gets covered... wait no, the foundation comes from underground but the rest of the house is still there.
Hmm, this could be jarring. Let me add a "fade out" stage at the end or just reset.
Option 1: At cycle end, everything fades out, then cycle restarts. Option 2: Reset to initial state (everything hidden) when cycle restarts.
Let me add a fade out at the end. Actually, since the cycle is 25 seconds and continuous, let me just have a clear reset. Let me add a brief fade to black or similar at the very end.
Actually, let me reconsider. If the cycle is 25s with 0.5s pauses = 6 pauses = 3s pauses + ~22s stages = 25s. Then loop. If we don't reset, the house just stays built. But we want to show the building again.
Simplest fix: at the start of each cycle, reset everything. So when stage 1 begins, foundation is underground (and nothing else is drawn). Let me make sure that's the case in my draw function.
Looking at my draw function: at the start (stage 0), only the foundation is shown (with rising animation). The walls etc. only show when stage index >= 1. So at the very start of each cycle, only the foundation is visible. Good, the reset is automatic.
But the transition might be jarring: one moment the full house is there, the next moment everything except the foundation is gone. Let me add a fade-in transition or just live with the snap.
Actually, let me make the foundation come from below ground rising up, which would cover the wall posts etc. Wait no, the foundation starts below ground level and rises up to the visible position. The wall posts etc. would still be visible above the foundation.
Hmm. Let me think. To make the reset smooth:
- Option A: After the house is fully built, fade everything to nothing over 1 second, then restart.
- Option B: At end of cycle, just restart with snap reset.
Let me go with Option A. Add a brief "fade out" period before restart.
Or even simpler: at the very end of the cycle, just don't reset but show "完成!" subtitle and the full house. Then when loop starts again, reset everything.
Actually, let me add a fade-out at the very end. Or just snap reset for simplicity. The user wants ~25 seconds of animation. Let me see if I can make the reset look intentional.
Let me add: at the end of stage 7, everything fades out (opacity 1 to 0 over say 0.5s). Then loop starts with foundation rising from underground.
Actually, the cleanest is: at the end of cycle, all elements fade to 0. At cycle restart, start fresh. Let me add an overall fade factor.
Let me add this: at the very end of the cycle (last 0.5s of totalMs), all elements fade out.
// Compute fade-out factor at end of cycle
let fadeOut = 1;
if (t > totalMs - 800) {
fadeOut = Math.max(0, 1 - (t - (totalMs - 800)) / 800);
}
Then apply fadeOut to all alpha values.
Actually let me not over-complicate. Let me just do snap reset. The cycle restarts every 25s and the construction is shown again. The viewer will see "construction 1 -> 2 -> 3" which is a nice loop.
But the snap from "complete house" to "nothing" might be jarring. Let me add a quick fade-out at the end.
OK let me add the fade-out. I'll make the last 0.8s fade everything to 0.
Now let me also think about the subtitles. They should appear for each stage. Maybe also fade with the elements.
Let me finalize the code. Let me also test the wall positions to make sure the roof sits correctly on top.
Let me write the final version:
Actually I realize there's an issue with my approach for the walls. The "四段竖直墙" - 4 vertical wall posts at corners. In 2D front view, the 4 corners of the foundation would be: front-left, front-right, back-left (hidden), back-right (hidden). To show 4 visible vertical posts, I'd need to either:
- Show slight 3D perspective with 2 front and 2 back posts
- Show 4 evenly-spaced wall studs (interior framing)
The user's description sounds more like 4 corner posts. Let me do a 3D-ish look: 2 front posts (visible corners of front face) and 2 back posts (slightly offset to show depth, like top edges of back wall visible above the roof base).
Hmm, but if I have a roof on top of the walls, the back posts would be hidden by the front wall when seen from the front.
Alternative interpretation: the house is shown at a slight 3/4 angle so you can see the front and one side. Then 4 corner posts are visible.
You know what, let me just commit to the interpretation of 4 evenly-spaced wall studs (vertical framing members). This is the simplest and looks fine in 2D. The "四段竖直墙" can be interpreted as 4 wall sections.
OK final design: 4 vertical posts at positions 0, 1/3, 2/3, 1.0 of the front wall width. These create 3 panels between them for the wall fill.
Let me finalize. Let me also think about the roof tile animation. I want the tiles to appear row by row from the bottom of the roof up. In my current code, tileP goes from 0 to 1, and visibleRows = floor(numRows * tileP). That should work.
But the tile rows should appear progressively. Each row fades in as it appears. The first row (at the bottom of the roof, at wallTop) appears first, then each subsequent row appears above it.
In my current draw code, the loop iterates i from 0 to visibleRows. Row 0 is at the bottom (yBot = wallTop, yTop = wallTop - rowHeight). Row 1 is above it. So yes, rows appear bottom-up. Good.
For fade-in: I compute rowP = numRows * tileP - i, clamped to [0,1]. So row 0 has rowP going 0 to 1 first, then row 1 starts fading in. Hmm actually that gives all rows equal fade timing. Let me think.
Actually with visibleRows = floor(numRows * tileP), once tileP > 1/numRows, row 0 is fully visible (rowP = 1). And row 1 starts to appear. The fade-in for each row is 1/numRows of total time. That's good.
OK let me finalize the code. Let me also double-check the smoke. Smoke should appear continuously after stage 7 completes, with puffs emerging from chimney.
In my drawSmoke function, I'm using t/1000 as a phase. This should give continuous animation. Let me verify it's only drawn when smoke is at least partially visible.
OK let me also reconsider the foundation. "一块灰色矩形板从地下升起" - a gray rectangular plate rises from underground. So it starts underground (below ground line) and rises up to its final position just above ground. My current code: foundation starts at groundY + 40 and rises to foundationTopY. Good.
Wait, "从地下升起" means rising from underground. So it should start below the ground line. groundY is the top of the ground. So foundation starts at y > groundY and ends at y = groundY - foundationH. My code has startY = groundY + 40 and end at foundationTopY = groundY - foundationH. Good.
Let me also reconsider walls. The walls grow from foundation upward. They should start at foundationTopY (top of foundation) and extend up to wallTopY. My code does this.
For the fill, the panels grow downward from wallTopY (or upward from foundationTopY). Let me have them grow from top down or bottom up. Growing upward from foundation makes more sense (construction order: walls up, then fill in panels). So panels grow from botY (foundationTopY) upward. My current code: ph = (botY - topY) * panelP, py = botY - ph. So they grow upward from botY. Good.
OK let me also think about the title positioning. Top center, with a nice background. Let me also add a subtle progress indicator (like dots showing which stage we're in).
Let me also handle the resize event so the animation continues to work on window resize.
Let me write the complete final code now. Let me also add some visual polish like grass blades, fence, etc. Actually, the user didn't ask for these, so let me keep it focused.
Wait, let me re-examine the chimney position calculation. The chimney should be on the right side of the roof. Let me place it at 0.7 of the house width (slightly right of center).
The y position where the chimney base sits on the roof: I need to compute where the chimney intersects the roof surface. Let me say the chimney base is at some y above wallTopY. Specifically, the chimney goes from the roof up. Let me place the chimney base on the right slope of the roof.
For a triangle with vertices (midX, peakY), (rightX, wallTop), (midX, wallTop): The right slope goes from (midX, peakY) to (rightX, wallTop). Parametrize: at parameter t (0 to 1), x = midX + (rightX - midX) * t, y = peakY + (wallTop - peakY) * t.
If I want chimney at x = leftX + 0.7 * houseW: 0.7 * houseW = (midX - leftX) + (rightX - midX) * t = houseW/2 + houseW/2 * t 0.7 = 0.5 + 0.5 * t t = 0.4
So at chimney x, the roof y is: peakY + (wallTop - peakY) * 0.4 = peakY + 0.4 * roofH.
Let me use this. Chimney baseY = peakY + 0.4 * (wallTop - peakY). Chimney height: 80. Chimney topY = baseY - 80.
But wait, I want the chimney to look like it's coming out of the roof. So the chimney base should be at the roof surface, and it extends upward. My current code does this: baseYChimney is on the roof, chimTopY = baseYChimney - chimH. Good.
Actually, looking at my code, I used cx = leftX + houseW * 0.7 and the formula matches what I derived. Good.
Let me also make sure the roof tile rendering doesn't have issues with the triangle proportions. The left triangle has vertices (leftX, wallTop), (midX, wallTop), (midX, peakY). For a row at y, the left x is midX - (midX - leftX) * (wallTop - y) / roofHeight. At y = wallTop, left x = midX. At y = peakY, left x = leftX. Wait, that's wrong direction. Let me think.
For the left triangle (right triangle in math terms):
- (leftX, wal