Canvas-六边形小球实验

对比主流模型的物理世界理解

8 个模型 · 百模竞速 · TOKRACE · 分享快照
分享与嵌入

社交平台直接发布;GitHub/README 使用 Markdown 或 Badge。

XFacebook微博LinkedIn
[![Canvas-六边形小球实验 在 TOKRACE 上的速度结果](/api/badge/share/M714uZcUyZ?locale=zh-CN)](/zh-CN/r/M714uZcUyZ)
写一个单文件 HTML(只输出完整代码,不要解释),用铺满整页的 Canvas、不使用任何库,在淡蓝色天空与一条绿色地面线前,分 7 个阶段、约 25 秒动画演示一栋简单的 2D 卡通房屋的建造:(1)地基——一块灰色矩形板从地下升起;(2)墙体——四段竖直墙从地基四角向上延伸;(3)填墙——墙框之间填入米黄色/浅褐色外墙板;(4)屋顶——两块三角形屋顶板从上方滑入并在屋脊相接,红色瓦片纹理逐行出现;(5)门——一扇棕色门在正面墙上淡入,带一个小小的金色门把手;(6)窗——门两侧各出现一扇窗,带可见的十字窗框和蓝色玻璃;(7)细节——烟囱从屋顶升起、开始冒出一小缕烟,一条踏脚石小径通向门口,房屋旁长出一棵小树。每个阶段顶部应有简短字幕(「打地基」「砌墙」等)。阶段之间停顿 0.5 秒。持续循环。
复跑这次评测完整复跑会带走标题、备注、Prompt 和模型;Prompt 复跑只复用任务文本。
显示
🏁 实时竞速思考 / 输出 token速度 t/s · 基准 300
阶跃星辰
🏁
30958
176 t/s
小米 MiMo-V2.5
🏁
30527
MiniMax-M3
🏁
23744
80 t/s
Kimi 月之暗面-high-think-budget
🏁
19841
236 t/s
DeepSeek
🏁
17251
159 t/s
DeepSeek
🏁
16888
118 t/s
小米 MiMo-V2.5 Pro
🏁
15540
智谱 GLM-5.2
0
🏁 本轮结算
🏆 最快
Kimi 月之暗面-high-think-budget
236 tok/s
💰 最省
小米 MiMo-V2.5 Pro
≈$0.0043
综合推荐
DeepSeek
综合最优
阶跃星辰
step-3.7-flash
完成
用户想要一个单文件HTML,包含Canvas动画,演示2D卡通房屋的建造过程,分为7个阶段,约25秒循环。要求不使用任何库,铺满整页的Canvas。 让我规划一下: 1. 背景:淡蓝色天空,绿色地面线 2. 7个阶段,每个约3-4秒,阶段间停顿0.5秒,总共约25秒 3. 阶段1:地基——灰色矩形板从地下升起 4. 阶段2:墙体——四段竖直墙从地基四角向上延伸 5. 阶段3:填墙——墙框之间填入米黄色/浅褐色外墙板 6. 阶段4:屋顶——两块三角形屋顶板从上方滑入并在屋脊相接,红色瓦片纹理逐行出现 7. 阶段5:门——棕色门在正面墙上淡入,带金色门把手 8. 阶段6:窗——门两侧各出现一扇窗,十字窗框和蓝色玻璃 9. 阶段7:细节——烟囱升起、冒烟,踏脚石小径,小树 10. 顶部字幕,阶段间停顿0.5秒,循环 让我设计房屋的尺寸和位置。假设Canvas全屏,房屋在中央偏下位置。 房屋尺寸参考(以屏幕比例计): - 地基:约400x30,位于地面 - 墙体:4面墙,前后左右,高度约300 - 屋顶:三角形 - 门:宽约60,高约100 - 窗户:宽约50,高约60 让我用具体的坐标来设计。假设画布大小为window.innerWidth x window.innerHeight。 我需要定义一些关键点: - 地面Y坐标(约屏幕高度的75%处) - 房屋中心X坐标 让我设置: - 地面Y = 画布高度的70% - 房屋中心X = 画布宽度的50% - 房屋宽度 = 300px - 墙体高度 = 250px - 屋顶高度 = 120px 坐标计算: - 地基左 = centerX - 150, 右 = centerX + 150, 上 = 地面Y, 下 = 地面Y + 30 - 前墙左 = centerX - 150, 右 = centerX + 150, 上 = 地面Y - 250, 下 = 地面Y - 后墙类似 - 左侧墙:从前墙左到后墙左 - 右侧墙:从前墙右到后墙右 实际上对于2D卡通房屋,我们可以用更简单的方式: - 正面是矩形 - 侧面可以简化为梯形或矩形(因为是2D正面视角) - 但题目说"2D卡通房屋",我理解为正面视角,有深度感 让我重新设计,使用简单的2D正面视角,带有一点侧墙来体现立体感: 实际上,对于2D卡通风格,常见的做法是: - 正面墙体(矩形) - 屋顶(三角形) - 可能有一个侧墙(梯形)来显示深度 但题目提到"四段竖直墙从地基四角向上延伸",以及"墙框之间填入米黄色/浅褐色外墙板",这暗示房屋有一个矩形基底,有四面墙。 对于2D视角,我可以: - 正面墙:大矩形 - 左侧墙:较小的梯形(透视效果),或者简单处理 让我简化设计,做一个有明显正面和侧面的房屋: 实际上,让我重新读题:"一栋简单的2D卡通房屋"。四段竖直墙从地基四角向上延伸,这描述的是一个长方体房屋的四个角。在2D表现中,我们可以画出正面和一侧侧面来显示立体感。 让我这样设计(2.5D效果): - 地基:地面上的灰色矩形 - 四角:前后左右四个角有竖直的柱子/墙 - 正面墙:填充米黄色 - 左侧墙(可选,显示透视) - 屋顶:三角形覆盖在上面 实际上,让我简化。对于纯2D正面视角,但显示立体感: - 地基矩形板 - 正面墙体(带门和窗) - 左侧有一个小的侧面墙(表示深度) - 屋顶是三角形 - 右侧是开放的(或者也画侧面) 嗯,让我重新考虑。用户说"2D卡通房屋","四段竖直墙从地基四角向上延伸"。这让我想到的是一个带有明显深度感的房屋。 让我设计如下结构: - 地基是矩形(在地面上) - 从地基的四个角向上有竖直的墙 - 前面(朝向观众)的墙较大 - 侧面(左侧)的墙较小(因为透视,后退) - 屋顶覆盖在上面 但这样会很复杂。让我简化:做一个正面视角的房屋,但带一点右侧的侧面墙来显示深度。实际上,对于2D卡通,我可以用简单的矩形+三角形来构建。 让我重新定义房屋结构: - 地基:灰色矩形,在绿色地面上 - 主体:正面矩形墙(米黄色),左侧有一个小的侧面墙(稍深色的米黄色,表示阴影) - 屋顶:三角形(红色瓦片) - 地面有绿色地面线 实际上,我想让这个更加卡通化和有趣。让我简化设计: 房屋正面: - 一个大的矩形作为正面墙体 - 屋顶是三角形(比正面宽,突出两侧) - 有门和窗 - 侧面可以有一个小的三角形或梯形来显示深度 让我用更简单的方法——正面视角的房屋,但屋顶的三角形比墙体宽,这样能看到屋顶边缘。 好的,让我开始设计具体的数值。我将使用中心为基准的坐标。 设定: - 地面线Y = canvas.height * 0.65 - 房屋中心X = canvas.width / 2 - 地基宽度 = 320, 高度 = 20(但动画中会从地下升起) - 墙体宽度 = 280(比地基稍小或相同),高度 = 220 - 墙体顶部Y = 地面线Y - 墙体高度 - 屋顶三角形高度 = 140,底边宽度 = 340(比墙体宽) 对于侧面墙(显示深度): - 左侧有一个深度面,比如从左侧墙向左延伸30px,形成一个梯形 - 但为了简单,我先做正面房屋,如果需要侧面可以用简单的斜线表示 实际上,用户提到"四段竖直墙从地基四角向上延伸",这明确暗示了四面墙。在2D表现中,如果房屋正对着观众,我们主要看到前面。但为了显示四面墙,我需要使用等轴测或透视视角。 对于2D卡通风格,我可以: - 正面墙完整显示 - 左侧或右侧显示一个侧面(侧墙),表示深度 - 这样四角中的前左、前右、后左、后右中的部分可见 让我简化:使用正面视角,但让屋顶覆盖超出墙体两侧,侧面有一个小的可见部分。或者,更简单地,画一个正面视角的房屋,有前后墙的暗示(比如墙体有厚度感)。 实际上我决定采用这样的方式: - 正面墙体是主要部分 - 屋顶是三角形,底部超出墙体 - 地基在底部 - 为了体现"四段竖直墙",我在墙体顶部画四个角落的装饰或暗示 - 侧面画一个简单的侧面墙 算了,让我务实一点。对于纯2D正面卡通房屋,最常见的做法是: 1. 矩形墙体 2. 三角形屋顶 3. 地基矩形 4. 门和窗在正面 5. 可能有一个侧面三角形表示立体感 但题目明确说"四段竖直墙从地基四角向上延伸"——这描述的是一个方盒子建筑的四个角柱。在2D中,这意味着房屋有一个可见的正面和侧面。 让我重新设计,加入左侧侧面墙(在正面视角中,侧面墙是一个梯形,左侧变窄,表示透视深度)。 好的,让我设计坐标。我会使用屏幕坐标,以(0,0)为左上角,Y向下为正。 设定参数(以画布中心为基准): - cx = canvas.width / 2 (房屋中心) - groundY = canvas.height * 0.65 (地面线) - 地基宽度 = 340 - 地基厚度 = 25 - 墙体正面宽度 = 300 - 墙体正面高度 = 240 - 墙体深度(侧面)= 50px(左侧侧面) - 屋顶底边宽度 = 360(覆盖墙体+突出) - 屋顶高度 = 150 坐标: - 地基:x=cx-170到cx+170, y=groundY到groundY+25 - 正面墙底:x=cx-150到cx+150, y=groundY到groundY-240 - 左侧面墙底:x=cx-150到cx-150+50=cx-100, 从地面线开始向上...实际上侧面墙是一个梯形 - 底部宽度50,顶部宽度(因为透视)可能更窄,约30 - 顶部在groundY-240 - 左侧面墙顶部x从cx-150到cx-150+30=cx-120 实际上对于2D卡通,侧面墙可以简化为一个梯形: - 底部:x=cx-150到cx-100 (宽度50),y=groundY - 顶部:x=cx-120到cx-100 (宽度20),y=groundY-240 - 这样形成左侧的透视面 屋顶三角形: - 顶点:(cx, groundY-240-150) = (cx, groundY-390) - 左底角:(cx-180, groundY-240) - 右底角:(cx+180, groundY-240) - 这样屋顶覆盖正面墙体并两侧各突出30px 好,这些坐标可以工作。现在让我考虑动画。 7个阶段,约25秒循环。每个阶段持续时间: - 阶段1:约3秒 - 阶段2:约3秒 - 阶段3:约3秒 - 阶段4:约4秒(屋顶滑入+瓦片纹理) - 阶段5:约2秒 - 阶段6:约2秒 - 阶段7:约3秒 - 阶段间停顿0.5秒 * 6 = 3秒 - 总计约20秒,加上一些余量 让我调整为: - 阶段1:3.5秒 - 阶段2:3秒 - 阶段3:2.5秒 - 阶段4:4秒 - 阶段5:2秒 - 阶段6:2秒 - 阶段7:3.5秒 - 停顿:0.5秒 * 6 = 3秒 - 总计:20.5秒,接近25秒,有些余量可以让动画更流畅 实际上让我分配更多时间,每个阶段约3秒,7个阶段21秒,加上6个停顿3秒,总共24秒,非常接近25秒。 每个阶段的动画参数: - 阶段1(打地基):灰色矩形从地面下方升起。动画持续3秒。地基从y=groundY+25(地面以下)升到y=groundY。 - 阶段2(砌墙):四段竖直墙从地基四角向上延伸。每段墙可以略有延迟。持续3秒。 - 阶段3(填墙):米黄色外墙板填充。持续2.5秒,从左到右或从中心向外扩展。 - 阶段4(屋顶):两块三角形屋顶板从上方滑入,屋脊相接。瓦片纹理逐行出现。持续4秒。 - 阶段5(门):棕色门淡入。持续2秒。 - 阶段6(窗):窗户出现。持续2秒。 - 阶段7(细节):烟囱升起、冒烟、踏脚石、小树。持续3.5秒。 让我开始编写代码。我会用requestAnimationFrame进行动画循环。 关于动画状态管理,我将使用一个时间线系统,记录每个阶段开始的时间。 让我也加入一个循环逻辑,总循环时间约25秒。 我需要仔细管理每个阶段的动画。每个阶段有自己的动画函数,接受进度(0-1)作为参数。 另外,我需要处理"当前阶段"和"阶段间停顿"。我会使用一个状态机: - 跟踪当前阶段索引 - 每个阶段有持续时间和动画函数 - 阶段之间有0.5秒停顿(显示空状态或已完成的状态) 让我定义一个时间线: - t=0到3.5: 阶段1(地基升起) - t=3.5到4: 停顿0.5秒 - t=4到7: 阶段2(砌墙) - t=7到7.5: 停顿0.5秒 - t=7.5到10: 阶段3(填墙) - t=10到10.5: 停顿0.5秒 - t=10.5到14.5: 阶段4(屋顶+瓦片) - t=14.5到15: 停顿0.5秒 - t=15到17: 阶段5(门) - t=17到17.5: 停顿0.5秒 - t=18到20: 阶段6(窗) - t=20到20.5: 停顿0.5秒 - t=20.5到24: 阶段7(细节) - t=24到25: 停顿0.5秒,然后循环回t=0 总计24秒。好的。 实际上用户说"约25秒",所以24-25秒都可以。让我设置总循环为24秒。 现在让我编写代码。我会仔细定义所有动画。 对于地基升起: - 地基矩形初始在地面以下(groundY + 30) - 在3.5秒内升起到groundY位置 - 使用easeOut或线性动画 对于墙体: - 四段竖直墙从地基四角向上延伸 - 前墙左段:从地基左角向上 - 前墙右段:从地基右角向上 - 后墙(可能部分可见) - 实际上,对于正面视角,主要可见的是正面墙和可能的一侧墙 - 让我简化:画正面墙体(一块完整的墙),然后加上左侧侧面墙,这样就有"四段"的感觉——前左、前右、后左、后右的暗示 - 实际上,在动画中,我可以从四个角开始画竖直的线条/墙板,然后它们汇聚 对于"四段竖直墙从地基四角向上延伸": - 我可以理解为从地基的四个角画四条竖线向上 - 这些竖线代表房屋的四个角柱 - 然后它们之间的区域被填充 在2D正面+侧面视角中: - 前左角柱:正面左侧 - 前右角柱:正面右侧 - 后左角柱:在侧面墙上(左侧面的后方) - 后右角柱:可能在屋顶后面或不可见 让我简化设计。正面视角中,四个角柱可以表现为: - 正面左侧的竖直墙段 - 正面右侧的竖直墙段 - 左侧面左侧的竖直墙段 - 左侧面右侧的竖直墙段(实际上左侧面有两个可见的角) 嗯,这变得复杂了。让我简化: - 正面有一个完整的墙体 - 左侧有一个侧面墙(梯形) - 四个"竖直墙"可以理解为正面左右两个角柱和侧面左右两个角柱 在动画阶段2,我可以: 1. 先画正面左侧的角柱向上延伸 2. 然后正面右侧的角柱 3. 然后侧面左侧的角柱 4. 然后侧面右侧的角柱 但实际上对于卡通房屋,四个角柱可以简单地用矩形表示。让我这样处理: 正面墙体是一个完整的矩形,但在动画中,我可以先画左右两条竖线(代表角柱),然后再填充中间。 实际上对于动画效果更好,我可以这样设计阶段2: - 从地基的左前角画一条竖线向上(前左柱) - 从地基的右前角画一条竖线向上(前右柱) - 从地基的左后角(在侧面)画一条竖线向上(后左柱) - 从地基的右后角画一条竖线向上(后右柱,可能在侧面后方) 然后阶段3中填充这些柱之间的区域。 好的,让我实际开始编码。我会先定义房屋的几何形状,然后为每个阶段编写动画。 关于烟囱和冒烟: - 烟囱在屋顶上方,是一个小矩形 - 烟是一缕轻烟,可以用半透明的白色/灰色圆圈或弧线表示 - 烟在上升过程中逐渐消散 - 对于动画,我可以创建几个烟粒子 关于小树: - 简单的树:棕色树干(小矩形)+ 绿色树冠(圆形或三角形) - 在房屋侧面生长出来 关于踏脚石小径: - 几个灰色小石头从底部通向门口 - 可以是小椭圆或矩形 关于屋顶瓦片纹理: - 红色瓦片纹理可以画成斜线或一排排的小矩形 - 在屋顶区域逐行出现 好的,现在让我编写完整的HTML代码。 我需要考虑响应式设计。Canvas铺满整页,所以我需要在resize时更新canvas尺寸。房屋的坐标相对于canvas尺寸计算。 让我设定一些基础参数,在初始化时根据canvas尺寸计算。 另外,关于"2D卡通房屋",我需要设计一个可爱的、圆润的卡通风格。墙体可以有略微圆润的边角,但为了简单,我先使用矩形,然后通过颜色和细节来增加卡通感。 好的,开始写代码。我会特别注意: 1. 动画的平滑过渡 2. 每个阶段的视觉质量 3. 字幕的显示 4. 循环逻辑 关于字幕,我会在Canvas顶部中央显示阶段名称。字幕也可以有淡入淡出效果。 对于阶段间的停顿,在0.5秒内显示已完成的状态,然后进入下一阶段。 让我也加入一些卡通风格的元素: - 房屋墙体使用温暖的米黄色 - 屋顶使用红色 - 地面使用绿色 - 天空使用淡蓝色 - 有一些简单的阴影 现在开始编写代码。我会特别注意代码结构。 关于侧面的设计,让我重新考虑。对于正面视角的2D房屋,我加入一个右侧的侧面来显示深度。实际上,传统的2D卡通房屋(比如《小房子》那种风格)通常有: - 正面墙 - 左侧面墙(显示深度) - 屋顶三角形 - 有时右侧也有部分可见 对于这个动画,让我保持简单: - 房屋正面占据主要部分 - 左侧有一个小的侧面(梯形,表示深度约40-50px) - 屋顶三角形覆盖正面并略微超出 这样四角是: - 前左角(正面左侧与地面交界) - 前右角(正面右侧与地面交界) - 后左角(侧面后方与地面交界) - 后右角(可能在侧面后方,不太可见) 好的,这个设计合理。让我开始编码。 另外关于"一块灰色矩形板从地下升起"——地基板从地面线下方升起。在动画中,地基初始时可能在地面以下(被地面遮挡),然后升起到地面上。 对于墙体"从地基四角向上延伸",墙体会从地基的顶部开始向上生长。 对于"填墙",指的是在已经建好的角柱之间填充墙体材料。 对于屋顶"从上方滑入",屋顶从画面上方滑入到位。 对于门"淡入",门的透明度从0到1。 对于窗"出现",窗户逐渐显现。 对于细节阶段,各种元素同时出现。 好,开始写代码。我会使用一个大的动画循环,管理所有状态。 另外,关于屋顶的"两块三角形屋顶板从上方滑入并在屋脊相接"——这意味着屋顶分成左右两块三角形(两片斜坡),它们从上方滑入,在中间的屋脊处相接。这很有趣!左坡和右坡分别从上方滑入,然后在中间汇合。 瓦片纹理逐行出现——在屋顶滑入完成后,红色瓦片纹理一行一行地出现。 好的,这个设计很详细。让我为屋顶设计: - 屋顶底边宽度约360(比墙体宽30px每侧) - 屋顶高度约140 - 屋脊在正中间 - 左坡三角形:从屋脊向左下方延伸 - 右坡三角形:从屋脊向右下方延伸 - 在动画中,左坡从上方偏左滑入,右坡从上方偏右滑入,然后在屋脊处相接 瓦片纹理:在屋顶区域画一排排的小矩形,颜色从红色到深红色,逐行出现。 好的,现在正式开始编写代码。 我会特别注意动画的easing函数,让动画看起来自然。例如使用easeInOutCubic或类似的函数。 对于烟囱冒烟,我将创建几个烟粒子,它们从烟囱顶部升起,逐渐变大变淡。 对于小树,树干是一个棕色矩形,树冠是一个绿色圆形(或几个重叠的圆形)。 关于踏脚石小径,我会在门前画几个灰色的小椭圆形石头,指向门口。 好的,现在让我把所有内容整合到一个HTML文件中。 我需要处理Canvas的高DPI显示。为了简单,我将使用CSS让canvas铺满整页,但内部坐标使用canvas的实际尺寸。对于高DPI设备,可能需要调整,但为了简单,我先使用1:1的像素映射,通过设置canvas.width和canvas.height为window.innerWidth和window.innerHeight。 实际上,对于高DPI,更好的做法是设置canvas的width/height为窗口尺寸,然后用CSS设置相同的尺寸。如果使用devicePixelRatio,画质会更好但代码更复杂。让我使用简单的1x映射,保持代码清晰。 好的,现在写代码。这是一个大型的动画系统。 让我也加入一些音效或视觉提示...不,用户没有要求音效,只需要视觉。 关于字幕的显示,每个阶段顶部有简短字幕。字幕在阶段开始时出现,在阶段结束或进入下一阶段时消失(或淡出)。 对于阶段间的停顿,在0.5秒内,显示当前已完成的状态,字幕可以保持显示或开始淡出。 好的,编写代码。我会把代码组织得清晰一些。 关键变量: - canvas, ctx - 总循环时间:24秒 - 阶段持续时间数组 - 阶段偏移时间数组 - 动画函数 - 当前阶段 让我也加入一些粒子效果,比如砖块掉落(在砌墙时)、烟雾粒子等。 实际上,对于砌墙动画,我可以让墙块从上方落下,看起来像砌墙的过程。这比简单的线条延伸更有趣。 对于阶段2(砌墙),我可以: - 四个角的墙段分别动画 - 前左墙段从上方落下 - 前右墙段从上方落下 - 左侧面墙段从上方落下 - 这些墙段落到正确位置时有一个小的弹跳效果 好的,这会增加很多
<!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>
1.39s
首Token
129
思考TPS
167s · ≈21,495 tok
176
输出TPS
53.6s · ≈9,463 tok
30,958
总Tokens(官方)
输入 249
总用时 223s · 平均 140 tok/s · 峰值 232 tok/s · 成本 ¥0.251
DeepSeek🥉
deepseek-v4-pro
完成
用户想要一个完整的单文件HTML,使用Canvas绘制一个2D卡通房屋的建造动画,分7个阶段,约25秒,循环播放。 我需要设计整个动画: **阶段划分(总共约25秒):** - 7个阶段,每个阶段之间停顿0.5秒 - 每个阶段约3秒左右 - 加上停顿,7个阶段×3秒 + 6个停顿×0.5秒 = 21+3 = 约24秒,再加上最后的停顿约25秒 让我重新规划: - 7个阶段,总时长约25秒 - 阶段间停顿0.5秒,共6个停顿=3秒 - 每个阶段约(25-3)/7 ≈ 3.14秒 - 我设定每个阶段约3秒,总时长=7×3+6×0.5=21+3=24秒,接近25秒 让我设计每个阶段的动画: 1. **地基**(0-3秒):灰色矩形板从地下升起 2. **砌墙**(3.5-6.5秒):四段竖直墙从地基四角向上延伸 3. **填墙**(7-10秒):墙框之间填入米黄色/浅褐色外墙板 4. **屋顶**(10.5-13.5秒):两块三角形屋顶板从上方滑入并在屋脊相接,红色瓦片纹理逐行出现 5. **门**(14-17秒):棕色门在正面墙上淡入,带金色门把手 6. **窗**(17.5-20.5秒):门两侧各出现一扇窗,带十字窗框和蓝色玻璃 7. **细节**(21-24秒):烟囱升起冒烟,踏脚石小径,小树长出 然后是24-25秒的停顿,然后循环。 让我设计房屋的坐标系统。Canvas大小我会设置为适合屏幕。假设Canvas是800×500或者响应式的。 让我使用响应式设计,Canvas铺满整页。 房屋的设计: - 地基:灰色矩形板,大约在画面中下部 - 房屋宽度约200px,高度约150px(不含屋顶) - 屋顶高度约80-100px - 门在正面中央 - 窗户在门两侧 让我具体设定坐标(以800×500的Canvas为例): - 地面线在y=380左右 - 地基:x=250-450, y=360-380(厚度20px的灰色板) - 房屋主体:x=270-430, y=210-360(宽160, 高150) - 屋顶三角形:从x=250到x=450,顶点在x=350, y=130 - 门:x=330-370, y=260-360 - 左窗:x=285-315, y=270-310 - 右窗:x=385-415, y=270-310 - 烟囱:x=400-430, y=120-170 - 小树:x=500左右 好的,让我编写完整的代码。我会使用window.devicePixelRatio和响应式设计来适应屏幕。 实际上,让我使用一个更简单的方法——设置Canvas为固定逻辑尺寸但响应式显示,或者直接使用window.innerWidth和window.innerHeight。 让我使用一个标准化的坐标系统。我会设置Canvas的width和height为窗口大小,然后基于这些来定位房屋。 为了简单起见,我会使用一个缩放因子,让房屋在画面中居中且大小合适。 让我设计房子在画面中的位置: - 画面中心水平位置 - 地面线在画面60%-65%的位置 - 房子宽度约为画面宽度的20-25% 画布尺寸我使用window.innerWidth和window.innerHeight,并处理devicePixelRatio。 房屋参数(基于画布尺寸动态计算): - houseCenterX = canvasWidth * 0.45(稍微偏左,给树留空间) - groundY = canvasHeight * 0.65 - houseWidth = Math.min(canvasWidth * 0.22, 200) - houseHeight = houseWidth * 0.9 - foundationY = groundY - foundationThickness = houseWidth * 0.1 - wallTop = foundationY - houseHeight - roofHeight = houseWidth * 0.55 - roofPeakY = wallTop - roofHeight 好的,让我开始编写完整代码。我会注重动画的细节。 动画循环使用requestAnimationFrame,总周期约25秒。 阶段时间分配(精确): - 阶段1(地基):0s - 3s,持续3秒 - 停顿:3s - 3.5s - 阶段2(砌墙):3.5s - 6.5s,持续3秒 - 停顿:6.5s - 7s - 阶段3(填墙):7s - 10s,持续3秒 - 停顿:10s - 10.5s - 阶段4(屋顶):10.5s - 13.5s,持续3秒 - 停顿:13.5s - 14s - 阶段5(门):14s - 17s,持续3秒 - 停顿:17s - 17.5s - 阶段6(窗):17.5s - 20.5s,持续3秒 - 停顿:20.5s - 21s - 阶段7(细节):21s - 24s,持续3秒 - 最后停顿:24s - 25s 然后循环回到0。 总时长25秒。 现在让我编写代码。我需要处理: - 地基从地下升起的动画(translateY从下方到原位) - 墙从地基四角向上延伸(使用clip或scaleY从0到1) - 填墙的淡入或从内向外展开 - 屋顶板从上方滑入 - 门淡入 - 窗户淡入 - 烟囱从屋顶升起,烟开始冒出 - 踏脚石小径出现 - 小树从地面长出 让我仔细实现每个阶段。 对于阶段7(细节),烟囱从屋顶升起、开始冒烟,踏脚石小径,小树长出。这些在3秒内同时发生。 关于"烟囱从屋顶升起"——烟囱应该从屋顶向上出现(比如从屋顶内部升上来),然后在顶部开始冒烟。 让我也用一些粒子或简单的圆形来表示烟。 关于小树——一棵简单的卡通树,从地面长出来(scale从0到1,或者从地面向上生长)。 踏脚石小径——从门通向画面底部,由几个椭圆形的石头组成,可以逐个出现。 好的,开始编写代码。 我会特别注意: - 阶段顶部的字幕 - 每个阶段的动画缓动 - 颜色和卡通风格 - 循环播放 让我也添加一些云朵在天空中,让背景更有趣。 关于"红色瓦片纹理逐行出现"——在屋顶三角形内部,我可以画一些水平线(瓦片行),从上到下逐行出现。这需要在阶段4中实现。 对于屋顶,我将画两个三角形(左右各一个斜面),然后在它们上面画瓦片纹理。瓦片纹理可以用水平排列的小矩形或线条来表示,逐行从上到下出现。 实际上,更简单的方法是用红色的三角形屋顶,然后在上面叠加瓦片线条(比屋顶颜色稍深或稍浅的水平线),这些线条逐行出现。 让我简化——屋顶板从上方滑入(两个三角形分别从左右上方滑入),然后在屋顶内部,瓦片纹理(水平线条)逐行出现。 好的,我需要将屋顶分成两部分:左斜面和右斜面。它们从上方滑入并在屋脊处相接。 让我重新整理一下结构: **阶段1 - 地基** - 灰色矩形板从地下升起 - 起始位置:foundationY + 30(地下),结束位置:foundationY - 字幕:「打地基」 **阶段2 - 砌墙** - 四段竖直墙从地基四角向上延伸 - 使用4个竖直的柱子/墙段,从地基高度延伸到wallTop - 字幕:「砌墙」 **阶段3 - 填墙** - 在四段墙之间填入米黄色外墙板 - 字幕:「填墙」 **阶段4 - 屋顶** - 两块三角形屋顶板从上方滑入 - 红色瓦片纹理逐行出现 - 字幕:「盖屋顶」 **阶段5 - 门** - 棕色门在正面墙上淡入 - 金色门把手 - 字幕:「安门」 **阶段6 - 窗** - 门两侧各出现一扇窗 - 十字窗框和蓝色玻璃 - 字幕:「装窗」 **阶段7 - 细节** - 烟囱升起、冒烟 - 踏脚石小径 - 小树长出 - 字幕:「完工啦」 好的,现在开始编写代码。要注意每个阶段的进度值(0到1),使用easeInOut或其他缓动函数。 我会使用: - requestAnimationFrame - 总循环25秒 - 使用performance.now()或Date.now()来跟踪时间 - 或者使用一个累加的时间变量 实际上使用performance.now()会更准确。但为了简单,我使用一个自增的时间计数器。 让我也添加一些云朵在天空中飘动。 关于烟囱的位置——它应该在屋顶的一侧,通常靠近屋脊。我把它放在屋顶右侧靠近屋脊的位置。 关于小树——放在房子旁边(比如右侧),由树干和圆形树冠组成,从地面向上生长。 关于踏脚石小径——从门通向画面底部边缘,由几个椭圆石头组成,可能略微弯曲。 好的,我现在开始编写完整的HTML代码。 需要注意Canvas的像素比,确保在高DPI设备上也能清晰显示。我会设置canvas的width/height为window.innerWidth和window.innerHeight乘以devicePixelRatio,然后使用scale。 实际上,为了简单起见,我直接使用window.innerWidth和window.innerHeight作为canvas的尺寸,并接受在retina设备上可能有些模糊。或者我可以使用devicePixelRatio。 让我使用devicePixelRatio来保持清晰度: ``` const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; ctx.scale(dpr, dpr); ``` 这样我就可以使用window.innerWidth和window.innerHeight作为逻辑坐标。 好的,现在让我仔细设计每个元素的绘制函数和动画。 另外关于"墙框之间填入米黄色/浅褐色外墙板"——在阶段2中建立了4个竖直的墙柱(在房子的四个角),阶段3在这些柱子之间填入墙板。墙板可以是浅褐色的矩形,填充在柱子之间的空间。 实际上在2D中,我们看到的是房子的正面。四段竖直墙从地基四角向上延伸——这意味着我们看到的是正面两个角和背面两个角。在2D正面视图中: - 两根前柱(可见)在左右两侧 - 两根后柱(被遮挡,但从透视角度可能会看到) 但如果这是纯正面视图(没有透视),那么四段墙就是: - 左前柱 - 右前柱 - 左后柱(被前墙遮挡) - 右后柱(被前墙遮挡) 这样的话在纯正面视图中只能看到两根前柱。但如果有一点透视或者我们画的是等距视图... 实际上,用户说的是"四段竖直墙从地基四角向上延伸",这更像是描述一个3D的过程但用2D来表现。在纯2D正面视图中,我可以理解为: - 房屋的四个角各有一根竖直的墙柱 - 前面两根可见 - 后面两根在透视中稍微可见(如果房子有一定深度) 但为了简单,我使用纯正面视图,只显示前面两根柱子,但用户说四段... 让我重新考虑。也许用户想象的是一个稍微有透视的视图,或者房屋的四角都有柱子,在2D中表现为: - 左前方柱子 - 右前方柱子 - 以及房屋侧面的柱子(如果房子有深度) 或者,也许用户的意思是房子的四面墙(四个方向的墙),但在2D中我们只能看到正面和可能的一个侧面。 我觉得为了简单且符合描述,我采用一个略带透视的2D视图,可以看到房子的正面和一个侧面。这样四段墙就有意义了——正面有两根柱子(左右),侧面可见的也有柱子。 实际上,让我简化一下。在2D正面视图中: - 四段竖直墙从地基四角向上延伸——我理解为房屋正面有两个角柱,如果房子有深度(侧面可见),那么侧面还有两个角柱 - 但纯2D卡通风格通常是纯正面 让我采用纯正面视图但把"四段竖直墙"理解为: - 房屋主体左右两侧各有一段墙(竖直方向的长矩形) - 加上前后各有一段(但在正面视图中前后重叠) 实际上,我觉得最简单且视觉效果好的做法是: - 阶段2中,4根柱子从地基的4个可见角升起(如果我们给房子一些深度,可以看到正面和侧面,那么4个角就是:正面左、正面右、侧面后、侧面前) 让我使用一个略带等距或透视的视图,让房子有正面和一个可见的右侧面。这样: - 地基是一个有深度的矩形板(可见顶面和前面) - 4根柱子在地基的4个角 - 正面2根,右侧面可见2根(或者后面2根在右侧面可见) 嗯,这变得复杂了。让我简化——使用纯正面视图,四段墙理解为正面左右两根明显的竖直墙柱,以及在房子内部提供结构支撑的另外两根(视觉上在填墙阶段会显现出来)。实际上用户可能只是想表达"四面墙"——房子的四面墙壁。在2D正面视图中,我们主要看到正面墙。 我觉得最好的处理方式是: - 使用纯正面视图 - 阶段2:左右两根竖直墙柱从地基升起(代表房子的结构框架) - 阶段3:在两根柱子之间填入墙板(正面墙) - 屋顶覆盖在上面 虽然用户说"四段",但在2D正面视图中显示4段独立的竖直墙会显得奇怪。让我稍微调整——画4根相对细的柱子在地基上,分布在地基的宽度上(左右各一根在边缘,中间两根在门两侧),这样总共4根。这样视觉上也合理——像是一个框架结构。 实际上,重新读一遍:"四段竖直墙从地基四角向上延伸"——四角,就是房子的四个角。在2D中如果房子是矩形,四个角就是左前、右前、左后、右后。在纯正面视图中,我们看到左前和右前,左后和右后被遮挡。 但如果采用略微倾斜的视角(比如2.5D或等距视图),四个角都能看到。不过这会大大增加绘制的复杂度。 让我采用折中方案——纯正面视图,但让房子有一定的深度暗示。在阶段2中,我会绘制可见的竖直结构(左右两侧的墙柱/边框),然后在阶段3中填充正面墙壁。对于"四段"的描述,我在正面视图中展示左右两根明显的结构柱,再加上一些暗示深度的元素。 实际上,我就按照最直观的方式来:使用纯正面2D视图,房子的框架由左右两根竖直的墙柱和顶部的一根横梁组成(类似门框结构),然后在阶段3填充墙壁。虽然用户说四段,但在2D正面中两根主要的结构柱看起来最好。我会在动画中让这看起来自然。 不,让我再想想。也许四段竖直墙就是在房屋矩形平面的四个角上的柱子。如果我使用一个稍微倾斜的俯视图(不是完全正面,而是略微从上方和侧面看),可以看到所有四个角。 好的,我决定使用一个简单的2D正面视图,但会给房子添加轻微的深度(通过显示侧面的一小部分)。这样: - 地基是一个有深度的板 - 四个角的柱子都能看到——前面两个(正面),后面两个在侧面可见 - 填墙时正面和侧面都填充 - 屋顶覆盖整个结构 这会是一个更丰富的画面。让我设计一下。 实际上,为了保持简单和卡通风格,让我使用纯正面视图。四段墙我会表现为: - 地基上四个可见的结构点(左右各两个,前后排列),但从正面看,后面的被前面遮挡 - 实际上在正面视图中,我只画左右两根粗柱子和中间可能的支撑 好吧,我决定简化处理。在阶段2中,我会绘制房屋的四面墙框架——在2D正面视图中表现为左右两根竖直的结构柱(可能还有微小的侧面暗示)。这个阶段重点是从地基向上延伸的动画效果。用户的核心意图是展示房屋建造的顺序,我不必过于字面地理解"四段"。让我画一个好看的2D房屋。 实际上,让我重新想——也许用户描述的就是一个简单的2D房屋建造过程,四段竖直墙就是房屋的四面墙(在2D中看到的是正面墙,但可以理解为四面墙从四个方向升起)。在动画中,我可以展示: - 房屋主体矩形从地基升起(代表四面墙同时从四边升起) - 墙体颜色是米黄色/浅褐色 这样阶段2和阶段3可以合并理解:阶段2是墙的框架(四段墙从四角升起,形成建筑的外轮廓),阶段3是填充墙壁面板。 好吧,实际做的时候,我让阶段2展示房屋的四面外墙从地基升起(整个房屋主体的矩形从地基高度向上增长),然后阶段3添加墙壁的纹理/颜色填充。不过这样阶段2和3之间的区别不够明显。 让我这样做: - 阶段2:四根竖直的角柱从地基升起(在2D正面看到左右两根,如果添加侧面视角则看到4根) - 阶段3:角柱之间填充墙壁面板 为了在2D中好看,我采用纯正面视图,阶段2中左右两根角柱升起(可能还有中间隐约的结构线),阶段3填充墙面。 好的,就这么定了。开始写代码吧。 我会特别注意房屋的比例和位置。让我设定一些关键参数。 实际上我觉得整个设计中最重要的是动画的时间控制和字幕显示。让我仔细规划。 总循环时间:25秒 - 0-3s: 阶段1(地基) - 3-3.5s: 停顿 - 3.5-6.5s: 阶段2(砌墙) - 6.5-7s: 停顿 - 7-10s: 阶段3(填墙) - 10-10.5s: 停顿 - 10.5-13.5s: 阶段4(屋顶) - 13.5-14s: 停顿 - 14-17s: 阶段5(门) - 17-17.5s: 停顿 - 17.5-20.5s: 阶段6(窗) - 20.5-21s: 停顿 - 21-24s: 阶段7(细节) - 24-25s: 停顿后循环 现在开始写完整的HTML代码。我会使用easeInOut缓动函数。 另外关于小树,在阶段7中从地面长出。小树可以有一个棕色树干和绿色圆形树冠(可能是几个重叠的圆)。 关于烟囱——在阶段7中从屋顶升起。烟从烟囱顶部冒出,可以用几个半透明的灰色圆形表示,它们向上飘动并逐渐消散。 关于踏脚石小径——几个椭圆形的灰色石头从门通向画面底部,可以在阶段7中逐个出现。 好的,开始编写代码。注意代码会比较长。 让我也处理窗口大小变化的情况,使用resize事件。 另外,关于云的动画——我可以在整个动画过程中让云缓慢飘动,增加生动感。云在天空中从右向左缓慢移动。 关于字幕——应该在每个活动阶段的顶部显示。字幕在阶段开始时出现,阶段结束时消失(或在整个阶段期间显示)。我让它在整个阶段期间显示。 字幕使用白色文字带阴影,或者深色文字,放在画面顶部中央。 实际上,用户说"每个阶段顶部应有简短字幕",字幕应该出现在Canvas顶部。我用深色文字在天空区域显示。 好的。现在让我开始编写完整的代码。 关于阶段4屋顶的瓦片纹理——我计划画水平线来模拟瓦片行。这些线逐行从上到下出现。屋顶是两个三角形斜面,瓦片纹理线水平排列在三角形内部。对于三角形屋顶,水平线在三角形内部有不同的长度(在顶点附近较短,在底部较长)。 我可以在屋顶三角形内部用clip来绘制瓦片行。 对于阶段7的烟——我可以维护一个烟雾粒子数组,在阶段7期间开始生成粒子,粒子向上飘动并逐渐消散。烟雾在整个动画过程中持续(即使循环后也继续)。在循环时,我会重置所有状态。 实际上,在25秒循环中,阶段7从21秒开始到24秒,烟雾只有3秒的时间出现。在循环回到阶段1时,烟雾应该消失(因为房子被拆除了,重新开始建造)。所以在每次循环开始时,我需要清除烟雾。 让我维护一个全局状态,在阶段7时生成烟雾粒子,在循环重置时清除。 好的,我已经想得足够多了,开始编写代码。 我会特
<!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>
0.72s
首Token
61
思考TPS
133s · 8,121 tok
118
输出TPS
74.1s · 8,767 tok
16,888
总Tokens(官方)
输入 241
总用时 209s · 平均 82 tok/s · 峰值 194 tok/s · 成本 $0.015
DeepSeek🥈
deepseek-v4-flash
完成
用户想让我创建一个单文件HTML,用Canvas动画演示一栋2D卡通房屋的建造过程,分为7个阶段,总时长约25秒,并且循环播放。 我需要仔细设计每个阶段的动画细节: 1. 地基 - 灰色矩形板从地下升起 2. 墙体 - 四段竖直墙从地基四角向上延伸 3. 填墙 - 墙框之间填入米黄色/浅褐色外墙板 4. 屋顶 - 两块三角形屋顶板从上方滑入,红色瓦片纹理逐行出现 5. 门 - 棕色门在正面墙上淡入,带金色门把手 6. 窗 - 门两侧各出现一扇窗,带十字窗框和蓝色玻璃 7. 细节 - 烟囱从屋顶升起、冒烟,踏脚石小径,小树 每个阶段顶部有字幕,阶段之间停顿0.5秒,持续循环。 我需要: - 铺满整页的Canvas - 淡蓝色天空 + 绿色地面线 - 纯Canvas 2D绘制,不使用任何库 - 约25秒循环(7阶段 + 6次停顿0.5秒 = 大约需要计算时间) 让我仔细设计时间分配: - 总时长约25秒 - 7个阶段,每个阶段约3秒 - 阶段之间停顿0.5秒 - 所以总时间 = 7*3 + 6*0.5 = 21 + 3 = 24秒,接近25秒 让我设计每个阶段的具体动画: 阶段0: "打地基" - 灰色矩形板从地下升起 (0-3秒) 阶段1: "砌墙" - 四段竖直墙从地基四角向上延伸 (3.5-6.5秒) 阶段2: "填墙" - 填入米黄色外墙板 (7-10秒) 阶段3: "盖屋顶" - 两块三角形屋顶板滑入,瓦片纹理逐行出现 (10.5-13.5秒) 阶段4: "装门" - 棕色门淡入,带金色把手 (14-17秒) 阶段5: "安窗" - 门两侧出现窗户,十字窗框和蓝色玻璃 (17.5-20.5秒) 阶段6: "细节装饰" - 烟囱、烟雾、小径、小树 (21-25秒) 实际上,让我重新设计更合理的动画流程,确保每个阶段有足够的视觉表现力。 我会把每个阶段设计为3秒动画 + 0.5秒停顿,7个阶段就是24.5秒。 让我开始写代码吧。 房屋的结构设计: - 房屋在一个水平面上,地面线在画布中部偏下位置 - 房屋是正面视角 坐标系设计: - 画布大小自适应窗口 - 房屋中心在画布水平中央,地基在groundLine上 让我定义关键尺寸: - groundLineY = canvas.height * 0.7 - houseWidth = canvas.width * 0.4 - houseHeight = canvas.width * 0.3 - 屋顶高度 = houseHeight * 0.5 我需要把这些尺寸基于画布大小动态计算。 让我写一个完整的实现: 房屋的基本结构: - 地基:灰色矩形板,宽度略大于墙体 - 墙体:四段竖直墙(左右两侧各两根立柱) - 墙板:填充在立柱之间的米黄色面板 - 屋顶:两个三角形,从两侧滑入 - 门:在正面墙中央,棕色 - 窗:门两侧各一个 - 烟囱:在屋顶一侧 - 小径:从门延伸到地面 - 小树:房屋旁边 我会用时间线来控制每个阶段。 让我先画一个详细的设计图: 房屋正面视角(面对观众): ``` /\ / \ <- 红色瓦片屋顶 / \ /______\ | || | <- 米黄色墙板 | || | | || | | || | | __ | | | | | <- 门 (棕色) | |__| | | || | <- 窗 (两侧) | || | |______| [地基] <- 灰色 ___________ 地面线 ``` 实际上,这是一个正面视角的房屋,所以"四段竖直墙"指的是房屋的四个角落的立柱?还是说房屋的框架? 重新读题:"(2)墙体——四段竖直墙从地基四角向上延伸" - 这应该是房屋框架的四个角柱。 "(3)填墙——墙框之间填入米黄色/浅褐色外墙板" - 在框架之间填充墙板。 所以房屋是一个框架结构,先有框架柱,再填充墙板。 对于正面视角,我们能看到: - 两个侧边的立柱(左右) - 中间的立柱(如果有) - 实际上"四段竖直墙"可能指的是房屋的四个角,但正面只能看到两个角(左右两侧),或者可能是三维视角? 但题目说是2D卡通房屋,所以应该是正面视角。四段竖直墙可能是指正面可见的四段框架柱?或者是指房屋的四个角落? 让我重新理解:可能这是一个略带透视的2D视图,或者是正视图但显示出房屋的四个角柱。 对于2D正面视图,"四角"可能是指正面墙的四个角?但正面墙只有四个角(左上、右上、左下、右下),这就有四段了。 嗯,更合理的理解是:正面视角的房屋,四段竖直墙是房屋正面可见的四根竖直框架柱(从左到右),但"四角"的说法... 或者,可能"四段竖直墙"指的是房屋四面墙的轮廓?在2D正面视图中,我们看到的是正面墙,但"从地基四角向上延伸"暗示了四个角。 我想最简单的处理方式:在正面视角中,显示房屋的四个角落的立柱(左右两侧各两根,但正面只能看到左右两个边缘),或者显示一个框架结构。 我决定采用这个设计:正面视角,房屋的轮廓由四根竖直的柱子构成(左右两侧各两根,形成房屋的四个角),但实际上在正面我们看到的是左右两侧的边缘柱和中间的门洞两侧的柱子。 让我简化设计:用"四段竖直墙"来表示房屋正面的四个主要垂直结构元素: - 左侧墙的左边和右边 - 右侧墙的左边和右边 或者更简单:房屋正面有四段竖直的墙段,形成三个开间(中间是门,两侧是窗)。 我觉得最合理的2D正面表现: - 四段竖直墙柱从地基四角升起,形成房屋的框架 - 然后填充墙板 在正面视角中,"四角"指的是: - 左下角、右下角、左上角、右上角 但竖直墙从四角延伸,所以是四根柱子:左柱、右柱、以及中间门洞两侧的柱子? 实际上,"四段竖直墙从地基四角向上延伸"可能是指房屋的四个墙角处各有一段竖直的墙体。 对于2D正面图,我这样设计: - 房屋正面宽度为W - 四段竖直墙分别位于:左侧边缘、左侧1/3处、右侧1/3处、右侧边缘 - 这样形成了三个区域:左窗、门、右窗 这样设计很合理!四段竖直墙就是四个垂直的柱子/墙段,它们从地基上升起。 好,我就采用这个设计。 让我重新规划房屋结构: ``` 屋顶 (两个三角形滑入) /\ / \ / \ /______\ | | | | <- 四段竖直墙 (柱子) | | | | | | | | | | | | | |__| | | | | | <- 门在中间,窗在两侧 | | | | |______| 地基 ``` 实际上,这个设计有点复杂。让我再简化一点,更卡通风格。 换个思路:房屋是正面视角,有四个竖直的墙柱,分别位于: 1. 最左侧 2. 左窗右侧(门左侧) 3. 门右侧(右窗左侧) 4. 最右侧 这样形成了两个窗户区域(左右)和一个门区域(中间)。 对,这样很清晰。 让我定义尺寸: - 房屋总宽度: houseW - 房屋总高度: houseH (不包括屋顶) - 四段墙柱宽度: 20px - 墙柱之间的间距: (houseW - 4*20) / 3 门在中间,窗在两侧。 屋顶是三角形,从两侧滑入。 好的,现在开始编码吧。 实际上,我想到一个更简单、更卡通化的表现方式: 房屋正面是一整面墙,但"四段竖直墙"是指从地基升起的四根立柱,它们构成了房屋的骨架,然后填墙是填充这些立柱之间的区域。 嗯,但"四段竖直墙"听起来像是四段墙体,而不是四根柱子。 让我重新解读:可能这是一个带有透视的2D视图,显示房屋的四个侧面?但2D房屋通常只显示正面。 最安全的解读:保持简单,在正面视图中,四段竖直墙就是房屋正面的四段垂直墙面元素。 我想这样设计: - 房屋正面分成三个垂直区域:左窗区、门区、右窗区 - 四段竖直墙是这些区域之间的分隔(也是结构支撑) - 位置:左边缘、左窗与门之间、门与右窗之间、右边缘 这四段墙从地基向上延伸,形成框架。 好,就这么办。 现在开始写HTML代码。 我会仔细实现每个阶段的动画: 阶段分配时间(每个阶段3秒,停顿0.5秒): - 阶段0 (0-3s): 地基 - 灰色矩形板从地下升起 - 停顿 (3-3.5s) - 阶段1 (3.5-6.5s): 墙体 - 四段竖直墙从地基四角向上延伸 - 停顿 (6.5-7s) - 阶段2 (7-10s): 填墙 - 墙框之间填入米黄色外墙板 - 停顿 (10-10.5s) - 阶段3 (10.5-13.5s): 屋顶 - 两块三角形屋顶板滑入,瓦片逐行出现 - 停顿 (13.5-14s) - 阶段4 (14-17s): 门 - 棕色门淡入,带金色把手 - 停顿 (17-17.5s) - 阶段5 (17.5-20.5s): 窗 - 门两侧出现窗户 - 停顿 (20.5-21s) - 阶段6 (21-25s): 细节 - 烟囱、烟雾、小径、小树 总时长 = 25秒 我现在来写这个完整的Canvas动画。 关于尺寸,我需要让房屋大小自适应画布。 让我设定一些关键比例: - 地面线: canvas.height * 0.72 - 房屋宽度: canvas.width * 0.44 - 房屋高度: canvas.width * 0.30 (从地基到屋檐) - 屋顶高度: 房屋高度的0.45 - 地基高度: 房屋高度的0.08 - 地基宽度: 房屋宽度 * 1.06 四段墙柱的位置: - 墙柱宽度: 房屋宽度的0.03 - 墙柱1: 最左侧 - 墙柱2: 左1/3处 (门左侧) - 墙柱3: 右1/3处 (门右侧) - 墙柱4: 最右侧 门: - 宽度: 房屋宽度的0.2 - 高度: 房屋高度的0.6 - 位置: 中央 窗: - 宽度: 房屋宽度的0.18 - 高度: 房屋高度的0.28 - 位置: 门两侧对称 烟囱: - 在屋顶左侧或右侧 - 宽度: 房屋宽度的0.06 - 高度: 屋顶高度的0.6 小径: - 从门到地面底部 - 由多个小石头组成 小树: - 在房屋一侧 - 树干 + 树冠 开始编码吧! 我需要留意: 1. 阶段之间的平滑过渡 2. 每个阶段的字幕显示 3. 循环播放 让我设计更精细的动画曲线,使用ease-in-out效果。 我会用requestAnimationFrame驱动动画,基于时间戳计算进度。 这里是一个复杂但完整的实现计划,我开始写了。 实际上,让我仔细想一下每个阶段的具体动画表现: 阶段0 - 地基:一块灰色矩形板从地面线下方升起,停在地面位置。地基比墙体略宽,厚度适中。 阶段1 - 墙体:四段竖直的灰色/混凝土色墙柱从地基的四个位置向上延伸,直到达到屋顶高度。 阶段2 - 填墙:在墙柱之间,米黄色/浅褐色的墙板从下往上填充,或者以某种方式出现。 阶段3 - 屋顶:两个三角形从左右两侧滑入,在中间汇合。然后红色瓦片纹理从下往上逐行出现。 阶段4 - 门:在中央位置,一扇棕色门逐渐淡入,然后出现金色门把手。 阶段5 - 窗:在门两侧,两扇窗同时出现,带十字窗框和蓝色玻璃。 阶段6 - 细节:烟囱从屋顶一侧升起,冒出一缕烟;踏脚石小径从门延伸到画面底部;一棵小树在房屋旁生长。 好,开始写代码。这将是一个比较大的单文件HTML。 让我注意一点:每阶段3秒,停顿0.5秒,7个阶段就是24.5秒,接近25秒。 为了更好的视觉效果,我可能会将某些阶段的动画分解成更细的动作。 我现在开始写具体代码: 实际上,为了视觉上更清晰,我可能会把房屋设计得更简单但更美观。 关于房屋的尺寸,我会基于画布动态计算,确保在不同屏幕上都铺满。 让我开始编码: 我会用以下方式管理时间: - 总循环周期: 25秒 - 每个阶段: 3秒动画 + 0.5秒停顿 = 3.5秒 - 7个阶段: 7 * 3.5 = 24.5秒 - 额外0.5秒作为缓冲 每个阶段的时间段: - 阶段0: 0 - 3.0 (动画), 3.0 - 3.5 (停顿) - 阶段1: 3.5 - 6.5 (动画), 6.5 - 7.0 (停顿) - 阶段2: 7.0 - 10.0 (动画), 10.0 - 10.5 (停顿) - 阶段3: 10.5 - 13.5 (动画), 13.5 - 14.0 (停顿) - 阶段4: 14.0 - 17.0 (动画), 17.0 - 17.5 (停顿) - 阶段5: 17.5 - 20.5 (动画), 20.5 - 21.0 (停顿) - 阶段6: 21.0 - 25.0 (动画), 25.0 - 25.5 (停顿) 周期总长: 25.5秒,但我们可以用25秒作为周期。 让每个阶段正好3秒,停顿0.5秒,7个阶段 = 24.5秒,周期设为25秒。 我将在代码中使用周期为25秒的循环。 好的,开始写了。 让我先定义全局变量和辅助函数,然后实现每个阶段的绘制。 我想使房屋的绘制函数化,每个阶段有独立的绘制逻辑,并共享房屋的基本参数。 让我也考虑一下循环的逻辑:当周期结束时,重置到开始状态。 对于循环,我需要清除画布,然后根据当前时间重新绘制所有内容。 在每个阶段,我会绘制所有已经完成的部分(从之前阶段保留的元素),再加上当前阶段的动画部分。 所以,我需要维护一个状态,记录哪些阶段已经完成,以及当前阶段的进度。 这样,在阶段2,地基和墙体都已经存在,然后填充墙板。 在阶段3,地基、墙体、墙板都已经存在,然后添加屋顶。 以此类推。 这是一个很好的设计模式:每个阶段绘制所有之前阶段的内容(已完成),再加上当前阶段的动画内容。 好,让我开始编码。这是一个大型项目,我会认真实现。 实际上,我可以使用一个更简单的方法:基于当前时间,计算当前阶段和进度,然后绘制所有阶段的内容,但每个阶段的内容是否完全显示取决于该阶段是否已经完成或正在进行。 这种方法更干净,不需要维护状态。 我会这样实现: 1. 计算当前时间在整个周期中的位置 (0-25秒) 2. 确定当前处于哪个阶段以及进度 3. 绘制所有阶段的内容,但根据阶段完成情况和当前阶段进度来控制显示程度 好,开始写! 等等,我还需要考虑"阶段之间停顿0.5秒"的要求,这意味着在阶段结束时,会有0.5秒的停顿,期间显示完整的阶段成果,然后进入下一阶段。 所以时间线应该是: - 每个阶段3秒动画 + 0.5秒展示 = 3.5秒 - 7个阶段共24.5秒 在动画的3秒内,阶段进度从0到1; 在停顿的0.5秒内,进度保持为1(完全显示)。 这样,总共7个阶段需要24.5秒,我设置周期为25秒(留一点缓冲)。 好,开始写代码吧。 让我想一下具体的绘图实现... 嗯,这确实是个大项目,我开始写了。 我将使用一个循环时间线,所有阶段根据时间进度来绘制。 让我定义房屋的几何参数(相对于画布大小的比例): ```javascript // 房屋参数 (在resize时计算) const params = { groundY: 0, // 地面线Y坐标 houseW: 0, // 房屋宽度 houseH: 0, // 房屋高度 foundationH: 0, // 地基高度 foundationW: 0, // 地基宽度 wallW: 0, // 墙柱宽度 roofH: 0, // 屋顶高度 doorW: 0, // 门宽度 doorH: 0, // 门高度 windowW: 0, // 窗宽度 windowH: 0, // 窗高度 // ... 更多 }; ``` 好的,我现在开始写完整的HTML代码。 让我仔细看看,这个房屋是正面视角,所以"四段竖直墙"就是正面可见的四根墙柱。 我会将房屋正面分为三个部分:左窗、门、右窗,由四根墙柱分隔。 这里的设计图: ``` _______________ / \ <- 屋顶 (两个三角形) / \ /___________________\ | | | | | <- 四段墙柱 (竖直) | | | | | | | □ | 门 | □ | <- 窗 (□) 和 门 | | | | | | | | | | |___|____|____|____| | 地基 (灰色) | <- 地基 _____________________ .................... <- 地面线 ``` 这样四段墙柱将房屋分成左中右三部分,中间是门,左右是窗。 很好,这个设计清晰又符合描述。 现在让我开始写代码。 在写之前,我想强调:我应该使用高质量的卡通风格绘图,颜色柔和美观。 颜色方案: - 天空: 淡蓝色 (#B0D4F1 或类似) - 地面: 绿色 (#7CCD7C) - 地基: 灰色 (#A0A0A0) - 墙柱: 灰色 (#B0B0B0) - 墙板: 米黄色/浅褐色 (#F5E6C8) - 屋顶: 红色 (#CC3333) - 门: 棕色 (#8B4513) - 门把手: 金色 (#FFD700) - 窗框: 白色/浅色 (#FFFFFF) - 窗玻璃: 浅蓝色 (#B0D4F1) - 烟囱: 红褐色 (#A0522D) 好的,现在开始写吧!这是一个大挑战。 我注意到我已经思考了很久,开始实际编码吧。 实际上,我需要仔细处理每个阶段的动画效果,让它们看起来流畅自然。 对于每个阶段,我会使用easing函数来使动画更平滑。 我会使用简单的缓入缓出函数。 好的,开始写代码了。这个代码会很长,因为每个阶段都有独特的动画逻辑。 让我再次确认循环逻辑:整个动画持续循环,每个阶段结束后自动进入下一阶段,全部完成后回到第一阶段。 我会用取模运算来实现循环。 好的,开始写了。 我会写出一个完整的、自包含的HTML文件,所有样式和脚本都在一个文件中。 让我开始编写吧... 实际上我意识到这是个很大的项目,我需要认真地、系统地写代码。让我先设计好数据结构,再逐步实现每个阶段。 时间管理: - cycleDuration = 25 (秒) - phaseDuration = 3 (秒) - pauseDuration = 0.5 (秒) - tota
<!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>
0.52s
首Token
91
思考TPS
68.6s · 6,247 tok
159
输出TPS
69.1s · 11,004 tok
17,251
总Tokens(官方)
输入 241
总用时 140s · 平均 125 tok/s · 峰值 275 tok/s · 成本 $0.0049
Kimi 月之暗面-high-think-budget🥇
kimi-for-coding
完成
We need produce a single file HTML with full code, no explanation. Need analyze requirements carefully, implement canvas animation of cartoon house building in 7 stages about 25 seconds, looping. Sky gradient, green ground line. Stages: (1) foundation gray rectangle rises from underground; (2) walls four vertical segments from corners upward; (3) fill wall panels between frames; (4) roof two triangular roof boards slide in from above and meet at ridge, red tile texture rows appear; (5) brown door fade in on front wall with gold knob; (6) windows on each side of door with cross frames and blue glass; (7) chimney rises from roof, small smoke, stepping stones path to door, small tree grows beside house. Each stage top subtitle brief, stage pause 0.5s. Loop. No libraries. Use full page canvas. Implement with requestAnimationFrame. Plan: define stages array with durations total ~25s. Need include 0.5s pause between stages. We can manage timeline. Stage durations: foundation 2.5s, walls 3s, fill 2s, roof 4.5s, door 2.5s, windows 3s, details 5s? Plus 6 pauses *0.5 =3s. Sum: 2.5+0.5+3+0.5+2+0.5+4.5+0.5+2.5+0.5+3+0.5+5 =? Compute: stages durations sum 22.5; pauses 3; total 25.5s. Need about 25 sec. Maybe reduce details to 4.5 =>25.0. Good. Need decide house coords. Canvas full page. Ground line maybe y = 0.75 height. Sky gradient. House centered horizontally, width maybe 240, height walls 160, foundation height 24. Foundation rises from below ground to ground. We'll animate foundation y from below ground to ground. Walls four vertical segments from foundation corners upward. Fill panels between frames appears maybe horizontal scan or fade. Roof slide from above and meet at ridge, triangular red roof. Tile texture rows appear. Need draw roof boards with tile texture. Door fade in. Windows fade/scale. Chimney rises, smoke particles, path stones appear, tree grows. Top subtitle centered text per stage. We need maybe display stage name during stage and pause? It says each stage top should have short subtitle. Stage pause 0.5s; probably subtitle visible during stage. During pause could keep previous or blank? We can keep current stage subtitle during its stage and pause, until next stage starts. Since 0.5s between, acceptable. Or show "..."? Requirements not strict. Implementation details: Canvas resize handling. Define scene objects with start/end values and easing functions. Use normalized t per stage (0..1). Use simple easeOutQuad/inOutQuad. Timeline: total duration. Stage list with start, end, label. Determine current stage by time modulo total. Coordinate system: houseX = center - width/2. houseY = groundY - foundationHeight. foundation width = 240, height=24. Walls: four vertical segments at corners? House 2D cartoon; four wall studs? "四段竖直墙从地基四角向上延伸" maybe four vertical frame pieces from the four corners. We'll draw four corner studs (vertical rectangles) thickness 8, rising to wallHeight. Then fill panels between them. Roof width maybe 280 overhang. Height 100. Roof triangles: left and right halves. Need slide in from above and meet at ridge. We can animate left triangle x offset from above? Maybe both triangles start above and slide down diagonally? Requirement "两块三角形屋顶板从上方滑入并在屋脊相接". We can have left half start offset up-left, right half up-right, slide to final positions meeting at ridge. Or simply drop from above, left and right pieces. We'll define left triangle final path from top-left ridge to eaves; right. For animation, position offset along vector (-50,-100) for left, (50,-100) for right, ease out. Red tiles rows appear as clip/mask grows? Simpler: draw red roof with tile texture; animate tile rows appearing vertically within each triangle. We can draw rows of tiles as horizontal segments with offset bricks. As t increases, reveal from top to bottom. We can clip to triangle path and draw rows up to revealY. Good. Door: on front wall, centered. Brown rectangle fades in (alpha), with gold knob. Maybe scaleY from bottom? fade enough. Windows: left and right of door. Cross frames and blue glass. Fade/scale. Chimney: rectangle rising from behind roof left side. Smoke particles: small white circles rising/fading. Path: stones (circles/rounded rects) appearing sequentially from bottom to door. Tree: grows beside house, maybe trunk extends and canopy scales up. Subtitles: Chinese labels: 打地基, 砌墙, 填外墙, 盖屋顶, 装门, 装窗, 加细节. Font. Need ensure no external libs. Single file. CSS full page no scroll. Implementation details of timeline: const stages = [ {name:'打地基', start:0, dur:2.5}, pause 0.5, {name:'砌墙', start:3.0, dur:3.0}, pause 0.5, {name:'填外墙', start:6.5, dur:2.0}, pause 0.5, {name:'盖屋顶', start:9.5, dur:4.5}, pause 0.5, {name:'装门', start:14.5, dur:2.5}, pause 0.5, {name:'装窗', start:17.5, dur:3.0}, pause 0.5, {name:'加细节', start:21.5, dur:4.5}, total 26.0? Wait sum: 2.5+0.5=3; +3+0.5=6.5; +2+0.5=9; +4.5+0.5=14; +2.5+0.5=17; +3+0.5=20.5; +4.5=25. Actually earlier miscalc. Let's compute with pauses included inside intervals: if stage includes pause? Need careful. Let stage durations: F=2.5, W=3, Fill=2, Roof=4.5, Door=2.5, Win=3, Det=4.5 => 22.5. Six pauses of 0.5 => 3.0, total 25.5. To exactly 25, reduce details to 4.0? 22.0+3=25. But requirements about details enough time. We can set details 4.5 and total 25.5 (about 25). Fine. Define each stage with start and end including preceding pause? Simpler: define stage events without pause property, use `total = sum(durs) + (count-1)*pause`. Compute `stageStart[i] = previousEnd + pause` except first start 0. Then current stage detection: if time is within pause after stage i, no stage active? We want subtitle persist during pause maybe. But for stage variable for drawing, we should base t only during active stage; during pause, keep previous stage's final state. Subtitle can be current stage name (the one just finished) until next stage begins, or display name of next stage? Requirements "阶段之间停顿0.5秒" and "每个阶段顶部应有简短字幕". It's natural to show subtitle during stage and pause maybe same as current/finished stage. Let's do: compute current stage index as the stage whose interval (start to end) or pause includes time; if in pause after stage k, subtitle can show next stage name? Hmm. Better: We treat pause as transition. Keep subtitle for the stage that is currently being built or just completed. During pause after stage k before k+1, show stage k+1 name? Actually the pause is between stages, so maybe no subtitle or display upcoming. But simple: Determine stageIndex where time < stageEnd+pause? Let's create intervals: stage active = [stageStart, stageStart+dur]. pause = after stage until next stageStart. During pause, we can set `phase='pause'` and `displayName = stages[nextIndex].name` maybe. But drawing uses previous final state and next stage t=0. That works. Implementation: compute `cycle = t % totalTime`. Loop through stages to find if cycle < stage.end: active, progress=(cycle-stage.start)/stage.dur. Else if cycle < stage.nextStart (which is stage.end+pause except last): pause, activeIndex = -1, display previous stage or next? Set displayName = stages[i+1].name (upcoming). But maybe during pause after stage 7? no next, no pause after last before loop. Actually after last stage end, there might be no pause before loop? We can include small pause? We said no pause after last. So cycle 0..total. The first stage starts at 0. Stage 1 end at 2.5, pause until 3.0. During that, show '砌墙'. Good. Drawing functions per stage: We need draw background every frame. Sky: gradient from light blue top to near white near horizon. ground line y=height*0.75. Grass fill below line green. Foundation: Stage1 progress p. Final: x=houseX, y=groundY-fh, w=fw, h=fh. Animation: from y=groundY (just below line? Actually from underground rising), initial y=groundY+fh (fully below), final y=groundY-fh? Wait foundation slab from underground rises to sit on ground? "从地下升起" maybe rises from below ground and stops
<!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>
1.29s
首Token
198
思考TPS
84.1s · ≈16,654 tok
236
输出TPS
13.5s · ≈3,187 tok
19,841
总Tokens(官方)
输入 234
总用时 100s · 平均 203 tok/s · 峰值 488 tok/s · 成本 $0.080
小米 MiMo-V2.5 Pro
mimo-v2.5-pro
中断
Let me create a single HTML file with a canvas animation that builds a cartoon house in 7 stages over ~25 seconds. Let me plan this out carefully. Stages: 1. Foundation - gray rectangle rises from ground 2. Walls - four vertical walls extend from foundation corners 3. Wall fill - fill between walls with beige/light brown exterior 4. Roof - two triangular roof pieces slide in from above, red tile texture appears row by row 5. Door - brown door fades in with golden doorknob 6. Windows - two windows appear on either side of door with cross frames and blue glass 7. Details - chimney rises, smoke appears, stepping stone path, small tree Each stage has a subtitle at top, 0.5s pause between stages, continuous loop. Total ~25 seconds, 7 stages + 6 pauses of 0.5s = 3 seconds pauses, so ~22 seconds for actual animation, ~3.14 seconds per stage. Let me think about the layout. Canvas fills entire page. Light blue sky, green ground line. House position: center of canvas. Let me define coordinates relative to a base point. Let me code this step by step. ```html <!DOCTYPE html> <html> <head> <style> * { margin: 0; padding: 0; } html, body { 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; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } resize(); window.addEventListener('resize', resize); // Timing const STAGE_DURATION = 3143; // ms per stage const PAUSE_DURATION = 500; // ms between stages const TOTAL_STAGES = 7; const CYCLE_DURATION = TOTAL_STAGES * STAGE_DURATION + (TOTAL_STAGES - 1) * PAUSE_DURATION; // Ground line position const GROUND_Y_RATIO = 0.65; function getLayout() { const groundY = H * GROUND_Y_RATIO; const houseW = Math.min(W * 0.3, 300); const houseH = houseW * 0.8; const houseX = W / 2 - houseW / 2; const houseY = groundY - houseH; return { groundY, houseW, houseH, houseX, houseY }; } function drawSky() { const grad = ctx.createLinearGradient(0, 0, 0, H * 0.65); grad.addColorStop(0, '#87CEEB'); grad.addColorStop(1, '#B0E0FF'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); } function drawGround(groundY) { ctx.fillStyle = '#4CAF50'; ctx.fillRect(0, groundY, W, H - groundY); // Ground line ctx.strokeStyle = '#388E3C'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(W, groundY); ctx.stroke(); } function drawSubtitle(text, alpha) { ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.font = `bold ${Math.max(20, W * 0.025)}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // Background pill const metrics = ctx.measureText(text); const px = 16, py = 8; const tx = W/2, ty = 30; ctx.fillStyle = 'rgba(0,0,0,0.5)'; roundRect(ctx, tx - metrics.width/2 - px, ty - py, metrics.width + px*2, Math.max(20, W*0.025) + py*2, 10); ctx.fill(); ctx.fillStyle = '#fff'; ctx.fillText(text, tx, ty); ctx.restore(); } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w, y, x+w, y+r); ctx.lineTo(x+w, y+h-r); ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h); ctx.lineTo(x+r, y+h); ctx.quadraticCurveTo(x, y+h, x, y+h-r); ctx.lineTo(x, y+r); ctx.quadraticCurveTo(x, y, x+r, y); ctx.closePath(); } // Stage drawing functions function drawStage1(t, L) { // Foundation - gray rectangle rises from underground const h = L.houseH * 0.1 * t; const y = L.groundY - h; ctx.fillStyle = '#808080'; ctx.fillRect(L.houseX - 10, y, L.houseW + 20, h + 10); // extend below ground a bit // Top edge highlight ctx.fillStyle = '#999'; ctx.fillRect(L.houseX - 10, y, L.houseW + 20, 3); } function drawStage2(t, L) { // Draw foundation first (fully visible) drawStage1(1, L); // Walls - four vertical walls from foundation corners const wallH = L.houseH * 0.9 * t; const wallThick = L.houseW * 0.06; const baseY = L.groundY - L.houseH * 0.1; ctx.fillStyle = '#A0522D'; // Left wall ctx.fillRect(L.houseX - 5, baseY - wallH, wallThick, wallH); // Right wall ctx.fillRect(L.houseX + L.houseW + 5 - wallThick, baseY - wallH, wallThick, wallH); // Top wall (horizontal) // Actually, let me think about this differently. // Four segments: left, right walls going up, and maybe top connecting? // Let's do: left wall, right wall going up vertically, then top beam // Left wall ctx.fillRect(L.houseX - 5, baseY - wallH, wallThick, wallH); // Right wall ctx.fillRect(L.houseX + L.houseW + 5 - wallThick, baseY - wallH, wallThick, wallH); } function drawStage3(t, L) { // Foundation + walls drawStage1(1, L); drawStage2(1, L); // Hmm, this would redraw foundation again. Let me restructure. } // Let me restructure to use cumulative drawing function drawFrame(stage, stageT) { const L = getLayout(); drawSky(); drawGround(L.groundY); // Draw all completed stages and current stage const subtitles = ['打地基', '砌墙', '填墙', '盖屋顶', '装门', '安窗', '收尾细节']; let currentSubtitle = ''; let subtitleAlpha = 1; // Stage 1: Foundation if (stage >= 1) { const t = stage === 1 ? stageT : 1; drawFoundation(t, L); if (stage === 1) currentSubtitle = subtitles[0]; } // ... etc if (currentSubtitle) drawSubtitle(currentSubtitle, subtitleAlpha); } // Actually, let me think about this more carefully and write cleaner code. // Let me define the total cycle and map time to stages. const STAGE_TIME = 3200; const PAUSE_TIME = 500; const STAGE_WITH_PAUSE = STAGE_TIME + PAUSE_TIME; const CYCLE = STAGE_WITH_PAUSE * 7; function getTimeInfo(elapsed) { const cycleTime = elapsed % CYCLE; const stageIndex = Math.floor(cycleTime / STAGE_WITH_PAUSE); const timeInStage = cycleTime - stageIndex * STAGE_WITH_PAUSE; const isPause = timeInStage >= STAGE_TIME; const t = isPause ? 1 : timeInStage / STAGE_TIME; return { stageIndex, t, isPause }; } // Let me also think about easing. Ease-out would look nice. function easeOut(t) { return 1 - Math.pow(1 - t, 3); } function easeInOut(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; } // Now let me write the full thing properly. // Layout calculations: // Ground at 65% of height // House centered horizontally // Foundation: thin rectangle at ground level // Walls: going up from foundation // etc. // Let me think about the house geometry more carefully. // The house front view: // - Foundation: wide thin rectangle // - Walls: left wall, right wall (vertical bars), then fill in between // - Roof: two triangles meeting at ridge // - Door: rectangle on front wall // - Windows: two rectangles with cross frames // For a 2D front view, we don't really need "four walls" - we see the front face. // But the prompt says "四段竖直墙从地基四角向上延伸" - four vertical wall segments from four corners. // This suggests a slight 3D perspective or just the outline. // Let me interpret it as: four corner posts rise up, then the wall fill appears between them. // This gives a nice visual effect. // Corner posts: 4 vertical bars at the 4 corners of the foundation rectangle. // But in 2D front view, we'd only see 2 corners (left and right). // Maybe the "four segments" are: left vertical, right vertical, top-left horizontal, top-right horizontal? // Or maybe: left post, right post going up, then top beam connecting them. // I'll interpret it as: left wall post and right wall post rise up vertically. // For "four segments", I'll add top-left and top-right horizontal segments too. // Or I could do: bottom-left to top-left, bottom-right to top-right (verticals), // and bottom-left to bottom-right, top-left to top-right (horizontals). // The verticals grow upward, the horizontals... hmm. // Let me just make it look good: two vertical posts rise up, then we fill. // Actually, re-reading: "四段竖直墙从地基四角向上延伸" - "four vertical wall segments extend upward from the four corners of the foundation" // In a 2D front view with slight perspective, you'd see: // - 2 front corner posts (left and right) // - Maybe the tops of 2 back corner posts visible above? // // For simplicity and visual appeal, let me do: 4 vertical bars - two at the outer edges and two slightly inward, creating a frame effect. Or just interpret it as the wall outline forming. // I think the simplest good interpretation: the wall frame is drawn - left, right sides going up, and a top beam. Then fill. // Let me just code it and make it look good. OK let me just write the full code now. I'll aim for clean, working code. ```html <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <style> *{margin:0;padding:0} html,body{width:100%;height:100%;overflow:hidden;background:#000} canvas{display:block} </style> </head> <body> <canvas id="c"></canvas> <script> const C = document.getElementById('c'); const $ = C.getContext('2d'); let W, H; function resize() { W = C.width = innerWidth; H = C.height = innerHeight; } resize(); addEventListener('resize', resize); const STAGE_DUR = 3200; const PAUSE = 500; const TOTAL = 7; const CYCLE = (STAGE_DUR + PAUSE) * TOTAL; function easeOut(t) { return 1 - Math.pow(1 - t, 3); } function easeInOut(t) { return t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; } function lerp(a, b, t) { return a + (b - a) * t; } function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } function layout() { const gy = H * 0.68; const scale = Math.min(W / 1200, H / 800, 1); const bw = 320 * scale; // base width const bh = 240 * scale; // base height (wall height) const fh = 30 * scale; // foundation height const bx = W/2 - bw/2; const by = gy - fh - bh; return { gy, bw, bh, fh, bx, by, scale }; } function drawSky() { const g = $.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#7EC8E3'); g.addColorStop(0.65, '#B8E4F9'); g.addColorStop(0.68, '#90C67C'); g.addColorStop(1, '#4A8B3C'); $.fillStyle = g; $.fillRect(0, 0, W, H); } function drawGroundLine(gy) { $.strokeStyle = '#3A7A2E'; $.lineWidth = 2; $.beginPath(); $.moveTo(0, gy); $.lineTo(W, gy); $.stroke(); } function subtitle(text) { if (!text) return; $.save(); const fs = Math.max(18, Math.min(28, W * 0.025)); $.font = `bold ${fs}px "PingFang SC","Microsoft YaHei",sans-serif`; $.textAlign = 'center'; $.textBaseline = 'middle'; const m = $.measureText(text); const px = 24, py = 12; const rx = W/2 - m.width/2 - px; const ry = 28 - py; const rw = m.width + px*2; const rh = fs + py*2; // Shadow $.fillStyle = 'rgba(0,0,0,0.45)'; roundRect($, rx+2, ry+2, rw, rh, 12); $.fill(); $.fillStyle = 'rgba(0,0,0,0.6)'; roundRect($, rx, ry, rw, rh, 12); $.fill(); $.fillStyle = '#fff'; $.fillText(text, W/2, 28 + fs*0.05); $.restore(); } function roundRect(c, x, y, w, h, r) { c.beginPath(); c.moveTo(x+r, y); c.arcTo(x+w, y, x+w, y+h, r); c.arcTo(x+w, y+h, x, y+h, r); c.arcTo(x, y+h, x, y, r); c.arcTo(x, y, x+w, y, r); c.closePath(); } // House components function drawFoundation(t, L) { const e = easeOut(t); const fh = L.fh; const y = L.gy - fh * e; $.fillStyle = '#787878'; $.fillRect(L.bx - 15*L.scale, y, L.bw + 30*L.scale, fh * e + 5*L.scale); // Top highlight $.fillStyle = '#8A8A8A'; $.fillRect(L.bx - 15*L.scale, y, L.bw + 30*L.scale, 3*L.scale); // Brick pattern if (e > 0.5) { const alpha = (e - 0.5) * 2; $.strokeStyle = `rgba(100,100,100,${alpha * 0.5})`; $.lineWidth = 1; for (let row = 0; row < 2; row++) { const ry = y + 4*L.scale + row * 10*L.scale; if (ry < L.gy) { $.beginPath(); $.moveTo(L.bx - 15*L.scale, ry); $.lineTo(L.bx + L.bw + 15*L.scale, ry); $.stroke(); } } } } function drawWallFrame(t, L) { const e = easeOut(t); const wallH = L.bh * e; const thick = 8 * L.scale; const baseY = L.gy - L.fh; $.fillStyle = '#8B4513'; // Left wall $.fillRect(L.bx - thick/2, baseY - wallH, thick, wallH); // Right wall $.fillRect(L.bx + L.bw - thick/2, baseY - wallH, thick, wallH); // Top beam if (e > 0.3) { const topE = clamp((e - 0.3) / 0.7, 0, 1); $.fillRect(L.bx - thick/2, baseY - wallH, L.bw + thick, thick); } } function drawWallFill(t, L) { const e = easeOut(t); const baseY = L.gy - L.fh; const wallH = L.bh; // Clip to fill only between walls $.save(); $.globalAlpha = e; // Main wall color - beige/cream $.fillStyle = '#F5DEB3'; $.fillRect(L.bx + 4*L.scale, baseY - wallH + 4*L.scale, L.bw - 8*L.scale, wallH - 4*L.scale); // Lighter center const g = $.createLinearGradient(L.bx, 0, L.bx + L.bw, 0); g.addColorStop(0, 'rgba(210,180,140,0.3)'); g.addColorStop(0.5, 'rgba(255,255,255,0.1)'); g.addColorStop(1, 'rgba(210,180,140,0.3)'); $.fillStyle = g; $.fillRect(L.bx + 4*L.scale, baseY - wallH + 4*L.scale, L.bw - 8*L.scale, wallH - 4*L.scale); $.restore(); } function drawRoof(t, L) { const e = easeInOut(t); const roofH = L.bh * 0.6; const overhang = 30 * L.scale; const baseY = L.gy - L.fh - L.bh; const ridge = baseY - roofH * e; const cx = L.bx + L.bw / 2; // Roof slides in from above - y starts higher and moves down to position // Actually, the triangles should slide down from above const slideOffset = (1 - e) * roofH * 2; // Left roof triangle $.fillStyle = '#B22222'; $.beginPath(); $.moveTo(cx, ridge + slideOffset); $.lineTo(L.bx - overhang, baseY + slideOffset); $.lineTo(cx, baseY + slideOffset); $.closePath(); $.fill(); // Right roof triangle $.beginPath(); $.moveTo(cx, ridge + slideOffset); $.lineTo(L.bx + L.bw + overhang, baseY + slideOffset); $.lineTo(cx, baseY + slideOffset); $.closePath(); $.fill(); // Tile texture - appears row by row if (t > 0.3) { const tileT = clamp((t - 0.3) / 0.7, 0, 1); const rows = 6; const visibleRows = Math.floor(tileT * rows) + 1; const rowH = roofH / rows; $.strokeStyle = 'rgba(139,0,0,0.4)'; $.lineWidth = 1.5 * L.scale; for (let i = 0; i < visibleRows && i < rows; i++) { const ry = baseY - (i + 1) * rowH * e + slideOffset; if (ry < ridge + slideOffset) continue; // Calculate width at this row const frac = (i + 1) / rows; const rowY = baseY - frac * roofH * e + slideOffset; // Left side const lx = lerp(L.bx - overhang, cx, frac); const rx2 = lerp(cx, L.bx + L.bw + overhang, frac); // Hmm, this is getting complicated. Let me simplify. // Draw horizontal lines across the roof for tile rows } // Simpler approach: draw horizontal lines for (let i = 1; i <= visibleRows && i <= rows; i++) { const frac = i / rows; const rowY = lerp(baseY, ridge, frac * e) + slideOffset; // Left roof slope const leftX = lerp(L.bx - overhang, cx, frac); const rightX = lerp(L.bx + L.bw + overhang, cx, frac); $.beginPath(); $.moveTo(leftX, rowY); $.lineTo(cx, rowY); $.stroke(); $.beginPath(); $.moveTo(cx, rowY); $.lineTo(rightX, rowY); $.stroke(); } } } // Hmm, this roof tile approach is getting messy. Let me rethink. // The roof: two triangular halves. For tile texture, I'll draw horizontal lines // that follow the slope, and small arc patterns. // Actually, let me reconsider the whole approach. Let me think about the geometry more carefully. // House front view: // The roof is an isosceles triangle sitting on top of the rectangular walls. // Ridge at center top, slopes going left and right. // Two halves: left triangle (from ridge to bottom-left) and right triangle (from ridge to bottom-right). // For tile texture on each half, I can draw horizontal bands with a slightly darker color. // Let me simplify the tile drawing: // OK let me just write the whole thing clean from scratch. I'm overcomplicating this. Actually, let me just write the complete code. I'll be more careful about the geometry. Let me define the house geometry clearly: - Foundation: rectangle at ground level, slightly wider than house - Wall frame: 4 posts (left, right) + top beam - Wall fill: beige rectangle filling the interior - Roof: isosceles triangle (two halves sliding in from top) - Door: brown rectangle on front wall - Windows: two rectangles with cross frames - Details: chimney, path, tree For the roof, I'll make it simple: the left half and right half both start above and slide down into position. Let me think about tile texture: I'll draw horizontal stripes across each roof half, appearing row by row. Let me code this properly now: ```javascript function drawRoof(t, L, wallTop) { const e = easeInOut(t); const roofH = L.bh * 0.55; const overhang = 25 * L.scale; const cx = L.bx + L.bw / 2; // Roof triangles: ridge at (cx, wallTop - roofH), base at wallTop level // Slide in: offset from above const slideDist = roofH * 1.5; const offsetY = (1 - e) * slideDist; const ridgeY = wallTop - roofH + offsetY; const baseY = wallTop + offsetY; const leftX = L.bx - overhang; const rightX = L.bx + L.bw + overhang; // Left roof $.fillStyle = '#CC3333'; $.beginPath(); $.moveTo(cx, ridgeY); $.lineTo(leftX, baseY); $.lineTo(cx, baseY); $.closePath(); $.fill(); // Right roof $.beginPath(); $.moveTo(cx, ridgeY); $.lineTo(rightX, baseY); $.lineTo(cx, baseY); $.closePath(); $.fill(); // Darker edge $.strokeStyle = '#991111'; $.lineWidth = 2 * L.scale; $.beginPath(); $.moveTo(leftX, baseY); $.lineTo(cx, ridgeY); $.lineTo(rightX, baseY); $.stroke(); // Tile texture lines if (t > 0.2) { const tileProgress = clamp((t - 0.2) / 0.8, 0, 1); const numRows = 8; const visibleRows = Math.ceil(tileProgress * numRows); $.strokeStyle = 'rgba(150,20,20,0.5)'; $.lineWidth = 1.5 * L.scale; for (let i = 1; i <= visibleRows; i++) { const frac = i / numRows; const y = lerp(baseY, ridgeY, frac); // At this y, what's the x range? // Left slope: from leftX at baseY to cx at ridgeY const xLeft = lerp(leftX, cx, frac); // Right slope: from rightX at baseY to cx at ridgeY const xRight = lerp(rightX, cx, frac); $.beginPath(); $.moveTo(xLeft, y); $.lineTo(xRight, y); $.stroke(); } } } ``` OK this is getting long. Let me just write the complete file now, all at once, carefully. Let me reconsider the timing. 7 stages × 3.2s = 22.4s, plus 6 pauses × 0.5s = 3s, total = 25.4s. Good, about 25 seconds. Let me also think about the subtitle fade in/out. I'll have the subtitle appear with a quick fade in at the start of each stage and fade out before the pause. For smoke, I'll use simple circles with decreasing opacity. For the tree, a simple brown trunk and green circle/triangle crown. For the path, a series of gray/stone colored ellipses leading to the door. Let me write the complete code now: Actually, let me reconsider the roof. The prompt says "两块三角形屋顶板从上方滑入并在屋脊相接" - two triangular roof panels slide in from above and meet at the ridge. So they should come from above and settle into position. "红色瓦片纹理逐行出现" - red tile texture appears row by row. OK I think I have a good plan. Let me write the code. One more thing: for the chimney, it should rise up from the roof. For smoke, a few gray circles drifting up. For the path: a few stepping stones (gray ellipses) from the door to the bottom of the screen. For the tree: a thin brown rectangle (trunk) and a green circle or triangle on top. Let me write it all now. I'll use a clean structure with a main draw function that checks the current stage and calls the appropriate drawing functions. Here's my plan for the code structure: ``` setup canvas animation loop: calculate elapsed time determine current stage (0-6) and progress (0-1) draw sky draw ground draw house components up to current stage draw subtitle requestAnimationFrame ``` For each stage, I need to draw all previous stages at full completion plus the current stage at its progress. Let me write it: ```javascript function render(time) { const elapsed = time % CYCLE; const stageIndex = Math.floor(elapsed / (STAGE_DUR + PAUSE)); const stageTime = elapsed - stageIndex * (STAGE_DUR + PAUSE); const stageT = Math.min(stageTime / STAGE_DUR, 1); const isPause = stageTime >= STAGE_DUR; const L = layout(); // Clear and draw background drawSky(); drawGroundLine(L.gy); // Draw completed stages and current drawFoundation(stageIndex >= 0 ? (stageIndex > 0 ? 1 : stageT) : 0, L); if (stageIndex >= 1) { drawWallFrame(stageIndex > 1 ? 1 : stageT, L); } if (stageIndex >= 2) { drawWallFill(stageIndex > 2 ? 1 : stageT, L); } if (stageIndex >= 3) { drawRoof(stageIndex > 3 ? 1 : stageT, L); } if (stageIndex >= 4) { drawDoor(stageIndex > 4 ? 1 : stageT, L); } if (stageIndex >= 5) { drawWindows(stageIndex > 5 ? 1 : stageT, L); } if (stageIndex >= 6) { drawDetails(stageIndex > 6 ? 1 : stageT, L); } // Subtitle const subtitles = ['打地基', '砌墙', '填外墙', '盖屋顶', '装门', '安窗户', '收尾细节']; if (stageIndex < 7 && !isPause) { // Fade in/out let alpha = 1; if (stageT < 0.1) alpha = stageT / 0.1; if (stageT > 0.85) alpha = (1 - stageT) / 0.15; subtitle(subtitles[stageIndex], alpha); } requestAnimationFrame(render); } ``` Wait, I need to be careful. At stageIndex 0 with stageT 0, drawFoundation should draw nothing. At stageT 1, it should draw the full foundation. That's correct with the code above. But actually, the problem is that drawFoundation(0, L) should draw nothing, and drawFoundation(1, L) should draw the full foundation. The progress `t` goes from 0 to 1. So when stageIndex is 0, we pass stageT (0 to 1). When stageIndex > 0, we pass 1 (fully drawn). That's correct. But wait, when stageIndex is 0 and stageT is 0, we're calling drawFoundation(0, L) which should draw nothing (height = 0). OK that works. Actually hmm, but in stage 0, t starts from 0 and goes to 1 over STAGE_DUR. So at the very start, the foundation has 0 height and grows to full. Good. Let me also think about the wall drawing. In stage 2 (index 1), wall frame grows. But I need to know the wall height at full extent to position things correctly. Actually, the wall positions are fixed - they always go from the top of the foundation to the full wall height. What changes is how much of the wall is visible (growing upward). So: ```javascript function drawWallFrame(t, L) { const e = easeOut(t); const wallTop = L.gy - L.fh; // top of foundation = base of wall const fullWallH = L.bh; const currentH = fullWallH * e; const thick = 10 * L.scale; $.fillStyle = '#6B3410'; // Left post $.fillRect(L.bx, wallTop - currentH, thick, currentH); // Right post $.fillRect(L.bx + L.bw - thick, wallTop - currentH, thick, currentH); // Top beam (appears when walls are ~60% up) if (e > 0.5) { const beamT = (e - 0.5) / 0.5; $.fillRect(L.bx, wallTop - currentH, L.bw * beamT + thick, thick); } } ``` Hmm, but the top beam should appear as a horizontal bar at the top. Let me make it simpler: when e > 0.5, draw the top beam spanning from left to right at the wall top. Actually, let me reconsider. The four wall segments: - Left vertical: grows up from bottom-left - Right vertical: grows up from bottom-right - Top-left horizontal: extends from left to center - Top-right horizontal: extends from right to center Or more simply: the wall frame outline grows. Left and right sides go up, then top connects them. Let me just make it look good. Two vertical posts growing up, then a horizontal beam at the top. OK here's my revised approach. Let me write the whole thing and stop overthinking individual pieces. Actually, I realize I should carefully plan the coordinate system. Let me define: ``` L.gy = ground Y (where the green line is) L.fh = foundation height (e.g., 25px scaled) L.bh = wall body height (e.g., 200px scaled) L.bw = house body width (e.g., 300px scaled) L.bx = house body left X (centered) wallBase = L.gy - L.fh (top of foundation = bottom of walls) wallTop = L.gy - L.fh - L.bh (top of walls) ``` Foundation rect: (L.bx - margin, L.gy - L.fh, L.bw + 2*margin, L.fh) Walls: from wallBase upward by L.bh Let me write it now. For the roof, I need the wall top Y coordinate. The roof ridge is above the wall top. OK here's the final code. Let me be very deliberate: ```html <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>建房动画</title> <style> *{margin:0;padding:0}html,body{width:100%;height:100%;overflow:hidden}canvas{display:block} </style> </head> <body> <canvas id="c"></canvas> <script> const c=document.getElementById('c'),x=c.getContext('2d'); let W,H; function resize(){W=c.width=innerWidth;H=c.height=innerHeight} resize();onresize=resize; const SD=3200,PS=500,CYCLE=(SD+PS)*7; const SUBS=['打地基','砌墙','填外墙','盖屋顶','装门','安窗户','收尾']; function eo(t){return 1-(1-t)*(1-t)*(1-t)} function ei(t){return t<.5?4*t*t*t:1-(-2*t+2)**3/2} function lp(a,b,t){return a+(b-a)*t} function cl(v,a,b){return Math.max(a,Math.min(b,v))} function lay(){ const s=Math.min(W/1100,H/750,1.2); const gy=H*.68; const bw=280*s,bh=200*s,fh=22*s; const bx=W/2-bw/2; return{s,gy,bw,bh,fh,bx,wt:gy-fh-bh,wb:gy-fh}; } function sky(){ const g=x.createLinearGradient(0,0,0,H); g.addColorStop(0,'#7EC8E3'); g.addColorStop(.6,'#C5E8F7'); g.addColorStop(.68,'#90C67C'); g.addColorStop(1,'#4A8B3C'); x.fillStyle=g;x.fillRect(0,0,W,H); // Ground line x.strokeStyle='#3A7A2E';x.lineWidth=2; x.beginPath();x.moveTo(0,H*.68);x.lineTo(W,H*.68);x.stroke(); } function sub(txt,a){ if(!txt)return; x.save();x.globalAlpha=a; const fs=Math.max(16,Math.min(26,W*.022)); x.font=`bold ${fs}px "PingFang SC","Microsoft YaHei",sans-serif`; x.textAlign='center';x.textBaseline='middle'; const m=x.measureText(txt),p=20,q=10; const rx=W/2-m.width/2-p,ry=22, rw=m.width+p*2,rh=fs+q; x.fillStyle='rgba(0,0,0,.55)'; x.beginPath(); const r=10; x.moveTo(rx+r,ry);x.arcTo(rx+rw,ry,rx+rw,ry+rh,r);x.arcTo(rx+rw,ry+rh,rx,ry+rh,r); x.arcTo(rx,ry+rh,rx,ry,r);x.arcTo(rx,ry,rx+rw,ry,r);x.closePath();x.fill(); x.fillStyle='#fff';x.fillText(txt,W/2,ry+rh/2); x.restore(); } // Stage 1: Foundation function s1(t,L){ const e=eo(t),s=L.s; const fh=L.fh; const y=L.gy-fh*e; // Foundation block x.fillStyle='#808080'; x.fillRect(L.bx-15*s,y,L.bw+30*s,fh*e+5*s); x.fillStyle='#909090'; x.fillRect(L.bx-15*s,y,L.bw+30*s,3*s); // Lines if(e>.3){ x.strokeStyle='rgba(100,100,100,.4)';x.lineWidth=1; for(let i=0;i<3;i++){ const ly=y+6*s+i*7*s; if(ly<L.gy){ x.beginPath();x.moveTo(L.bx-15*s,ly);x.lineTo(L.bx+L.bw+15*s,ly);x.stroke(); } } } } // Stage 2: Wall frame function s2(t,L){ const e=eo(t),s=L.s; const thick=10*s; const wallH=L.bh*e; // Left post x.fillStyle='#5C3317'; x.fillRect(L.bx,L.wb-wallH,thick,wallH); // Right post x.fillRect(L.bx+L.bw-thick,L.wb-wallH,thick,wallH); // Top beam if(e>.4){ x.fillRect(L.bx,L.wb-wallH,L.bw,thick); } // Bottom beam if(e>.6){ x.fillRect(L.bx,L.wb-thick,L.bw,thick); } } // Stage 3: Wall fill function s3(t,L){ const e=eo(t); x.save();x.globalAlpha=e; x.fillStyle='#F5DEB3'; x.fillRect(L.bx+5*L.s,L.wt+5*L.s,L.bw-10*L.s,L.bh-5*L.s); // Slight gradient const g=x.createLinearGradient(L.bx,0,L.bx+L.bw,0); g.addColorStop(0,'rgba(210,180,140,.2)');g.addColorStop(.5,'rgba(255,250,240,.1)');g.addColorStop(1,'rgba(210,180,140,.2)'); x.fillStyle=g;x.fillRect(L.bx+5*L.s,L.wt+5*L.s,L.bw-10*L.s,L.bh-5*L.s); x.restore(); } // Stage 4: Roof function s4(t,L){ const e=ei(t),s=L.s; const roofH=L.bh*.55; const oh=25*s; const cx=L.bx+L.bw/2; const ridge=L.wt-roofH; const base=L.wt; const slide=(1-e)*roofH*2; const ry=ridge+slide, by=base+slide; // Left roof x.fillStyle='#CC3333'; x.beginPath();x.moveTo(cx,ry);x.lineTo(L.bx-oh,by);x.lineTo(cx,by);x.closePath();x.fill(); // Right roof x.beginPath();x.moveTo(cx,ry);x.lineTo(L.bx+L.bw+oh,by);x.lineTo(cx,by);x.closePath();x.fill(); // Edge x.strokeStyle='#8B1A1A';x.lineWidth=2.5*s; x.beginPath();x.moveTo(L.bx-oh,by);x.lineTo(cx,ry);x.lineTo(L.bx+L.bw+oh,by);x.stroke(); // Tile rows if(t>.15){ const tt=cl((t-.15)/.85,0,1); const rows=7, vis=Math.ceil(tt*rows); x.strokeStyle='rgba(139,20,20,.45)';x.lineWidth=1.5*s; for(let i=1;i<=vis;i++){ const f=i/rows; const rowY=lp(by,ry,f); const lx=lp(L.bx-oh,cx,f),rx=lp(L.bx+L.bw+oh,cx,f); x.beginPath();x.moveTo(lx,rowY);x.lineTo(rx,rowY);x.stroke(); } } // Roof bottom edge (eave) x.fillStyle='#8B1A1A'; x.fillRect(L.bx-oh,by-2*s,L.bw+oh*2,4*s); } // Stage 5: Door function s5(t,L){ const e=eo(t),s=L.s; const dw=50*s,dh=80*s; const dx=L.bx+L.bw/2-dw/2; const dy=L.wb-dh; x.save();x.globalAlpha=e; // Door frame x.fillStyle='#4A2810'; x.fillRect(dx-3*s,dy-3*s,dw+6*s,dh+3*s); // Door x.fillStyle='#8B5E3C'; x.fillRect(dx,dy,dw,dh); // Panel detail x.fillStyle='#6B4226'; x.fillRect(dx+6*s,dy+6*s,dw-12*s,dh/2-9*s); x.fillRect(dx+6*s,dy+dh/2+3*s,dw-12*s,dh/2-9*s); // Doorknob x.fillStyle='#DAA520'; x.beginPath();x.arc(dx+dw-12*s,dy+dh/2,5*s,0,Math.PI*2);x.fill(); x.fillStyle='#FFD700'; x.beginPath();x.arc(dx+dw-13*s,dy+dh/2-1*s,2.5*s,0,Math.PI*2);x.fill(); x.restore(); } // Stage 6: Windows function s6(t,L){ const e=eo(t),s=L.s; const ww=45*s,wh=45*s; const doorW=50*s; // Left window const lwx=L.bx+L.bw*.15; const rwx=L.bx+L.bw*.85-ww; const wy=L.wt+L.bh*.25; x.save();x.globalAlpha=e; [lwx,rwx].forEach(wx=>{ // Frame x.fillStyle='#4A2810'; x.fillRect(wx-3*s,wy-3*s,ww+6*s,wh+6*s); // Glass x.fillStyle='#87CEEB'; x.fillRect(wx,wy,ww,wh); // Cross frame x.fillStyle='#4A2810'; x.fillRect(wx+ww/2-2*s,wy,4*s,wh); x.fillRect(wx,wy+wh/2-2*s,ww,4*s); // Glass shine x.fillStyle='rgba(255,255,255,.3)'; x.fillRect(wx+3*s,wy+3*s,ww/2-5*s,wh/2-5*s); }); x.restore(); } // Stage 7: Details function s7(t,L){ const s=L.s; // Sub-stages within this stage // 0-0.4: chimney rises // 0.2-0.6: smoke appears // 0.4-0.8: path appears // 0.6-1.0: tree grows // Chimney if(t>0){ const ce=eo(cl(t/0.4,0,1)); const cw=30*s,ch=50*s; const cx=L.bx+L.bw*.7; const roofBaseY=L.wt; // The chimney sits on the roof slope on the right side // Approximate position on right roof slope const roofH=L.bh*.55; const ridgeY=L.wt-roofH; // At x=cx on the right slope, what's the y? const roofCx=L.bx+L.bw/2; const frac=(cx-roofCx)/(L.bx+L.bw+25*s-roofCx); const slopeY=lp(roofBaseY,ridgeY,frac); const chimneyBase=slopeY; const chimneyH=ch*ce; const chimneyY=chimneyBase-chimneyH; x.fillStyle='#8B4513'; x.fillRect(cx,chimneyY,cw,chimneyH+5*s); // Cap x.fillStyle='#6B3410'; x.fillRect(cx-4*s,chimneyY-4*s,cw+8*s,6*s); // Smoke if(t>0.25){ const st=cl((t-.25)/.75,0,1); for(let i=0;i<4;i++){ const delay=i*0.15; const si=cl((st-delay)/(1-delay),0,1); if(si<=0)continue; const es=eo(si); const sy=chimneyY-10*s-40*s*si; const sx=cx+cw/2+Math.sin(si*3+i)*8*s*si; const sr=(4+i*2)*s*(0.5+si*0.5); x.beginPath();x.arc(sx,sy,sr,0,Math.PI*2); x.fillStyle=`rgba(180,180,180,${.5*(1-si)})`; x.fill(); } } } // Path (stepping stones) if(t>0.35){ const pe=eo(cl((t-.35)/.3,0,1)); const doorCx=L.bx+L.bw/2; const numStones=5; for(let i=0;i<numStones;i++){ const si=cl((pe-i*0.15)/(0.85),0,1); if(si<=0)continue; const stoneY=L.wb+5*s+i*25*s; const stoneW=(20+i*3)*s; const stoneH=12*s; x.save();x.globalAlpha=si; x.fillStyle='#A0A0A0'; x.beginPath(); x.ellipse(doorCx+Math.sin(i*1.5)*10*s,stoneY,stoneW/2,stoneH/2,0,0,Math.PI*2); x.fill(); x.strokeStyle='#808080';x.lineWidth=1; x.stroke(); x.restore(); } } // Tree if(t>0.55){ const te=eo(cl((t-.55)/.45,0,1)); const tx=L.bx+L.bw+60*s; const ty=L.wb; // Trunk const trunkH=60*s*te; x.fillStyle='#8B5E3C'; x.fillRect(tx-5*s,ty-trunkH,10*s,trunkH); // Foliage (3 circles) if(te>0.3){ const fe=(te-.3)/.7; const cr=25*s*fe; x.fillStyle='#228B22'; x.beginPath();x.arc(tx,ty-trunkH-cr*.5,cr,0,Math.PI*2);x.fill(); x.fillStyle='#2E8B2E'; x.beginPath();x.arc(tx-cr*.8,ty-trunkH+cr*.3,cr*.7,0,Math.PI*2);x.fill(); x.fillStyle='#32A032'; x.beginPath();x.arc(tx+cr*.8,ty-trunkH+cr*.3,cr*.7,0,Math.PI*2);x.fill(); } } } function render(t){ const elapsed=t%CYCLE; const si=Math.floor(elapsed/(SD+PS)); const st=elapsed-si*(SD+PS); const pt=cl(st/SD,0,1); const L=lay(); sky(); if(si>=0)s1(si>0?1:pt,L); if(si>=1)s2(si>1?1:pt,L); if(si>=2)s3(si>2?1:pt,L); if(si>=3)s4(si>3?1:pt,L); if(si>=4)s5(si>4?1:pt,L); if(si>=5)s6(si>5?1:pt,L); if(si>=6)s7(si>6?1:pt,L); // Subtitle if(si<7){ let a=1; if(pt<.1)a=pt/.1; if(pt>.85)a=(1-pt)/.15; if(st<SD)sub(SUBS[si],a); } requestAnimationFrame(render); } requestAnimationFrame(render); </script> </body> </html> ``` Wait, I have some issues: 1. The chimney position calculation is off. Let me fix that. 2. The path should go from the door to below (towards the viewer). 3. The tree position needs to be right. 4. I need to make sure the wall fill doesn't extend outside the wall frame. Let me also reconsider the layout. The wall fill in stage 3 should be between the wall posts. If the posts are at L.bx and L.bx+L.bw-thick, the fill should be inside those. Actually, let me reconsider the whole layout approach. Let me make the house drawing more robust. Let me define the house as a proper structure: - The house body is a rectangle from (bx, wt) to (bx+bw, wb) where wt = wall top, wb = wall bottom (top of foundation) - Wall frame: outer posts at bx and bx+bw, with some thickness - Wall fill: inside the frame - Foundation: below wb - Roof: above wt Let me also think about drawing order carefully: 1. Foundation (partially below ground) 2. Wall frame (posts + top beam) 3. Wall fill (inside the frame, but should be drawn BEFORE the frame posts in some cases? No, the frame is on the outside) Actually, the wall fill should be drawn first (background), then the frame posts on top. But in my staged approach, fill comes after frame. That means the fill will cover the inner parts of the frame. That's actually fine - the frame posts are at the edges. Hmm, but if I draw the fill after the frame, it will cover the frame's inner faces. Let me think... The frame posts are thin rectangles at the left and right edges of the house. The fill is the area between them. If I draw the fill after the frame, the fill will be on top of the inner portions of the frame. Since the fill is a solid color, it will look like the frame is only visible on the outside edges. That's actually fine for a cartoon style. But wait - in stage 2, only the frame is visible (no fill). In stage 3, the fill appears. So the fill should go between the posts. If I draw the fill over the inner part of the posts, that's OK because the posts are still visible on the outside. Actually, for cleanliness, let me draw the fill FIRST, then the frame on top. That way the frame is always visible as a border around the fill. But with my staged approach, stage 2 draws the frame, and stage 3 draws the fill. Since stage 2 is drawn first and stage 3 is drawn on top, the fill would cover the inner frame parts. That's not ideal. Solution: In stage 3, draw the fill inside the frame boundary. Or better: draw the fill first, then redraw the frame. Let me restructure: I'll have a "drawHouseSoFar" function that draws everything up to the current stage in the correct order. Actually, the simplest approach: always draw things in the correct visual order (back to front), but with the appropriate t value for each stage. Let me do: 1. Wall fill (background) 2. Wall frame (on top of fill) 3. Foundation 4. Roof 5. Door 6. Windows 7. Details But the stages are: 1. Foundation rises 2. Wall frame grows 3. Wall fill appears 4. Roof slides in 5. Door fades in 6. Windows fade in 7. Details appear So the visual order doesn't match the stage order. Let me handle this by always drawing in the correct visual order, passing the appropriate t value: ``` function drawAllStages(si, pt) { const t = (stage) => { if (stage < si) return 1; if (stage === si) return pt; return -1; // not started }; // Draw in visual order (back to front): // Wall fill (stage 2) if (t(2) >= 0) drawWallFill(t(2)); // Wall frame (stage 1) if (t(1) >= 0) drawWallFrame(t(1)); // Foundation (stage 0) if (t(0) >= 0) drawFoundation(t(0)); // Roof (stage 3) if (t(3) >= 0) drawRoof(t(3)); // Door (stage 4) if (t(4) >= 0) drawDoor(t(4)); // Windows (stage 5) if (t(5) >= 0) drawWindows(t(5)); // Details (stage 6) if (t(6) >= 0) drawDetails(t(6)); } ``` Hmm, this is cleaner. Let me use this approach. But actually, for stage 2 (wall frame), the frame needs to be visible before the fill. The fill appears in stage 3. So visually, the frame should be drawn on top of the fill. With this ordering, the fill is drawn first and the frame on top - correct! But what about during stage 2? The frame is growing but there's no fill yet. That's fine - the fill has t=-1 so it's not drawn. And during stage 3? The fill is appearing (t between 0 and 1) and the frame is fully drawn (t=1). The fill is drawn first (back), then the frame on top. Correct! What about the foundation? The foundation is at the base of the walls. It should be drawn... hmm, it's below the wall fill. So it should be drawn before the wall fill. Let me adjust: Visual order (back to front): 1. Wall fill (stage 2) - between the wall frame posts 2. Foundation (stage 0) - but wait, the foundation is below the walls, not behind them 3. Wall frame (stage 1) 4. Roof (stage 3) 5. Door (stage 4) 6. Windows (stage 5) 7. Details (stage 6) Actually, the foundation doesn't overlap with the wall fill. The foundation is below the walls. So the order between foundation and wall fill doesn't matter much. But the wall frame posts should be drawn after the fill (on top). Let me just use: 1. Foundation 2. Wall fill 3. Wall frame (posts on top of fill) 4. Roof 5. Door 6. Windows 7. Details This way, the foundation is drawn first (it's at the bottom), the fill covers the interior, and the frame posts are drawn on top as a border. OK let me rewrite the code with this approach. Actually, for the wall frame: the posts are on the edges. The fill is inside. If I draw fill first and then frame, the frame will nicely border the fill. Good. But during stage 2 (frame growing, no fill), the frame is just the posts - good. During stage 3 (fill appearing, frame complete), the fill fades in inside the frame - good. Let me also fix the chimney. The chimney should sit on the right side of the roof. I need to calculate its position properly. For the chimney: - It's on the right slope of the roof - It should appear to sit on the roof slope - It starts hidden behind/inside the roof and rises upward The chimney position: let's say it's at about 70% of the way from center to right edge of the roof. The chimney base should be on the roof slope. Right roof slope: from (cx, ridgeY) to (rightX, baseY) At 70% from center: x = cx + 0.7 * (rightX - cx) = cx + 0.7 * (bw/2 + overhang) The y on the slope at this x: frac = (x - cx) / (rightX - cx) y = ridgeY + frac * (baseY - ridgeY) = ridgeY + frac * roofH Wait, let me parameterize the right slope: - Start: (cx, ridgeY) - End: (rightX, baseY) - At fraction f along the slope: x = cx + f*(rightX - cx), y = ridgeY + f*(baseY - ridgeY) For the chimney at f=0.6: x = cx + 0.6*(rightX - cx) y = ridgeY + 0.6*(baseY - ridgeY) = ridgeY + 0.6*roofH Then the chimney goes from y upward (y - chimneyHeight). But for the animation, the chimney should "rise up" from the roof. So initially it's hidden inside the roof and grows upward. The chimney base stays on the roof slope, and the chimney extends upward from there. OK, let me write the final code. I'll be more careful this time. Actually, let me reconsider the chimney animation. The prompt says "烟囱从屋顶升起" - chimney rises from the roof. This means the chimney starts flat/hidden and rises up out of the roof. So the chimney's bottom stays at the roof level, and it grows upward. For the animation: chimney height goes from 0 to full, positioned on the right side of the roof. For smoke: after chimney is up, smoke puffs appear and drift upward. Let me also reconsider the tree position. It should be to the right (or left) of the house. The prompt says "房屋旁长出一棵小树" - a small tree grows beside the house. OK, I'll put the tree to the right of the house. For the path: "一条踏脚石小径通向门口" - stepping stone path leading to the door. This should go from the door downward (toward the viewer). Now let me finalize the code. I'll write it clean and complete. One more consideration: the ground at H*0.68. The foundation should be at ground level. The walls go up from the top of the foundation. The roof sits on top of the walls. Let me define all the y-coordinates: - groundY = H * 0.68 - foundationTop = groundY - foundationHeight - wallBottom = foundationTop (top of foundation = bottom of walls) - wallTop = wallBottom - wallHeight (top of walls) - roofRidge = wallTop - roofHeight And x-coordinates: - houseCenterX = W / 2 - houseLeft = houseCenterX - houseWidth / 2 - houseRight = houseCenterX + houseWidth / 2 Let me write the code now. For real this time. ```html <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <style> *{margin:0;padding:0}html,body{width:100%;height:100%;overflow:hidden}canvas{display:block} </style> </head> <body> <canvas id="c"></canvas> <script> const c = document.getElementById('c'); const $ = c.getContext('2d'); let W, H; function resize() { W = c.width = innerWidth; H = c.height = innerHeight; } resize(); onresize = resize; const SD = 3200; // stage duration const PS = 500; // pause const CYCLE = (SD + PS) * 7; const SUBS = ['打地基', '砌墙', '填外墙', '盖屋顶', '装门', '安窗户', '收尾']; // Easing function eo(t) { return 1 - (1-t)*(1-t)*(1-t); } function ei(t) { return t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; } function lp(a, b, t) { return a + (b-a) * t; } function cl(v, a, b) { return v < a ? a : v > b ? b : v; } // Layout function L() { const s = Math.min(W / 1100, H / 750, 1.3); const gy = H * 0.68; const bw = 280 * s, bh = 200 * s, fh = 22 * s; const bx = W/2 - bw/2; return { s, gy, bw, bh, fh, bx, wtop: gy - fh - bh, wbot: gy - fh, roofH: bh * 0.55 }; } // Background function bg() { const g = $.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#7EC8E3'); g.addColorStop(0.55, '#C5E8F7'); g.addColorStop(0.68, '#8FCA6E'); g.addColorStop(1, '#4A8B3C'); $.fillStyle = g; $.fillRect(0, 0, W, H); // Ground line $.strokeStyle = '#3A7A2E'; $.lineWidth = 2; $.beginPath(); $.moveTo(0, L().gy); $.lineTo(W, L().gy); $.stroke(); } // Subtitle function sub(txt, a) { if (!txt || a <= 0) return; $.save(); $.globalAlpha = a; const fs = Math.max(16, Math.min(26, W * 0.022)); $.font = `bold ${fs}px "PingFang SC","Microsoft YaHei",sans-serif`; $.textAlign = 'center'; $.textBaseline = 'middle'; const m = $.measureText(txt); const p = 20, q = 8; const rw = m.width + p * 2, rh = fs + q * 2; const rx = W/2 - rw/2, ry = 24; // Rounded rect bg $.fillStyle = 'rgba(0,0,0,0.55)'; $.beginPath(); const r = Math.min(12, rh/2); $.moveTo(rx+r, ry); $.arcTo(rx+rw, ry, rx+rw, ry+rh, r); $.arcTo(rx+rw, ry+rh, rx, ry+rh, r); $.arcTo(rx, ry+rh, rx, ry, r); $.arcTo(rx, ry, rx+rw, ry, r); $.closePath(); $.fill(); $.fillStyle = '#fff'; $.fillText(txt, W/2, ry + rh/2); $.restore(); } // Draw functions for each stage // Stage 0: Foundation function drawFoundation(t, o) { if (t < 0) return; const e = eo(t), s = o.s; const h = o.fh * e; const y = o.gy - h; // Main block $.fillStyle = '#808080'; $.fillRect(o.bx - 15*s, y, o.bw + 30*s, h + 5*s); // Highlight top edge $.fillStyle = '#959595'; $.fillRect(o.bx - 15*s, y, o.bw + 30*s, 3*s); // Brick lines if (e > 0.3) { $.strokeStyle = `rgba(100,100,100,${(e-0.3)*0.7})`; $.lineWidth = 1; for (let i = 1; i < 3; i++) { const ly = y + 4*s + i * 7*s; if (ly < o.gy) { $.beginPath(); $.moveTo(o.bx - 15*s, ly); $.lineTo(o.bx + o.bw + 15*s, ly); $.stroke(); } } } } // Stage 1: Wall frame function drawWallFrame(t, o) { if (t < 0) return; const e = eo(t), s = o.s; const tk = 10 * s; const wallH = o.bh * e; $.fillStyle = '#5C3317'; // Left post $.fillRect(o.bx, o.wbot - wallH, tk, wallH); // Right post $.fillRect(o.bx + o.bw - tk, o.wbot - wallH, tk, wallH); // Top beam if (e > 0.3) { $.fillRect(o.bx, o.wbot - wallH, o.bw, tk); } // Bottom rail if (e > 0.6) { $.fillRect(o.bx, o.wbot - tk, o.bw, tk); } } // Stage 2: Wall fill function drawWallFill(t, o) { if (t < 0) return; const e = eo(t), s = o.s; $.save(); $.globalAlpha = e; // Beige wall $.fillStyle = '#F5DEB3'; $.fillRect(o.bx + 5*s, o.wtop + 5*s, o.bw - 10*s, o.bh - 5*s); // Gradient for depth const g = $.createLinearGradient(o.bx, 0, o.bx + o.bw, 0); g.addColorStop(0, 'rgba(200,170,130,0.25)'); g.addColorStop(0.5, 'rgba(255,250,240,0.1)'); g.addColorStop(1, 'rgba(200,170,130,0.25)'); $.fillStyle = g; $.fillRect(o.bx + 5*s, o.wtop + 5*s, o.bw - 10*s, o.bh - 5*s); $.restore(); } // Stage 3: Roof function drawRoof(t, o) { if (t < 0) return; const e = ei(t), s = o.s; const rh = o.roofH; const oh = 25 * s; const cx = o.bx + o.bw / 2; const ridgeY = o.wtop - rh; const baseY = o.wtop; const slide = (1 - e) * rh * 2; const ry = ridgeY + slide; const by = baseY + slide; const lx = o.bx - oh; const rx = o.bx + o.bw + oh; // Left half $.fillStyle = '#CC3333'; $.beginPath(); $.moveTo(cx, ry); $.lineTo(lx, by); $.lineTo(cx, by); $.closePath(); $.fill(); // Right half $.beginPath(); $.moveTo(cx, ry); $.lineTo(rx, by); $.lineTo(cx, by); $.closePath(); $.fill(); // Outline $.strokeStyle = '#8B1A1A'; $.lineWidth = 2.5 * s; $.beginPath(); $.moveTo(lx, by); $.lineTo(cx, ry); $.lineTo(rx, by); $.stroke(); // Eave $.fillStyle = '#8B1A1A'; $.fillRect(lx, by - 2*s, rx - lx, 4*s); // Tile rows if (t > 0.1) { const tt = cl((t - 0.1) / 0.9, 0, 1); const rows = 7; const vis = Math.ceil(tt * rows); $.strokeStyle = 'rgba(139,20,20,0.4)'; $.lineWidth = 1.5 * s; for (let i = 1; i <= vis; i++) { const f = i / rows; const rowY = lp(by, ry, f); const rowLx = lp(lx, cx, f);
⚠ 输出中断:未收到正常结束信号(多为生成耗时过长被服务端超时切断,或网络中断),下方内容可能不完整。点「重跑」重试。
这次运行已结束,但厂商没有返回最终正文流,只返回了 reasoning / 中间推理 token。上方「思考过程」已自动展开,避免结果区显示为空。
1.10s
首Token
52
思考TPS
299s · ≈15,540 tok
输出TPS
15,540
总Tokens(估算)
总用时 301s · 平均 52 tok/s · 峰值 89 tok/s · 成本 ¥0.031
小米 MiMo-V2.5
mimo-v2.5
中断
The user wants me to create a single HTML file with a canvas animation showing a house being built in 7 stages over approximately 25 seconds, with stage labels, pauses between stages, and continuous looping. Let me plan this out carefully: 1. Foundation - gray rectangle rises from underground 2. Walls - vertical wall frames extend up from foundation corners 3. Wall filling - beige/light brown exterior panels fill between walls 4. Roof - two triangular roof panels slide in from above, red tile texture appears row by row 5. Door - brown door fades in on front wall with gold doorknob 6. Windows - two windows appear on either side of door with cross frames and blue glass 7. Details - chimney rises from roof with smoke, stepping stone path to door, small tree beside house Each stage has a subtitle at the top, with 0.5 second pauses between stages. Total ~25 seconds, continuous loop. Let me write this step by step: - Canvas fills the entire page - Background: light blue sky, green ground line - 7 stages with animations - Subtitles for each stage I'll use requestAnimationFrame for smooth animation. Let me calculate timing: - 25 seconds total - 7 stages with 0.5 second pauses between them - So 6 pauses = 3 seconds - 22 seconds of actual animation / 7 stages ≈ 3.14 seconds per stage Let me write the code now. I need to carefully plan each stage's animation: Stage 1 (Foundation): ~3s - gray rectangle rises from below ground Stage 2 (Walls): ~3s - wall frames extend upward Stage 3 (Wall filling): ~3s - panels fill in Stage 4 (Roof): ~3s - triangular panels slide in, tiles appear Stage 5 (Door): ~3s - door fades in with doorknob Stage 6 (Windows): ~3s - windows appear with cross frames and blue glass Stage 7 (Details): ~3s - chimney rises, smoke appears, path appears, tree grows Let me code this up now. I'll structure it with: - A timeline-based system where each stage has a start time and duration - Within each stage, progress is calculated for smooth animations - The whole thing loops after completion Let me think about the coordinate system. Canvas will be full screen. I'll center the house. House dimensions (approximate): - Foundation: centered, about 400px wide, 30px tall - Walls: about 400px wide, 250px tall from foundation top - Roof: triangular, peaked at center Let me code this properly now. Actually, let me think about the canvas coordinate system and house positioning more carefully. Canvas: full viewport Ground line: at about 75% from top House base sits on ground line House: - Foundation width: 400, height: 30 - Wall width: 400, height: 200 - Roof: triangle above walls - Center of house: canvas.width/2, ground_y Let me write the full code now: ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>House Building Animation</title> <style> * { margin: 0; padding: 0; } body { overflow: hidden; } canvas { display: block; } </style> </head> <body> <canvas id="c"></canvas> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } resize(); window.addEventListener('resize', resize); // Animation timing const STAGE_DURATION = 3000; // ms per stage const PAUSE_DURATION = 500; // ms pause between stages const TOTAL_STAGES = 7; const TOTAL_DURATION = STAGE_DURATION * TOTAL_STAGES + PAUSE_DURATION * (TOTAL_STAGES - 1); const stageNames = ['地基', '砌墙', '填墙', '屋顶', '门', '窗', '细节']; const startTime = Date.now(); function getStage(t) { let elapsed = t % TOTAL_DURATION; let accumulated = 0; for (let i = 0; i < TOTAL_STAGES; i++) { if (i > 0) { accumulated += PAUSE_DURATION; if (elapsed < accumulated) { return { stage: i - 1, progress: 1, paused: true }; } } accumulated += STAGE_DURATION; if (elapsed < accumulated) { let stageProgress = (elapsed - (accumulated - STAGE_DURATION)) / STAGE_DURATION; return { stage: i, progress: Math.min(1, stageProgress), paused: false }; } } return { stage: TOTAL_STAGES - 1, progress: 1, paused: false }; } function easeInOut(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; } function easeOut(t) { return 1 - Math.pow(1 - t, 3); } function easeIn(t) { return t * t * t; } function draw() { const now = Date.now() - startTime; const { stage, progress } = getStage(now); const W = canvas.width; const H = canvas.height; const groundY = H * 0.75; // House parameters const houseWidth = Math.min(400, W * 0.35); const houseHeight = houseWidth * 0.5; const foundationHeight = houseWidth * 0.08; const wallHeight = houseWidth * 0.45; const roofHeight = houseWidth * 0.35; const houseX = W / 2 - houseWidth / 2; const houseY = groundY - foundationHeight - wallHeight; // Clear // Sky gradient let skyGrad = ctx.createLinearGradient(0, 0, 0, groundY); skyGrad.addColorStop(0, '#87CEEB'); skyGrad.addColorStop(1, '#B0E0FF'); ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, W, groundY); // Ground ctx.fillStyle = '#4CAF50'; ctx.fillRect(0, groundY, W, H - groundY); // Draw ground line ctx.strokeStyle = '#388E3C'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(W, groundY); ctx.stroke(); // Now draw each stage based on current stage // Stage 0: Foundation if (stage >= 0) { let p = stage === 0 ? easeOut(progress) : 1; let fh = foundationHeight * p; ctx.fillStyle = '#808080'; ctx.fillRect(houseX, groundY - fh, houseWidth, fh); // Foundation details ctx.fillStyle = '#707070'; ctx.fillRect(houseX, groundY - fh, houseWidth, fh * 0.3); ctx.strokeStyle = '#606060'; ctx.lineWidth = 1; ctx.strokeRect(houseX, groundY - fh, houseWidth, fh); } // Stage 1: Walls (frame) if (stage >= 1) { let p = stage === 1 ? easeOut(progress) : 1; let currentWallHeight = wallHeight * p; let wallTop = groundY - foundationHeight - currentWallHeight; ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 4; // Left wall ctx.beginPath(); ctx.moveTo(houseX, groundY - foundationHeight); ctx.lineTo(houseX, wallTop); ctx.stroke(); // Right wall ctx.beginPath(); ctx.moveTo(houseX + houseWidth, groundY - foundationHeight); ctx.lineTo(houseX + houseWidth, wallTop); ctx.stroke(); // Top beam if (p > 0.8) { let beamP = (p - 0.8) / 0.2; let beamWidth = houseWidth * beamP; ctx.beginPath(); ctx.moveTo(houseX, wallTop); ctx.lineTo(houseX + beamWidth, wallTop); ctx.stroke(); } // Bottom beam (on foundation) ctx.beginPath(); ctx.moveTo(houseX, groundY - foundationHeight); ctx.lineTo(houseX + houseWidth, groundY - foundationHeight); ctx.stroke(); } // Stage 2: Wall filling if (stage >= 2) { let p = stage === 2 ? easeOut(progress) : 1; let wallTop = groundY - foundationHeight - wallHeight; ctx.fillStyle = '#F5DEB3'; let fillWidth = houseWidth * p; ctx.fillRect(houseX + 2, wallTop + 2, fillWidth - 4, wallHeight - 4); // Some texture lines if (p > 0.3) { ctx.strokeStyle = '#DEB887'; ctx.lineWidth = 1; for (let i = 0; i < 5; i++) { let y = wallTop + wallHeight * (i + 1) / 6; let lineW = fillWidth * Math.min(1, (p - 0.3 - i * 0.1) / 0.3); if (lineW > 0) { ctx.beginPath(); ctx.moveTo(houseX + 5, y); ctx.lineTo(houseX + lineW - 5, y); ctx.stroke(); } } } // Redraw wall frame on top ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(houseX, groundY - foundationHeight); ctx.lineTo(houseX, wallTop); ctx.stroke(); ctx.beginPath(); ctx.moveTo(houseX + houseWidth, groundY - foundationHeight); ctx.lineTo(houseX + houseWidth, wallTop); ctx.stroke(); ctx.beginPath(); ctx.moveTo(houseX, wallTop); ctx.lineTo(houseX + houseWidth, wallTop); ctx.stroke(); ctx.beginPath(); ctx.moveTo(houseX, groundY - foundationHeight); ctx.lineTo(houseX + houseWidth, groundY - foundationHeight); ctx.stroke(); } // Stage 3: Roof if (stage >= 3) { let p = stage === 3 ? progress : 1; // Two triangular panels sliding in let slideP = easeOut(Math.min(p * 2, 1)); let tileP = p > 0.5 ? easeOut((p - 0.5) * 2) : 0; let wallTop = groundY - foundationHeight - wallHeight; let roofPeakX = W / 2; let roofPeakY = wallTop - roofHeight; // Left roof panel let leftSlide = -houseWidth * (1 - slideP); ctx.fillStyle = '#C0392B'; ctx.beginPath(); ctx.moveTo(roofPeakX + leftSlide, roofPeakY); ctx.lineTo(houseX + leftSlide, wallTop); ctx.lineTo(roofPeakX + leftSlide, wallTop); ctx.closePath(); ctx.fill(); // Right roof panel let rightSlide = houseWidth * (1 - slideP); ctx.fillStyle = '#E74C3C'; ctx.beginPath(); ctx.moveTo(roofPeakX + rightSlide, roofPeakY); ctx.lineTo(roofPeakX + rightSlide, wallTop); ctx.lineTo(houseX + houseWidth + rightSlide, wallTop); ctx.closePath(); ctx.fill(); // Roof outline ctx.strokeStyle = '#8B0000'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(roofPeakX + leftSlide, roofPeakY); ctx.lineTo(houseX + leftSlide, wallTop); ctx.stroke(); ctx.beginPath(); ctx.moveTo(roofPeakX + rightSlide, roofPeakY); ctx.lineTo(houseX + houseWidth + rightSlide, wallTop); ctx.stroke(); // Tile texture (row by row) if (tileP > 0 && slideP >= 0.95) { ctx.save(); // Clip to roof shape ctx.beginPath(); ctx.moveTo(roofPeakX, roofPeakY); ctx.lineTo(houseX, wallTop); ctx.lineTo(houseX + houseWidth, wallTop); ctx.closePath(); ctx.clip(); let rows = 8; for (let row = 0; row < rows; row++) { let rowP = tileP * rows - row; if (rowP <= 0) continue; rowP = Math.min(1, rowP); let y1 = roofPeakY + (wallTop - roofPeakY) * row / rows; let y2 = roofPeakY + (wallTop - roofPeakY) * (row + 1) / rows; ctx.strokeStyle = 'rgba(139, 0, 0, 0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(houseX, y2); ctx.lineTo(houseX + houseWidth, y2); ctx.stroke(); // Half-brick offset for each row let tileWidth = 30; let offset = (row % 2) * tileWidth / 2; for (let x = houseX + offset; x < houseX + houseWidth; x += tileWidth) { if (rowP < 1) { let cutX = x + tileWidth * rowP; ctx.beginPath(); ctx.moveTo(cutX, y1); ctx.lineTo(cutX, y2); ctx.stroke(); } else { ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke(); } } } ctx.restore(); // Roof outline again ctx.strokeStyle = '#8B0000'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(roofPeakX, roofPeakY); ctx.lineTo(houseX, wallTop); ctx.lineTo(houseX + houseWidth, wallTop); ctx.lineTo(roofPeakX, roofPeakY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(roofPeakX, roofPeakY); ctx.lineTo(houseX + houseWidth, wallTop); ctx.stroke(); } } // Stage 4: Door if (stage >= 4) { let p = stage === 4 ? easeOut(progress) : 1; let wallTop = groundY - foundationHeight - wallHeight; let doorWidth = houseWidth * 0.18; let doorHeight = wallHeight * 0.55; let doorX = W / 2 - doorWidth / 2; let doorY = groundY - foundationHeight - doorHeight; ctx.globalAlpha = p; // Door ctx.fillStyle = '#8B4513'; ctx.fillRect(doorX, doorY, doorWidth, doorHeight); // Door frame ctx.strokeStyle = '#654321'; ctx.lineWidth = 3; ctx.strokeRect(doorX, doorY, doorWidth, doorHeight); // Door panels ctx.strokeStyle = '#A0522D'; ctx.lineWidth = 1; let panelMargin = doorWidth * 0.15; ctx.strokeRect(doorX + panelMargin, doorY + panelMargin, doorWidth - panelMargin * 2, doorHeight * 0.4); ctx.strokeRect(doorX + panelMargin, doorY + doorHeight * 0.55, doorWidth - panelMargin * 2, doorHeight * 0.4); // Doorknob ctx.fillStyle = '#FFD700'; ctx.beginPath(); ctx.arc(doorX + doorWidth * 0.8, doorY + doorHeight * 0.5, doorWidth * 0.08, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#DAA520'; ctx.lineWidth = 1; ctx.stroke(); ctx.globalAlpha = 1; } // Stage 5: Windows if (stage >= 5) { let p = stage === 5 ? easeOut(progress) : 1; let wallTop = groundY - foundationHeight - wallHeight; let windowWidth = houseWidth * 0.15; let windowHeight = wallHeight * 0.3; // Left window let leftWinX = houseX + houseWidth * 0.12; let winY = wallTop + wallHeight * 0.25; // Right window let rightWinX = houseX + houseWidth * 0.73; ctx.globalAlpha = p; [leftWinX, rightWinX].forEach(wx => { // Window frame ctx.fillStyle = '#DEB887'; ctx.fillRect(wx - 3, winY - 3, windowWidth + 6, windowHeight + 6); // Glass ctx.fillStyle = '#87CEEB'; ctx.fillRect(wx, winY, windowWidth, windowHeight); // Cross frame ctx.strokeStyle = '#F5F5DC'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(wx + windowWidth / 2, winY); ctx.lineTo(wx + windowWidth / 2, winY + windowHeight); ctx.stroke(); ctx.beginPath(); ctx.moveTo(wx, winY + windowHeight / 2); ctx.lineTo(wx + windowWidth, winY + windowHeight / 2); ctx.stroke(); // Outer frame ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 2; ctx.strokeRect(wx, winY, windowWidth, windowHeight); }); ctx.globalAlpha = 1; } // Stage 6: Details if (stage >= 6) { let p = stage === 6 ? progress : 1; let wallTop = groundY - foundationHeight - wallHeight; let roofPeakX = W / 2; let roofPeakY = wallTop - roofHeight; // Chimney let chimneyP = easeOut(Math.min(p * 3, 1)); let chimneyWidth = houseWidth * 0.08; let chimneyHeight = roofHeight * 0.5; let chimneyX = W / 2 + houseWidth * 0.2; let chimneyBaseY = roofPeakY + roofHeight * 0.3; // approximate let chimneyTopY = chimneyBaseY - chimneyHeight * chimneyP; ctx.fillStyle = '#8B4513'; ctx.fillRect(chimneyX - chimneyWidth / 2, chimneyTopY, chimneyWidth, chimneyHeight * chimneyP); ctx.strokeStyle = '#654321'; ctx.lineWidth = 2; ctx.strokeRect(chimneyX - chimneyWidth / 2, chimneyTopY, chimneyWidth, chimneyHeight * chimneyP); // Chimney top if (chimneyP > 0.8) { ctx.fillStyle = '#A0522D'; ctx.fillRect(chimneyX - chimneyWidth * 0.7, chimneyTopY - 5, chimneyWidth * 1.4, 8); } // Smoke if (chimneyP >= 1 && p > 0.33) { let smokeP = (p - 0.33) / 0.67; let time = Date.now() / 1000; for (let i = 0; i < 5; i++) { let age = (smokeP * 3 + i * 0.5) % 2; let sy = chimneyTopY - 10 - age * 60; let sx = chimneyX + Math.sin(time + i) * (5 + age * 10); let radius = 5 + age * 8; let alpha = Math.max(0, 0.5 - age * 0.25); ctx.fillStyle = `rgba(200, 200, 200, ${alpha})`; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); ctx.fill(); } } // Stepping stone path let pathP = easeOut(Math.min((p - 0.1) * 3, 1)); if (pathP > 0) { let doorWidth = houseWidth * 0.18; let doorX = W / 2; let pathStartY = groundY - foundationHeight; let stones = 5; for (let i = 0; i < stones; i++) { let stoneP = pathP * stones - i; if (stoneP <= 0) continue; stoneP = Math.min(1, stoneP); let sy = pathStartY + (i + 1) * (H * 0.25) / stones; let sx = doorX + (i % 2 === 0 ? -5 : 5); let size = 20 + i * 3; ctx.globalAlpha = stoneP; ctx.fillStyle = '#A0A0A0'; ctx.beginPath(); ctx.ellipse(sx, sy, size, size * 0.6, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#808080'; ctx.lineWidth = 1; ctx.stroke(); ctx.globalAlpha = 1; } } // Tree let treeP = easeOut(Math.min((p - 0.2) * 2.5, 1)); if (treeP > 0) { let treeX = houseX - houseWidth * 0.25; let treeBaseY = groundY; // Trunk let trunkHeight = 80 * treeP; ctx.fillStyle = '#8B4513'; ctx.fillRect(treeX - 8, treeBaseY - trunkHeight, 16, trunkHeight); // Foliage if (treeP > 0.3) { let foliageP = (treeP - 0.3) / 0.7; let foliageSize = 40 * foliageP; ctx.fillStyle = '#228B22'; ctx.beginPath(); ctx.arc(treeX, treeBaseY - trunkHeight - foliageSize * 0.5, foliageSize, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#32CD32'; ctx.beginPath(); ctx.arc(treeX - foliageSize * 0.3, treeBaseY - trunkHeight - foliageSize * 0.3, foliageSize * 0.7, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#2E8B57'; ctx.beginPath(); ctx.arc(treeX + foliageSize * 0.3, treeBaseY - trunkHeight - foliageSize * 0.4, foliageSize * 0.6, 0, Math.PI * 2); ctx.fill(); } } } // Subtitle // Find which stage we're in based on the time let currentStage = -1; let elapsed = now % TOTAL_DURATION; let acc = 0; for (let i = 0; i < TOTAL_STAGES; i++) { if (i > 0) { acc += PAUSE_DURATION; if (elapsed < acc) { currentStage = i - 1; break; } } acc += STAGE_DURATION; if (elapsed < acc) { currentStage = i; break; } } if (currentStage === -1) currentStage = TOTAL_STAGES - 1; // Draw subtitle let subText = ['① 打地基', '② 砌墙', '③ 填墙', '④ 盖屋顶', '⑤ 装门', '⑥ 装窗', '⑦ 细节']; ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; let textW = 300; ctx.fillRect(W / 2 - textW / 2, 20, textW, 50); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 28px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(subText[currentStage], W / 2, 45); requestAnimationFrame(draw); } draw(); </script> </body> </html> ``` Wait, I need to reconsider the timing and make sure things look right. Let me revise. Actually, let me reconsider the getStage function. The current one might have issues. Let me simplify: ```javascript function getElapsed(t) { return t % TOTAL_DURATION; } function getStageInfo(elapsed) { let acc = 0; for (let i = 0; i < TOTAL_STAGES; i++) { // Pause before this stage (except first) if (i > 0) { if (elapsed < acc + PAUSE_DURATION) { return { stage: i - 1, progress: 1 }; } acc += PAUSE_DURATION; } // Stage duration if (elapsed < acc + STAGE_DURATION) { let progress = (elapsed - acc) / STAGE_DURATION; return { stage: i, progress: Math.min(1, progress) }; } acc += STAGE_DURATION; } return { stage: TOTAL_STAGES - 1, progress: 1 }; } ``` Hmm, actually this is getting complicated. Let me restructure. The timeline is: - Stage 0: 0 to STAGE_DURATION - Pause: STAGE_DURATION to STAGE_DURATION + PAUSE - Stage 1: STAGE_DURATION + PAUSE to 2*STAGE_DURATION + PAUSE - Pause: ... - ... - Stage 6: 6*(STAGE_DURATION + PAUSE) to 7*STAGE_DURATION + 6*PAUSE Total = 7 * STAGE_DURATION + 6 * PAUSE_DURATION Let me rewrite: ```javascript const STAGE_MS = 3000; const PAUSE_MS = 500; const TOTAL = 7 * STAGE_MS + 6 * PAUSE_MS; function getStageInfo(elapsed) { elapsed = elapsed % TOTAL; for (let i = 0; i < 7; i++) { let stageStart = i * (STAGE_MS + PAUSE_MS); if (elapsed < stageStart + STAGE_MS) { let progress = (elapsed - stageStart) / STAGE_MS; return { stage: i, progress: Math.max(0, Math.min(1, progress)) }; } // In pause after stage i if (i < 6 && elapsed < stageStart + STAGE_MS + PAUSE_MS) { return { stage: i, progress: 1 }; } } return { stage: 6, progress: 1 }; } ``` Wait, this still has an issue. After stage 6, there's no pause before looping back. Let me just keep it simple: ```javascript function getStageInfo(elapsed) { elapsed = elapsed % TOTAL; let acc = 0; for (let i = 0; i < 7; i++) { if (elapsed < acc + STAGE_MS) { let progress = (elapsed - acc) / STAGE_MS; return { stage: i, progress: Math.min(1, Math.max(0, progress)) }; } acc += STAGE_MS; if (i < 6 && elapsed < acc + PAUSE_MS) { return { stage: i, progress: 1 }; } acc += PAUSE_MS; } return { stage: 6, progress: 1 }; } ``` OK this should work. Now let me also think about the drawing logic more carefully. I need to make sure each stage's drawing is correct and all previous stages are maintained. Let me structure it so that I draw stages 0 through `stage` (current), with each at its appropriate state. Actually, let me reconsider the drawing approach. Instead of conditionally drawing, let me draw everything in order and check if each stage should be visible: For stage 0 (foundation): - If current stage > 0 or (current stage == 0), draw foundation at appropriate progress For stage 1 (wall frame): - If current stage > 1 or (current stage == 1), draw walls at appropriate progress etc. Let me rewrite more carefully now. Let me also think about the house positioning and make it responsive. Let me also think about what each drawing stage needs: Foundation (stage 0): - Gray rectangle rising from ground level - The rectangle starts at groundY (partially underground, i.e., we show it rising up) Actually, "从地下升起" means it rises from underground. So initially it's below ground and moves up. But we need to draw it as a solid rectangle. I think the animation should show it sliding up into view, with the ground masking the bottom part. Actually, for simplicity, let's just show a gray rectangle that grows in height from the ground upward. Or better, we can clip the foundation to the area above the ground line initially, and as it rises, more of it becomes visible. Hmm, actually the simplest interpretation is: a gray rectangle starts with zero height at the ground line and grows upward. Let me go with that. Wait, "从地下升起" literally means "rises from underground". So it should appear to come up from below the ground. Let me implement it as: the gray rectangle starts fully below the ground (with only its top edge at ground level), and rises up to its final position above ground. To do this, I can animate the Y position: starts at groundY (bottom of foundation at groundY, so it's entirely underground or at ground level), and rises to groundY - foundationHeight (bottom of foundation is at groundY, top is at groundY - foundationHeight). Actually, let me just make the foundation grow from the ground upward. That looks more like a construction animation anyway. Let me finalize the code. I'll also make it cleaner and more robust. Let me think about the house more carefully: The house consists of: - Foundation: gray slab, sits at ground level - Walls: rise from foundation top - Filling: fills the wall frame - Roof: triangle on top of walls - Door and windows: on the front face - Details: chimney, path, tree House positioning (centered): - houseCenterX = W/2 - groundY = H * 0.75 (ground line) House dimensions (scaled): - foundationWidth = min(400, W * 0.35) → let's say 380 - foundationHeight = 25 - wallWidth = foundationWidth (same as foundation) - wallHeight = 200 - roofHeight = 140 - doorWidth = 60 - doorHeight = 120 - windowWidth = 50 - windowHeight = 50 Let me also draw the foundation as part of stage 0, and the gray slab should appear from below. OK let me just write the whole thing cleanly. I'll be more careful about the drawing. Let me rewrite this from scratch more carefully: ```html <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>房屋建造动画</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #000; } canvas { display: block; width: 100%; height: 100%; } </style> </head> <body> <canvas id="c"></canvas> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resize); resize(); // Timing constants const STAGE_MS = 3000; const PAUSE_MS = 500; const STAGES = 7; const TOTAL_MS = STAGES * STAGE_MS + (STAGES - 1) * PAUSE_MS; const stageLabels = ['① 打地基', '② 砌墙', '③ 填墙', '④ 盖屋顶', '⑤ 装门', '⑥ 装窗', '⑦ 加细节']; function getStage(elapsed) { elapsed = ((elapsed % TOTAL_MS) + TOTAL_MS) % TOTAL_MS; let acc = 0; for (let i = 0; i < STAGES; i++) { if (elapsed < acc + STAGE_MS) { return { idx: i, t: (elapsed - acc) / STAGE_MS }; } acc += STAGE_MS; if (i < STAGES - 1) { if (elapsed < acc + PAUSE_MS) { return { idx: i, t: 1 }; } acc += PAUSE_MS; } } return { idx: STAGES - 1, t: 1 }; } function easeOut(t) { return 1 - Math.pow(1 - t, 3); } function easeInOut(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; } const t0 = performance.now(); function render(now) { requestAnimationFrame(render); const elapsed = now - t0; const { idx, t } = getStage(elapsed); const W = canvas.width; const H = canvas.height; // Draw sky let skyGrad = ctx.createLinearGradient(0, 0, 0, H * 0.75); skyGrad.addColorStop(0, '#87CEEB'); skyGrad.addColorStop(1, '#E0F0FF'); ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, W, H); // Ground const groundY = Math.floor(H * 0.75); ctx.fillStyle = '#5CB85C'; ctx.fillRect(0, groundY, W, H - groundY); ctx.fillStyle = '#4A9A4A'; ctx.fillRect(0, groundY, W, 3); // House layout const hw = Math.min(400, W * 0.35); // house width const hx = W / 2 - hw / 2; // house left x const fh = 25; // foundation height const wh = Math.min(200, H * 0.25); // wall height const rh = Math.min(140, H * 0.18); // roof height const foundationTop = groundY - fh; const wallTop = groundY - fh - wh; const roofPeakY = wallTop - rh; // Helper: clamp const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); // =========== Stage 0: Foundation =========== if (idx >= 0) { const p = idx === 0 ? easeOut(t) : 1; // Gray slab rising from below ground const riseAmount = fh * p; const slabY = groundY - riseAmount; ctx.fillStyle = '#9E9E9E'; ctx.fillRect(hx, slabY, hw, riseAmount); // Top face highlight ctx.fillStyle = '#B0B0B0'; ctx.fillRect(hx, slabY, hw, 4); // Slight border ctx.strokeStyle = '#757575'; ctx.lineWidth = 1; ctx.strokeRect(hx, slabY, hw, riseAmount); // Foundation texture (horizontal lines) if (p > 0.5) { ctx.strokeStyle = 'rgba(117,117,117,0.3)'; ctx.lineWidth = 1; for (let i = 1; i < 3; i++) { let ly = slabY + riseAmount * i / 3; ctx.beginPath(); ctx.moveTo(hx + 2, ly); ctx.lineTo(hx + hw - 2, ly); ctx.stroke(); } } } // =========== Stage 1: Wall Frame =========== if (idx >= 1) { const p = idx === 1 ? easeOut(t) : 1; // Walls grow upward from foundation top const currentWh = wh * p; const currentWallTop = foundationTop - currentWh; ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 4; ctx.lineCap = 'round'; // Left vertical ctx.beginPath(); ctx.moveTo(hx, foundationTop); ctx.lineTo(hx, currentWallTop); ctx.stroke(); // Right vertical ctx.beginPath(); ctx.moveTo(hx + hw, foundationTop); ctx.lineTo(hx + hw, currentWallTop); ctx.stroke(); // Top horizontal (after walls are mostly up) if (p > 0.7) { let bp = (p - 0.7) / 0.3; ctx.beginPath(); ctx.moveTo(hx, currentWallTop); ctx.lineTo(hx + hw * bp, currentWallTop); ctx.stroke(); } // Bottom horizontal ctx.beginPath(); ctx.moveTo(hx, foundationTop); ctx.lineTo(hx + hw, foundationTop); ctx.stroke(); } // =========== Stage 2: Wall Fill =========== if (idx >= 2) { const p = idx === 2 ? easeOut(t) : 1; // Fill from left to right const fillW = (hw - 4) * p; ctx.fillStyle = '#F5DEB3'; ctx.fillRect(hx + 2, wallTop + 2, fillW, wh - 4); // Horizontal line texture if (p > 0.2) { ctx.strokeStyle = 'rgba(210,180,140,0.4)'; ctx.lineWidth = 1; for (let i = 1; i <= 6; i++) { let lineP = clamp((p - 0.2) * 1.5 - i * 0.1, 0, 1); if (lineP <= 0) continue; let y = wallTop + 2 + (wh - 4) * i / 7; ctx.beginPath(); ctx.moveTo(hx + 4, y); ctx.lineTo(hx + 2 + fillW * lineP, y); ctx.stroke(); } } // Redraw wall frame on top ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.strokeRect(hx, wallTop, hw, wh); } // =========== Stage 3: Roof =========== if (idx >= 3) { const p = idx === 3 ? t : 1; const slideP = idx === 3 ? easeOut(clamp(t * 1.5, 0, 1)) : 1; const tileP = idx === 3 ? clamp((t - 0.5) * 2, 0, 1) : 1; const midX = W / 2; // Left roof panel slides in from left const leftOffset = (1 - slideP) * (-hw * 0.5); // Right roof panel slides in from right const rightOffset = (1 - slideP) * (hw * 0.5); // Left panel ctx.fillStyle = '#C0392B'; ctx.beginPath(); ctx.moveTo(midX + leftOffset, roofPeakY); ctx.lineTo(hx + leftOffset, wallTop); ctx.lineTo(midX + leftOffset, wallTop); ctx.closePath(); ctx.fill(); // Right panel ctx.fillStyle = '#E74C3C'; ctx.beginPath(); ctx.moveTo(midX + rightOffset, roofPeakY); ctx.lineTo(midX + rightOffset, wallTop); ctx.lineTo(hx + hw + rightOffset, wallTop); ctx.closePath(); ctx.fill(); // Roof outline ctx.strokeStyle = '#922B21'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(midX, roofPeakY); ctx.lineTo(hx, wallTop); ctx.lineTo(hx + hw, wallTop); ctx.closePath(); ctx.stroke(); // Tile texture row by row if (tileP > 0 && slideP > 0.9) { ctx.save(); ctx.beginPath(); ctx.moveTo(midX, roofPeakY); ctx.lineTo(hx, wallTop); ctx.lineTo(hx + hw, wallTop); ctx.closePath(); ctx.clip(); let rows = 10; for (let r = 0; r < rows; r++) { let rp = tileP * (rows + 1) - r; if (rp <= 0) continue; rp = Math.min(1, rp); let y1 = roofPeakY + (wallTop - roofPeakY) * r / rows; let y2 = roofPeakY + (wallTop - roofPeakY) * (r + 1) / rows; // Horizontal line ctx.strokeStyle = 'rgba(120, 0, 0, 0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(hx, y2); ctx.lineTo(hx + hw, y2); ctx.stroke(); // Vertical tile lines (offset for each row) let tw = 25; let offset = (r % 2) * tw * 0.5; for (let x = hx + offset; x <= hx + hw; x += tw) { let xp = clamp((rp - 0.3) / 0.7, 0, 1); if (xp > 0) { let cutX = Math.min(x + tw * xp, hx + hw); ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke(); } } } ctx.restore(); // Re-draw outline ctx.strokeStyle = '#922B21'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(midX, roofPeakY); ctx.lineTo(hx, wallTop); ctx.stroke(); ctx.beginPath(); ctx.moveTo(midX, roofPeakY); ctx.lineTo(hx + hw, wallTop); ctx.stroke(); } } // =========== Stage 4: Door =========== if (idx >= 4) { const p = idx === 4 ? easeOut(t) : 1; const doorW = hw * 0.15; const doorH = wh * 0.55; const doorX = W / 2 - doorW / 2; const doorY = foundationTop - doorH; ctx.globalAlpha = p; // Door shadow ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.fillRect(doorX + 4, doorY + 4, doorW, doorH); // Door body ctx.fillStyle = '#8B4513'; ctx.fillRect(doorX, doorY, doorW, doorH); // Door frame ctx.strokeStyle = '#654321'; ctx.lineWidth = 3; ctx.strokeRect(doorX, doorY, doorW, doorH); // Door panels ctx.strokeStyle = '#A0622D'; ctx.lineWidth = 1.5; let pm = doorW * 0.12; // Upper panel ctx.strokeRect(doorX + pm, doorY + pm, doorW - pm * 2, doorH * 0.38); // Lower panel ctx.strokeRect(doorX + pm, doorY + doorH * 0.52, doorW - pm * 2, doorH * 0.4); // Doorknob ctx.fillStyle = '#FFD700'; ctx.beginPath(); ctx.arc(doorX + doorW * 0.82, doorY + doorH * 0.5, 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#DAA520'; ctx.lineWidth = 1; ctx.stroke(); ctx.globalAlpha = 1; } // =========== Stage 5: Windows =========== if (idx >= 5) { const p = idx === 5 ? easeOut(t) : 1; const winW = hw * 0.13; const winH = wh * 0.3; const winY = wallTop + wh * 0.2; // Positions: left and right of door const doorCX = W / 2; const leftWinX = doorCX - hw * 0.28; const rightWinX = doorCX + hw * 0.28 - winW; ctx.globalAlpha = p; [leftWinX, rightWinX].forEach(wx => { // Window outer frame (slightly larger) ctx.fillStyle = '#DEB887'; ctx.fillRect(wx - 4, winY - 4, winW + 8, winH + 8); // Glass let glassGrad = ctx.createLinearGradient(wx, winY, wx, winY + winH); glassGrad.addColorStop(0, '#B0D4F1'); glassGrad.addColorStop(1, '#87CEEB'); ctx.fillStyle = glassGrad; ctx.fillRect(wx, winY, winW, winH); // Cross frame ctx.strokeStyle = '#F5F5DC'; ctx.lineWidth = 3; // Vertical ctx.beginPath(); ctx.moveTo(wx + winW / 2, winY); ctx.lineTo(wx + winW / 2, winY + winH); ctx.stroke(); // Horizontal ctx.beginPath(); ctx.moveTo(wx, winY + winH / 2); ctx.lineTo(wx + winW, winY + winH / 2); ctx.stroke(); // Outer frame ctx.strokeStyle = '#8B6914'; ctx.lineWidth = 2.5; ctx.strokeRect(wx, winY, winW, winH); // Glass reflection ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillRect(wx + 3, winY + 3, winW * 0.3, winH * 0.3); }); ctx.globalAlpha = 1; } // =========== Stage 6: Details =========== if (idx >= 6) { const p = t; const midX = W / 2; // --- Chimney --- const chimneyP = easeOut(clamp(p * 3, 0, 1)); const chimneyW = 30; const chimneyH = 60; const chimneyX = midX + hw * 0.2; // Calculate where the chimney base is on the roof // Roof left edge: (hx, wallTop), roof peak: (midX, roofPeakY) // At chimneyX, the roof line height (left side) is: let roofYAtChimney = wallTop - rh * (chimneyX - hx) / (midX - hx); // But chimneyX might be on the right side... if (chimneyX > midX) { roofYAtChimney = wallTop - rh * (hx + hw - chimneyX) / (midX - hx + hw - hw); // hmm // Actually on right side: from (midX, roofPeakY) to (hx+hw, wallTop) roofYAtChimney = roofPeakY + (wallTop - roofPeakY) * (chimneyX - midX) / (midX + hw/2 - midX); // Simplify: roof line from peak to right: goes from (midX, roofPeakY) to (hx+hw, wallTop) // At x=chimneyX: y = roofPeakY + (wallTop - roofPeakY) * (chimneyX - midX) / (hx + hw - midX) roofYAtChimney = roofPeakY + (wallTop - roofPeakY) * (chimneyX - midX) / (hw / 2); } let chBaseY = roofYAtChimney; let chTopY = chBaseY - chimneyH * chimneyP; ctx.fillStyle = '#A0522D'; ctx.fillRect(chimneyX - chimneyW / 2, chTopY, chimneyW, chimneyH * chimneyP + 5); ctx.strokeStyle = '#654321'; ctx.lineWidth = 2; ctx.strokeRect(chimneyX - chimneyW / 2, chTopY, chimneyW, chimneyH * chimneyP + 5); // Chimney cap if (chimneyP > 0.9) { ctx.fillStyle = '#8B4513'; ctx.fillRect(chimneyX - chimneyW * 0.7, chTopY - 4, chimneyW * 1.4, 8); } // Smoke if (chimneyP >= 1) { let smokeStart = 0.4; if (p > smokeStart) { let smokeP = (p - smokeStart) / (1 - smokeStart); let time = now / 1000; for (let i = 0; i < 6; i++) { let age = (smokeP * 4 + i * 0.7) % 3; let sy = chTopY - 5 - age * 25; let sx = chimneyX + Math.sin(time * 1.5 + i * 1.3) * (3 + age * 6); let radius = 4 + age * 5; let alpha = Math.max(0, 0.4 - age * 0.13); ctx.fillStyle = `rgba(210,210,210,${alpha})`; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); ctx.fill(); } } } // --- Stepping stones path --- const pathP = easeOut(clamp((p - 0.15) * 2.5, 0, 1)); if (pathP > 0) { let stones = 5; let pathLength = H * 0.22; for (let i = 0; i < stones; i++) { let sp = clamp(pathP * stones - i, 0, 1); if (sp <= 0) continue; let sy = foundationTop + 10 + (i + 0.5) * pathLength / stones; let sx = midX + (i % 2 === 0 ? -8 : 8); let rw = 22 + i * 2; let rh2 = 12 + i; ctx.globalAlpha = sp; ctx.fillStyle = '#B0B0B0'; ctx.beginPath(); ctx.ellipse(sx, sy, rw, rh2, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#909090'; ctx.lineWidth = 1; ctx.stroke(); ctx.globalAlpha = 1; } } // --- Tree --- const treeP = easeOut(clamp((p - 0.25) * 2.5, 0, 1)); if (treeP > 0) { let treeX = hx - hw * 0.2; let treeBase = groundY; // Trunk let trunkH = 70 * treeP; ctx.fillStyle = '#8B5A2B'; ctx.fillRect(treeX - 7, treeBase - trunkH, 14, trunkH); // Bark texture ctx.strokeStyle = '#6B3A1B'; ctx.lineWidth = 1; for (let i = 0; i < 3; i++) { let by = treeBase - trunkH + trunkH * (i + 1) / 4; ctx.beginPath(); ctx.moveTo(treeX - 5, by); ctx.lineTo(treeX + 5, by + 3); ctx.stroke(); } // Foliage (circles) if (treeP > 0.3) { let fp = clamp((treeP - 0.3) / 0.7, 0, 1); let maxR = 35; let r = maxR * fp; let topY = treeBase - trunkH - r * 0.3; ctx.fillStyle = '#228B22'; ctx.beginPath(); ctx.arc(treeX, topY, r, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#2E8B57'; ctx.beginPath(); ctx.arc(treeX - r * 0.35, topY + r * 0.15, r * 0.75, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#32CD32'; ctx.beginPath(); ctx.arc(treeX + r * 0.3, topY - r * 0.1, r * 0.65, 0, Math.PI * 2); ctx.fill(); // Some light spots ctx.fillStyle = 'rgba(144,238,144,0.4)'; ctx.beginPath(); ctx.arc(treeX - r * 0.2, topY - r * 0.25, r * 0.2, 0, Math.PI * 2); ctx.fill(); } } } // =========== Subtitle =========== ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'; let labelW = 240; let labelH = 46; let labelX = W / 2 - labelW / 2; let labelY = 20; // Rounded rect ctx.beginPath(); ctx.roundRect(labelX, labelY, labelW, labelH, 10); ctx.fill(); ctx.fillStyle = '#FFFFFF'; ctx.font = `bold ${Math.min(24, W * 0.025)}px "Microsoft YaHei", "PingFang SC", sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(stageLabels[idx], W / 2, labelY + labelH / 2); } requestAnimationFrame(render); </script> </body> </html> ``` Hmm, wait, `roundRect` might not be supported in all browsers. Let me use a manual rounded rect or just a regular rect. Also, I realize I need to double-check a few things: 1. The foundation animation: "从地下升起" - rises from underground. I'm making it grow upward from the ground level, which is close enough. 2. The wall frame stage: I draw the wall frame, then in stage 2 I fill between them. Good. 3. The roof: I need two triangular panels sliding in from the sides. Let me make sure the geometry is correct. Actually, let me reconsider the roof. The roof consists of two triangular panels that slide in from above (or from the sides) and meet at the ridge. For a simple 2D front view, the roof would be two right triangles: - Left panel: peak at (midX, roofPeakY), base at (hx, wallTop) to (midX, wallTop) - Right panel: peak at (midX, roofPeakY), base at (midX, wallTop) to (hx+hw, wallTop) They should slide in from above and meet at the center. So the left one slides from the left, and the right one slides from the right. Actually, rethinking - in 2D front view, "两块三角形屋顶板从上方滑入并在屋脊相接" means two triangular roof panels slide in from above and meet at the ridge. If viewed from the front, the roof is typically a trapezoid or triangle. For a simple gable roof viewed from the front, you'd see a triangle (the gable end). But for a side view, you'd see the two panels. Hmm, let me think about this differently. Maybe the user wants a front view where: - The house front is the wall with door and windows - The roof is a triangle above In that case, the roof is a single triangle, not two panels. But the user says "两块三角形屋顶板" (two triangular roof panels). Maybe it's a side/perspective view where you can see both roof panels. Or maybe it's a simplified view where the roof is shown as two triangular halves that come together. I think for a simple 2D cartoon, let me show the roof as two triangular halves: left half and right half, each sliding in from the top to form the peak. This is what I was doing. Let me refine the sliding animation: instead of sliding from left/right, they should slide down from above: - Left panel starts above and to the left, slides down-right to its final position - Right panel starts above and to the right, slides down-left to its final position Actually, I think sliding from the sides (left and right) makes more visual sense for "meeting at the ridge". Let me keep it as is. Let me also make sure the tile texture appears properly. The tiles should appear row by row from the top (ridge) downward. OK let me also reconsider the overall house proportions and make it look good. Actually, I think I should simplify some things and make the code cleaner. Let me write the final version now. One more thing - I need to handle the case where `roundRect` is not available. Let me just use `fillRect` for the subtitle background. Here's my final plan: Total duration: 7 * 3000 + 6 * 500 = 21000 + 3000 = 24000ms ≈ 24 seconds. Close enough to 25. Let me write the final code now, clean and complete: Actually, I realize there's a subtle issue with my getStage function. When elapsed reaches TOTAL_DURATION, it should loop back to 0. The modulo handles this. Let me also make sure that when a stage is complete (progress = 1), the drawing looks correct. Let me also think about what happens with the wall frame and fill. In stage 1, I draw the frame. In stage 2, I fill between the frame lines. The fill should appear between the frame lines, and the frame should be redrawn on top. Actually wait - in my code, for stage 2, I'm drawing the fill AND redrawing the wall frame on top. That should work. But in stage 1, I only draw the frame. In stage 2, I draw the fill first, then the complete frame on top. That's correct. For stage 3 (roof), the roof should be drawn on top of the wall fill. That's handled by drawing order. Let me also make the colors more cartoon-like and vibrant. OK, final code: Actually, I want to make sure the code works well. Let me trace through it mentally: 1. Stage 0 (foundation): gray rectangle grows upward from ground. ✓ 2. Stage 1 (wall frame): wall frame lines extend upward. ✓ 3. Stage 2 (wall fill): beige fill appears between frame lines. ✓ 4. Stage 3 (roof): two triangular panels slide in, tiles appear. ✓ 5. Stage 4 (door): brown door fades in with gold knob. ✓ 6. Stage 5 (windows): two windows with cross frames and blue glass. ✓ 7. Stage 6 (details): chimney rises with smoke, path appears, tree grows. ✓ Looks good. Let me write the final version with all the details polished. One more consideration: the window positions should account for the door being in the center. Left window is to the left of the door, right window is to the right. Let m
⚠ 输出中断:未收到正常结束信号(多为生成耗时过长被服务端超时切断,或网络中断),下方内容可能不完整。点「重跑」重试。
这次运行已结束,但厂商没有返回最终正文流,只返回了 reasoning / 中间推理 token。上方「思考过程」已自动展开,避免结果区显示为空。
1.79s
首Token
102
思考TPS
298s · ≈30,527 tok
输出TPS
30,527
总Tokens(估算)
总用时 301s · 平均 102 tok/s · 峰值 167 tok/s · 成本 ¥0.061
智谱 GLM-5.2
glm-5.2
失败
用户想要一个单文件 HTML,用纯 Canvas 绘制 2D 卡通房屋建造动画。让我仔细规划: 7 个阶段,约 25 秒,阶段间停顿 0.5 秒: 1. 地基 - 灰色矩形板从地下升起 2. 墙体 - 四段竖直墙从地基四角向上延伸 3. 填墙 - 米黄色/浅褐色外墙板填充 4. 屋顶 - 两块三角形屋顶板从上方滑入,红色瓦片纹理逐行出现 5. 门 - 棕色门淡入,金色门把手 6. 窗 - 门两侧各一扇窗,十字窗框,蓝色玻璃 7. 细节 - 烟囱、烟雾、踏脚石小径、小树 每个阶段顶部有字幕。循环播放。 让我设计时间分配: - 总时长 25 秒 - 7 个阶段 + 6 个 0.5 秒停顿 = 3 秒停顿 - 实际动画时间 22 秒,每阶段约 3.14 秒 不过 2D 房屋是等距视图还是正视图?用户说"四段竖直墙从地基四角向上延伸",这暗示是等距/轴测视图。让我做一个简单的等距视图房屋。 实际上"四段竖直墙"在 2D 中可以做等距视图,前后左右四面墙。让我设计一个简单等距房屋: - 地基是一个平行四边形(等距视角) - 四面墙:前面、右面、后面(被遮挡或部分)、左面(部分) - 实际上从等距视角看,主要看到两面墙:前面和右面 但用户说"四段竖直墙",可能是指墙的四个边角立柱?我理解为四个角的柱子,然后填充墙体。 让我重新设计:用等距/俯视斜视图: - 地基:等距平行四边形 - 四根立柱:地基四个角向上延伸的竖线 - 填墙:在立柱之间填充墙面(前面、右面、可能左面和后面被遮挡) - 屋顶:两个三角形板(双坡屋顶) - 门:在前墙 - 窗:门两侧 - 烟囱、树、小径 我用等距视图来做,更有 2D 卡通感觉。 让我开始编码: ```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>建造一栋小屋</title> <style> html, body { margin: 0; padding: 0; overflow: hidden; background: #87CEEB; height: 100%; } canvas { display: block; width: 100vw; height: 100vh; } .caption { position: fixed; top: 30px; left: 50%; transform: translateX(-50%); font-family: ...; ... } </style> </head> <body> <canvas id="c"></canvas> <div class="caption" id="cap"></div> <script> // 代码 </script> </body> </html> ``` 让我详细设计动画时间线: 阶段时间分配(总 25 秒): - 阶段 1 地基:0-3.5s(动画 3s + 停顿 0.5s) - 阶段 2 墙体:3.5-7s - 阶段 3 填墙:7-10.5s - 阶段 4 屋顶:10.5-15s(4.5s,因为瓦片要逐行) - 阶段 5 门:15-18s - 阶段 6 窗:18-21.5s - 阶段 7 细节:21.5-25s - 然后循环 每个阶段开始时显示字幕,结束时保持显示直到下一阶段。 让我用时间阶段变量: ```js const phases = [ { name: '打地基', start: 0, dur: 3.0 }, { name: '砌墙', start: 3.5, dur: 3.0 }, { name: '填墙', start: 7.0, dur: 3.0 }, { name: '盖屋顶', start: 10.5, dur: 4.0 }, { name: '装门', start: 15.0, dur: 2.5 }, { name: '安窗', start: 18.0, dur: 2.5 }, { name: '添细节', start: 21.0, dur: 3.5 }, ]; const totalDur = 25.0; // + 0.5 间隔后 ``` 实际我需要重新计算: - Phase 1: 0-3, gap 3-3.5 - Phase 2: 3.5-6.5, gap 6.5-7 - Phase 3: 7-10, gap 10-10.5 - Phase 4: 10.5-14.5, gap 14.5-15 - Phase 5: 15-17.5, gap 17.5-18 - Phase 6: 18-20.5, gap 20.5-21 - Phase 7: 21-24.5, gap 24.5-25 - Loop at 25 好,这个时间安排合理。 现在设计等距视图的几何: 中心点 设在屏幕中下方 地基: - 等距平行四边形 - 宽 W = 300, 深 D = 200 - 等距投影:x' = x + z*cos(30°), y' = y - z*sin(30°) 让我简化,使用斜投影:x 轴水平,y 轴垂直,z 轴倾斜(30度角向右下) 房屋底面四个角(俯视): - 前左:(cx - W/2, cy) - 前右:(cx + W/2, cy) - 后右:(cx + W/2 + D*cos(30°), cy - D*sin(30°)) - 后左:(cx - W/2 + D*cos(30°), cy - D*sin(30°)) 但这样房子看起来是面向左下,让门开在正面(前左到前右之间)。 我需要重新思考视角。让我让房子的"前面"朝向观众右下,"右面"朝向左下。 实际上更常见的卡通等距房屋: - 前面朝右下(你能看到前墙和右墙) - 屋顶是两个三角形坡面 让我设置坐标系: - 中心 - 前左角 (FL): (cx - W/2, cy) - 前右角 (FR): (cx + W/2, cy) - 后右角 (BR): (cx + W/2 - D*0.5, cy - D*0.866) // D 在左后方 - 后左角 (BL): (cx - W/2 - D*0.5, cy - D*0.866) 这样 z 轴向左上方延伸。然后: - 前墙:FL 到 FR - 右墙:FR 到 BR - 后墙:BR 到 BL(部分被屋顶遮挡) - 左墙:BL 到 FL(部分被遮挡) 可见两面墙:前墙和右墙。 实际上为了让动画清晰,我让前墙正对观众,z 轴向后上方延伸。这样: - FL: (cx - W/2, cy + D*0.3) - FR: (cx + W/2, cy + D*0.3) - BR: (cx + W/2 - D*0.4, cy - D*0.3) - BL: (cx - W/2 - D*0.4, cy - D*0.3) 不行,让我做真正的等距视图。这是 2D 卡通,可以简化为: - 前墙:梯形(等距视角下前墙变成略微倾斜的形状,但简化为正面对观众) - 侧面:用平行四边形显示侧面(厚度感) 最简单方式:正面视角的房子,但屋顶是 3D 的(用侧面三角形展示厚度)。 让我换思路:用"前视图为主,屋顶和侧面有厚度"的卡通风格。 房屋前视图: - 主体是一个矩形(前墙):宽 360, 高 220 - 屋顶是三角形 + 一个侧面的平行四边形显示厚度 - 这样看起来有立体感但简单 具体坐标(中心 cx, cy 为房屋前墙中心): - 前墙四角: - 左下 BL: (cx - 180, cy + 110) - 右下 BR: (cx + 180, cy + 110) - 右上 TR: (cx + 180, cy - 110) - 左上 TL: (cx - 180, cy - 110) - 屋顶顶点(在墙上方): - 屋脊前左: (cx - 180, cy - 110) - 屋脊前右: (cx + 180, cy - 110) - 顶点 Apex: (cx, cy - 230) 侧面墙(右侧深度): - 偏移 dx = 60, dy = -30(向右上) - 前墙右下到侧面右下:(cx + 180, cy + 110) → (cx + 240, cy + 80) - 侧面右下到侧面右上:(cx + 240, cy + 80) → (cx + 240, cy - 140) - 侧面右上到屋脊右:(cx + 240, cy - 140) → (cx + 180, cy - 110) (回到前墙右上) 屋顶侧面: - 前屋顶三角形:TL, TR, Apex - 屋脊后顶点:Apex + (60, -30) = (cx + 60, cy - 260) - 侧屋顶:(cx + 180, cy - 110), (cx + 240, cy - 140), (cx + 60, cy - 260), (cx, cy - 230) 太复杂了。让我用更简单的画法:纯正面 2D 视图,房屋就是矩形墙 + 三角形屋顶。 正面视图房屋: - 墙体:矩形 (cx-180, cy-100) 到 (cx+180, cy+120) - 屋顶:三角形 (cx-200, cy-100), (cx+200, cy-100), (cx, cy-240) - 门:在墙体中央偏下,矩形 (cx-30, cy+20) 到 (cx+30, cy+120) - 窗户:门两侧,左窗 (cx-150, cy-40) 到 (cx-80, cy+30),右窗 (cx+80, cy-40) 到 (cx+150, cy+30) - 烟囱:屋顶左侧 - 树:房屋右侧 - 小径:从门前到画面下方 但用户说"四段竖直墙从地基四角向上延伸"和"墙框之间填入",这意味着墙是 2D 矩形框架,先有四个角的柱子,再填充。 让我重新理解:"四段竖直墙" 可能指墙的四个边框线(在 2D 中就是一个矩形的四条边,但说"四段竖直"意味着四根垂直的柱子)。 或者可能就是矩形的四条边?或者地基四个角向上长出立柱? 我理解为:地基四个角向上各长出一根立柱(垂直柱),然后填充墙面。但 2D 正视图中我们只能看到两个角的柱子(左右)。 或者另一种理解:墙体本身是四段(前面、后面、左面、右面),每段从地基向上长。但 2D 视图只能看到前墙。 我决定用等距视图,让用户能看到四面墙的"四个角"。在等距视图中,"四段竖直墙从地基四角向上延伸" 可以理解为:从地基的四个角(在 2D 等距投影中可见的四个角)向上长出四根立柱。 让我用 2.5D 等距视图: 地基四个角(等距投影): - 前角 F: (cx, cy + 60) - 右角 R: (cx + 180, cy + 0) - 后角 B: (cx, cy - 60) - 左角 L: (cx - 180, cy + 0) 这是一个菱形地基。然后四个立柱从这四个角向上长出相同高度(墙高 = 120)。 立柱顶端: - 前顶 FT: (cx, cy + 60 - 120) = (cx, cy - 60) - 右顶 RT: (cx + 180, cy + 0 - 120) = (cx + 180, cy - 120) - 后顶 BT: (cx, cy - 60 - 120) = (cx, cy - 180) - 左顶 LT: (cx - 180, cy + 0 - 120) = (cx - 180, cy - 120) 可见的墙面: - 前右墙(F-R 边):从 F, R 向上的矩形 → 可见 - 前左墙(F-L 边):从 F, L 向上的矩形 → 可见 - 后右墙、后左墙:被遮挡 实际等距视图中,我们能看两面墙:右后墙(R-B)和左后墙(L-B)。但卡通风格中,常常画前两墙(F-R 和 F-L),让观众看到房屋的"前面"两面墙。 让我调整:让房屋面向观众,菱形地基指向观众。 - 前角 F 朝下(前方) - 后角 B 朝上(后方) - 左角 L、右角 R 在两侧 可见墙面是 F-L 和 F-R 两面(前左墙、前右墙)。 OK 这就是经典等距视图。让我用这个设计。 立柱动画:从四个角向上长出 4 根立柱(每根是一个细长的矩形或粗线)。 填墙:在 F-L 之间、L-B 之间、B-R 之间、R-F 之间各填一面墙(但只画可见的两面:F-L 和 F-R)。 屋顶:等距屋顶是两个坡面,呈菱形或三角形。 - 屋脊:从 B 上方到 F 上方(或从左到右) - 实际上是双坡屋顶,脊线沿左右方向 让我设计屋顶: - 屋脊高度:墙顶上方 100 - 屋脊前后位置:在房屋中心 - 屋脊从 L 上方延伸到 R 上方 - L 处屋脊点: (cx - 180, cy + 0 - 120 - 100) = (cx - 180, cy - 220) - R 处屋脊点: (cx + 180, cy + 0 - 120 - 100) = (cx + 180, cy - 220) 但这样脊线是水平的。等距视图中,L 和 R 在屏幕上 y 都是 0(相对中心),所以脊线左右两端 y 相同。但是 L 的世界坐标 y=0, R 的世界坐标 y=0(深度),它们在屏幕上 y 都是 0。OK 脊线水平。 屋顶面: - 前坡:从脊线到 F 顶(FT 和 RT/LT 连接) - 前左坡:LT, FT, 脊线左点 (cx-180, cy-220) → 这是三角形 - 前右坡:RT, FT, 脊线右点 (cx+180, cy-220) → 三角形 - 后坡:从脊线到 B 顶(被遮挡) 实际上整个前屋顶是一个大三角形:(cx-180, cy-120), (cx+180, cy-120), (cx, cy-220)?不对,因为 LT 和 RT 高度是 cy-120,FT 高度是 cy-60。 让我重新考虑。在等距视图中: - 墙顶四个角: - FT: (cx, cy - 60) - RT: (cx + 180, cy - 120) - BT: (cx, cy - 180) - LT: (cx - 180, cy - 120) - 屋脊:脊线在世界坐标中沿 x 轴(左右),位于 y=0, z=height+roof_h - 左脊点 (世界 (-180, 0, wall_h + roof_h)) - 右脊点 (世界 (180, 0, wall_h + roof_h)) - 等距投影后: - 左脊点: (cx - 180, cy + 0 - (wall_h + roof_h)) = (cx - 180, cy - 220) - 右脊点: (cx + 180, cy + 0 - (wall_h + roof_h)) = (cx + 180, cy - 220) - 屋顶前坡(朝向观众):四边形 - 左下:LT (cx - 180, cy - 120) - 右下:RT (cx + 180, cy - 120) - 右上:右脊点 (cx + 180, cy - 220) - 左上:左脊点 (cx - 180, cy - 220) 这其实是一个矩形(梯形?),等距投影下变成平行四边形(因为脊线和墙顶线在等距投影下是平行的)。 但用户说"两块三角形屋顶板从上方滑入并在屋脊相接" - 所以屋顶应该是三角形。 让我用不同的屋顶设计:四坡顶或简单的双坡顶(脊线沿前后方向)。 如果脊线沿前后方向(世界 y 轴): - 前脊点 (世界 (0, 90, wall_h + roof_h)) → 等距 (cx + 90*0.5, cy - 90*0.5 - 220) 我用更简单的等距投影:x_screen = cx + world_x + world_y * 0.5, y_screen = cy - world_z + world_y * 0.5 不对,应该是:x_screen = cx + world_x - world_y * cos(60°), y_screen = cy - world_z + world_y * sin(60°) 让我用标准等距: - world_x → screen_x: +x - world_y → screen_x: +y * cos(30°), screen_y: -y * sin(30°)(深度方向) - world_z → screen_y: -z(高度) 但这样会让深度方向往右上倾斜,让房子右后角朝右上方。OK。 设原点 在房屋前墙底部中心,深度方向(y 轴)向后延伸,z 轴向上。 ``` function iso(x, y, z) { const isoX = x + y * 0.5; // 深度向右倾斜 const isoY = -z - y * 0.5; // 深度向后上方 return [cx + isoX, cy + isoY]; } ``` 但等距视图通常深度方向是向左上或右上。如果向右上,房子前面朝向观众左下方。 让我让深度方向向右后(屏幕右上): - 房屋底面四角(俯视世界坐标,房屋中心在原点,宽 W, 深 D): - 前左 (-W/2, -D/2) - 前右 (W/2, -D/2) - 后右 (W/2, D/2) - 后左 (-W/2, D/2) - 等距投影: - 前左 screen: (-W/2 + (-D/2)*0.5, -(-D/2)*0.5) = (-W/2 - D/4, D/4) - 前右 screen: (W/2 - D/4, D/4) - 后右 screen: (W/2 + D/4, -D/4) - 后左 screen: (-W/2 + D/4, -D/4) 设 W = 320, D = 200: - 前左: (-160 - 50, 50) = (-210, 50) - 前右: (160 - 50, 50) = (110, 50) - 后右: (160 + 50, -50) = (210, -50) - 后左: (-160 + 50, -50) = (-110, -50) OK 这是一个菱形地基。中心, 加上这些偏移: - FL: (cx - 210, cy + 50) - FR: (cx + 110, cy + 50) - BR: (cx + 210, cy - 50) - BL: (cx - 110, cy - 50) 墙高 z = 130, 屋脊高 z = 130 + 110 = 240。 立柱顶端: - FLT: (cx - 210, cy + 50 - 130) = (cx - 210, cy - 80) - FRT: (cx + 110, cy - 80) - BRT: (cx + 210, cy - 50 - 130) = (cx + 210, cy - 180) - BLT: (cx - 110, cy - 180) 可见墙:前墙(FL-FR)和右墙(FR-BR)。 - 前墙顶部:FLT-FRT(z=130) - 右墙顶部:FRT-BRT 屋顶:双坡顶,脊线沿 x 轴方向(左右),脊线位于 y=0, z=240。 - 脊线左端 (世界 -W/2, 0, 240):screen (-W/2 + 0, -240) = (-160, -240) - 脊线右端 (世界 W/2, 0, 240):screen (160, -240) 屋顶前坡(朝观众):从脊线到前墙顶 - 屋脊左 screen: (cx - 160, cy - 240) - 屋脊右 screen: (cx + 160, cy - 240) - 前墙左顶 FLT: (cx - 210, cy - 80) - 前墙右顶 FRT: (cx + 110, cy - 80) 这是一个平行四边形:左上 (cx-160, cy-240), 右上 (cx+160, cy-240), 右下 (cx+110, cy-80), 左下 (cx-210, cy-80)。 屋顶右坡(朝右):从脊线到右墙顶 - 屋脊右 screen: (cx + 160, cy - 240) - 屋脊后端(沿 y 方向):(0, D/2, 240) → screen (D/4, -240 - D/4) = (cx + 50, cy - 290) - 实际上脊线后端应该延伸到 y = D/2(房屋后部),但屋顶脊线长度等于房屋宽度(沿 x 方向)。脊线是从 (-W/2, 0, 240) 到 (W/2, 0, 240),位于房屋中部。 但屋顶右坡是三角形(因为脊线在 y=0,右墙顶在 y=D/2)。 - 屋脊右端 (cx+160, cy-240) - 屋脊... 等等,脊线是直线,从 (-W/2, 0, 240) 到 (W/2, 0, 240)。投影到屏幕:(cx-160, cy-240) 到 (cx+160, cy-240)。 屋顶右坡(朝向右后方): - 三角形:脊线右半 + 右墙顶 - 脊线右端 (cx+160, cy-240) - 脊线... 我需要重新思考。 屋顶右坡实际上是从脊线到右墙顶(FR顶到BR顶)。但脊线只在 y=0 平面上,右墙顶在 y=D/2 平面上。所以右坡是一个三角形吗?不,应该是一个梯形或四边形: 屋顶右坡的四个角(世界坐标): - 脊线前端 (W/2, 0, 240) - 脊线后端 (-W/2, 0, 240) -- 但脊线是从 x=-W/2 到 x=W/2,所以脊线两端是 (-W/2, 0, 240) 和 (W/2, 0, 240) 等等,脊线沿 x 方向,所以两端是 (-W/2, 0, 240) 和 (W/2, 0, 240)。 右坡:从脊线到右墙顶(右墙顶是 (W/2, 0, 130) 到 (W/2, D/2, 130)) - 屋脊前端 (W/2, 0, 240) - 但右墙前端也在 (W/2, 0, 130),所以这点直接在右墙前端正上方 - 屋脊后端 (-W/2, 0, 240) - 但这点在左墙后端正上方,不属于右墙 - 实际上脊线应该只覆盖房屋的中部到右部? 我搞混了。让我重新设计: 经典双坡屋顶(脊线沿 x 方向,覆盖整个房屋): - 屋脊:从 (-W/2, 0, 240) 到 (W/2, 0, 240) - 前坡(朝 -y 方向):四边形 - (-W/2, 0, 240), (W/2, 0, 240), (W/2, -D/2, 130), (-W/2, -D/2, 130) - 后坡(朝 +y 方向):四边形 - (-W/2, 0, 240), (W/2, 0, 240), (W/2, D/2, 130), (-W/2, D/2, 130) - 左山墙(朝 -x 方向):三角形 - (-W/2, -D/2, 130), (-W/2, D/2, 130), (-W/2, 0, 240) - 右山墙(朝 +x 方向):三角形 - (W/2, -D/2, 130), (W/2, D/2, 130), (W/2, 0, 240) 可见的屋顶面:前坡、右坡(其实右坡不存在,应该是右山墙)和右山墙。 让我重新看:脊线沿 x 方向(房屋长度方向)意味着前后是斜面,左右是山墙。 在等距视图中(看到前墙和右墙),可见屋顶: - 前坡(朝观众):可见 - 右山墙(朝右):可见 - 后坡(背对观众):不可见 - 左山墙(朝左):不可见 用户说"两块三角形屋顶板从上方滑入并在屋脊相接"——这暗示两块三角形板。在等距视图中,前坡是四边形(不是三角形),右山墙是三角形。 也许用户想要的是简单 2D 视图(正面视图),其中屋顶就是经典的三角形(两边坡面)。这样"两块三角形板"就是左右两个坡面,在屋脊相接。 让我用 2D 正面视图,但增加一些侧面元素(屋顶侧面、地基侧面)来体现立体感。 正面视图房屋: - 主体墙面(前墙):矩形 (cx-180, cy-100) 到 (cx+180, cy+120) - 屋顶:两块三角形板 - 左板:(cx-180, cy-100), (cx, cy-100), (cx, cy-240) → 不对,应该是 (cx-200, cy-100), (cx, cy-100), (cx, cy-240) 等等,两块三角形板从上方滑入,在屋脊相接: - 左板三角形:(cx-200, cy-100), (cx, cy-100), (cx, cy-240) - 右板三角形:(cx, cy-100), (cx+200, cy-100), (cx, cy-240) - 这两个三角形在屋脊点 (cx, cy-240) 和底部点 (cx, cy-100) 相接 但这样屋脊只是一个点,不是线。两块板共享屋脊点和底部中点。 实际上"两块三角形屋顶板"可能就是双坡屋顶的两侧。在 2D 视图中,前坡和后坡重叠(看起来是一块三角形),但用户说两块板相接——也许是从上方滑入的两块板,组成一个完整三角形屋顶。 OK 我用这个方案:两块直角三角形板从上方滑入: - 左板:(cx-200, cy-100), (cx, cy-100), (cx, cy-240) [直角在右下] - 右板:(cx, cy-100), (cx+200, cy-100), (cx, cy-240) [直角在左下] - 组合成完整的等腰三角形屋顶 两块板从上方滑入,意味着初始时它们在画面上方,y 坐标远小于 cy-240,然后向下滑动到目标位置。 让我用 2D 正面视图,简洁清晰。这样所有元素都好画。 设计: 中心点 = (canvas.width/2, canvas.height * 0.55) 地基: - 矩形:(cx-220, cy+110) 到 (cx+220, cy+140) // 厚 30, 宽 440 - 动画:从地下升起,y 从 cy+200 滑到 cy+140 墙体(4 段竖直墙 = 4 根立柱): - 左立柱:(cx-200, cy+110) 到 (cx-180, cy-100),宽 20 - 右立柱:(cx+180, cy+110) 到 (cx+200, cy-100),宽 20 - 顶横梁:(cx-200, cy-120) 到 (cx+200, cy-100),高 20 - 底横梁(在地基上方):(cx-200, cy+90) 到 (cx+200, cy+110) 不过用户说"四段竖直墙从地基四角向上延伸"。四角是地基的四个角。如果地基是 2D 矩形,四角是四个点。竖直墙从这四角向上延伸 = 四根立柱。但 2D 视图中,地基只有左右两边可见,"四角"可能指地基矩形的四个角(左上、右上、左下、右下)。 我重新解释:地基是一个矩形板,"四角"指矩形的四个角点,竖直墙从这四角向上延伸形成墙体的边框(4 根立柱或 4 条边)。但 4 条边 = 矩形 = 一面墙的边框。 最合理的解释(卡通风格):墙体是一个矩形边框(4 条线),动画时这 4 条线从地基向上"长出来"或者从 4 个角向中间延伸形成矩形。 我用这个方案: - 墙体边框 = 矩形 (cx-200, cy-100) 到 (cx+200, cy+110) - 四条边分别动画: - 左竖边:从 (cx-200, cy+110) 向上长到 (cx-200, cy-100) - 右竖边:从 (cx+200, cy+110) 向上长到 (cx+200, cy-100) - 上横边:从两端向中间延伸 - 下横边:在地基顶部,从两端向中间延伸 但 4 段同时动画比较复杂。简化为:墙体的 4 条边同时从地基向上"长出来"。 实际我觉得最简单合理的方案: - "四段竖直墙" = 4 根立柱(在房屋的 4 个角) - 2D 正面视图中,只能看到 2 根立柱(左右),但我们可以画 4 根(中间 2 根代表房屋的内部支撑或者就是装饰) 但 2D 视图这样画有点奇怪。我决定用 2.5D 等距视图,能展现房屋的多面,让"4 根立柱"在视觉上有意义。 OK 最终决定:用等距视图。让我详细设计。 等距投影函数: ```js function iso(x, y, z) { // x: 房屋宽度方向(左右) // y: 房屋深度方向(前后) // z: 高度方向(上下) // 等距投影 return { x: cx + (x - y) * Math.cos(Math.PI/6), // 30度 y: cy + (x + y) * Math.sin(Math.PI/6) - z }; } ``` 简化版: ```js function iso(x, y, z) { return { x: cx + (x - y) * 0.866, y: cy + (x + y) * 0.5 - z }; } ``` 设房屋参数: - W = 240(宽度) - D = 180(深度) - wallH = 140(墙高) - roofH = 110(屋顶高) - 地基厚度 = 20 房屋底面四角(z=0): - FL (-W/2, -D/2, 0) = (-120, -90, 0) - FR (W/2, -D/2, 0) = (120, -90, 0) - BR (W/2, D/2, 0) = (120, 90, 0) - BL (-W/2, D/2, 0) = (-120, 90, 0) 等距投影(cx, cy 是画布中心,向下偏移让房子在画面中下方): - FL → (cx + (-120 - (-90))*0.866, cy + (-120 + (-90))*0.5 - 0) = (cx - 26, cy - 105) - FR → (cx + (120 - (-90))*0.866, cy + (120 + (-90))*0.5) = (cx + 182, cy + 15) - BR → (cx + (120 - 90)*0.866, cy + (120 + 90)*0.5) = (cx + 26, cy + 105) - BL → (cx + (-120 - 90)*0.866, cy + (-120 + 90)*0.5) = (cx - 182, cy - 15) 这是一个菱形,FL 在最上方?不对,FL 的 y 是 cy - 105,BR 的 y 是 cy + 105,所以 FL 在屏幕上方,BR 在下方。但 FL 是房屋的前左角,应该在前方(屏幕下方)才对。 我搞反了。让我让 y 轴向"屏幕内"(远离观众)方向: - y 增大 = 远离观众 = 屏幕上方(等距视图中深度方向往后上方) 但 sin(30°) = 0.5,所以 y 越大,y_screen 越大(往下)。这反了。 应该是: ```js function iso(x, y, z) { return { x: cx + (x - y) * 0.866, y: cy + (x + y) * 0.5 - z // y 越大,屏幕 y 越大(越往下) }; } ``` 如果 y 是深度方向(向后),y 越大应该越靠后(屏幕上方),但 (x+y)*0.5 中 y 增大让屏幕 y 增大(向下)。所以应该用 -y: ```js function iso(x, y, z) { return { x: cx + (x + y) * 0.866, // x 和 y 都向右倾斜?不对 y: cy + (x - y) * 0.5 - z }; } ``` 让我重新推导。标准等距投影: - x 轴:屏幕上向右下方倾斜 30° - y 轴:屏幕上向左下方倾斜 30°(如果 y 是深度方向) - z 轴:屏幕上垂直向上 或者: - x 轴:屏幕上向右(水平) - y 轴:屏幕上向右上方(45° 或 30°) - z 轴:屏幕上垂直向上 我用第二种(更像卡通房屋的视角): - x 轴:屏幕水平 - y 轴(深度):屏幕 30° 向右后方 - z 轴(高度):屏幕垂直 ```js function iso(x, y, z) { return { x: cx + x + y * Math.cos(Math.PI/6), y: cy - z - y * Math.sin(Math.PI/6) }; } ``` 简化:cos(30°) ≈ 0.866, sin(30°) = 0.5 ```js function iso(x, y, z) { return { x: cx + x + y * 0.5, // 深度方向向右上倾斜 y: cy - z - y * 0.3 // 深度方向向右上(屏幕 y 减小) }; } ``` 等等,让我让深度方向更明显一些。30° 角的等距投影: ```js function iso(x, y, z) { const angle = Math.PI / 6; // 30度 return { x: cx + x + y * Math.cos(angle), y: cy - z - y * Math.sin(angle) }; } ``` 设 cos(30°) ≈ 0.866, sin(30°) = 0.5 底面四角(z=0): - FL (-120, 0): (cx - 120, cy + 0) - FR (120, 0): (cx + 120, cy) - BR (120, 90): (cx + 120 + 90*0.866, cy - 90*0.5) = (cx + 198, cy - 45) - BL (-120, 90): (cx - 120 + 78, cy - 45) = (cx - 42, cy - 45) 等等,y 我应该用什么?让 y=0 是房屋前面,y=D 是房屋后面。 - FL (-120, 0): (cx - 120, cy) - FR (120, 0): (cx + 120, cy) - BR (120, 180): (cx + 120 + 156, cy - 90) = (cx + 276, cy - 90) - BL (-120, 180): (cx - 120 + 156, cy - 90) = (cx + 36, cy - 90) 这是个梯形地基,前宽后窄。前面在屏幕下方(y=cx),后面在屏幕上方(y=cy-90)。深度方向朝屏幕右上方。 可见墙面: - 前墙 (FL-FR):正面朝观众 - 右墙 (FR-BR):朝右后方,在屏幕上是右侧的梯形 可见屋顶: - 前坡(朝观众) - 右坡(朝右后) 立柱(4 根,从地基四角向上): - FL → FLT (-120, 0, 140): (cx - 120, cy - 140) - FR → FRT (120, 0, 140): (cx + 120, cy - 140) - BR → BRT (120, 180, 140): (cx + 276, cy - 230) - BL → BLT (-120, 180, 140): (cx + 36, cy - 230) 立柱位置都设好了。 填墙: - 前墙:FL, FR, FRT, FLT 四个点形成的矩形 (在 2D 等距投影中是矩形,因为深度相同) - (cx-120, cy), (cx+120, cy), (cx+120, cy-140), (cx-120, cy-140) - 右墙:FR, BR, BRT, FRT 形成的梯形(在等距投影中是平行四边形) - (cx+120, cy), (cx+276, cy-90), (cx+276, cy-230), (cx+120, cy-140) - 左墙(不可见或部分可见) - 后墙(不可见) 只画前墙和右墙。 屋顶:双坡顶,脊线沿 x 方向(左右),位于 y=D/2 平面(房屋中部)? 不,脊线应该位于 y=D/2 平面是不对的。让我想清楚: 如果脊线沿 x 方向(房屋的宽度方向),那么脊线在 y = D/2(房屋中间深度): - 脊线左端 (-120, 90, 140+110) = (-120, 90, 250) - 脊线右端 (120, 90, 250) 前坡:脊线到前墙顶 - 脊线左 (-120, 90, 250): (cx - 120 + 78, cy - 90 - 250*1) = (cx - 42, cy - 340) - 等等,让我重新算: (cx + x + y*0.866, cy - z - y*0.5) = (cx + (-120) + 90*0.866, cy - 250 - 90*0.5) = (cx - 120 + 78, cy - 250 - 45) = (cx - 42, cy - 295) - 脊线右 (120, 90, 250): (cx + 120 + 78, cy - 295) = (cx + 198, cy - 295) - 前墙左顶 FLT (-120, 0, 140): (cx - 120, cy - 140) - 前墙右顶 FRT (120, 0, 140): (cx + 120, cy - 140) 前坡四边形(4 个点):(cx-42, cy-295), (cx+198, cy-295), (cx+120, cy-140), (cx-120, cy-140) 后坡:脊线到后墙顶(背对观众,不可见) 右山墙(朝右):三角形 - FR (120, 0, 0): (cx+120, cy) - BR (120, 180, 0): (cx+276, cy-90) - 屋脊右端投影到 y=0 到 y=D 之间,但脊线在 y=90 平面上 等等,脊线在 y=90 平面上,所以右山墙是从 (120, 0, 0), (120, 180, 0), 到脊线右端... 但脊线右端在 y=90,所以右山墙的三角形是: - (120, 0, 140) 前顶 - (120, 180, 140) 后顶 - (120, 90, 250) 脊线右端 不对,右山墙是房屋右端的整个面,从墙底到屋脊。三角形: - FR (120, 0, 0) - BR (120, 180, 0) - 脊线右端 (120, 90, 250) 但墙的右端是矩形(高 140),屋顶部分是三角形(从 140 到 250)。所以右山墙是一个五边形: - FR (120, 0, 0) - BR (120, 180, 0) - BRT (120, 180, 140) - 脊线右端 (120, 90, 250) - FRT (120, 0, 140) 但右墙(矩形部分)已经被算作"墙"了,所以右山墙的"屋顶部分"是三角形: - FRT (120, 0, 140) - BRT (120, 180, 140) - 脊线右端 (120, 90, 250) 但用户说"两块三角形屋顶板从上方滑入并在屋脊相接"。两块板在屋脊相接意味着脊线是两块板的交线。 在双坡屋顶中,前坡和后坡在屋脊相接。在等距视图中,前坡可见,后坡不可见。 如果用户希望"两块三角形屋顶板"都可见并在屋脊相接,那应该是右山墙的三角形(屋顶部分)+ 一个对称的三角形。 我想用户可能想象的还是 2D 正面视图中的双坡屋顶:两块三角形板(左半三角和右半三角),从上方滑入到墙上,在屋脊点相接。 我决定还是用 2D 正面视图,简化设计。这样最直观。 最终设计 - 2D 正面视图: 中心点 = (W/2, H * 0.58),其中 W、H 是画布宽高 地基: - 矩形板,宽 480,厚 30 - 左下 (cx-240, cy+140),右上 (cx+240, cy+110) - 起始位置:y 偏移 +60(在地下),动画时 y 减小到目标位置 墙体(4 段): - 墙体边框矩形:(cx-200, cy+110) 到 (cx+200, cy-100) - 4 段:左竖、右竖、上横、下横 - 动画:4 段同时从地基向上"延伸"或"绘制" - 简单做法:4 段同时从下到上"长出",进度 0→1 时高度从 0 到 210 填墙: - 墙体内部矩形:(cx-180, cy+90) 到 (cx+180, cy-80) - 颜色:米黄色 #F5DEB3 或浅褐色 - 动画:从中心向外扩散填充,或者从下到上填充 屋顶: - 两块三角形板 - 屋脊点:(cx, cy-240) - 左板三角形:(cx-220, cy-100), (cx, cy-100), (cx, cy-240) - 右板三角形:(cx, cy-100), (cx+220, cy-100), (cx, cy-240) - 动画:从上方滑入(y 从 cy-400 到目标位置) - 红色瓦片纹理:在三角形内画水平的瓦片行,逐行出现 门: - 矩形:(cx-30, cy+110) 到 (cx+30, cy+20) - 等等,门顶部应该高于墙底(地基顶),底部应该接触地面或地基顶。 - 门底部 y = cy + 110(地基顶),门顶部 y = cy + 10(高 100,宽 60) - 实际上让门底在地基顶部下方一点:门底 y = cy + 110,门顶 y = cy + 0 - 棕色 #8B4513 - 门把手:金色圆点,在门右侧 (cx+20, cy+60) 窗: - 左窗:(cx-150, cy-50) 到 (cx-80, cy+20),宽 70 高 70 - 右窗:(cx+80, cy-50) 到 (cx+150, cy+20) - 蓝色玻璃 + 十字窗框 烟囱: - 在屋顶左侧 - 矩形:(cx-130, cy-200) 到 (cx-100, cy-260) - 烟雾:从烟囱顶部冒出的灰色圆形粒子 踏脚石小径: - 从门到画面下方 - 几个椭圆形石头,错落排列 小树: - 在房屋右侧 - 棕色树干 + 绿色圆形树冠 OK 让我开始写代码。 ```html <!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; } body { background: linear-gradient(to bottom, #a8d8ff 0%, #c8e8ff 60%, #d8f0d8 60%, #b8e0a0 100%); font-family: 'Georgia', serif; } canvas { display: block; position: absolute; top: 0; left: 0; } .caption { position: fixed; top: 5%; left: 50%; transform: translateX(-50%); padding: 12px 32px; background: rgba(255, 255, 255, 0.85); border-radius: 30px; font-size: 22px; font-weight: bold; color: #4a3520; letter-spacing: 4px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); backdrop-filter: blur(10px); z-index: 10; transition: opacity 0.3s; } .progress { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); width: 60%; height: 4px; background: rgba(255, 255, 255, 0.4); border-radius: 2px; overflow: hidden; z-index: 10; } .progress-bar { height: 100%; background: linear-gradient(to right, #ff7e5f, #feb47b); border-radius: 2px; width: 0%; } </style> </head> <body> <canvas id="c"></canvas> <div class="caption" id="caption">打地基</div> <div class="progress"><div class="progress-bar" id="bar"></div></div> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const capEl = document.getElementById('caption'); const barEl = document.getElementById('bar'); let W, H, cx, cy; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; cx = W / 2; cy = H * 0.6; } resize(); window.addEventListener('resize', resize); // 阶段时间(秒) const phases = [ { name: '打地基', start: 0.0, dur: 3.0 }, { name: '砌墙', start: 3.5, dur: 3.0 }, { name: '填墙', start: 7.0, dur: 3.0 }, { name: '盖屋顶', start: 10.5, dur: 4.5 }, { name: '装门', start: 15.5, dur: 2.5 }, { name: '安窗', start: 18.5, dur: 3.0 }, { name: '添细节', start: 22.0, dur: 3.0 }, ]; const TOTAL = 25.5; // 总循环时间 const GAP = 0.5; // 缓动函数 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, c3 = c1 + 1; return 1 + c3 * Math.pow(t-1, 3) + c1 * Math.pow(t-1, 2); } // 获取阶段进度(0-1,未开始为 -1 或负数,已结束为 1+) function phaseProgress(idx, t) { const p = phases[idx]; return (t - p.start) / p.dur; } // 烟雾粒子 let smokeParticles = []; // 主循环 let startTime = performance.now(); function loop() { const now = performance.now(); let t = ((now - startTime) / 1000) % TOTAL; // 背景 drawBackground(); // 绘制各阶段 drawGround(t); drawFoundation(t); drawWalls(t); drawWallFill(t); drawRoof(t); drawDoor(t); drawWindows(t); drawDetails(t); // 更新字幕 updateCaption(t); // 进度条 barEl.style.width = (t / TOTAL * 100) + '%'; requestAnimationFrame(loop); } function drawBackground() { // 天空渐变 const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#87ceeb'); sky.addColorStop(0.5, '#b8e0f0'); sky.addColorStop(0.7, '#d8f0e0'); ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H); // 远山 ctx.fillStyle = 'rgba(150, 180, 160, 0.5)'; ctx.beginPath(); ctx.moveTo(0, H * 0.7); ctx.lineTo(W * 0.2, H * 0.55); ctx.lineTo(W * 0.35, H * 0.62); ctx.lineTo(W * 0.5, H * 0.5); ctx.lineTo(W * 0.7, H * 0.58); ctx.lineTo(W * 0.85, H * 0.52); ctx.lineTo(W, H * 0.6); ctx.lineTo(W, H * 0.7); ctx.closePath(); ctx.fill(); // 云 drawCloud(W * 0.15, H * 0.15, 60); drawCloud(W * 0.75, H * 0.1, 80); drawCloud(W * 0.5, H * 0.22, 50); // 太阳 ctx.fillStyle = 'rgba(255, 220, 100, 0.8)'; ctx.beginPath(); ctx.arc(W * 0.85, H * 0.15, 40, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255, 220, 100, 0.3)'; ctx.beginPath(); ctx.arc(W * 0.85, H * 0.15, 55, 0, Math.PI * 2); ctx.fill(); } function drawCloud(x, y, size) { ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.beginPath(); ctx.arc(x, y, size * 0.5, 0, Math.PI * 2); ctx.arc(x + size * 0.4, y - size * 0.1, size * 0.4, 0, Math.PI * 2); ctx.arc(x + size * 0.8, y, size * 0.45, 0, Math.PI * 2); ctx.arc(x + size * 0.4, y + size * 0.15, size * 0.35, 0, Math.PI * 2); ctx.fill(); } function drawGround(t) { // 地面绿线 const groundY = cy + 140; // 草地 const grad = ctx.createLinearGradient(0, groundY, 0, H); grad.addColorStop(0, '#7ec850'); grad.addColorStop(1, '#4a9530'); ctx.fillStyle = grad; ctx.fillRect(0, groundY, W, H - groundY); // 地面线(深绿) ctx.strokeStyle = '#3a7520'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(W, groundY); ctx.stroke(); // 草地纹理 ctx.fillStyle = '#5da535'; for (let i = 0; i < W; i += 15) { const gx = i + ((i * 7) % 13); const gh = 4 + ((i * 3) % 5); ctx.fillRect(gx, groundY, 1, -gh); } } function drawFoundation(t) { const p = phaseProgress(0, t); if (p < 0) return; const progress = Math.min(1, easeOutCubic(p)); // 地基尺寸 const fLeft = cx - 240; const fRight = cx + 240; const fTopTarget = cy + 110; const fBottom = cy + 170; // 从地下升起:起始位置在地下 60px const offsetY = (1 - progress) * 80; const fTop = fTopTarget + offsetY; // 地基阴影(在地面上) ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; ctx.beginPath(); ctx.ellipse(cx, cy + 145, 260, 12, 0, 0, Math.PI * 2); ctx.fill(); // 地基本体 const grad = ctx.createLinearGradient(0, fTop, 0, fBottom); grad.addColorStop(0, '#a0a0a0'); grad.addColorStop(0.5, '#888888'); grad.addColorStop(1, '#606060'); ctx.fillStyle = grad; ctx.fillRect(fLeft, fTop, fRight - fLeft, fBottom - fTop); // 地基纹理(砖块缝) ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; ctx.lineWidth = 1; for (let y = fTop + 10; y < fBottom; y += 12) { ctx.beginPath(); ctx.moveTo(fLeft, y); ctx.lineTo(fRight, y); ctx.stroke(); } for (let x = fLeft + 20; x < fRight; x += 40) { ctx.beginPath(); ctx.moveTo(x, fTop); ctx.lineTo(x, fBottom); ctx.stroke(); } // 地基顶面亮线 ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.fillRect(fLeft, fTop, fRight - fLeft, 2); } function drawWalls(t) { const p = phaseProgress(1, t); if (p < 0) return; const progress = Math.min(1, easeOutCubic(p)); // 墙体边框(4 段) const wallLeft = cx - 200; const wallRight = cx + 200; const wallTopTarget = cy - 100; const wallBottom = cy + 110; const wallFullHeight = wallBottom - wallTopTarget; // 当前墙高(从下向上长) const currentHeight = wallFullHeight * progress; const wallTop = wallBottom - currentHeight; ctx.strokeStyle = '#5a3820'; ctx.lineWidth = 6; ctx.lineCap = 'square'; // 左竖 ctx.beginPath(); ctx.moveTo(wallLeft, wallBottom); ctx.lineTo(wallLeft, wallTop); ctx.stroke(); // 右竖 ctx.beginPath(); ctx.moveTo(wallRight, wallBottom); ctx.lineTo(wallRight, wallTop); ctx.stroke(); // 顶部横梁(当墙接近顶部时显示) if (progress > 0.7) { const topProgress = (progress - 0.7) / 0.3; const beamY = wallTopTarget; ctx.beginPath(); ctx.moveTo(wallLeft, beamY); ctx.lineTo(wallLeft + (wallRight - wallLeft) * topProgress, beamY); ctx.stroke(); } } function drawWallFill(t) { const p = phaseProgress(2, t); if (p < 0) return; const progress = Math.min(1, easeOutCubic(p)); // 墙体填充 const wallLeft = cx - 197; const wallRight = cx + 197; const wallTop = cy - 97; const wallBottom = cy + 107; // 从下到上填充 const fillHeight = (wallBottom - wallTop) * progress; ctx.save(); ctx.beginPath(); ctx.rect(wallLeft, wallBottom - fillHeight, wallRight - wallLeft, fillHeight); ctx.clip(); // 米黄色外墙 const grad = ctx.createLinearGradient(0, wallTop, 0, wallBottom); grad.addColorStop(0, '#f0d8a8'); grad.addColorStop(1, '#d8b878'); ctx.fillStyle = grad; ctx.fillRect(wallLeft, wallTop, wallRight - wallLeft, wallBottom - wallTop); // 木板纹理 ctx.strokeStyle = 'rgba(120, 80, 40, 0.25)'; ctx.lineWidth = 1; for (let x = wallLeft + 25; x < wallRight; x += 35) { ctx.beginPath(); ctx.moveTo(x, wallTop); ctx.lineTo(x, wallBottom); ctx.stroke(); } ctx.restore(); // 重新画边框(在填充上方) ctx.strokeStyle = '#5a3820'; ctx.lineWidth = 6; ctx.strokeRect(cx - 200, cy - 100, 400, 210); } function drawRoof(t) { const p = phaseProgress(3, t); if (p < 0) return; // 屋顶板从上方滑入 const slideProgress = Math.min(1, easeOutCubic(p / 0.6)); // 0-0.6 时间滑入 const tilesProgress = p < 0.6 ? 0 : Math.min(1, (p - 0.6) / 0.4); // 0.6-1.0 时间画瓦片 // 屋顶顶点 const apexX = cx; const apexY = cy - 240; const wallLeft = cx - 220; const wallRight = cx + 220; const wallTopY = cy - 100; // 滑入偏移 const offsetY = (1 - slideProgress) * -250; // 左板 ctx.save(); ctx.fillStyle = '#8b3a2a'; ctx.beginPath(); ctx.moveTo(wallLeft, wallTopY + offsetY); ctx.lineTo(apexX, wallTopY + offsetY); ctx.lineTo(apexX, apexY + offsetY); ctx.closePath(); ctx.fill(); // 右板 ctx.fillStyle = '#9c4234'; ctx.beginPath(); ctx.moveTo(apexX, wallTopY + offsetY); ctx.lineTo(wallRight, wallTopY + offsetY); ctx.lineTo(apexX, apexY + offsetY); ctx.closePath(); ctx.fill(); // 瓦片纹理(逐行出现) if (slideProgress >= 1) { drawTiles(wallLeft, wallRight, wallTopY, apexX, apexY, tilesProgress); } ctx.restore(); } function drawTiles(left, right, wallTopY, apexX, apexY, progress) { // 瓦片行 const leftTri = { a: [left, wallTopY], b: [apexX, wallTopY], c: [apexX, apexY] }; const rightTri = { a: [apexX, wallTopY], b: [right, wallTopY], c: [apexX, apexY] }; const totalHeight = wallTopY - apexY; const rows = 8; for (let i = 0; i < rows; i++) { const rowProgress = i / rows; if (rowProgress > progress) break; const y1 = wallTopY - (i / rows) * totalHeight; const y2 = wallTopY - ((i + 1) / rows) * totalHeight; // 左侧瓦片行(梯形) // 在 y=y1 时,左边界 x = left,右边界 x = apexX // 在 y=y2 时,左边界 x = lerp(left, apexX, (i+1)/rows),右边界 x = apexX const left1 = left; const right1 = apexX; const ratio = (i + 1) / rows; const left2 = left + (apexX - left) * ratio; const right2 = apexX; // 半圆瓦片 const tileColor = i % 2 === 0 ? '#a04838' : '#8b3a2a'; ctx.fillStyle = tileColor; ctx.beginPath(); ctx.moveTo(left1, y1); ctx.lineTo(right1, y1); ctx.lineTo(right2, y2); ctx.lineTo(left2, y2); ctx.closePath(); ctx.fill(); // 瓦片分割 const numTiles = 5 + i; for (let j = 0; j < numTiles; j++) { const t1 = j / numTiles; const t2 = (j + 1) / numTiles; const xa = left1 + (right1 - left1) * t1; const xb = left1 + (right1 - left1) * t2; const xc = left2 + (right2 - left2) * t1; const xd = left2 + (right2 - left2) * t2; // 半圆瓦片顶部 ctx.strokeStyle = 'rgba(50, 20, 10, 0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(xa, y1); ctx.lineTo(xc, y2); ctx.stroke(); } // 右侧对称 const rleft1 = apexX; const rright1 = right; const rleft2 = apexX; const rright2 = apexX + (right - apexX) * (1 - ratio); ctx.fillStyle = tileColor; ctx.beginPath(); ctx.moveTo(rleft1, y1); ctx.lineTo(rright1, y1); ctx.lineTo(rright2, y2); ctx.lineTo(rleft2, y2); ctx.closePath(); ctx.fill(); } } ``` 这个瓦片画法太复杂了。让我简化。 简化瓦片:直接用半圆形瓦片堆叠。每一行是一排半圆,逐行从下到上出现。 或者用更简单的方式:在三角形屋顶上画水平线条 + 半圆形瓦片纹理。 我用一个简单方法:在三角形屋顶内画一系列水平的"瓦片行",每行是一些圆弧形瓦片。 ```js function drawTiles(left, right, wallTopY, apexX, apexY, progress) { ctx.save(); // 裁剪到屋顶区域 ctx.beginPath(); ctx.moveTo(left, wallTopY); ctx.lineTo(right, wallTopY); ctx.lineTo(apexX, apexY); ctx.closePath(); ctx.clip(); const totalHeight = wallTopY - apexY; const numRows = 7; const rowHeight = totalHeight / numRows; for (let i = 0; i < numRows; i++) { const rowProgress = i / numRows; if (rowProgress > progress) break; const y = wallTopY - i * rowHeight; // 该行宽度(根据高度) const widthRatio = 1 - i / numRows; const rowWidth = (right - left) * widthRatio; const rowLeft = apexX - rowWidth / 2; // 瓦片 const tileWidth = 30; const numTiles = Math.ceil(rowWidth / tileWidth); for (let j = 0; j < numTiles; j++) { const tx = rowLeft + j * tileWidth; // 半圆瓦片 ctx.fillStyle = i % 2 === 0 ? '#b04838' : '#963828'; ctx.beginPath(); ctx.moveTo(tx, y); ctx.lineTo(tx + tileWidth - 2, y); ctx.arc(tx + tileWidth/2 - 1, y, tileWidth/2 - 1, 0, Math.PI, false); // 等等,这里逻辑有问题。半圆形瓦片通常是底部为半圆。 ctx.closePath(); ctx.fill(); } } ctx.restore(); } ``` 让我重新设计瓦片:每一行是一排"半圆"瓦片,从下到上堆叠。每个瓦片像一个圆头朝下的U形。 实际上简单的卡通瓦片纹理:水平的瓦片行,每行用一条粗线 + 半圆顶。 最简单方案:每一行用一条横线 + 一些垂直分隔线,形成瓦片网格。 ```js function drawTiles(...) { // 已经填充了红色背景 // 在屋顶上画水平线和垂直线,形成瓦片网格 for (let i = 0; i < rows; i++) { // 第 i 行(从下到上) const y = wallTopY - i * rowHeight; const widthRatio = 1 - i / numRows; const rowWidth = (right - left) * widthRatio; const rowLeft = apexX - rowWidth / 2; const rowRight = apexX + rowWidth / 2; // 顶部边界(深色线) ctx.strokeStyle = 'rgba(60, 20, 10, 0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(rowLeft, y - rowHeight); ctx.lineTo(rowRight, y - rowHeight); ctx.stroke(); // 半圆形瓦片 for (let x = rowLeft; x < rowRight; x += tileWidth) { ctx.fillStyle = 'rgba(160, 70, 50, 0.8)'; ctx.beginPath(); ctx.arc(x + tileWidth/2, y - rowHeight/2, tileWidth/2 - 1, 0, Math.PI); // 等等,这画的是上半圆 ctx.fill(); } } } ``` OK 让我先放弃复杂的瓦片,用简单的水平条纹纹理代替: ```js function drawRoofTiles(left, right, wallTopY, apexX, apexY, progress) { const totalHeight = wallTopY - apexY; const numRows = 8; const rowHeight = totalHeight / numRows; for (let i = 0; i < numRows; i++) { const rowT = i / numRows; if (rowT > progress) break; const y_top = wallTopY - i * rowHeight; const y_bot = y_top - rowHeight; // 注意:y 越小越上 // 在高度 y_top 处的屋顶宽度 const widthAtTop = (right - left) * (1 - i / numRows); const widthAtBot = (right - left) * (1 - (i + 1) / numRows); const leftAtTop = apexX - widthAtTop / 2; const rightAtTop = apexX + widthAtTop / 2; const leftAtBot = apexX - widthAtBot / 2; const rightAtBot = apexX + widthAtBot / 2; // 瓦片行(梯形) ctx.fillStyle = i % 2 === 0 ? '#a8443a' : '#8e362c'; ctx.beginPath(); ctx.moveTo(leftAtTop, y_top); ctx.lineTo(rightAtTop, y_top); ctx.lineTo(rightAtBot, y_bot); ctx.lineTo(leftAtBot, y_bot); ctx.closePath(); ctx.fill(); // 瓦片底边阴影(凸出效果) ctx.strokeStyle = 'rgba(50, 15, 10, 0.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(leftAtBot, y_bot); ctx.lineTo(rightAtBot, y_bot); ctx.stroke(); // 瓦片垂直分隔 const numTiles = Math.max(2, Math.floor(widthAtTop / 40)); for (let j = 1; j < numTiles; j++) { const t = j / numTiles; const x_top = leftAtTop + (rightAtTop - leftAtTop) * t; const x_bot = leftAtBot + (rightAtBot - leftAtBot) * t; ctx.beginPath(); ctx.moveTo(x_top, y_top); ctx.lineTo(x_bot, y_bot); ctx.stroke(); } } } ``` 这样比较清晰。 继续画门、窗、细节。 ```js function drawDoor(t) { const p = phaseProgress(4, t); if (p < 0) return; const progress = Math.min(1, p); // 门 const doorLeft = cx - 35; const doorRight = cx + 35; const doorTop = cy - 5; const doorBottom = cy + 110; ctx.save(); ctx.globalAlpha = progress; // 门框 ctx.fillStyle = '#5a3820'; ctx.fillRect(doorLeft - 3, doorTop - 3, 76, doorBottom - doorTop + 6); // 门 const grad = ctx.createLinearGradient(doorLeft, 0, doorRight, 0); grad.addColorStop(0, '#8b5a2b'); grad.addColorStop(0.5, '#a87040'); grad.addColorStop(1, '#7a4a25'); ctx.fillStyle = grad; ctx.fillRect(doorLeft, doorTop, 70, doorBottom - doorTop); // 门木板纹理 ctx.strokeStyle = 'rgba(60, 30, 10, 0.4)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(doorLeft + 23, doorTop); ctx.lineTo(doorLeft + 23, doorBottom); ctx.moveTo(doorLeft + 47, doorTop); ctx.lineTo(doorLeft + 47, doorBottom); ctx.stroke(); // 门把手(金色) ctx.fillStyle = '#ffc83a'; ctx.beginPath(); ctx.arc(doorRight - 12, cy + 50, 5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#a87820'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); } function drawWindows(t) { const p = phaseProgress(5, t); if (p < 0) return; // 左窗 0-0.5, 右窗 0.5-1 const leftP = Math.min(1, Math.max(0, p * 2)); const rightP = Math.min(1, Math.max(0, p * 2 - 1)); drawWindow(cx - 140, cy - 40, 70, 70, easeOutCubic(leftP)); drawWindow(cx + 70, cy - 40, 70, 70, easeOutCubic(rightP)); } function drawWindow(x, y, w, h, progress) { if (progress <= 0) return; ctx.save(); ctx.globalAlpha = progress; // 窗框 ctx.fillStyle = '#5a3820'; ctx.fillRect(x - 3, y - 3, w + 6, h + 6); // 玻璃 const grad = ctx.createLinearGradient(x, y, x + w, y + h); grad.addColorStop(0, '#a8d8f0'); grad.addColorStop(0.5, '#7ab8e0'); grad.addColorStop(1, '#5898c8'); ctx.fillStyle = grad; ctx.fillRect(x, y, w, h); // 玻璃反光 ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.beginPath(); ctx.moveTo(x + 5, y + 5); ctx.lineTo(x + 20, y + 5); ctx.lineTo(x + 5, y + 25); ctx.closePath(); ctx.fill(); // 十字窗框 ctx.fillStyle = '#5a3820'; ctx.fillRect(x + w/2 - 2, y, 4, h); ctx.fillRect(x, y + h/2 - 2, w, 4); ctx.restore(); } function drawDetails(t) { const p = phaseProgress(6, t); if (p < 0) return; // 多个细节:烟囱、烟、踏脚石、树 // 0-0.3: 烟囱 // 0.2-0.5: 烟 // 0.4-0.7: 踏脚石 // 0.6-1.0: 树 const chimneyP = Math.min(1, p / 0.3); const smokeP = p < 0.2 ? 0 : Math.min(1, (p - 0.2) / 0.3); const stonesP = p < 0.4 ? 0 : Math.min(1, (p - 0.4) / 0.3); const treeP = p < 0.6 ? 0 : Math.min(1, (p - 0.6) / 0.4); drawChimney(chimneyP); drawSmoke(smokeP, t); drawSteppingStones(stonesP); drawTree(treeP); } function drawChimney(progress) { if (progress <= 0) return; // 烟囱位置:屋顶左侧 const chimX = cx - 110; const chimY_top = cy - 200; const chimY_bot = cy - 130; const chimW = 35; // 从屋顶升起的动画 const offsetY = (1 - progress) * 70; ctx.fillStyle = '#8a4a3a'; ctx.fillRect(chimX - chimW/2, chimY_top + offsetY, chimW, chimY_bot - chimY_top); // 烟囱顶(深色砖) ctx.fillStyle = '#5a2818'; ctx.fillRect(chimX - chimW/2 - 3, chimY_top + offsetY - 8, chimW + 6, 8); // 砖块纹理 ctx.strokeStyle = 'rgba(40, 15, 10, 0.4)'; ctx.lineWidth = 1; for (let y = chimY_top + offsetY + 10; y < chimY_bot; y += 12) { ctx.beginPath(); ctx.moveTo(chimX - chimW/2, y); ctx.lineTo(chimX + chimW/2, y); ctx.stroke(); } } function drawSmoke(progress, t) { if (progress <= 0) return; // 烟雾粒子持续产生 const chimX = cx - 110; const chimY = cy - 208; // 生成新粒子 if (Math.random() < 0.3) { smokeParticles.push({ x: chimX + (Math.random() - 0.5) * 8, y: chimY, vx: (Math.random() - 0.5) * 0.3, vy: -0.5 - Math.random() * 0.4, size: 8 + Math.random() * 6, life: 0, maxLife: 2 + Math.random() }); } // 更新和绘制粒子 ctx.fillStyle = 'rgba(180, 180, 180, 0.6)'; for (let i = smokeParticles.length - 1; i >= 0; i--) { const p = smokeParticles[i]; p.x += p.vx; p.y += p.vy; p.vx += (Math.random() - 0.5) * 0.05; p.size += 0.15; p.life += 1/60; if (p.life > p.maxLife) { smokeParticles.splice(i, 1); continue; } const alpha = (1 - p.life / p.maxLife) * 0.6 * progress; ctx.fillStyle = `rgba(200, 200, 200, ${alpha})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); } } function drawSteppingStones(progress) { if (progress <= 0) return; // 踏脚石:从门前到画布下方 const numStones = 5; for (let i = 0; i < numStones; i++) { const stoneP = Math.min(1, Math.max(0, progress * numStones - i)); if (stoneP <= 0) continue; const y = cy + 150 + i * 35; const x = cx + (i % 2 === 0 ? -8 : 8) + Math.sin(i * 0.5) * 5; const w = 35; const h = 12; ctx.save(); ctx.globalAlpha = stoneP; // 石头 const grad = ctx.createLinearGradient(0, y - h/2, 0, y + h/2); grad.addColorStop(0, '#a8a098'); grad.addColorStop(1, '#6a6258'); ctx.fillStyle = grad; ctx.beginPath(); ctx.ellipse(x, y, w/2, h/2, 0, 0, Math.PI * 2); ctx.fill(); // 石头纹理 ctx.fillStyle = 'rgba(60, 55, 50, 0.3)'; ctx.beginPath(); ctx.ellipse(x - 5, y + 2, 3, 1.5, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } function drawTree(progress) { if (progress <= 0) return; const treeX = cx + 320; const treeY = cy + 110; // 树干 const trunkH = 80 * progress; ctx.fillStyle = '#6a4525'; ctx.fillRect(treeX - 8, treeY - trunkH, 16, trunkH); // 树干纹理 ctx.strokeStyle = 'rgba(40, 20, 10, 0.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(treeX - 4, treeY - trunkH + 10); ctx.lineTo(treeX - 4, treeY - 5); ctx.stroke(); // 树冠(圆形,根据 progress 长大) if (progress > 0.4) { const crownP = (progress - 0.4) / 0.6; const crownSize = 50 * crownP; ctx.fillStyle = '#4a8a35'; ctx.beginPath(); ctx.arc(treeX - 20, treeY - trunkH - 5, crownSize * 0.7, 0, Math.PI * 2); ctx.arc(treeX + 15, treeY - trunkH - 10, crownSize * 0.8, 0, Math.PI * 2); ctx.arc(treeX, treeY - trunkH - 25, crownSize * 0.9, 0, Math.PI * 2); ctx.arc(treeX - 10, treeY - trunkH - 15, crownSize * 0.7, 0, Math.PI * 2); ctx.fill(); // 高光 ctx.fillStyle = 'rgba(150, 200, 100, 0.5)'; ctx.beginPath(); ctx.arc(treeX - 15, treeY - trunkH - 30, crownSize * 0.3, 0, Math.PI * 2); ctx.fill(); } } function updateCaption(t) { let current = phases[0]; for (const p of phases) { if (t >= p.start) current = p; } if (t > phases[phases.length - 1].start + phases[phases.length - 1].dur) { current = { name: '完成!' }; } if (capEl.textContent !== current.name) { capEl.textContent = current.name; } } loop(); </script> </body> </html> ``` 让我整理一下完整代码,确保没有遗漏。 我需要重新审视: 1. 阶段 6(窗户)应该开始得更早一点,让所有阶段在 25 秒内完成 2. 进度条 3. 字幕动画 4. 烟雾粒子系统需要持续运行(即使在阶段 6 之前,但只在阶段 7 才显示) 让我重新计算时间: - Phase 1: 0 - 3.0, then gap 0.5 - Phase 2: 3.5 - 6.5, then gap 0.5 - Phase 3: 7.0 - 10.0, then gap 0.5 - Phase 4: 10.5 - 14.5, then gap 0.5 (4s 给屋顶和瓦片) - Phase 5: 15.0 - 17.5, then gap 0.5 - Phase 6: 18.0 - 20.5, then gap 0.5 - Phase 7: 21.0 - 24.0, then gap 1.0 (展示完成) - Total: 25.0 让我用这个时间表。 我还需要: - 一个标题或者结束信息 - 循环时清空 smokeParticles 让我重新审视代码,写得更精炼。 我注意到一些问题: 1. 烟雾粒子在阶段 7 之前不应该出现,但在阶段 7 出现时应该立即有烟。所以烟雾粒子系统应该在阶段 7 开始时启动。 2. 循环重置时,需要清空粒子。 3. 字幕在每个阶段开始时切换。 让我把代码写完整: 实际上我觉得这个动画的关键是要看起来美观。让我再考虑下整体视觉风格。 视觉风格: - 卡通可爱风 - 颜色温暖、饱和度适中 - 柔和的阴影 - 整体氛围感 我决定在画布上画一切(包括天空、地面、太阳、云),不依赖 CSS 背景。这样所有元素集成在一起。 让我开始最终编码。我会注意: 1. 使用 requestAnimationFrame 2. 时间驱动的动画 3. 平滑的缓动 4. 美观的视觉 注意:用户要求"淡蓝色天空与一条绿色地面线",所以背景应该简单——淡蓝色天空,绿色地面线。不要过度装饰背景。 让我简化背景: - 上半部分淡蓝色 - 下半部分(地面以下)浅绿色 - 一条明显的绿色地面线 但我也想要视觉上美观,所以我会加: - 几朵云 - 远处的山或装饰 但用户明确说"淡蓝色天空与一条绿色地面线",所以基础就是这个,加少量装饰可以。 OK 让我写最终代码。 我注意到字体处理:用户没指定,但我用中文字体应该用系统中文字体栈。 最终代码: ```html <!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; background: #c8e8f5; } canvas { display: block; } .caption-wrap { position: fixed; top: 36px; left: 0; right: 0; text-align: center; z-index: 10; pointer-events: none; } .caption { display: inline-block; padding: 10px 36px; background: rgba(255, 255, 255, 0.75); border-radius: 999px; font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif; font-size: 22px; font-weight: 600; color: #3d2b1f; letter-spacing: 6px; box-shadow: 0 6px 24px rgba(50, 80, 110, 0.15); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.6); transition: opacity 0.35s ease; } .progress { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); width: min(560px, 70vw); z-index: 10; } .progress-track { width: 100%; height: 4px; background: rgba(255, 255, 255, 0.5); border-radius: 2px; overflow: hidden; } .progress-bar { height: 100%; background: linear-gradient(90deg, #ff8a5b, #ffb86b); border-radius: 2px; width: 0%; } .phase-dots { display: flex; justify-content: space-between; margin-top: 10px; } .dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.5); transition: all 0.3s; } .dot.active { background: #ff8a5b; transform: scale(1.5); } .dot.done { background: rgba(255, 138, 91, 0.7); } </style> </head> <body> <canvas id="c"></canvas> <div class="caption-wrap"> <div class="caption" id="caption">准备开始</div> </div> <div class="progress"> <div class="progress-track"><div class="progress-bar" id="bar"></div></div> <div class="phase-dots" id="dots"></div> </div> <script> // 代码... </script> </body> </html> ``` 这样视觉更精致。但要注意字幕"准备开始"会显示在 t<0 时(不存在),所以应该一开始就显示第一阶段字幕"打地基"。 让我把所有代码整理好。 我决定让动画循环时间是 25 秒,每个阶段加 0.5 秒间隔。 精确时间表: - 0.0 - 3.0: 阶段 1 打地基 - 3.0 - 3.5: 停顿 - 3.5 - 6.5: 阶段 2 砌墙 - 6.5 - 7.0: 停顿 - 7.0 - 10.0: 阶段 3 填墙 - 10.0 - 10.5: 停顿 - 10.5 - 14.5: 阶段 4 盖屋顶 - 14.5 - 15.0: 停顿 - 15.0 - 17.5: 阶段 5 装门 - 17.5 - 18.0: 停顿 - 18.0 - 20.5: 阶段 6 安窗 - 20.5 - 21.0: 停顿 - 21.0 - 25.0: 阶段 7 添细节(持续 4 秒,留出时间展示烟雾和最终效果) 总时长 25 秒。 OK 让我写最终代码。 我决定让阶段 6 简化:直接画 7 个 dot 对应 7 个阶段。 完整代码(整理): ```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>建造小屋 · 25 秒动画</title> <style> ... </style> </head> <body> <canvas id="c"></canvas> <div class="caption-wrap"><div class="caption" id="caption">打地基</div></div> <div class="progress"> <div class="progress-track"><div class="progress-bar" id="bar"></div></div> <div class="phase-dots" id="dots"></div> </div> <script> ... </script> </body> </html> ``` 让我把所有部分整合,写完整代码。我会尝试控制在合理长度。 让我注意: 1. canvas 高度宽度需要适配 devicePixelRatio,但简单起见先不处理 2. 烟雾粒子在阶段切换时清空 3. 字幕更新平滑 最后,让我审视代码确保运行正确。 我注意到一个问题:phaseProgress 在阶段开始前返回负数,在阶段结束后返回 >1 的数。这样我可以判断"未开始"和"已完成"。但在绘制函数中,我应该: - p < 0: 不绘制 - 0 <= p <= 1: 按进度绘制 - p > 1: 完整绘制(progress = 1) 所以我需要 `if (p < 0) return;` 然后 `const progress = Math.min(1, easeOutCubic(Math.max(0, p)));`。 最终代码(完整): 让我注意一些细节: - 屋顶板的"从上方滑入"动画——两块板从画面上方滑入到位置 - 瓦片纹理"逐行出现"——从屋脊到屋檐或从屋檐到屋脊? 我用从屋檐(下)到屋脊(上)逐行出现,因为这样视觉上像在"盖瓦"。 烟囱位置——在屋顶左半部分的某处。我放在 cx-110,cy-200 附近。烟囱底部应该在屋顶下方(屋顶斜面上)。 让我画烟囱时考虑它和屋顶的关系:烟囱从屋顶斜面上升起。烟囱底部在屋顶斜面上,y 大约是 cy-130(屋顶左板中部)。 我决定让烟囱底部在 cy-130(屋顶板底部 y),顶部在 cy-215。这样烟囱在屋顶左半部分。 OK 我开始写最终代码。 让我整理一遍代码结构: ```js // 1. 初始化 // 2. resize // 3. 时间阶段定义 // 4. 缓动函数 // 5. 主循环 // - 计算 t // - 清空画布 // - 绘制天空 // - 绘制地面 // - 绘制远山、云(背景装饰) // - 绘制房屋(按阶段顺序) // 6. 各阶段绘制函数 // 7. 辅助函数(绘制矩形、圆形等) ``` 我开始写: ```js const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const capEl = document.getElementById('caption'); const barEl = document.getElementById('bar'); const dotsEl = document.getElementById('dots'); let W, H, cx, cy, scale; function resize() { const 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(dpr, 0, 0, dpr, 0, 0); cx = W / 2; cy = H * 0.62; scale = Math.min(W / 1200, H / 800); // 响应式缩放 scale = Math.max(0.6, Math.min(1.2, scale)); } resize(); window.addEventListener('resize', resize); const phases = [ { name: '打地基', start: 0.0, end: 3.0 }, { name: '砌墙', start: 3.5, end: 6.5 }, { name: '填墙', start: 7.0, end: 10.0 }, { name: '盖屋顶', start: 10.5, end: 14.5 }, { name: '装门', start: 15.0, end: 17.5 }, { name: '安窗', start: 18.0, end: 20.5 }, { name: '添细节', start: 21.0, end: 25.0 } ]; const TOTAL = 25.0; // 创建 dots phases.forEach((p, i) => { const d = document.createElement('div'); d.className = 'dot'; d.dataset.idx = i; dotsEl.appendChild(d); }); const dotEls = Array.from(dotsEl.children); function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function easeInOutQuad(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2; } function easeOutBack(t) { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t-1, 3) + c1 * Math.pow(t-1, 2); } function clamp(x, a, b) { return Math.max(a, Math.min(b, x)); } function pp(idx, t) { // phase progress: <0 before, 0-1 during, >1 after const p = phases[idx]; return (t - p.start) / (p.end - p.start); } let smokeParticles = []; let startTime = performance.now(); function loop() { const now = performance.now(); let t = ((now - startTime) / 1000) % TOTAL; // 循环重置时清空粒子 if (t < 0.1 && smokeParticles.length > 0) { smokeParticles = []; } // 背景 drawSky(t); drawClouds(t); drawGround(t); // 房屋各阶段 drawFoundation(t); drawWalls(t); drawWallFill(t); drawRoof(t); drawDoor(t); drawWindows(t); drawChimneyAndSmoke(t); drawSteppingStones(t); drawTree(t); updateUI(t); requestAnimationFrame(loop); } function drawSky(t) { // 渐变天空 const sky = ctx.createLinearGradient(0, 0, 0, H * 0.7); sky.addColorStop(0, '#7ec5e8'); sky.addColorStop(0.5, '#a8d8ee'); sky.addColorStop(1, '#d8eaf2'); ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H * 0.7); // 太阳 const sunX = W * 0.85; const sunY = H * 0.15; // 光晕 const halo = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 80); halo.addColorStop(0, 'rgba(255, 230, 130, 0.6)'); halo.addColorStop(1, 'rgba(255, 230, 130, 0)'); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(sunX, sunY, 80, 0, Math.PI * 2); ctx.fill(); // 太阳本体 ctx.fillStyle = '#ffe066'; ctx.beginPath(); ctx.arc(sunX, sunY, 32, 0, Math.PI * 2); ctx.fill(); } function drawClouds(t) { // 几朵云,缓慢漂移 const drift = t * 8; drawCloud(((W * 0.15 + drift) % (W + 200)) - 100, H * 0.12, 50); drawCloud(((W * 0.6 + drift * 0.7) % (W + 200)) - 100, H * 0.08, 65); drawCloud(((W * 0.4 + drift * 1.2) % (W + 200)) - 100, H * 0.2, 45); drawCloud(((W * 0.85 + drift * 0.5) % (W + 200)) - 100, H * 0.25, 55); } function drawCloud(x, y, size) { ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; ctx.beginPath(); ctx.arc(x, y, size * 0.6, 0, Math.PI * 2); ctx.arc(x + size * 0.5, y - size * 0.15, size * 0.5, 0, Math.PI * 2); ctx.arc(x + size * 1.0, y, size * 0.55, 0, Math.PI * 2); ctx.arc(x + size * 0.5, y + size * 0.2, size * 0.45, 0, Math.PI * 2); ctx.fill(); } function drawGround(t) { const groundY = cy + 140; // 地面渐变 const grad = ctx.createLinearGradient(0, groundY, 0, H); grad.addColorStop(0, '#86cb56'); grad.addColorStop(0.4, '#65b340'); grad.addColorStop(1, '#3d8c20'); ctx.fillStyle = grad; ctx.fillRect(0, groundY, W, H - groundY); // 绿色地面线 ctx.strokeStyle = '#2d7019'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(W, groundY); ctx.stroke(); // 草 ctx.strokeStyle = '#4a9028'; ctx.lineWidth = 1.2; for (let x = 0; x < W; x += 8) { const h = 4 + Math.sin(x * 0.3) * 2 + (x % 7); ctx.beginPath(); ctx.moveTo(x, groundY); ctx.lineTo(x + 1, groundY - h); ctx.stroke(); } } ``` 继续: ```js function drawFoundation(t) { const p = pp(0, t); if (p < 0) return; const pr = easeOutCubic(clamp(p, 0, 1)); const fW = 460 * scale; const fH = 36 * scale; const fLeft = cx - fW / 2; const fRight = cx + fW / 2; const fTopTarget = cy + 110 * scale; const fBottom = fTopTarget + fH; // 从地下升起 const offsetY = (1 - pr) * (fH + 30); const fTop = fTopTarget + offsetY; // 阴影 ctx.fillStyle = 'rgba(0, 0, 0, 0.18)'; ctx.beginPath(); ctx.ellipse(cx, fBottom +
连接中断:传输途中与服务器的连接断开(多为浏览器↔本站的跨境网络波动,与模型厂商无关)。点「重跑」通常可恢复。原始:network error
首Token
思考TPS
输出TPS
总Tokens
MiniMax-M3
MiniMax-M3
中断
⚠ 输出中断:未收到正常结束信号(多为生成耗时过长被服务端超时切断,或网络中断),下方内容可能不完整。点「重跑」重试。

<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:

  1. Foundation - gray rectangle rises from underground
  2. Walls - four vertical walls extend up from corners of foundation
  3. Fill walls - beige/light brown exterior panels fill between wall frames
  4. Roof - two triangular roof panels slide in from above and meet at ridge, red tile texture appears row by row
  5. Door - brown door fades in on front wall with small gold doorknob
  6. Windows - windows on either side of door with cross frames and blue glass
  7. 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:

  1. The wall fill animation: I have 3 panels each with their own timing within stage 3's 3 seconds. Each panel takes 1s. Good.

  2. Roof tiles: I'm drawing tile rows. The horizontal lines between rows and vertical divisions create the tile look.

  3. Chimney position calculation needs to be consistent between drawChimney and drawSmoke.

  4. The path: stepping stones from door base going down.

  5. 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
1.76s
首Token
思考TPS
80
输出TPS
298s · 23,744 tok
23,744
总Tokens(估算)
总用时 301s · 平均 80 tok/s · 峰值 179 tok/s · 成本 $0.028