@@ -4337,6 +5498,8 @@ function freshClass(f) {
class="slot"
:class="{ filled: s }"
:data-tip="tipTextForSlot(s)"
+ :data-slot="'main:' + i"
+ @pointerdown="dragStart($event, 'main', i)"
@click="clickSlot('main', i)"
@mouseenter="showSlotTip($event, tipTextForSlot(s))"
@mousemove="showSlotTip($event, tipTextForSlot(s))"
@@ -4773,6 +5936,83 @@ function freshClass(f) {
pointer-events: none;
animation: banner-in 0.6s ease-out;
}
+/* 新手目标引导:顶部居中小条 */
+.guide-panel {
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 4;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 62%;
+ padding: 6px 14px;
+ font-size: 13px;
+ color: #f4ead2;
+ background: rgba(20, 16, 10, 0.78);
+ border: 1.6px solid rgba(240, 226, 190, 0.3);
+ border-radius: 20px;
+ pointer-events: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.guide-ico {
+ flex-shrink: 0;
+}
+/* Boss 血条:引导条下方居中 */
+.boss-bar {
+ position: absolute;
+ top: 44px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 4;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: min(380px, 56%);
+ padding: 5px 12px;
+ background: rgba(20, 10, 8, 0.8);
+ border: 1.6px solid rgba(224, 72, 64, 0.55);
+ border-radius: 18px;
+ pointer-events: none;
+}
+.boss-bar b {
+ color: #f4b8a8;
+ font-size: 12px;
+ white-space: nowrap;
+ text-shadow: 1px 1px 2px #000;
+}
+.bb-track {
+ flex: 1;
+ height: 9px;
+ background: rgba(60, 20, 16, 0.85);
+ border-radius: 5px;
+ overflow: hidden;
+ border: 1px solid rgba(240, 200, 180, 0.2);
+}
+.bb-track i {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, #d84a3a, #f0923c);
+ border-radius: 5px;
+ transition: width 0.25s ease;
+}
+/* 洞穴指示:左上角(避开可展开的顶栏按钮区) */
+.cave-tag {
+ position: absolute;
+ top: 52px;
+ left: 12px;
+ z-index: 4;
+ padding: 5px 12px;
+ font-size: 12px;
+ color: #cfe6f4;
+ background: rgba(10, 14, 22, 0.78);
+ border: 1.6px solid rgba(140, 190, 230, 0.4);
+ border-radius: 16px;
+ pointer-events: none;
+}
@keyframes banner-in {
from { opacity: 0; letter-spacing: 30px; }
}
@@ -4830,10 +6070,35 @@ function freshClass(f) {
border-radius: 9px;
cursor: pointer;
padding: 0;
+ touch-action: none; /* 触屏拖拽:槽位上禁掉滚动手势,交给 pointermove */
}
.slot:hover {
border-color: #e8bc5a;
}
+/* 拖拽幽灵:跟随指针的半透明物品影像 */
+.drag-ghost {
+ position: fixed;
+ z-index: 999;
+ width: 42px;
+ height: 42px;
+ margin: -21px 0 0 -21px;
+ pointer-events: none;
+ opacity: 0.85;
+ filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
+}
+.drag-ghost img {
+ width: 36px;
+ height: 36px;
+ margin-top: 3px;
+}
+.drag-ghost b {
+ position: absolute;
+ right: 1px;
+ bottom: 0;
+ font-size: 11px;
+ color: #fff;
+ text-shadow: 1px 1px 2px #000;
+}
.slot img {
width: 32px;
height: 32px;
@@ -5190,9 +6455,9 @@ function freshClass(f) {
border-radius: 4px;
}
.icon-btn {
- width: 34px;
- height: 34px;
- min-width: 34px;
+ width: 42px;
+ height: 42px;
+ min-width: 42px;
padding: 0;
display: inline-flex;
align-items: center;
@@ -5201,6 +6466,11 @@ function freshClass(f) {
font-size: 16px;
image-rendering: pixelated;
}
+.icon-btn svg {
+ width: 27px;
+ height: 27px;
+ display: block;
+}
.craft-tab {
border-radius: 0;
image-rendering: pixelated;
@@ -5226,6 +6496,12 @@ function freshClass(f) {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M5 2v10l-3 2v6c0 4.2 3.3 7 7.6 7h2.6c5 0 8.2-3.3 8.2-7.4V9l-3.6-2-2.6-2.4-3.2-3z' fill='%23f4ead2' stroke='%231d1409' stroke-width='2'/%3E%3Cpath d='M11 8v9M15 9v9' stroke='%231d1409' stroke-width='1.6'/%3E%3C/svg%3E") 4 4, auto;
}
+/* 游戏进行中:画布隐藏系统光标,改用 canvas 自绘情境光标(drawGameCursor) */
+.starve-wrap.hide-cursor .starve-canvas,
+.starve-wrap.hide-cursor .starve-canvas * {
+ cursor: none !important;
+}
+
/* 攻击光标:短剑 */
.starve-wrap.cur-attack .starve-canvas {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath d='M4 3L22 21l-2 2L2 5z' fill='%23b0b4bc' stroke='%231d1409' stroke-width='1.8'/%3E%3Cpath d='M5 21l-3-3 6-6 3 3z' fill='%237a5b33' stroke='%231d1409' stroke-width='1.4'/%3E%3C/svg%3E") 4 4, crosshair;
diff --git a/src/games/components/marioLevels.js b/src/games/components/marioLevels.js
index dc5b745..e64753b 100644
--- a/src/games/components/marioLevels.js
+++ b/src/games/components/marioLevels.js
@@ -1,317 +1,715 @@
-// 超级玛丽关卡数据:按原版《超级马里奥兄弟》World 1-1 / 1-2 / 1-3 / 1-4 布局还原
-// 网格 15 行(13 行天空 + 2 行地面),每格 32px,与原版 NES 屏幕比例一致
-// 图例:
-// # 地面砖/墙 % 硬块(阶梯石块) = 可顶碎的砖块
-// ? 问号块(金币) M 问号块(蘑菇) [ ] 水管左/右半
-// o 金币 E 板栗仔(走到悬崖会掉下去)
-// T 乌龟(红龟习性,走到平台边缘会折返)
-// S 出生点 F 旗杆
-// 城堡关(1-4)专用:
-// ~ 岩浆(碰到即死) X 旋转火棍轴心(硬块 + 火球链)
-// - 木桥板(砍斧后塌落) A 斧头(摸到砍断桥,库巴落岩浆过关)
-// B 库巴(守桥 Boss,不可踩,只能跳过)
-export const ROWS = 15
+// marioLevels.js — 超级马里奥 1-1~3-4 关卡数据(字符网格,完全重写版)
+// 网格 15 行 × N 列,TILE=32。图例:
+// '#' 地面 '%' 硬块 '=' 砖块 '~' 岩浆 '-' 桥板
+// '?' 金币块 'M' 蘑菇块 'F' 火之花块 'W' 星星块 'U' 1UP 块
+// '[' ']' 水管口 '{' '}' 水管身 'P' 大嘴花(置于管顶上方一格)
+// 'o' 金币 '@' 出生点
+// 'E' 板栗仔 'T' 乌龟 'B' 库巴 'X' 火棒中心 'A' 斧头
+// '!' 旗杆基座 'C' 城堡锚点
+// 每关:{ name, theme, grids:[主网格, 奖励网格...], spawn:{g,c,r}, warps:[{g,c,r,dir,to:{g,c,r}}] }
+// 主题:overworld 地上 / underground 地下 / athletic 高空 / castle 城堡
-function emptyGrid(width) {
- return Array.from({ length: ROWS }, () => Array(width).fill(' '))
+export const ROWS = 15
+export const TILE = 32
+
+export const THEMES = {
+ // brick:砖块/砖块碎片专用色(此前缺失导致砖块继承上一笔 fillStyle 乱色)
+ overworld: { sky: '#5c94fc', ground: '#c84c0c', groundDark: '#8c3000', brick: '#c84c0c', hard: '#9c9c9c', deco: true },
+ underground: { sky: '#000000', ground: '#2078b4', groundDark: '#0058a8', brick: '#2078b4', hard: '#9c9c9c', deco: false },
+ athletic: { sky: '#5c94fc', ground: '#c84c0c', groundDark: '#8c3000', brick: '#c84c0c', hard: '#9c9c9c', deco: true },
+ castle: { sky: '#000000', ground: '#9c9c9c', groundDark: '#6c6c6c', brick: '#9c9c9c', hard: '#9c9c9c', deco: false },
+}
+
+function blank(cols) {
+ return Array.from({ length: ROWS }, () => Array(cols).fill(' '))
+}
+function ground(g, c0, c1, top = 13) {
+ for (let c = c0; c <= c1; c++) for (let r = top; r < ROWS; r++) g[r][c] = '#'
+}
+function ceil(g, c0, c1) {
+ for (let c = c0; c <= c1; c++) g[0][c] = '%'
+}
+function pipe(g, c, top, bodyTo = 12) {
+ g[top][c] = '['
+ g[top][c + 1] = ']'
+ for (let r = top + 1; r <= bodyTo; r++) {
+ g[r][c] = '{'
+ g[r][c + 1] = '}'
+ }
+}
+function stairUp(g, c0, h) {
+ for (let i = 0; i < h; i++) for (let r = 12 - i; r <= 12; r++) g[r][c0 + i] = '%'
+}
+function stairDown(g, c0, h) {
+ for (let i = 0; i < h; i++) for (let r = 12 - (h - 1 - i); r <= 12; r++) g[r][c0 + i] = '%'
+}
+function row(g, c0, c1, r, ch) {
+ for (let c = c0; c <= c1; c++) g[r][c] = ch
}
function put(g, c, r, ch) {
g[r][c] = ch
}
-function fillRow(g, r, c1, c2, ch) {
- for (let c = c1; c <= c2; c++) g[r][c] = ch
-}
-// 地面:两行厚的地砖(原版地面即 2 格厚)
-function ground(g, c1, c2) {
- fillRow(g, 13, c1, c2, '#')
- fillRow(g, 14, c1, c2, '#')
-}
-// 水管:高 h 格,占两列
-function pipe(g, c, h) {
- for (let r = 13 - h; r <= 12; r++) {
- put(g, c, r, '[')
- put(g, c + 1, r, ']')
- }
-}
-// 实心柱:从地面往上 h 格
-function column(g, c, h, ch) {
- for (let r = 13 - h; r <= 12; r++) put(g, c, r, ch)
-}
-// 上行阶梯:从左到右逐级升高(原版金字塔/终点阶梯)
-function stairUp(g, c1, h) {
- for (let i = 0; i < h; i++) column(g, c1 + i, i + 1, '%')
-}
-// 下行阶梯:从左到右逐级降低
-function stairDown(g, c1, h) {
- for (let i = 0; i < h; i++) column(g, c1 + i, h - i, '%')
-}
-// 竖墙:指定行区间填实
-function wall(g, c, r1, r2) {
- for (let r = r1; r <= r2; r++) put(g, c, r, '#')
-}
-// 岩浆池:填满地面两行的坑
-function lava(g, c1, c2) {
- fillRow(g, 13, c1, c2, '~')
- fillRow(g, 14, c1, c2, '~')
-}
-// ---------------------------------------------------------------
-// World 1-1:地面关。所有标志性段落按原版顺序还原:
-// 开场问号块阵 → 四根渐高水管(第四根可钻入地下奖励室)→ 双坑 →
-// 高空砖排 → 金字塔阶梯对 → 终点八级阶梯 → 旗杆与城堡
-// ---------------------------------------------------------------
-function buildWorld11() {
- const g = emptyGrid(234)
- // 地面四段,之间是三个坑(2 格、3 格、2 格,与原版一致)
+// —— 世界 1-1:地上主关 + 地下奖励房 ——
+function build11() {
+ const g = blank(236)
ground(g, 0, 68)
- ground(g, 71, 87)
- ground(g, 91, 135)
- ground(g, 138, 203)
- put(g, 3, 12, 'S')
- // 开场:单个问号块 + 砖?砖?砖 五连阵,高处再悬一个问号块
+ ground(g, 71, 85)
+ ground(g, 89, 152)
+ ground(g, 155, 235)
+ put(g, 2, 12, '@')
put(g, 16, 9, '?')
- put(g, 20, 9, '=')
- put(g, 21, 9, 'M') // 原版第一个蘑菇就藏在这里
- put(g, 22, 9, '=')
+ row(g, 20, 24, 9, '=')
+ put(g, 21, 9, 'M')
put(g, 23, 9, '?')
- put(g, 24, 9, '=')
- put(g, 22, 5, '?')
- put(g, 25, 12, 'E') // 全游戏第一只板栗仔
- // 四根水管:高度 2/3/4/4,最后一根是通往地下奖励室的暗道
- pipe(g, 28, 2)
- pipe(g, 38, 3)
- pipe(g, 46, 4)
- pipe(g, 57, 4)
- put(g, 36, 12, 'E')
- put(g, 51, 12, 'E')
- put(g, 53, 12, 'E')
- // 第一个坑之后:砖?砖 + 8 连高空砖排
- put(g, 77, 9, '=')
+ row(g, 21, 25, 5, '=')
+ put(g, 23, 5, '?')
+ pipe(g, 28, 11)
+ pipe(g, 38, 10)
+ pipe(g, 46, 9)
+ put(g, 46, 8, 'P')
+ pipe(g, 57, 9)
+ row(g, 77, 79, 9, '=')
put(g, 78, 9, '?')
- put(g, 79, 9, '=')
- fillRow(g, 5, 80, 87, '=')
- put(g, 81, 12, 'E')
- put(g, 84, 12, 'E')
- // 第二个坑上方:高空砖收尾 + 蘑菇问号块
- fillRow(g, 5, 91, 92, '=')
- put(g, 93, 5, 'M')
- put(g, 96, 9, '=')
- put(g, 100, 12, 'E')
- put(g, 102, 12, 'E')
- // 双问号块 + 乌龟(原版中段唯一一只绿龟位置)
- put(g, 106, 9, '?')
- put(g, 109, 9, '?')
- put(g, 107, 12, 'T')
- fillRow(g, 5, 112, 114, '=')
- // 金字塔阶梯第一对:上四级、平两格、下四级
- stairUp(g, 118, 4)
- stairDown(g, 124, 4)
- // 金字塔第二对:上四级后隔着 2 格深坑接双柱下三级(原版著名的"坑上跳")
- stairUp(g, 132, 4)
- column(g, 138, 4, '%')
- column(g, 139, 4, '%')
- stairDown(g, 140, 3)
- // 终点前两根矮水管夹着一对板栗仔
- pipe(g, 147, 2)
- put(g, 151, 12, 'E')
- put(g, 153, 12, 'E')
- pipe(g, 163, 2)
- // 终点八级大阶梯 → 旗杆 → 城堡
- stairUp(g, 169, 8)
- put(g, 184, 12, 'F')
- // 右侧挡墙:防止跑出主世界(正好在镜头最大范围之外)
- wall(g, 204, 0, 12)
- wall(g, 205, 0, 12)
- ground(g, 204, 205)
- // 地下奖励室(原版 1-1 暗道):顶棚 + 四面墙 + 两排金币 + 出口水管
- fillRow(g, 0, 206, 232, '#')
- fillRow(g, 1, 206, 232, '#')
- ground(g, 206, 232)
- wall(g, 206, 2, 12)
- wall(g, 232, 2, 12)
- fillRow(g, 8, 212, 222, 'o')
- fillRow(g, 11, 212, 222, 'o')
- pipe(g, 227, 2)
+ row(g, 78, 80, 5, '=')
+ put(g, 80, 5, 'F')
+ put(g, 101, 9, 'W')
+ row(g, 100, 104, 5, '=')
+ row(g, 106, 108, 9, '=')
+ row(g, 109, 112, 5, '=')
+ row(g, 118, 121, 9, '=')
+ put(g, 119, 9, '?')
+ put(g, 120, 9, '?')
+ put(g, 121, 5, 'U')
+ stairUp(g, 134, 4)
+ stairDown(g, 140, 4)
+ stairUp(g, 148, 4)
+ stairDown(g, 155, 4)
+ pipe(g, 163, 11)
+ stairUp(g, 181, 8)
+ put(g, 198, 12, '!')
+ put(g, 204, 12, 'C')
+ ;[[22], [40], [51], [53], [80], [82], [97], [99], [107], [114], [116], [124], [126], [128], [130]].forEach(([c]) => put(g, c, 12, 'E'))
+ // 地下奖励房
+ const b = blank(30)
+ ground(b, 0, 29)
+ row(b, 0, 29, 0, '%')
+ for (let r = 1; r <= 12; r++) { b[r][0] = '%'; b[r][29] = '%' }
+ row(b, 4, 24, 9, 'o')
+ row(b, 5, 23, 11, 'o')
+ pipe(b, 26, 11)
return {
name: '1-1',
theme: 'overworld',
- grid: g,
- mainCols: 204, // 主世界镜头边界(列)
- bonusStartCol: 206, // 奖励室起始列(镜头与配色切换分界)
- castleCol: 189,
+ grids: [g, b],
+ spawn: { g: 0, c: 2, r: 12 },
warps: [
- // 站在第四根水管顶按 ↓ → 进地下奖励室;站在出口水管顶按 ↓ → 回主世界
- { cols: [57, 58], row: 9, dest: { c: 209, r: 4 } },
- { cols: [227, 228], row: 11, dest: { c: 61, r: 13 } },
+ { g: 0, c: 57, r: 9, dir: 'down', to: { g: 1, c: 3, r: 11 } },
+ { g: 1, c: 26, r: 11, dir: 'down', to: { g: 0, c: 61, r: 11 } },
],
}
}
-// ---------------------------------------------------------------
-// World 1-2:地下关。全程顶棚封顶,蓝砖配色:
-// 开场双层砖台阶配金币 → 砖柱穿行 → 深坑 → 高台金币 →
-// 硬块阶梯群 → 地底水管 → 出口长阶梯
-// ---------------------------------------------------------------
-function buildWorld12() {
- const g = emptyGrid(140)
- // 顶棚(原版地下关标志性的两格厚天花板)
- fillRow(g, 0, 0, 139, '#')
- fillRow(g, 1, 0, 139, '#')
- // 地面三段,两个深坑
- ground(g, 0, 45)
- ground(g, 49, 65)
- ground(g, 69, 139)
- put(g, 2, 12, 'S')
- // 开场双层砖平台 + 顶上金币排(原版进洞第一眼)
- fillRow(g, 9, 8, 13, '=')
- fillRow(g, 8, 9, 12, 'o')
- fillRow(g, 5, 16, 21, '=')
- fillRow(g, 4, 17, 20, 'o')
- put(g, 18, 12, 'E')
- put(g, 24, 12, 'E')
- // 砖柱穿行段:三根渐高砖柱,柱间夹金币
- column(g, 28, 3, '=')
- column(g, 34, 4, '=')
- column(g, 40, 3, '=')
- fillRow(g, 11, 30, 32, 'o')
- fillRow(g, 11, 36, 38, 'o')
- put(g, 37, 12, 'T')
- // 第一个坑(46-48)后的高台:砖排 + 金币 + 台上乌龟巡逻
- fillRow(g, 9, 50, 54, '=')
- fillRow(g, 8, 50, 54, 'o')
- put(g, 52, 8, 'T')
- put(g, 58, 12, 'E')
- // 蘑菇问号块 + 金币问号块
- put(g, 60, 9, 'M')
- put(g, 62, 9, '?')
- // 第二个坑(66-68)后的硬块阶梯群:2/3/4 三柱,柱间乌龟看守
- column(g, 72, 2, '%')
- column(g, 76, 3, '%')
- column(g, 80, 4, '%')
- put(g, 78, 12, 'T')
- // 地面金币长排 + 板栗仔双人组
- fillRow(g, 11, 84, 90, 'o')
+// —— 世界 1-2:地下关,尽头上水管出地面到旗杆 ——
+function build12() {
+ const g = blank(200)
+ ground(g, 0, 168)
+ ceil(g, 0, 168)
+ put(g, 2, 12, '@')
+ row(g, 10, 14, 9, '=')
+ row(g, 10, 14, 10, 'o')
+ row(g, 18, 20, 6, '=')
+ put(g, 19, 6, 'M')
+ row(g, 26, 32, 9, '=')
+ row(g, 27, 31, 10, 'o')
+ put(g, 34, 12, 'E')
+ put(g, 40, 12, 'E')
+ stairUp(g, 46, 4)
+ stairDown(g, 52, 4)
+ row(g, 60, 64, 9, '%')
+ row(g, 61, 63, 10, 'o')
+ put(g, 70, 12, 'T')
+ row(g, 76, 80, 6, '=')
+ row(g, 76, 80, 7, 'o')
put(g, 88, 12, 'E')
- put(g, 91, 12, 'E')
- // 地底水管两根(原版地下关也有水管)
- pipe(g, 96, 2)
- pipe(g, 104, 3)
- put(g, 101, 12, 'E')
- // 砖台 + 金币收尾
- fillRow(g, 10, 108, 112, '=')
- fillRow(g, 9, 108, 112, 'o')
- // 出口六级长阶梯 → 旗杆
- stairUp(g, 118, 6)
- put(g, 134, 12, 'F')
- return { name: '1-2', theme: 'underground', grid: g, mainCols: 140, warps: [] }
+ put(g, 90, 12, 'E')
+ put(g, 96, 9, 'W')
+ row(g, 100, 106, 9, '=')
+ row(g, 101, 105, 10, 'o')
+ put(g, 112, 12, 'T')
+ stairUp(g, 120, 6)
+ row(g, 126, 130, 6, '=')
+ put(g, 128, 6, '?')
+ put(g, 130, 12, 'E')
+ pipe(g, 138, 7)
+ // 出口段:地上旗杆
+ const o = blank(48)
+ ground(o, 0, 47)
+ put(o, 2, 12, '@')
+ stairUp(o, 20, 8)
+ put(o, 38, 12, '!')
+ put(o, 43, 12, 'C')
+ return {
+ name: '1-2',
+ theme: 'underground',
+ grids: [g, o],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [
+ { g: 0, c: 138, r: 6, dir: 'side', to: { g: 1, c: 2, r: 11 } },
+ ],
+ }
}
-// ---------------------------------------------------------------
-// World 1-3:高空平台关。大部分是悬崖,踩着树顶平台跳跃前进:
-// 起步島 → 渐高平台链(乌龟看守)→ 中段小岛(蘑菇块)→
-// 高空金币平台 → 踏板跳 → 终点阶梯与旗杆
-// ---------------------------------------------------------------
-function buildWorld13() {
- const g = emptyGrid(124)
- // 起步岛
- ground(g, 0, 8)
- put(g, 2, 12, 'S')
- // 平台链第一段:低台阶配金币
- fillRow(g, 11, 12, 16, '=')
- fillRow(g, 10, 13, 15, 'o')
- // 乌龟看守的平台(红龟习性来回巡逻)
- fillRow(g, 9, 20, 23, '=')
- fillRow(g, 7, 20, 23, 'o')
- put(g, 21, 8, 'T')
- fillRow(g, 11, 27, 29, '=')
- // 渐高平台 + 金币 + 乌龟
- fillRow(g, 8, 33, 37, '=')
- fillRow(g, 7, 34, 36, 'o')
- put(g, 35, 7, 'T')
- fillRow(g, 10, 41, 44, '=')
- put(g, 42, 9, 'T')
- fillRow(g, 12, 48, 52, '=')
- // 中段落脚小岛:悬空蘑菇块 + 板栗仔双人组
- ground(g, 56, 63)
- put(g, 58, 8, 'M')
+// —— 世界 1-3:高空平台关 ——
+function build13() {
+ const g = blank(160)
+ ground(g, 0, 20)
+ ground(g, 30, 44)
+ ground(g, 78, 96)
+ ground(g, 128, 159)
+ put(g, 2, 12, '@')
+ row(g, 24, 28, 9, '%')
+ row(g, 33, 35, 6, '%')
+ put(g, 34, 5, 'o')
+ row(g, 40, 44, 9, '%')
+ put(g, 42, 8, 'T')
+ row(g, 48, 52, 9, '%')
+ row(g, 49, 51, 5, 'o')
+ row(g, 58, 62, 8, '%')
+ put(g, 60, 7, 'E')
+ row(g, 66, 70, 6, '%')
+ put(g, 68, 5, 'M')
+ row(g, 74, 76, 9, '%')
+ put(g, 84, 12, 'E')
+ put(g, 86, 12, 'E')
+ row(g, 100, 104, 8, '%')
+ row(g, 101, 103, 4, 'o')
+ put(g, 108, 7, 'T')
+ row(g, 112, 116, 9, '%')
+ row(g, 122, 126, 6, '%')
+ put(g, 124, 5, 'W')
+ stairUp(g, 134, 8)
+ put(g, 148, 12, '!')
+ put(g, 154, 12, 'C')
+ put(g, 34, 12, 'E')
+ put(g, 90, 12, 'E')
+ return {
+ name: '1-3',
+ theme: 'athletic',
+ grids: [g],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [],
+ }
+}
+
+// —— 世界 1-4:城堡关,火棒 + 库巴 + 斧头断桥 ——
+function build14() {
+ const g = blank(180)
+ ground(g, 0, 36)
+ ground(g, 42, 62)
+ ground(g, 68, 94)
+ ground(g, 100, 120)
+ ground(g, 138, 179)
+ put(g, 2, 12, '@')
+ row(g, 37, 41, 13, '~')
+ row(g, 37, 41, 14, '~')
+ row(g, 63, 67, 13, '~')
+ row(g, 63, 67, 14, '~')
+ row(g, 95, 99, 13, '~')
+ row(g, 95, 99, 14, '~')
+ row(g, 121, 137, 13, '~')
+ row(g, 121, 137, 14, '~')
+ put(g, 30, 9, 'X')
+ put(g, 50, 8, 'X')
+ put(g, 58, 11, 'X')
+ put(g, 78, 9, 'X')
+ put(g, 86, 11, 'X')
+ put(g, 106, 8, 'X')
+ put(g, 114, 11, 'X')
+ row(g, 44, 48, 6, '=')
+ row(g, 72, 76, 6, '=')
+ put(g, 74, 6, 'M')
+ row(g, 100, 104, 5, '=')
+ put(g, 102, 5, 'F')
+ row(g, 122, 137, 12, '-')
+ put(g, 130, 8, 'B')
+ put(g, 143, 12, 'A')
+ return {
+ name: '1-4',
+ theme: 'castle',
+ grids: [g],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [],
+ }
+}
+
+// —— 世界 2-1:地上关,水管迷宫 + 奖励房,敌人密度提升 ——
+function build21() {
+ const g = blank(230)
+ ground(g, 0, 38)
+ ground(g, 42, 72)
+ ground(g, 76, 118)
+ ground(g, 122, 165)
+ ground(g, 169, 229)
+ put(g, 2, 12, '@')
+ put(g, 10, 9, '?')
+ row(g, 14, 18, 9, '=')
+ put(g, 15, 9, '?')
+ put(g, 17, 9, '?')
+ row(g, 15, 17, 5, '=')
+ put(g, 16, 5, 'F')
+ put(g, 22, 12, 'E')
+ pipe(g, 24, 11)
+ put(g, 28, 12, 'E')
+ pipe(g, 32, 10)
+ put(g, 32, 9, 'P')
+ put(g, 36, 12, 'E')
+ row(g, 46, 48, 9, '=')
+ put(g, 47, 9, 'M')
+ put(g, 50, 12, 'T')
+ pipe(g, 54, 9)
+ put(g, 66, 12, 'E')
+ put(g, 68, 12, 'E')
+ row(g, 64, 68, 9, '=')
+ row(g, 64, 68, 8, 'o')
+ put(g, 70, 12, 'T')
+ row(g, 78, 80, 9, '=')
+ put(g, 79, 9, 'W')
+ row(g, 82, 83, 9, '=')
+ row(g, 84, 88, 5, '=')
+ row(g, 84, 88, 4, 'o')
+ put(g, 90, 12, 'E')
+ put(g, 92, 12, 'E')
+ stairUp(g, 96, 4)
+ stairDown(g, 102, 4)
+ put(g, 100, 12, 'T')
+ row(g, 106, 110, 9, '=')
+ put(g, 108, 9, '?')
+ put(g, 112, 12, 'T')
+ pipe(g, 116, 11)
+ put(g, 116, 10, 'P')
+ row(g, 124, 126, 9, '=')
+ put(g, 125, 9, '?')
+ put(g, 128, 12, 'E')
+ put(g, 130, 12, 'E')
+ row(g, 134, 138, 9, '=')
+ row(g, 134, 138, 5, '=')
+ put(g, 136, 5, '?')
+ put(g, 142, 12, 'T')
+ stairUp(g, 148, 4)
+ stairDown(g, 154, 4)
+ put(g, 158, 9, 'U')
+ row(g, 155, 159, 5, '=')
+ row(g, 156, 158, 4, 'o')
+ put(g, 162, 12, 'E')
+ put(g, 164, 12, 'E')
+ stairUp(g, 176, 8)
+ put(g, 188, 12, 'E')
+ put(g, 190, 12, 'E')
+ put(g, 200, 12, '!')
+ put(g, 206, 12, 'C')
+ // 奖励房
+ const b = blank(24)
+ ground(b, 0, 23)
+ row(b, 0, 23, 0, '%')
+ for (let r = 1; r <= 12; r++) { b[r][0] = '%'; b[r][23] = '%' }
+ row(b, 4, 19, 9, 'o')
+ row(b, 5, 18, 11, 'o')
+ pipe(b, 20, 11)
+ return {
+ name: '2-1',
+ theme: 'overworld',
+ grids: [g, b],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [
+ { g: 0, c: 54, r: 9, dir: 'down', to: { g: 1, c: 3, r: 11 } },
+ { g: 1, c: 20, r: 11, dir: 'down', to: { g: 0, c: 58, r: 11 } },
+ ],
+ }
+}
+
+// —— 世界 2-2:地下关,高低通道 + 龟阵,尽头上水管出地面 ——
+function build22() {
+ const g = blank(156)
+ ground(g, 0, 140)
+ ceil(g, 0, 140)
+ put(g, 2, 12, '@')
+ row(g, 8, 12, 9, '=')
+ row(g, 9, 11, 10, 'o')
+ put(g, 16, 12, 'E')
+ put(g, 18, 12, 'E')
+ row(g, 22, 26, 6, '=')
+ put(g, 24, 6, 'M')
+ stairUp(g, 30, 4)
+ stairDown(g, 36, 4)
+ put(g, 34, 12, 'T')
+ row(g, 44, 50, 9, '%')
+ row(g, 45, 49, 10, 'o')
+ put(g, 54, 12, 'E')
+ put(g, 56, 12, 'E')
+ put(g, 58, 12, 'E')
+ row(g, 60, 64, 6, '=')
+ row(g, 60, 64, 7, 'o')
+ put(g, 68, 9, 'W')
+ put(g, 72, 12, 'T')
+ stairUp(g, 76, 6)
+ row(g, 82, 86, 5, '%')
+ row(g, 82, 86, 4, 'o')
+ stairDown(g, 88, 6)
+ put(g, 96, 12, 'E')
+ put(g, 98, 12, 'E')
+ row(g, 102, 108, 9, '=')
+ put(g, 105, 9, '?')
+ row(g, 103, 107, 10, 'o')
+ put(g, 112, 12, 'T')
+ put(g, 114, 12, 'T')
+ stairUp(g, 118, 4)
+ stairDown(g, 124, 4)
+ row(g, 128, 132, 9, '=')
+ put(g, 130, 9, '?')
+ row(g, 129, 131, 8, 'o')
+ row(g, 134, 135, 9, '%')
+ put(g, 132, 12, 'E')
+ pipe(g, 138, 7)
+ // 出口段:地上旗杆
+ const o = blank(48)
+ ground(o, 0, 47)
+ put(o, 2, 12, '@')
+ stairUp(o, 20, 8)
+ put(o, 38, 12, '!')
+ put(o, 43, 12, 'C')
+ return {
+ name: '2-2',
+ theme: 'underground',
+ grids: [g, o],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [
+ { g: 0, c: 138, r: 7, dir: 'side', to: { g: 1, c: 2, r: 11 } },
+ ],
+ }
+}
+
+// —— 世界 2-3:高空平台关,连续跳台 + 平台乌龟 ——
+function build23() {
+ const g = blank(170)
+ ground(g, 0, 18)
+ ground(g, 26, 40)
+ ground(g, 48, 60)
+ ground(g, 68, 80)
+ ground(g, 88, 102)
+ ground(g, 110, 124)
+ ground(g, 132, 169)
+ put(g, 2, 12, '@')
+ row(g, 20, 24, 9, '%')
+ row(g, 21, 23, 5, 'o')
+ put(g, 34, 12, 'E')
+ row(g, 30, 34, 8, '%')
+ put(g, 32, 7, 'T')
+ row(g, 42, 45, 9, '%')
+ row(g, 43, 44, 8, 'o')
+ row(g, 50, 54, 9, '%')
+ put(g, 52, 5, 'M')
+ put(g, 52, 8, 'E')
+ row(g, 57, 59, 6, '%')
+ row(g, 57, 59, 5, 'o')
+ row(g, 63, 65, 10, '%')
+ put(g, 72, 12, 'E')
+ put(g, 74, 12, 'E')
+ row(g, 70, 74, 9, '%')
+ row(g, 71, 73, 8, 'o')
+ row(g, 83, 85, 10, '%')
+ put(g, 90, 12, 'E')
+ put(g, 92, 12, 'E')
+ put(g, 90, 9, 'W')
+ row(g, 96, 100, 9, '%')
+ row(g, 97, 99, 5, 'o')
+ put(g, 98, 8, 'T')
+ row(g, 104, 106, 10, '%')
+ row(g, 104, 106, 9, 'o')
+ put(g, 114, 12, 'E')
+ put(g, 116, 12, 'E')
+ row(g, 108, 110, 9, '%')
+ row(g, 112, 116, 6, '%')
+ put(g, 114, 5, 'F')
+ row(g, 127, 129, 10, '%')
+ row(g, 127, 129, 9, 'o')
+ put(g, 134, 12, 'T')
+ stairUp(g, 140, 8)
+ put(g, 154, 12, '!')
+ put(g, 160, 12, 'C')
+ return {
+ name: '2-3',
+ theme: 'athletic',
+ grids: [g],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [],
+ }
+}
+
+// —— 世界 2-4:城堡关,火棒阵 + 长岩浆桥 + 库巴 ——
+function build24() {
+ const g = blank(190)
+ ground(g, 0, 30)
+ ground(g, 34, 56)
+ ground(g, 60, 84)
+ ground(g, 88, 108)
+ ground(g, 112, 130)
+ ground(g, 148, 189)
+ ;[[31, 33], [57, 59], [85, 87], [109, 111], [131, 147]].forEach(([c0, c1]) => {
+ row(g, c0, c1, 13, '~')
+ row(g, c0, c1, 14, '~')
+ })
+ put(g, 2, 12, '@')
+ put(g, 24, 9, 'X')
+ row(g, 38, 42, 9, '=')
+ put(g, 40, 9, 'M')
+ put(g, 46, 8, 'X')
+ put(g, 50, 11, 'X')
+ put(g, 62, 9, '?')
+ put(g, 66, 9, 'X')
+ put(g, 70, 8, 'X')
+ row(g, 72, 76, 9, '=')
+ put(g, 74, 9, 'F')
+ put(g, 76, 11, 'X')
+ put(g, 92, 9, 'X')
+ put(g, 96, 9, 'W')
+ put(g, 100, 8, 'X')
+ put(g, 114, 11, 'X')
+ put(g, 118, 9, 'X')
+ put(g, 124, 8, 'X')
+ row(g, 131, 147, 12, '-')
+ put(g, 138, 8, 'B')
+ put(g, 152, 12, 'A')
+ put(g, 158, 12, 'C')
+ return {
+ name: '2-4',
+ theme: 'castle',
+ grids: [g],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [],
+ }
+}
+
+// —— 世界 3-1:大嘴花大道,五根花管 + 高密度敌人 ——
+function build31() {
+ const g = blank(240)
+ ground(g, 0, 34)
+ ground(g, 38, 70)
+ ground(g, 74, 108)
+ ground(g, 112, 150)
+ ground(g, 154, 196)
+ ground(g, 200, 239)
+ put(g, 2, 12, '@')
+ row(g, 12, 16, 9, '=')
+ put(g, 14, 9, '?')
+ row(g, 13, 15, 5, '=')
+ put(g, 14, 5, 'M')
+ put(g, 20, 12, 'E')
+ pipe(g, 26, 11)
+ put(g, 26, 10, 'P')
+ put(g, 31, 12, 'E')
+ pipe(g, 44, 10)
+ put(g, 44, 9, 'P')
+ put(g, 48, 12, 'E')
+ put(g, 50, 12, 'E')
+ put(g, 54, 12, 'T')
+ put(g, 64, 12, 'E')
+ put(g, 66, 12, 'E')
+ put(g, 68, 9, '?')
+ put(g, 84, 12, 'E')
+ put(g, 86, 12, 'E')
+ pipe(g, 96, 9)
+ put(g, 96, 8, 'P')
+ put(g, 100, 9, 'W')
+ put(g, 104, 12, 'E')
+ put(g, 106, 12, 'E')
+ put(g, 116, 12, 'E')
+ put(g, 118, 12, 'E')
+ put(g, 126, 12, 'T')
+ row(g, 130, 134, 9, '=')
+ row(g, 130, 134, 8, 'o')
+ stairUp(g, 140, 4)
+ stairDown(g, 146, 4)
+ put(g, 144, 12, 'T')
+ put(g, 156, 12, 'T')
+ pipe(g, 160, 10)
+ put(g, 160, 9, 'P')
+ row(g, 166, 170, 9, '=')
+ put(g, 168, 9, '?')
+ put(g, 174, 12, 'E')
+ put(g, 176, 12, 'E')
+ pipe(g, 182, 9)
+ put(g, 182, 8, 'P')
+ row(g, 186, 190, 5, '=')
+ row(g, 186, 190, 4, 'o')
+ put(g, 194, 12, 'T')
+ stairUp(g, 210, 8)
+ put(g, 220, 12, 'E')
+ put(g, 222, 12, 'E')
+ put(g, 226, 12, '!')
+ put(g, 232, 12, 'C')
+ return { name: '3-1', theme: 'overworld', grids: [g], spawn: { g: 0, c: 2, r: 12 }, warps: [] }
+}
+
+// —— 世界 3-2:地下回廊,三连板栗仔 + 高低货架 ——
+function build32() {
+ const g = blank(156)
+ ground(g, 0, 152)
+ ceil(g, 0, 152)
+ put(g, 2, 12, '@')
+ row(g, 8, 14, 9, '=')
+ row(g, 9, 13, 10, 'o')
+ put(g, 18, 12, 'E')
+ put(g, 20, 12, 'E')
+ put(g, 22, 12, 'E')
+ row(g, 24, 30, 6, '=')
+ put(g, 27, 6, 'M')
+ stairUp(g, 34, 5)
+ stairDown(g, 41, 5)
+ put(g, 39, 12, 'T')
+ row(g, 48, 54, 9, '%')
+ row(g, 49, 53, 10, 'o')
+ put(g, 56, 12, 'E')
+ put(g, 58, 12, 'E')
put(g, 60, 12, 'E')
- put(g, 62, 12, 'E')
- // 后半段高空平台链
- fillRow(g, 10, 67, 70, '=')
- fillRow(g, 7, 74, 77, '=')
- fillRow(g, 4, 74, 77, 'o')
- put(g, 75, 6, 'T')
- // 双踏板跳
- fillRow(g, 10, 82, 84, '=')
- fillRow(g, 10, 87, 89, '=')
- fillRow(g, 9, 87, 89, 'o')
- // 终点大陆:板栗仔迎接 + 四级阶梯 + 旗杆 + 城堡
- ground(g, 93, 123)
- put(g, 97, 12, 'E')
- put(g, 99, 12, 'E')
- stairUp(g, 103, 4)
- put(g, 112, 12, 'F')
- return { name: '1-3', theme: 'athletic', grid: g, mainCols: 124, castleCol: 116, warps: [] }
+ row(g, 62, 66, 6, '=')
+ row(g, 62, 66, 7, 'o')
+ put(g, 70, 9, 'W')
+ put(g, 74, 12, 'T')
+ stairUp(g, 78, 6)
+ row(g, 84, 88, 5, '%')
+ row(g, 84, 88, 4, 'o')
+ stairDown(g, 90, 6)
+ put(g, 96, 12, 'E')
+ put(g, 98, 12, 'E')
+ row(g, 100, 106, 9, '=')
+ put(g, 103, 9, '?')
+ row(g, 101, 105, 10, 'o')
+ put(g, 110, 12, 'T')
+ put(g, 112, 12, 'T')
+ stairUp(g, 116, 4)
+ stairDown(g, 122, 4)
+ row(g, 126, 130, 9, '=')
+ put(g, 128, 9, '?')
+ put(g, 134, 12, 'E')
+ put(g, 136, 12, 'E')
+ row(g, 136, 137, 9, '%')
+ pipe(g, 140, 7)
+ const o = blank(48)
+ ground(o, 0, 47)
+ put(o, 2, 12, '@')
+ stairUp(o, 20, 8)
+ put(o, 38, 12, '!')
+ put(o, 43, 12, 'C')
+ return {
+ name: '3-2',
+ theme: 'underground',
+ grids: [g, o],
+ spawn: { g: 0, c: 2, r: 12 },
+ warps: [
+ { g: 0, c: 140, r: 7, dir: 'side', to: { g: 1, c: 2, r: 11 } },
+ ],
+ }
}
-// ---------------------------------------------------------------
-// World 1-4:城堡关(第一世界收官)。灰砖封顶配色,按原版节奏还原:
-// 入口大厅 → 岩浆坑与旋转火棍交替的长廊 → 高空砖台捷径 →
-// 桥前高台 → 库巴守木桥 → 摸到斧头砍断锁链,桥塌库巴落岩浆 → 过关
-// ---------------------------------------------------------------
-function buildWorld14() {
- const g = emptyGrid(150)
- // 城堡封顶(两格厚天花板)
- fillRow(g, 0, 0, 149, '#')
- fillRow(g, 1, 0, 149, '#')
- // 地面段,坑内全部是岩浆
- ground(g, 0, 37)
- ground(g, 42, 63)
- ground(g, 68, 95)
- ground(g, 100, 121)
- ground(g, 138, 149)
- lava(g, 38, 41)
- lava(g, 64, 67)
- lava(g, 96, 99)
- lava(g, 122, 137) // 库巴桥下的大岩浆池
- put(g, 2, 12, 'S')
- // 入口大厅:一对装饰柱,先见识第一根火棍
- column(g, 8, 1, '%')
- column(g, 11, 1, '%')
- put(g, 16, 9, 'X') // 半空火棍:练习钻档期
- put(g, 25, 12, 'X') // 贴地火棍:跳过轴心块
- fillRow(g, 8, 30, 33, 'o')
- // 第一岩浆坑上方的高空砖台(可走上路避火)
- fillRow(g, 9, 44, 48, '=')
- fillRow(g, 8, 45, 47, 'o')
- put(g, 52, 9, 'X')
- put(g, 60, 12, 'X')
- // 第二段长廊:蘑菇补给 + 双火棍
- put(g, 71, 9, 'M')
- put(g, 76, 9, 'X')
- fillRow(g, 10, 80, 83, '=')
- fillRow(g, 9, 80, 83, 'o')
- put(g, 88, 12, 'X')
- fillRow(g, 8, 91, 93, 'o')
- // 桥前最后一根火棍 + 上桥高台
- put(g, 106, 9, 'X')
- fillRow(g, 11, 112, 114, 'o')
- column(g, 118, 1, '%')
- column(g, 119, 1, '%')
- column(g, 120, 1, '%')
- column(g, 121, 1, '%')
- // 库巴木桥(横跨大岩浆池,砍斧后整段塌落)
- fillRow(g, 12, 122, 137, '-')
- put(g, 128, 10, 'B')
- // 斧头:桥尾地面上方一格
- put(g, 139, 12, 'A')
- // 尾墙封死
- wall(g, 148, 2, 12)
- wall(g, 149, 2, 12)
- return { name: '1-4', theme: 'castle', grid: g, mainCols: 150, warps: [] }
+// —— 世界 3-3:高空连续跳台,平台乌龟 + 花块 ——
+function build33() {
+ const g = blank(190)
+ ground(g, 0, 16)
+ ground(g, 24, 36)
+ ground(g, 44, 56)
+ ground(g, 64, 76)
+ ground(g, 84, 96)
+ ground(g, 104, 116)
+ ground(g, 124, 136)
+ ground(g, 144, 189)
+ put(g, 2, 12, '@')
+ row(g, 18, 22, 9, '%')
+ row(g, 19, 21, 5, 'o')
+ put(g, 28, 12, 'E')
+ put(g, 30, 12, 'E')
+ row(g, 30, 34, 8, '%')
+ put(g, 32, 7, 'T')
+ row(g, 40, 42, 10, '%')
+ row(g, 48, 52, 9, '%')
+ put(g, 50, 5, 'M')
+ put(g, 50, 8, 'E')
+ put(g, 50, 12, 'T')
+ row(g, 58, 60, 6, '%')
+ row(g, 58, 60, 5, 'o')
+ row(g, 66, 68, 10, '%')
+ put(g, 68, 12, 'E')
+ put(g, 70, 12, 'E')
+ row(g, 79, 81, 10, '%')
+ row(g, 86, 90, 8, '%')
+ row(g, 87, 89, 7, 'o')
+ put(g, 94, 9, 'W')
+ put(g, 90, 12, 'E')
+ put(g, 92, 12, 'E')
+ row(g, 99, 101, 10, '%')
+ row(g, 108, 112, 8, '%')
+ put(g, 110, 7, 'T')
+ row(g, 114, 118, 6, '%')
+ row(g, 114, 118, 5, 'o')
+ put(g, 116, 5, 'F')
+ put(g, 130, 12, 'E')
+ put(g, 132, 12, 'E')
+ row(g, 119, 121, 10, '%')
+ row(g, 127, 129, 10, '%')
+ row(g, 139, 141, 10, '%')
+ put(g, 146, 12, 'T')
+ put(g, 148, 12, 'E')
+ stairUp(g, 152, 8)
+ put(g, 166, 12, '!')
+ put(g, 172, 12, 'C')
+ return { name: '3-3', theme: 'athletic', grids: [g], spawn: { g: 0, c: 2, r: 12 }, warps: [] }
+}
+
+// —— 世界 3-4:城堡,密集火棒 + 长岩浆桥 + 库巴 ——
+function build34() {
+ const g = blank(210)
+ ground(g, 0, 28)
+ ground(g, 32, 54)
+ ground(g, 58, 82)
+ ground(g, 86, 104)
+ ground(g, 108, 126)
+ ground(g, 152, 209)
+ ;[[29, 31], [55, 57], [83, 85], [105, 107], [127, 151]].forEach(([c0, c1]) => {
+ row(g, c0, c1, 13, '~')
+ row(g, c0, c1, 14, '~')
+ })
+ row(g, 127, 151, 12, '-')
+ put(g, 2, 12, '@')
+ put(g, 20, 9, 'X')
+ put(g, 36, 8, 'X')
+ row(g, 38, 42, 9, '=')
+ put(g, 40, 9, '?')
+ put(g, 44, 11, 'X')
+ put(g, 48, 9, 'X')
+ put(g, 62, 9, 'X')
+ row(g, 64, 68, 9, '=')
+ put(g, 66, 9, 'M')
+ put(g, 70, 8, 'X')
+ put(g, 76, 11, 'X')
+ put(g, 90, 9, 'X')
+ row(g, 92, 96, 9, '=')
+ put(g, 94, 9, 'F')
+ put(g, 98, 8, 'X')
+ put(g, 112, 11, 'X')
+ row(g, 114, 118, 9, '=')
+ put(g, 118, 9, '?')
+ put(g, 122, 9, 'X')
+ put(g, 136, 9, 'X')
+ put(g, 142, 8, 'X')
+ put(g, 140, 8, 'B')
+ put(g, 158, 12, 'A')
+ put(g, 172, 12, 'C')
+ return { name: '3-4', theme: 'castle', grids: [g], spawn: { g: 0, c: 2, r: 12 }, warps: [] }
}
-// 构建全部关卡(每次调用返回全新副本,供重开/复活时重置砖块状态)
export function buildLevels() {
- return [buildWorld11(), buildWorld12(), buildWorld13(), buildWorld14()]
+ return [build11(), build12(), build13(), build14(), build21(), build22(), build23(), build24(), build31(), build32(), build33(), build34()].map((lv) => ({
+ ...lv,
+ grids: lv.grids.map((gr) => gr.map((r) => r.slice())),
+ }))
}
diff --git a/src/games/components/marioSprites.js b/src/games/components/marioSprites.js
new file mode 100644
index 0000000..bd4d68c
--- /dev/null
+++ b/src/games/components/marioSprites.js
@@ -0,0 +1,499 @@
+// marioSprites.js — 马里奥像素图案精灵(手绘近似像素画 + 离屏缓存)
+// 字符图例随各图案 palette 定义;'.' 为透明。绘制走 drawImage 缓存,性能友好。
+
+// 小马里奥 12×16(stand / walk1 / walk2 / jump / skid / dead)
+const SM_STAND = [
+ '...RRRRRR...',
+ '..RRRRRRRRRR',
+ '..BBBSSKS...',
+ '.BSBSSSKSSS.',
+ '.BSBBSSSKSS.',
+ '.BBSSSSKKKK.',
+ '...SSSSSS...',
+ '..RROORRR...',
+ '.RRROOOORRR.',
+ 'RRRROOOORRRR',
+ 'SSROYOOYORSS',
+ 'SSSOOOOOOSSS',
+ 'SSOOOOOOOOSS',
+ '..OOO..OOO..',
+ '.BBB....BBB.',
+ 'BBBB....BBBB',
+]
+const SM_WALK1 = [
+ '...RRRRRR...',
+ '..RRRRRRRRRR',
+ '..BBBSSKS...',
+ '.BSBSSSKSSS.',
+ '.BSBBSSSKSS.',
+ '.BBSSSSKKKK.',
+ '...SSSSSS...',
+ '..RROORRS...',
+ '..RROOORSS..',
+ '.RRROOORRSS.',
+ '.SSOOOOOSS..',
+ '..OOOOOOO...',
+ '..OOOOOOO...',
+ '...OOOOO....',
+ '..BBBB.BBB..',
+ '.BBBB..BBBB.',
+]
+const SM_WALK2 = [
+ '...RRRRRR...',
+ '..RRRRRRRRRR',
+ '..BBBSSKS...',
+ '.BSBSSSKSSS.',
+ '.BSBBSSSKSS.',
+ '.BBSSSSKKKK.',
+ '...SSSSSS...',
+ '.SRROORRR...',
+ 'SSRROOOORR..',
+ 'SSRROOOORRR.',
+ '..OOOOOORR..',
+ '..OOOOOOO...',
+ '..OOOOOO....',
+ '..OOOOO.....',
+ '..BBBBB.....',
+ '.BBBBBBB....',
+]
+const SM_JUMP = [
+ '...RRRRRR.SS',
+ '..RRRRRRRRSS',
+ '..BBBSSKS.SS',
+ '.BSBSSSKSS..',
+ '.BSBBSSSKSS.',
+ '.BBSSSSKKKK.',
+ '...SSSSSS...',
+ 'SSRROORRR...',
+ 'SSRROOOORR..',
+ '.RROOOOOOR..',
+ '..OOOOOORR..',
+ '..OOOOOOOO..',
+ '..OOOOOOO...',
+ '..OOOOOO....',
+ '.BBB..BBB...',
+ 'BBBB...BBBB.',
+]
+const SM_SKID = [
+ '...RRRRRR...',
+ '..RRRRRRRRRR',
+ '..BBBSSKK...',
+ '.BSBSSSKKSS.',
+ '.BSBBSSSKSS.',
+ '.BBSSSSKKKK.',
+ '...SSSSSS...',
+ 'S.RROORRR...',
+ 'SSRROOOOR...',
+ '.RROOOOORRR.',
+ '..OOOOOOORSS',
+ '..OOOOOOOSSS',
+ '..OOOOOOOO..',
+ '..OOO.OOO...',
+ '.BBB...BBB..',
+ 'BBBB...BBBB.',
+]
+const SM_DEAD = [
+ '...RRRRRR...',
+ '..RRRRRRRRRR',
+ '..BBBSSSS...',
+ '.BSBSSSKSS..',
+ '.BSBBSSSKSS.',
+ '.BBSSSSSSS..',
+ '...SSSSSS...',
+ 'SSRROORRRSS.',
+ '.RROOOORRR..',
+ '..OOOOOOO...',
+ '.OOOOOOOOO..',
+ '.OO.OOO.OO..',
+ '...OOOOO....',
+ '..O.O.O.O...',
+ '.BB.....BB..',
+ 'BBBB...BBBB.',
+]
+// 大马里奥 16×32(stand / walk1 / walk2 / jump / crouch)
+const BG_STAND = [
+ '....RRRRRRR.....',
+ '...RRRRRRRRRR...',
+ '...RRRRRRRRRRR..',
+ '...BBBSSSKS.....',
+ '..BSBSSSSKSS....',
+ '..BSBSSSSSKSSS..',
+ '..BSBBSSSSKSSS..',
+ '..BBSSSSSSKKKK..',
+ '....SSSSSSSS....',
+ '...RRRROORRRR...',
+ '..RRRRROOOORRRR.',
+ '.RRRRRROOOORRRRR',
+ '.RRRRROOOOOORRRR',
+ '.SSRROOYYOOORRSS',
+ '.SSROOYYYYOORSSS',
+ '.SSOOOYYYYOOOSSS',
+ '.SSOOOOYYOOOOSSS',
+ '.SSOOOOOOOOOOSSS',
+ '..SOOOOOOOOOOS..',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '..OOOOO..OOOOO..',
+ '..OOOO....OOOO..',
+ '..OOOO....OOOO..',
+ '..OOOO....OOOO..',
+ '..OOOO....OOOO..',
+ '..BBBB....BBBB..',
+ '..BBBB....BBBB..',
+ '.BBBBB....BBBBB.',
+ '.BBBBB....BBBBB.',
+ 'BBBBBB....BBBBBB',
+ 'BBBBBB....BBBBBB',
+]
+const BG_WALK1 = [
+ '....RRRRRRR.....',
+ '...RRRRRRRRRR...',
+ '...RRRRRRRRRRR..',
+ '...BBBSSSKS.....',
+ '..BSBSSSSKSS....',
+ '..BSBSSSSSKSSS..',
+ '..BSBBSSSSKSSS..',
+ '..BBSSSSSSKKKK..',
+ '....SSSSSSSS....',
+ '...RRRROORRRR...',
+ '..RRRRROOOORRR..',
+ '..RRRRROOOORRRR.',
+ '..RRRROOOOOORRR.',
+ '..SRROOYYOOORRS.',
+ '..SROOYYYYOORSS.',
+ '..SOOOYYYYOOOSS.',
+ '..SOOOOYYOOOOSS.',
+ '..SOOOOOOOOOOS..',
+ '...OOOOOOOOOOS..',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '...OOOOO.OOOO...',
+ '..OOOOO..OOOO...',
+ '..OOOO....OOO...',
+ '..OOOO....OOOO..',
+ '..BBBB....BBBB..',
+ '..BBBBB...BBBB..',
+ '.BBBBB....BBBB..',
+ '.BBBB......BBBB.',
+ 'BBBBB......BBBB.',
+ 'BBBB.......BBBBB',
+ 'BBBB........BBB.',
+]
+const BG_WALK2 = [
+ '....RRRRRRR.....',
+ '...RRRRRRRRRR...',
+ '...RRRRRRRRRRR..',
+ '...BBBSSSKS.....',
+ '..BSBSSSSKSS....',
+ '..BSBSSSSSKSSS..',
+ '..BSBBSSSSKSSS..',
+ '..BBSSSSSSKKKK..',
+ '....SSSSSSSS....',
+ '...RRRROORRRR...',
+ '..RRRRROOOORRR..',
+ '..RRRRROOOORRRR.',
+ '.RRRRROOOOOORRR.',
+ '.SSRROOYYOOORRS.',
+ '.SSROOYYYYOORS..',
+ '.SSOOOYYYYOOOS..',
+ '.SSOOOOYYOOOO...',
+ '..SOOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOO....',
+ '...OOOO.OOOO....',
+ '..OOOO...OOOO...',
+ '..OOOO...OOOO...',
+ '..BBBB...BBBB...',
+ '..BBBB...BBBB...',
+ '.BBBBB..BBBBB...',
+ '.BBBBB..BBBBB...',
+ 'BBBBB....BBBB...',
+ 'BBBB.....BBBBB..',
+ 'BBB.......BBBBB.',
+ 'BBB.........BBB.',
+]
+const BG_JUMP = [
+ '....RRRRRRR..SSS',
+ '...RRRRRRRRRRSSS',
+ '...RRRRRRRRRRSSS',
+ '...BBBSSSKS..SS.',
+ '..BSBSSSSKSS....',
+ '..BSBSSSSSKSSS..',
+ '..BSBBSSSSKSSS..',
+ '..BBSSSSSSKKKK..',
+ '....SSSSSSSS....',
+ 'SSSRRRROORRRR...',
+ 'SSSRRRROOOORRR..',
+ '.SRRRROOOOORRRR.',
+ '..RRROOOOOOORRR.',
+ '..RROOYYOOOORR..',
+ '..ROOYYYYOORRS..',
+ '..SOOOYYYYOOOS..',
+ '..SOOOOYYOOOO...',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '...OOOOOOOOOO...',
+ '..OOOOOO.OOOO...',
+ '..OOOOO..OOOO...',
+ '..OOOO...OOOO...',
+ '..OOOO...OOOO...',
+ '..OOOO...OOOO...',
+ '..BBBB...BBBB...',
+ '..BBBB...BBBB...',
+ '.BBBBB..BBBBB...',
+ '.BBBBB..BBBBB...',
+ 'BBBBB....BBBBB..',
+ 'BBBBB.....BBBB..',
+ 'BBBB.......BBB..',
+]
+const BG_CROUCH = [
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '................',
+ '....RRRRRRR.....',
+ '...RRRRRRRRRR...',
+ '...BBBSSSKS.....',
+ '..BSBSSSSKSS....',
+ '..BSBBSSSSKSSS..',
+ '..BBSSSSSSKKKK..',
+ '....SSSSSSSS....',
+ '..RRRRROOOORRR..',
+ '.RRRRROOOOOORRRR',
+ '.SSROOYYYYOORRSS',
+ '.SSOOOYYYYOOOSSS',
+ '..SOOOOOOOOOOS..',
+ '...OOOOOOOOOO...',
+ '..OOOOO..OOOOO..',
+ '..OOOO....OOOO..',
+ '..OOOO....OOOO..',
+ '..OOOO....OOOO..',
+ '..BBBB....BBBB..',
+ '.BBBBB....BBBBB.',
+ 'BBBBBB....BBBBBB',
+ 'BBBBBB....BBBBBB',
+ '................',
+]
+// 板栗仔 14×14(walk1 / walk2 / flat)
+const GOOMBA1 = [
+ '....KKKKKK....',
+ '..KKBBBBBBKK..',
+ '.KBBBBBBBBBBK.',
+ 'KBBWWKBBKWWBBK',
+ 'KBWWKKBBKKWWBK',
+ 'KBBKKBBBBKKBBK',
+ 'KBBBBBBBBBBBBK',
+ 'KBBBBBBBBBBBBK',
+ '.KBBBBBBBBBBK.',
+ '..KTTTTTTTTK..',
+ '.KTTKKKKKKTTK.',
+ 'KTTTK....KTTTK',
+ 'KTTTK....KTTTK',
+ '.KKK......KKK.',
+]
+const GOOMBA2 = [
+ '....KKKKKK....',
+ '..KKBBBBBBKK..',
+ '.KBBBBBBBBBBK.',
+ 'KBBWWKBBKWWBBK',
+ 'KBWWKKBBKKWWBK',
+ 'KBBKKBBBBKKBBK',
+ 'KBBBBBBBBBBBBK',
+ 'KBBBBBBBBBBBBK',
+ '.KBBBBBBBBBBK.',
+ '..KTTTTTTTTK..',
+ '.KTTTKKKKTTTK.',
+ '.KTTTK..KTTTK.',
+ 'KTTTK....KTTKK',
+ '.KKK......KKK.',
+]
+const GOOMBA_FLAT = [
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..............',
+ '..KKKKKKKKKK..',
+ '.KBBWWBBWWBBK.',
+ 'KBBBBBBBBBBBBK',
+ 'KTTTTTTTTTTTTK',
+ '.KKKKKKKKKKKK.',
+]
+// 乌龟 14×20(walk1 / walk2)与龟壳 14×14
+const KOOPA1 = [
+ '.....KKKK.....',
+ '....KGGGGK....',
+ '....KGWKGK....',
+ '....KGGGGK....',
+ '.....KGGK.....',
+ '.....KSSK.....',
+ '..KKKKSSKK....',
+ '.KGGGGKSSK....',
+ 'KGGYYGGGGK....',
+ 'KGYGYYGYYGK...',
+ 'KGYGGYYGGYGK..',
+ 'KGGYYGGYYGGK..',
+ '.KGGGGGGGGK...',
+ '..KKGGGGKK....',
+ '...KSSSSSK....',
+ '...KSS.KSSK...',
+ '..KSSK.KSSK...',
+ '..KKK...KKK...',
+ '..............',
+ '..............',
+]
+const KOOPA2 = [
+ '.....KKKK.....',
+ '....KGGGGK....',
+ '....KGWKGK....',
+ '....KGGGGK....',
+ '.....KGGK.....',
+ '.....KSSK.....',
+ '..KKKKSSKK....',
+ '.KGGGGKSSK....',
+ 'KGGYYGGGGK....',
+ 'KGYGYYGYYGK...',
+ 'KGYGGYYGGYGK..',
+ 'KGGYYGGYYGGK..',
+ '.KGGGGGGGGK...',
+ '..KKGGGGKK....',
+ '..KSSSSSSK....',
+ '..KSSK.KSSK...',
+ '.KSSK...KSSK..',
+ '.KKK.....KKK..',
+ '..............',
+ '..............',
+]
+const SHELL = [
+ '....KKKKKK....',
+ '..KKGGGGGGKK..',
+ '.KGYYGGGGYYGK.',
+ 'KGGYGGGGGGYGGK',
+ 'KGYGGYYYYGGYGK',
+ 'KGYGYYYYYYGYGK',
+ 'KGGYGGGGGGYGGK',
+ 'KGGGGGGGGGGGGK',
+ '.KGGGGGGGGGGK.',
+ '..KKGGGGGGKK..',
+ '...KKKKKKKK...',
+ '..............',
+ '..............',
+ '..............',
+]
+// 蘑菇 / 1UP / 火之花 / 星星 14×14
+const MUSHROOM = [
+ '....RRRRRR....',
+ '..RRRWWRRRRR..',
+ '.RRWWWWRRRWWR.',
+ '.RWWWWWRRRWWB.',
+ 'RRRWWWRRRRWWRR',
+ 'RRRRRRRRRRRRRR',
+ 'RRWWRRRRRRWWRR',
+ '.RRRRRRRRRRRR.',
+ '..SSSSSSSSSS..',
+ '..SSKSSSSKSS..',
+ '..SSKSSSSKSS..',
+ '..SSSSSSSSSS..',
+ '...SSSSSSSS...',
+ '....SSSSSS....',
+]
+const ONEUP = [
+ '....GGGGGG....',
+ '..GGGWWGGGGG..',
+ '.GGWWWWGGGWWG.',
+ '.GWWWWWGGGWWD.',
+ 'GGGWWWGGGGWWGG',
+ 'GGGGGGGGGGGGGG',
+ 'GGWWGGGGGGWWGG',
+ '.GGGGGGGGGGGG.',
+ '..SSSSSSSSSS..',
+ '..SSKSSSSKSS..',
+ '..SSKSSSSKSS..',
+ '..SSSSSSSSSS..',
+ '...SSSSSSSS...',
+ '....SSSSSS....',
+]
+const FLOWER = [
+ '...WWRRRRWW...',
+ '..WRRYYRRRRW..',
+ '..RRYYYYYYRR..',
+ '..RRYYYYYYRR..',
+ '..WRRYYRRRRW..',
+ '...WWRRRRWW...',
+ '.....GGGG.....',
+ '..GG.GGGG.GG..',
+ '.GGGGGGGGGGGG.',
+ '..GG.GGGG.GG..',
+ '.....GGGG.....',
+ '.....GGGG.....',
+ '....GGGGGG....',
+ '...GGGGGGGG...',
+]
+const STAR = [
+ '......KK......',
+ '.....KYYK.....',
+ '.....KYYK.....',
+ '.KKKKKYYKKKKK.',
+ '.KYYYYYYYYYYK.',
+ '..KYYYYYYYYK..',
+ '...KYYKKYYK...',
+ '...KYYKKYYK...',
+ '..KYYYYYYYYK..',
+ '..KYYK..KYYK..',
+ '.KYYK....KYYK.',
+ '.KYK......KYK.',
+ '.KK........KK.',
+ '..............',
+]
+
+// 调色板
+const PAL_MARIO = { R: '#e04838', S: '#f3c898', B: '#7a3a10', O: '#2a5ac8', K: '#1d1409' }
+const PAL_FIRE = { R: '#f4ead2', S: '#f3c898', B: '#7a3a10', O: '#e04838', K: '#1d1409' }
+const PAL_STAR = { R: '#ffd76a', S: '#f3c898', B: '#7a3a10', O: '#7fd8f0', K: '#1d1409' }
+const PAL_STAR2 = { R: '#7fd8f0', S: '#f3c898', B: '#7a3a10', O: '#ffd76a', K: '#1d1409' }
+const PAL_GOOMBA = { K: '#1d1409', B: '#a05018', W: '#f4ead2', T: '#e8c8a0' }
+const PAL_KOOPA = { K: '#1d1409', G: '#3ab554', Y: '#e8d860', W: '#f4ead2', S: '#f3c898' }
+const PAL_MUSH = { R: '#e04838', W: '#f4ead2', S: '#f3c898', B: '#1d1409', K: '#1d1409' }
+const PAL_1UP = { G: '#3ab554', W: '#f4ead2', S: '#f3c898', B: '#1d1409', K: '#1d1409', D: '#7a3a10' }
+const PAL_FLOWER = { R: '#e04838', Y: '#ffd76a', W: '#f4ead2', G: '#3ab554' }
+const PAL_STAR_SPR = { Y: '#ffd76a', K: '#1d1409' }
+
+const cache = new Map()
+function spr(key, rows, pal) {
+ let c = cache.get(key)
+ if (!c) {
+ c = document.createElement('canvas')
+ c.width = rows[0].length
+ c.height = rows.length
+ const g = c.getContext('2d')
+ rows.forEach((r, ri) => {
+ for (let i = 0; i < r.length; i++) {
+ const ch = r[i]
+ if (ch === '.' || ch === ' ') continue
+ g.fillStyle = pal[ch] || '#f0f'
+ g.fillRect(i, ri, 1, 1)
+ }
+ })
+ cache.set(key, c)
+ }
+ return c
+}
+export {
+ SM_STAND, SM_WALK1, SM_WALK2, SM_JUMP, SM_SKID, SM_DEAD,
+ BG_STAND, BG_WALK1, BG_WALK2, BG_JUMP, BG_CROUCH,
+ GOOMBA1, GOOMBA2, GOOMBA_FLAT, KOOPA1, KOOPA2, SHELL,
+ MUSHROOM, ONEUP, FLOWER, STAR,
+ PAL_MARIO, PAL_FIRE, PAL_STAR, PAL_STAR2, PAL_GOOMBA, PAL_KOOPA, PAL_MUSH, PAL_1UP, PAL_FLOWER, PAL_STAR_SPR,
+ spr,
+}
diff --git a/src/games/gameDetails.js b/src/games/gameDetails.js
new file mode 100644
index 0000000..bebe0d7
--- /dev/null
+++ b/src/games/gameDetails.js
@@ -0,0 +1,77 @@
+// 游戏详情页内容数据:按游戏码提供详情页展示的富内容(注册表 detail:true 的游戏才需要)。
+// 与 gameRegistry 的动态信息(操作说明/关卡/能力标记)互补,这里放静态的介绍性文案。
+// 结构:tagline 宣传语 / tags 标签 / features 特性卡片 / controls 结构化操作说明 / sections 玩法板块 / tips 技巧
+export const gameDetails = {
+ starve: {
+ tagline: '生火、采集、狩猎、越冬——每活过一天,都是荒野里的一次胜利',
+ tags: ['生存冒险', '四季轮转', '2~4 人联机', '45 款皮肤', '19 款 Mod', '永久死亡'],
+ features: [
+ { icon: '🌍', title: '随机荒野世界', desc: '噪声岛屿 + 六大生态群系 + 天然道路,每次开局都是全新地图;世界大小支持 1×~4× 自由选择' },
+ { icon: '🕯️', title: '生存三要素', desc: '饥饿 / 生命 / 理智实时管理:黄昏与黑暗蚕食理智,理智过低时暗影怪会找上门来' },
+ { icon: '🔬', title: '科技与烹饪', desc: '科学机器 / 炼金引擎两级科技树,烹饪锅炖出肉丸、蜜汁火腿等料理,火边还能烤制食材' },
+ { icon: '❄️', title: '四季轮转', desc: '秋冬春夏各有机宜:冬季保暖防冻伤、提防独眼巨鹿夜袭;雨季会浇灭篝火、加速燃料消耗' },
+ { icon: '👾', title: '怪物图鉴', desc: '蜘蛛、猎犬、杀人蜂、猪人、皮弗娄牛等 10 种生物独立 AI;给猪人喂肉可结盟助战' },
+ { icon: '🧩', title: 'Mod 自由组装', desc: '悠闲 / 噩梦等难度 Mod 与农场、工业、切斯特、魔法等内容 Mod,难度 Mod 影响得分系数' },
+ { icon: '👗', title: '45 款人物皮肤', desc: '更衣室自由换装,从经典威尔逊到节日限定;联机时向队友展示你的独特形象' },
+ { icon: '🤝', title: '组队联机', desc: '2~4 人共享同一世界,邀请码一键建房;倒下后化身幽灵继续观战,队友可施救' },
+ ],
+ sections: [
+ {
+ title: '特色系统',
+ blocks: [
+ { icon: '💾', name: '云存档 · 永久死亡', desc: '最多 12 个命名存档,每活一天自动云存;死亡即删档,开局请谨慎' },
+ { icon: '🗺️', name: '战争迷雾地图', desc: '192px 小地图随探索揭开,M 键查看全图,火堆 / 建筑 / Boss 实时标记' },
+ { icon: '🎨', name: '手绘像素美术', desc: '全部实体骨骼动画重绘,情境自绘光标:攻击 / 采集 / 建造一眼可辨' },
+ { icon: '📱', name: '触屏适配', desc: '虚拟摇杆 + 点按双模式,手机竖屏自动横排界面' },
+ ],
+ },
+ ],
+ controls: [
+ { keys: 'WASD / 方向键', desc: '移动' },
+ { keys: '空格', desc: '采集资源' },
+ { keys: 'F', desc: '攻击' },
+ { keys: 'E', desc: '一键交互(缺工具自动装备 / 合成)' },
+ { keys: '1 ~ 9', desc: '使用物品栏' },
+ { keys: 'C', desc: '打开合成' },
+ { keys: 'B', desc: '打开背包' },
+ { keys: 'M', desc: '开关地图' },
+ { keys: 'Esc', desc: '关闭面板 / 暂停' },
+ { keys: '鼠标', desc: '点击移动与交互 · 右键取消 / 迁移建筑' },
+ ],
+ tips: [
+ '夜晚完全黑暗会遭查理致命袭击——出门带火把,营地常备营火',
+ '食物会随时间腐败,尽早点亮科学机器、用烹饪锅加工延长储备',
+ '猎犬定期成群来袭,提前筑墙设防,或喂肉结盟猪人并肩作战',
+ '每活一天 +100 分并自动云存档;得分系数受所选难度 Mod 影响',
+ ],
+ },
+
+ mario: {
+ tagline: '顶?块、吃蘑菇、踩酷栗宝——复刻红白机 1-1 ~ 3-4 的十二关经典冒险',
+ tags: ['平台跳跃', '12 大关', 'NES 风 BGM', '经典复刻', '游戏内选关'],
+ features: [
+ { icon: '🍄', title: '蘑菇变身', desc: '顶?块掉出金币与蘑菇,吃蘑菇变大马里奥——能顶碎砖块、硬抗一次伤害' },
+ { icon: '🌸', title: '大嘴花水管', desc: '水管里藏着会咬人的大嘴花,时机不对别乱踩;↓ 可钻进特定水管抄近路' },
+ { icon: '🚩', title: '旗杆计分', desc: '关底旗杆抓得越高分越多,助跑起跳的位置就是分数的差距' },
+ { icon: '🌉', title: '城堡 Boss 战', desc: '每大关末尾的城堡里躲着火棍,跳过库巴摸到斧头即可断桥通关' },
+ { icon: '🎵', title: 'NES 风 BGM', desc: '内置 8-bit 芯片音乐引擎,程序合成的原创旋律原汁原味' },
+ { icon: '🕹️', title: '世界地图选关', desc: '游戏内马里奥风格选关画面,解锁进度自动保存,随时从任意已解锁世界开始' },
+ ],
+ sections: [
+ {
+ title: '关卡一览',
+ blocks: [], // 由注册表 levels.names 动态渲染(世界 1-1 ~ 3-4 共 12 关)
+ },
+ ],
+ controls: [
+ { keys: '← →', desc: '左右移动' },
+ { keys: '空格 / ↑', desc: '跳跃(长按跳得更高)' },
+ { keys: '↓', desc: '钻入特定水管' },
+ ],
+ tips: [
+ '旗杆抓得越高分越多,起跳前先看清助跑距离',
+ '隐藏砖块与地下水管里常有惊喜,多顶多试',
+ '变小状态下挨一下就会重开,城堡关优先抢蘑菇',
+ ],
+ },
+}
diff --git a/src/games/index.js b/src/games/index.js
index ba78653..35b84e2 100644
--- a/src/games/index.js
+++ b/src/games/index.js
@@ -159,8 +159,10 @@ export const gameRegistry = {
mario: {
loader: () => import('./components/MarioGame.vue'),
supports: ['double_points', 'revive', 'shield'],
- controls: '←→ 移动,空格/↑ 跳跃,↓ 可钻特定水管;复刻原版 1-1~1-4 四关:顶?块出金币蘑菇,吃蘑菇变大能顶碎砖,旗杆抓得越高分越多,1-4 城堡关躲火棍跳过库巴摸斧头断桥',
- levels: { total: 4, names: ['世界 1-1', '世界 1-2', '世界 1-3', '世界 1-4'] },
+ detail: true, // 独立详情页:大厅点击卡片先进 /detail/mario
+ inGameSelect: true, // 选关在游戏内完成(马里奥风格世界选关),平台只留开始按钮
+ controls: '←→ 移动,空格/↑ 跳跃,↓ 可钻特定水管;复刻原版 1-1~3-4 十二关:顶?块出金币蘑菇,吃蘑菇变大能顶碎砖,水管里有会咬人的大嘴花,旗杆抓得越高分越多,城堡关躲火棍跳过库巴摸斧头断桥',
+ levels: { total: 12, names: ['世界 1-1', '世界 1-2', '世界 1-3', '世界 1-4', '世界 2-1', '世界 2-2', '世界 2-3', '世界 2-4', '世界 3-1', '世界 3-2', '世界 3-3', '世界 3-4'] },
},
stack3d: {
loader: () => import('./components/Stack3dGame.vue'),
@@ -200,6 +202,7 @@ export const gameRegistry = {
starve: {
loader: () => import('./components/StarveGame.vue'),
supports: ['double_points'],
+ detail: true, // 独立详情页:大厅点击卡片先进 /detail/starve
saveable: true, // 云存档:每天清晨自动存,死亡删档(永久死亡);仅单人模式
wide: true, // 宽屏游戏:游玩页解除 1200px 限宽,画布自适应铺满可视区域
coop: true, // 组队联机:进入时可选单人冒险 / 组队联机(2~4 人共享世界)
diff --git a/src/games/starve/audio.js b/src/games/starve/audio.js
new file mode 100644
index 0000000..fe428df
--- /dev/null
+++ b/src/games/starve/audio.js
@@ -0,0 +1,290 @@
+// 轻量程序化音频引擎:WebAudio 全合成,零外部资源(本项目没有音频文件)。
+// - sfx(name):短音效(砍/挖/采集/受击/吃/合成/添柴/拾取/猎犬嚎/查理/Boss 等)
+// - startMusic/setMood/stopMusic:极简氛围 BGM(五声音阶琶音 + 长低音),按昼夜/季节切换情绪
+// - 开关持久化在 localStorage(starve_sfx / starve_music),GamePlay 设置面板读写
+let ac = null
+let master = null
+let sfxBus = null
+let musBus = null
+let sfxOn = typeof localStorage !== 'undefined' && localStorage.getItem('starve_sfx') !== '0'
+let musicOn = typeof localStorage !== 'undefined' && localStorage.getItem('starve_music') !== '0'
+let noiseBuf = null
+
+function ensure() {
+ if (ac) return ac.state !== 'closed'
+ const AC = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext)
+ if (!AC) return false
+ try {
+ ac = new AC()
+ } catch {
+ return false
+ }
+ master = ac.createGain()
+ master.gain.value = 1
+ master.connect(ac.destination)
+ sfxBus = ac.createGain()
+ sfxBus.gain.value = sfxOn ? 0.55 : 0
+ sfxBus.connect(master)
+ musBus = ac.createGain()
+ musBus.gain.value = musicOn ? 0.3 : 0
+ musBus.connect(master)
+ return true
+}
+
+// 需要用户手势后才能出声(开始游戏按钮点击时机调用)
+function resume() {
+ if (ac && ac.state === 'suspended') ac.resume().catch(() => {})
+}
+
+function noise() {
+ if (noiseBuf) return noiseBuf
+ noiseBuf = ac.createBuffer(1, ac.sampleRate, ac.sampleRate)
+ const d = noiseBuf.getChannelData(0)
+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1
+ return noiseBuf
+}
+
+function env(g, t0, attack, peak, dur) {
+ g.gain.setValueAtTime(0.0001, t0)
+ g.gain.exponentialRampToValueAtTime(Math.max(0.001, peak), t0 + attack)
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
+}
+
+let baseDelay = 0 // sfx(name, delay) 设置:把整组音推迟 delay 秒(对齐挥击命中帧)
+
+function tone({ type = 'sine', f0 = 440, f1 = 0, dur = 0.15, peak = 0.4, delay = 0, out = null }) {
+ const t0 = ac.currentTime + baseDelay + delay
+ const o = ac.createOscillator()
+ o.type = type
+ o.frequency.setValueAtTime(Math.max(1, f0), t0)
+ if (f1) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t0 + dur)
+ const g = ac.createGain()
+ env(g, t0, 0.01, peak, dur)
+ o.connect(g)
+ g.connect(out || sfxBus)
+ o.start(t0)
+ o.stop(t0 + dur + 0.06)
+}
+
+function noiseHit({ dur = 0.12, peak = 0.3, fc = 1200, q = 1, type = 'bandpass', delay = 0, out = null }) {
+ const t0 = ac.currentTime + baseDelay + delay
+ const src = ac.createBufferSource()
+ src.buffer = noise()
+ src.loop = true
+ const f = ac.createBiquadFilter()
+ f.type = type
+ f.frequency.value = fc
+ f.Q.value = q
+ const g = ac.createGain()
+ env(g, t0, 0.006, peak, dur)
+ src.connect(f)
+ f.connect(g)
+ g.connect(out || sfxBus)
+ src.start(t0)
+ src.stop(t0 + dur + 0.06)
+}
+
+// ---- 音效配方表:全部即时合成 ----
+const RECIPES = {
+ whoosh: () => noiseHit({ dur: 0.09, peak: 0.1, fc: 700, q: 0.8 }),
+ chop: () => {
+ noiseHit({ dur: 0.09, peak: 0.32, fc: 900, q: 1.2 })
+ tone({ type: 'triangle', f0: 190, f1: 90, dur: 0.1, peak: 0.3 })
+ },
+ mine: () => {
+ noiseHit({ dur: 0.07, peak: 0.3, fc: 2400, q: 2.5 })
+ tone({ type: 'square', f0: 620, f1: 180, dur: 0.06, peak: 0.12 })
+ },
+ dig: () => {
+ noiseHit({ dur: 0.14, peak: 0.26, fc: 420, q: 0.9 })
+ tone({ type: 'triangle', f0: 130, f1: 70, dur: 0.12, peak: 0.22 })
+ },
+ gather: () => noiseHit({ dur: 0.12, peak: 0.16, fc: 2600, q: 0.6, type: 'highpass' }),
+ hitmob: () => {
+ noiseHit({ dur: 0.08, peak: 0.3, fc: 500, q: 1 })
+ tone({ type: 'triangle', f0: 150, f1: 60, dur: 0.12, peak: 0.35 })
+ },
+ hurt: () => {
+ tone({ type: 'sawtooth', f0: 300, f1: 120, dur: 0.18, peak: 0.3 })
+ noiseHit({ dur: 0.1, peak: 0.18, fc: 800, q: 1 })
+ },
+ eat: () => {
+ tone({ type: 'triangle', f0: 340, f1: 220, dur: 0.08, peak: 0.25 })
+ tone({ type: 'triangle', f0: 280, f1: 180, dur: 0.08, peak: 0.25, delay: 0.11 })
+ },
+ pickup: () => tone({ type: 'sine', f0: 660, f1: 990, dur: 0.09, peak: 0.22 }),
+ craft: () => {
+ tone({ type: 'triangle', f0: 523, dur: 0.1, peak: 0.25 })
+ tone({ type: 'triangle', f0: 784, dur: 0.14, peak: 0.25, delay: 0.09 })
+ },
+ place: () => {
+ tone({ type: 'triangle', f0: 200, f1: 120, dur: 0.1, peak: 0.3 })
+ noiseHit({ dur: 0.08, peak: 0.16, fc: 600, q: 1 })
+ },
+ fire: () => noiseHit({ dur: 0.45, peak: 0.24, fc: 1400, q: 0.4, type: 'lowpass' }),
+ done: () => {
+ tone({ type: 'sine', f0: 660, dur: 0.12, peak: 0.24 })
+ tone({ type: 'sine', f0: 880, dur: 0.16, peak: 0.24, delay: 0.11 })
+ tone({ type: 'sine', f0: 1100, dur: 0.2, peak: 0.2, delay: 0.22 })
+ },
+ hound: () => {
+ tone({ type: 'sawtooth', f0: 700, f1: 240, dur: 0.55, peak: 0.2 })
+ tone({ type: 'sawtooth', f0: 760, f1: 260, dur: 0.5, peak: 0.14, delay: 0.28 })
+ },
+ boss: () => {
+ tone({ type: 'sawtooth', f0: 98, dur: 0.9, peak: 0.3 })
+ tone({ type: 'sawtooth', f0: 74, dur: 1.1, peak: 0.3, delay: 0.35 })
+ noiseHit({ dur: 0.8, peak: 0.12, fc: 220, q: 0.7, delay: 0.1 })
+ },
+ bossfall: () => {
+ tone({ type: 'sawtooth', f0: 160, f1: 40, dur: 0.9, peak: 0.3 })
+ noiseHit({ dur: 0.6, peak: 0.2, fc: 300, q: 0.6 })
+ },
+ charlie: () => {
+ noiseHit({ dur: 0.5, peak: 0.35, fc: 2000, q: 0.3, type: 'highpass' })
+ tone({ type: 'sawtooth', f0: 900, f1: 90, dur: 0.45, peak: 0.25 })
+ },
+ death: () => {
+ tone({ type: 'triangle', f0: 420, f1: 60, dur: 1.1, peak: 0.3 })
+ noiseHit({ dur: 0.7, peak: 0.14, fc: 400, q: 0.5 })
+ },
+ goal: () => {
+ tone({ type: 'sine', f0: 660, dur: 0.1, peak: 0.2 })
+ tone({ type: 'sine', f0: 990, dur: 0.18, peak: 0.2, delay: 0.1 })
+ },
+ splash: () => noiseHit({ dur: 0.25, peak: 0.25, fc: 1000, q: 0.5 }),
+}
+
+export function sfx(name, delay = 0) {
+ if (!sfxOn || !ensure()) return
+ resume()
+ const fn = RECIPES[name]
+ if (fn) {
+ try {
+ baseDelay = delay
+ fn()
+ } catch {
+ /* 音频失败不影响游戏 */
+ } finally {
+ baseDelay = 0
+ }
+ }
+}
+
+// ---- 氛围 BGM:步进音序器(无音频文件,程序化五声音阶琶音) ----
+let musTimer = null
+let musStep = 0
+let mood = 'day'
+const MOODS = {
+ day: { scale: [0, 2, 4, 7, 9], root: 220, step: 340, wave: 'triangle', peak: 0.1 },
+ night: { scale: [0, 3, 5, 7, 10], root: 165, step: 520, wave: 'sine', peak: 0.09 },
+ winter: { scale: [0, 2, 3, 7, 8], root: 196, step: 440, wave: 'sine', peak: 0.08 },
+ rain: { scale: [0, 2, 3, 5, 10], root: 185, step: 380, wave: 'sine', peak: 0.07 },
+}
+
+function musNote(freq, dur, peak, delay = 0) {
+ const t0 = ac.currentTime + delay
+ const o = ac.createOscillator()
+ o.type = 'triangle'
+ o.frequency.value = freq
+ const g = ac.createGain()
+ g.gain.setValueAtTime(0.0001, t0)
+ g.gain.linearRampToValueAtTime(peak, t0 + 0.04)
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
+ o.connect(g)
+ g.connect(musBus)
+ o.start(t0)
+ o.stop(t0 + dur + 0.1)
+}
+
+function musBass(freq, dur, peak) {
+ const t0 = ac.currentTime
+ const o = ac.createOscillator()
+ o.type = 'sine'
+ o.frequency.value = freq
+ const g = ac.createGain()
+ g.gain.setValueAtTime(0.0001, t0)
+ g.gain.linearRampToValueAtTime(peak, t0 + 0.3)
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
+ o.connect(g)
+ g.connect(musBus)
+ o.start(t0)
+ o.stop(t0 + dur + 0.1)
+}
+
+function musicTick() {
+ if (!ac || !musicOn) return
+ const m = MOODS[mood] || MOODS.day
+ const semi = (n) => m.root * Math.pow(2, n / 12)
+ musStep++
+ // 低音每 8 步一次
+ if (musStep % 8 === 1) musBass(semi(m.scale[0]) / 2, 3.2, 0.1)
+ // 琶音:随机挑音阶音,休止概率 35% 避免密集
+ if (Math.random() < 0.65) {
+ const deg = m.scale[Math.floor(Math.random() * m.scale.length)]
+ const oct = Math.random() < 0.3 ? 2 : 1
+ musNote(semi(deg) * oct, 1.6, m.peak)
+ }
+ // 偶发高音点缀
+ if (Math.random() < 0.12) {
+ const deg = m.scale[Math.floor(Math.random() * m.scale.length)]
+ musNote(semi(deg) * 4, 1.2, m.peak * 0.6, 0.2)
+ }
+}
+
+function startMusicTimer() {
+ if (musTimer) return
+ const step = (MOODS[mood] || MOODS.day).step
+ musTimer = setInterval(musicTick, step)
+}
+
+function stopMusicTimer() {
+ if (musTimer) {
+ clearInterval(musTimer)
+ musTimer = null
+ }
+}
+
+// 场景情绪:day / night / winter / rain(雨优先级高于冬季)
+export function setMood(m) {
+ if (!MOODS[m] || mood === m) return
+ mood = m
+ if (musTimer) {
+ stopMusicTimer()
+ startMusicTimer()
+ }
+}
+
+export function startMusic() {
+ if (!musicOn || !ensure()) return
+ resume()
+ startMusicTimer()
+}
+
+export function stopMusic() {
+ stopMusicTimer()
+}
+
+// ---- 开关(GamePlay 设置面板读写;persist 到 localStorage) ----
+export function sfxEnabled() {
+ return sfxOn
+}
+export function setSfxEnabled(v) {
+ sfxOn = !!v
+ try {
+ localStorage.setItem('starve_sfx', sfxOn ? '1' : '0')
+ } catch {}
+ if (sfxBus) sfxBus.gain.value = sfxOn ? 0.55 : 0
+}
+export function musicEnabled() {
+ return musicOn
+}
+export function setMusicEnabled(v) {
+ musicOn = !!v
+ try {
+ localStorage.setItem('starve_music', musicOn ? '1' : '0')
+ } catch {}
+ if (musBus) musBus.gain.value = musicOn ? 0.3 : 0
+ if (musicOn) startMusicTimer()
+ else stopMusicTimer()
+}
diff --git a/src/games/starve/cave.js b/src/games/starve/cave.js
new file mode 100644
index 0000000..799d30c
--- /dev/null
+++ b/src/games/starve/cave.js
@@ -0,0 +1,95 @@
+// 洞穴地图生成:与地表同尺寸网格(GW×GH,configureWorld 不重入),
+// 全岩洞地面(复用 tile 5 岩石/6 沼泽灰调,无需新瓦片美术)。
+// 布局节奏:荧光草「光岛」簇 + 簇边宝石矿,洞内探索 = 在光岛间跳跃;
+// 出口洞与玩家进入点同位。lights 表同时供黑暗渲染挖孔与洞穴光照判定使用。
+import { GW, GH, TILE } from './world.js'
+
+export function genCave() {
+ const tiles = new Uint8Array(GW * GH).fill(5)
+ // 沼泽灰斑块:随机游走涂抹,制造地面层次
+ const blobN = 10 + Math.floor(GW * GH / 2048)
+ for (let b = 0; b < blobN; b++) {
+ let x = 4 + Math.floor(Math.random() * (GW - 8))
+ let y = 4 + Math.floor(Math.random() * (GH - 8))
+ const len = 12 + Math.floor(Math.random() * 26)
+ for (let i = 0; i < len; i++) {
+ for (let oy = -1; oy <= 1; oy++) {
+ for (let ox = -1; ox <= 1; ox++) {
+ const tx = x + ox
+ const ty = y + oy
+ if (tx > 0 && tx < GW - 1 && ty > 0 && ty < GH - 1) tiles[ty * GW + tx] = 6
+ }
+ }
+ x = Math.max(2, Math.min(GW - 3, x + (Math.random() < 0.5 ? 1 : -1)))
+ y = Math.max(2, Math.min(GH - 3, y + (Math.random() < 0.5 ? 1 : -1)))
+ }
+ }
+
+ const px = (tx) => tx * TILE + 20 + Math.random() * (TILE - 40)
+ const py = (ty) => ty * TILE + 20 + Math.random() * (TILE - 40)
+ const randTile = () => ({
+ tx: 3 + Math.floor(Math.random() * (GW - 6)),
+ ty: 3 + Math.floor(Math.random() * (GH - 6)),
+ })
+
+ const ents = []
+ const lights = [] // 荧光草位置(世界坐标):黑暗渲染挖孔 + 洞内光照判定
+
+ // 出生点:世界中部附近,清出一小块「营地感」
+ const cTx = Math.floor(GW / 2) + Math.floor(Math.random() * 8) - 4
+ const cTy = Math.floor(GH / 2) + Math.floor(Math.random() * 6) - 3
+ const spawn = { x: cTx * TILE + TILE / 2, y: cTy * TILE + TILE / 2 }
+ ents.push({ type: 'caveout', x: spawn.x - 60, y: spawn.y - 40 })
+ ents.push({ type: 'lightflower', x: spawn.x + 70, y: spawn.y - 30 })
+ lights.push({ x: spawn.x + 70, y: spawn.y - 30 })
+
+ // 荧光草「光岛」簇:簇中心彼此远离,形成黑暗中的跳跃节奏
+ const centers = [{ x: spawn.x, y: spawn.y }]
+ let tries = 0
+ while (centers.length < 7 && tries < 300) {
+ tries++
+ const t = randTile()
+ const cx2 = t.tx * TILE + TILE / 2
+ const cy2 = t.ty * TILE + TILE / 2
+ if (centers.some((c) => Math.hypot(c.x - cx2, c.y - cy2) < 620)) continue
+ centers.push({ x: cx2, y: cy2 })
+ // 簇内:2~3 株荧光草 + 1~2 宝石矿 + 0~2 洞穴菇
+ const nF = 2 + Math.floor(Math.random() * 2)
+ for (let i = 0; i < nF; i++) {
+ const fx = cx2 + (Math.random() - 0.5) * 150
+ const fy = cy2 + (Math.random() - 0.5) * 110
+ ents.push({ type: 'lightflower', x: fx, y: fy })
+ lights.push({ x: fx, y: fy })
+ }
+ const nG = 1 + Math.floor(Math.random() * 2)
+ for (let i = 0; i < nG; i++) {
+ ents.push({ type: 'gemrock', x: cx2 + (Math.random() - 0.5) * 210, y: cy2 + (Math.random() - 0.5) * 160 })
+ }
+ const nM = Math.floor(Math.random() * 3)
+ for (let i = 0; i < nM; i++) {
+ ents.push({ type: 'cavemushroom', x: cx2 + (Math.random() - 0.5) * 180, y: cy2 + (Math.random() - 0.5) * 140 })
+ }
+ }
+
+ // 散装补充:黑暗中的零星矿/菇(引诱玩家带光源深入)
+ for (let i = 0; i < 4; i++) {
+ const t = randTile()
+ ents.push({ type: 'gemrock', x: px(t.tx), y: py(t.ty) })
+ }
+ for (let i = 0; i < 4; i++) {
+ const t = randTile()
+ ents.push({ type: 'cavemushroom', x: px(t.tx), y: py(t.ty) })
+ }
+ // 石虾群:中立生物,散布在远离出生点的两处
+ const herds = []
+ tries = 0
+ while (herds.length < 2 && tries < 200) {
+ tries++
+ const t = randTile()
+ const hx = t.tx * TILE + TILE / 2
+ const hy = t.ty * TILE + TILE / 2
+ if (Math.hypot(hx - spawn.x, hy - spawn.y) < 900) continue
+ herds.push({ x: hx, y: hy })
+ }
+ return { tiles, ents, lights, spawn, herds }
+}
diff --git a/src/games/starve/climate.js b/src/games/starve/climate.js
index 0adb511..1e838a3 100644
--- a/src/games/starve/climate.js
+++ b/src/games/starve/climate.js
@@ -1,5 +1,5 @@
// 饥荒气候系统:季节推算、昼夜分段、环境温度、天气(雨/雪)、体温
-import { SEASONS, YEAR_DAYS, SEG_SPLIT, SEG, DAY_LEN } from './data.js'
+import { SEASONS, YEAR_DAYS, SEG_SPLIT, SEG, DAY_LEN, TUNE } from './data.js'
// 第 day 天所处季节:{code,name,idx,dayIn(季节内第几天),len,temp,tint}
export function seasonOf(day) {
@@ -57,7 +57,8 @@ export function tickWeather(wx, dt) {
// ---- 体温 ----
// 体温向环境漂移;篝火/火把加热;保暖装备减缓失温;保暖石托底
// p: {temp, ...} · heat: 附近火源强度 0~1 · insul: 保暖值(冬帽 120)
-export function updateBodyTemp(p, ambient, heat, insul, dt) {
+// season/phase: 夏季白天烈日直晒额外升温(中暑来源) · vent: 散热值(夏帽 60,烈日升温减半)
+export function updateBodyTemp(p, ambient, heat, insul, dt, season = '', phase = 'day', vent = 0) {
if (heat > 0) {
// 火边取暖:快速升向 50
p.temp += (50 - p.temp) * dt * 0.5 * heat
@@ -67,5 +68,9 @@ export function updateBodyTemp(p, ambient, heat, insul, dt) {
const k = 0.05 * (cooling ? 30 / (30 + insul) : 1)
p.temp += (ambient - p.temp) * dt * k
}
+ // 夏季中暑:白天无火遮蔽时烈日直晒持续升温,戴夏帽可降低升温
+ if (season === 'summer' && phase === 'day' && heat <= 0.35) {
+ p.temp += dt * TUNE.heatGain * (1 - Math.min(0.6, vent / 100))
+ }
p.temp = Math.max(-20, Math.min(80, p.temp))
}
diff --git a/src/games/starve/data.js b/src/games/starve/data.js
index 297dbf3..b9596bc 100644
--- a/src/games/starve/data.js
+++ b/src/games/starve/data.js
@@ -41,6 +41,14 @@ export const TUNE = {
charlieSan: 20, // 查理袭击附带理智损失
freezeHp: 1.25, // 体温 <=0 冰冻掉血/秒(官方 1.25)
freezeAt: 0, // 冰冻阈值
+ heatAt: 68, // 体温 >=68 过热掉血阈值(夏季中暑)
+ heatHp: 0.8, // 过热掉血/秒(比冻伤温和)
+ heatGain: 1.5, // 夏季白天烈日升温/秒
+ iceboxCool: 7, // 靠近冰箱的降温/秒
+ iceboxMul: 0.25, // 冰箱内食物腐败速度倍率
+ caveSan: 1.2, // 洞穴黑暗中理智流失/秒(比地表夜晚温和)
+ flowerLightR: 95, // 荧光草照明半径
+ beeboxQueenAt: 8, // 蜂箱收蜜多少次后进化为巨型蜂巢
packSlots: 8, // 背包附加格数
invSlots: 15, // 主物品栏格数
stackMax: 40, // 堆叠上限(官方 40)
@@ -74,6 +82,10 @@ export const ITEMS = {
petals: { name: '花瓣', food: { hunger: 1, hp: 0, san: 0 }, perish: 6 },
reeds: { name: '芦苇' },
ash: { name: '灰烬' },
+ feather: { name: '鹅毛' },
+ gem: { name: '宝石' },
+ lightbulb: { name: '荧光果' },
+ stinger: { name: '蜂刺' },
beefwool: { name: '牛毛' },
pigskin: { name: '猪皮' },
rot: { name: '腐烂物' },
@@ -89,6 +101,11 @@ export const ITEMS = {
froglegs: { name: '蛙腿', food: { hunger: 12.5, hp: 0, san: -10 }, cook: 'cookedfroglegs', perish: 6, meatv: 0.5 },
cookedfroglegs: { name: '烤蛙腿', food: { hunger: 12.5, hp: 1, san: 0 }, perish: 10, meatv: 0.5 },
honey: { name: '蜂蜜', food: { hunger: 9.4, hp: 3, san: 0 }, perish: 40, sweet: 1 },
+ honeycomb: { name: '蜂巢块' },
+ fish: { name: '鱼', food: { hunger: 12.5, hp: 0, san: -5 }, cook: 'cookedfish', perish: 6, meatv: 0.5 },
+ cookedfish: { name: '烤鱼', food: { hunger: 18.75, hp: 1, san: 0 }, perish: 10, meatv: 0.5 },
+ cavemushroom: { name: '洞穴菇', food: { hunger: 12.5, hp: 0, san: -5 }, cook: 'cookedcavemushroom', perish: 8, veg: 0.5 },
+ cookedcavemushroom: { name: '烤洞穴菇', food: { hunger: 18.75, hp: 1, san: 0 }, perish: 12, veg: 0.5 },
eyeball: { name: '巨鹿眼球', food: { hunger: 75, hp: 40, san: -15 }, perish: 20, meatv: 1 },
// ---- 烹饪锅料理 ----
meatballs: { name: '肉丸', food: { hunger: 62.5, hp: 3, san: 5 }, perish: 10 },
@@ -103,6 +120,7 @@ export const ITEMS = {
honeypoultice: { name: '蜂蜜药膏', heal: 30, stack: 40 },
// ---- 手部装备(官方耐久:斧 100 / 镐 33 / 铲 25 / 矛 150 / 火把计时) ----
axe: { name: '斧头', equip: 'hand', tool: 'axe', dmg: 27, uses: 100, stack: 1 },
+ fishingrod: { name: '钓竿', equip: 'hand', tool: 'fish', dmg: 5, uses: 12, stack: 1 },
pickaxe: { name: '镐子', equip: 'hand', tool: 'pick', dmg: 27, uses: 33, stack: 1 },
shovel: { name: '铲子', equip: 'hand', tool: 'dig', dmg: 17, uses: 25, stack: 1 },
spear: { name: '长矛', equip: 'hand', dmg: 34, uses: 150, stack: 1 },
@@ -115,6 +133,10 @@ export const ITEMS = {
garland: { name: '花环', equip: 'head', sanAura: 0.08, burn: 640, stack: 1 },
footballhelmet: { name: '猪皮帽', equip: 'head', absorb: 0.8, armor: 315, stack: 1 },
winterhat: { name: '冬帽', equip: 'head', insul: 120, stack: 1 },
+ strawhat: { name: '夏帽', equip: 'head', vent: 60, stack: 1 },
+ eyebrella: { name: '眼球伞', equip: 'head', vent: 50, waterproof: true, stack: 1 },
+ minerhat: { name: '矿工帽', equip: 'head', burn: 240, stack: 1 },
+ beecrown: { name: '蜂王冠', equip: 'head', absorb: 0.6, armor: 500, sanAura: 0.12, stack: 1 },
// ---- 特殊 ----
trap: { name: '陷阱', place: true, uses: 8, stack: 10 },
thermalstone: { name: '保暖石', thermal: true, stack: 1 },
@@ -139,6 +161,7 @@ export const CRAFT_TABS = [
]
export const RECIPES = [
{ code: 'axe', name: '斧头', tab: 'tools', tech: 0, cost: { twig: 1, flint: 1 }, desc: '砍树 · 耐久 100 次' },
+ { code: 'fishingrod', name: '钓竿', tab: 'tools', tech: 1, cost: { twig: 2, silk: 2 }, desc: '在池塘垂钓 · 耐久 12 次' },
{ code: 'pickaxe', name: '镐子', tab: 'tools', tech: 0, cost: { twig: 2, flint: 2 }, desc: '采矿 · 耐久 33 次' },
{ code: 'shovel', name: '铲子', tab: 'tools', tech: 1, cost: { twig: 2, flint: 2 }, desc: '挖树桩得木头 · 耐久 25 次' },
{ code: 'torch', name: '火把', tab: 'light', tech: 0, cost: { grass: 2, twig: 2 }, desc: '手持光源 · 燃烧 60 秒' },
@@ -155,10 +178,15 @@ export const RECIPES = [
{ code: 'logsuit', name: '木甲', tab: 'fight', tech: 1, cost: { log: 8, rope: 2 }, desc: '身体护甲 · 减伤 80%' },
{ code: 'footballhelmet', name: '猪皮帽', tab: 'fight', tech: 1, cost: { pigskin: 1, rope: 1 }, desc: '头部护甲 · 减伤 80%' },
{ code: 'crockpot', name: '烹饪锅', tab: 'food', tech: 1, cost: { rock: 6, twig: 6 }, desc: '放置后投入 4 份食材炖煮料理' },
+ { code: 'beebox', name: '蜂箱', tab: 'food', tech: 2, cost: { honeycomb: 1, log: 4, rock: 2 }, build: true, desc: '定期酿蜜(上限 4)· 收蜜会被蜇,戴夏帽可防' },
{ code: 'sciencemachine', name: '科学机器', tab: 'science', tech: 0, cost: { gold: 1, log: 4, rock: 4 }, desc: '解锁一级科技(靠近合成)' },
{ code: 'alchemyengine', name: '炼金引擎', tab: 'science', tech: 1, cost: { gold: 6, log: 4, rock: 4 }, desc: '解锁二级科技(靠近合成)' },
{ code: 'garland', name: '花环', tab: 'dress', tech: 0, cost: { petals: 12 }, desc: '头戴缓慢回复理智,会枯萎' },
{ code: 'winterhat', name: '冬帽', tab: 'dress', tech: 2, cost: { silk: 4, beefwool: 2 }, desc: '保暖 · 冬季生存必备' },
+ { code: 'strawhat', name: '夏帽', tab: 'dress', tech: 1, cost: { grass: 8, petals: 3 }, desc: '头戴散热 · 夏季防中暑' },
+ { code: 'eyebrella', name: '眼球伞', tab: 'dress', tech: 2, cost: { eyeball: 1, rope: 2, silk: 4 }, desc: '巨鹿眼球所制 · 防雨淋 + 散热,四季皆宜' },
+ { code: 'minerhat', name: '矿工帽', tab: 'dress', tech: 2, cost: { strawhat: 1, gold: 1, lightbulb: 2 }, desc: '洞穴照明 · 用荧光果补燃' },
+ { code: 'icebox', name: '冰箱', tab: 'science', tech: 2, cost: { gold: 2, rock: 8, rope: 1 }, build: true, desc: '9 格储物 · 食物保鲜减速 75%' },
]
// 烹饪锅配方:按优先级匹配(meatv/sweet/mm/veg/grain;itemTable 可传入 mod 合并后的物品表)
@@ -202,6 +230,10 @@ export const MON = {
pig: { hp: 250, speed: 125, dmg: 33, score: 0, name: '猪人', friendly: true },
beefalo: { hp: 500, speed: 100, dmg: 34, score: 10, name: '皮弗娄牛', neutral: true },
deerclops: { hp: 1600, speed: 70, dmg: 75, score: 400, name: '独眼巨鹿', hostile: true, boss: true },
+ moosegoose: { hp: 1300, speed: 95, dmg: 60, score: 350, name: '麋鹿鹅', hostile: true, boss: true },
+ dragonfly: { hp: 2000, speed: 62, dmg: 90, score: 500, name: '龙蝇', hostile: true, boss: true },
+ slurtle: { hp: 300, speed: 42, dmg: 30, score: 25, name: '石虾', neutral: true },
+ beequeen: { hp: 2250, speed: 78, dmg: 60, score: 800, name: '蜂王', hostile: true, boss: true },
}
// 击杀掉落(落地为拾取物):数组每项 [概率, code, 数量]
export const MOB_DROPS = {
@@ -215,6 +247,10 @@ export const MOB_DROPS = {
pig: [[0.75, 'meat', 1], [0.25, 'pigskin', 1]],
beefalo: [[1, 'meat', 2], [1, 'beefwool', 1]],
deerclops: [[1, 'meat', 3], [1, 'eyeball', 1]],
+ moosegoose: [[1, 'meat', 2], [1, 'feather', 2]],
+ dragonfly: [[1, 'meat', 2], [1, 'gem', 2]],
+ slurtle: [[0.75, 'rock', 2], [0.5, 'meat', 1]],
+ beequeen: [[1, 'honey', 6], [1, 'stinger', 2], [1, 'beecrown', 1]],
}
// =====================================================================
@@ -236,12 +272,21 @@ export const RES = {
rabbithole: { size: 18 }, // 兔子洞不挡路(否则劳工会卡在洞边)
spidernest: { hp: 200, size: 36, nest: true },
beehive: { hp: 100, size: 30, nest: true },
+ gianthive: { hp: 300, size: 46, nest: true },
+ beebox: { size: 30, struct: true },
+ pond: { size: 46 },
+ cavein: { size: 44, struct: true },
+ caveout: { size: 40, struct: true },
+ lightflower: { hits: 1, drops: { lightbulb: 1 }, size: 20, regrow: 300 },
+ gemrock: { hits: 3, tool: 'pick', drops: { gem: 1, flint: 1, rock: 1 }, size: 30 },
+ cavemushroom: { hits: 1, drops: { cavemushroom: 1 }, size: 18, regrow: 240 },
pighouse: { size: 40, struct: true },
tentacle: { hp: 250, size: 26, lurk: true },
sciencemachine: { size: 34, struct: true, techTier: 1 },
alchemyengine: { size: 38, struct: true, techTier: 2 },
crockpot: { size: 26, struct: true, pot: true },
woodchest: { size: 28, struct: true, chest: true },
+ icebox: { size: 34, struct: true, chest: true },
trap: { size: 22, placeable: true },
loot: { size: 12, loot: true },
}
@@ -251,7 +296,8 @@ export const DECOR = { stump: { size: 20 }, rubble: { size: 16 } }
export const NAMES = {
tree: '树', rock: '岩石', goldrock: '金矿石', grass: '草丛', sapling: '树枝苗', berry: '浆果丛',
reeds: '芦苇', flower: '野花', flint: '燧石', rabbit: '兔子', rabbithole: '兔子洞',
- spidernest: '蜘蛛巢', beehive: '蜂巢', pighouse: '猪屋', tentacle: '触手', trap: '陷阱',
- sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', woodchest: '木箱', loot: '掉落物',
+ spidernest: '蜘蛛巢', beehive: '蜂巢', beebox: '蜂箱', pond: '池塘', pighouse: '猪屋', tentacle: '触手', trap: '陷阱',
+ gianthive: '巨型蜂巢', cavein: '洞穴入口', caveout: '洞穴出口', lightflower: '荧光草', gemrock: '宝石矿', cavemushroom: '洞穴菇', slurtle: '石虾',
+ sciencemachine: '科学机器', alchemyengine: '炼金引擎', crockpot: '烹饪锅', woodchest: '木箱', icebox: '冰箱', loot: '掉落物',
stump: '树桩', rubble: '碎石',
}
diff --git a/src/games/starve/fx.js b/src/games/starve/fx.js
new file mode 100644
index 0000000..b357308
--- /dev/null
+++ b/src/games/starve/fx.js
@@ -0,0 +1,153 @@
+// starve/fx.js — 轻量命中/采集粒子(纯视觉,不入存档、不同步联机)
+// 世界坐标存储;由 StarveGame 每帧调用 updateFx(dt) + drawFx(ctx, camX, camY)。
+// mod 不直接 import 本模块,通过 gCtx.fx(type, x, y, opts)(在 StarveGame 里接线)调用。
+// delay 选项用于把特效对齐到挥击动画的实际命中帧(挥击起手即结算伤害,视觉命中晚约 0.12~0.18s)。
+
+const MAX = 220
+const live = []
+const pending = [] // 延迟生成的粒子(等待挥击命中帧)
+
+const TAU = Math.PI * 2
+const rnd = (a, b) => a + Math.random() * (b - a)
+
+// 类型配置:n 默认数量 / 颜色组 / 初速与重力 / 形状
+const KINDS = {
+ wood: { n: 7, cols: ['#a8763e', '#8a5a2b', '#c9a36b'], sp: [70, 150], up: [-140, -40], g: 320, shape: 'chip', ttl: [0.5, 0.8], w: [2.5, 4.5] },
+ stone: { n: 7, cols: ['#9aa0a8', '#7a8088', '#b8bec6'], sp: [60, 140], up: [-150, -50], g: 340, shape: 'chip', ttl: [0.45, 0.75], w: [2, 3.6] },
+ gold: { n: 8, cols: ['#e0b83a', '#f4dc8a', '#c9971e'], sp: [70, 150], up: [-150, -50], g: 340, shape: 'chip', ttl: [0.5, 0.8], w: [2, 3.6] },
+ dust: { n: 5, cols: ['rgba(180,170,150,0.5)', 'rgba(160,150,130,0.45)'], sp: [16, 46], up: [-34, -10], g: -26, shape: 'puff', ttl: [0.55, 0.95], w: [3, 5.5] },
+ spark: { n: 9, cols: ['#ffd76a', '#ffb43c', '#fff2b8'], sp: [120, 230], up: [-200, -20], g: 240, shape: 'spark', ttl: [0.2, 0.4], w: [1.2, 2] },
+ hit: { n: 1, cols: ['#fff6e0'], sp: [0, 0], up: [0, 0], g: 0, shape: 'star', ttl: [0.18, 0.24], w: [10, 14] },
+ ring: { n: 1, cols: ['rgba(255,246,224,0.9)'], sp: [0, 0], up: [0, 0], g: 0, shape: 'ring', ttl: [0.26, 0.3], w: [8, 10] },
+ leaf: { n: 6, cols: ['#6a8a3a', '#8aa84a', '#4a7a34'], sp: [40, 90], up: [-110, -40], g: 190, shape: 'leaf', ttl: [0.6, 1], w: [2.6, 4] },
+ silk: { n: 5, cols: ['rgba(232,226,212,0.85)'], sp: [30, 70], up: [-70, -20], g: 90, shape: 'silk', ttl: [0.5, 0.9], w: [1, 1.6] },
+ blood: { n: 6, cols: ['#a8323a', '#c9484a'], sp: [50, 110], up: [-120, -30], g: 300, shape: 'dot', ttl: [0.3, 0.55], w: [1.6, 2.8] },
+ ice: { n: 7, cols: ['#bfe4f2', '#8ec8e0', '#e8f6fc'], sp: [60, 130], up: [-140, -40], g: 260, shape: 'chip', ttl: [0.4, 0.7], w: [2, 3.4] },
+}
+
+export function spawnFx(type, x, y, o = {}) {
+ const cfg = KINDS[type]
+ if (!cfg) return
+ if (o.delay) {
+ pending.push({ type, x, y, o: { ...o }, t: o.delay })
+ return
+ }
+ const n = Math.min(o.n ?? cfg.n, 18)
+ for (let i = 0; i < n; i++) {
+ if (live.length >= MAX) live.shift()
+ const a = o.dir != null
+ ? o.dir + rnd(-(o.spread ?? 0.9), o.spread ?? 0.9)
+ : rnd(0, TAU)
+ const sp = rnd(cfg.sp[0], cfg.sp[1]) * (o.power ?? 1)
+ live.push({
+ type,
+ x: x + rnd(-3, 3),
+ y: y + rnd(-4, 2),
+ vx: Math.cos(a) * sp,
+ vy: Math.sin(a) * sp + rnd(cfg.up[0], cfg.up[1]) * (o.power ?? 1),
+ g: cfg.g,
+ rot: rnd(0, TAU),
+ vr: rnd(-9, 9),
+ w: rnd(cfg.w[0], cfg.w[1]),
+ col: cfg.cols[(Math.random() * cfg.cols.length) | 0],
+ ttl: rnd(cfg.ttl[0], cfg.ttl[1]),
+ age: 0,
+ })
+ }
+}
+
+export function updateFx(dt) {
+ for (let i = pending.length - 1; i >= 0; i--) {
+ pending[i].t -= dt
+ if (pending[i].t <= 0) {
+ const p = pending[i]
+ pending.splice(i, 1)
+ spawnFx(p.type, p.x, p.y, { ...p.o, delay: 0 })
+ }
+ }
+ for (let i = live.length - 1; i >= 0; i--) {
+ const p = live[i]
+ p.age += dt
+ if (p.age >= p.ttl) {
+ live.splice(i, 1)
+ continue
+ }
+ p.vy += p.g * dt
+ p.x += p.vx * dt
+ p.y += p.vy * dt
+ p.rot += p.vr * dt
+ }
+}
+
+export function drawFx(ctx, camX, camY) {
+ if (!live.length) return
+ ctx.save()
+ live.forEach((p) => {
+ const k = 1 - p.age / p.ttl
+ const sx = p.x - camX
+ const sy = p.y - camY
+ ctx.save()
+ ctx.translate(sx, sy)
+ ctx.rotate(p.rot)
+ ctx.globalAlpha = Math.min(1, k * 1.6)
+ ctx.fillStyle = p.col
+ if (p.shape === 'chip') {
+ ctx.fillRect(-p.w * 0.5, -p.w * 0.28, p.w, p.w * 0.56)
+ } else if (p.shape === 'puff') {
+ ctx.beginPath()
+ ctx.arc(0, 0, p.w * (1 + p.age * 2.2), 0, TAU)
+ ctx.fill()
+ } else if (p.shape === 'spark') {
+ ctx.strokeStyle = p.col
+ ctx.lineWidth = p.w
+ ctx.beginPath()
+ ctx.moveTo(0, 0)
+ ctx.lineTo(p.vx * 0.03, p.vy * 0.03)
+ ctx.stroke()
+ } else if (p.shape === 'star') {
+ // 四芒星闪光
+ const r = p.w * (0.6 + k * 0.6)
+ ctx.strokeStyle = p.col
+ ctx.lineWidth = 1.6
+ ctx.beginPath()
+ for (let a = 0; a < 4; a++) {
+ const ang = (a / 4) * TAU + Math.PI / 4
+ ctx.moveTo(0, 0)
+ ctx.lineTo(Math.cos(ang) * r, Math.sin(ang) * r)
+ }
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.arc(0, 0, r * 0.28, 0, TAU)
+ ctx.fill()
+ } else if (p.shape === 'ring') {
+ const r = p.w * (1 + (1 - k) * 2.6)
+ ctx.strokeStyle = p.col
+ ctx.lineWidth = 2 * k
+ ctx.beginPath()
+ ctx.arc(0, 0, r, 0, TAU)
+ ctx.stroke()
+ } else if (p.shape === 'leaf') {
+ ctx.beginPath()
+ ctx.ellipse(0, 0, p.w, p.w * 0.45, 0, 0, TAU)
+ ctx.fill()
+ } else if (p.shape === 'silk') {
+ ctx.strokeStyle = p.col
+ ctx.lineWidth = p.w
+ ctx.beginPath()
+ ctx.moveTo(0, 0)
+ ctx.quadraticCurveTo(p.w * 2, -2, p.w * 3.6, 1)
+ ctx.stroke()
+ } else {
+ ctx.beginPath()
+ ctx.arc(0, 0, p.w, 0, TAU)
+ ctx.fill()
+ }
+ ctx.restore()
+ })
+ ctx.restore()
+}
+
+export function clearFx() {
+ live.length = 0
+ pending.length = 0
+}
diff --git a/src/games/starve/guide.js b/src/games/starve/guide.js
new file mode 100644
index 0000000..a16304b
--- /dev/null
+++ b/src/games/starve/guide.js
@@ -0,0 +1,75 @@
+// 新手目标引导:开局 7 步任务链,由核心动作事件驱动(gather/craft/place/eat/newday)。
+// 每完成一步给小奖励(+20 分);数据随存档持久化(旧档无 guide 字段视为不需要引导)。
+// 用法:
+// const gd = newGuide() // 新开局
+// guideEvent(gd, 'gather', { type: 'grass' }) // 喂事件;返回 true 表示本步刚完成
+// guideText(gd) // 当前目标文案('' = 全部完成/无引导)
+
+export const GUIDE_REWARD = 20
+
+export function newGuide() {
+ return { step: 0, grass: 0, twig: 0 }
+}
+
+export const GUIDE_STEPS = 7
+
+export function guideDone(gd) {
+ return !gd || gd.step >= GUIDE_STEPS
+}
+
+// 喂事件:ev ∈ gather/craft/place/eat/newday;p 为事件参数
+// 返回刚完成的步数(0 = 无变化),调用方据此发奖励与提示
+export function guideEvent(gd, ev, p) {
+ if (!gd || gd.step >= GUIDE_STEPS) return 0
+ const before = gd.step
+ switch (gd.step) {
+ case 0: // 采集基础材料:3 草 + 2 树枝
+ if (ev === 'gather') {
+ if (p?.type === 'grass') gd.grass++
+ if (p?.type === 'sapling') gd.twig++
+ if (gd.grass >= 3 && gd.twig >= 2) gd.step = 1
+ }
+ break
+ case 1: // 制作斧头
+ if (ev === 'craft' && p?.code === 'axe') gd.step = 2
+ break
+ case 2: // 砍倒一棵树
+ if (ev === 'gather' && p?.type === 'tree') gd.step = 3
+ break
+ case 3: // 生起火
+ if (ev === 'place' && (p?.code === 'campfire' || p?.code === 'firepit')) gd.step = 4
+ break
+ case 4: // 吃东西
+ if (ev === 'eat') gd.step = 5
+ break
+ case 5: // 建造科学机器
+ if (ev === 'place' && p?.code === 'sciencemachine') gd.step = 6
+ break
+ case 6: // 熬过第一夜(进入新的一天)
+ if (ev === 'newday') gd.step = 7
+ break
+ }
+ return gd.step > before ? gd.step : 0
+}
+
+export function guideText(gd) {
+ if (!gd || gd.step >= GUIDE_STEPS) return ''
+ switch (gd.step) {
+ case 0:
+ return `新手目标 · 采集材料:草 ${Math.min(3, gd.grass)}/3 · 树枝 ${Math.min(2, gd.twig)}/2(走近按空格)`
+ case 1:
+ return '新手目标 · 按 C 打开合成,做一把斧头'
+ case 2:
+ return '新手目标 · 砍倒一棵树获得木头(装备斧头走近采集)'
+ case 3:
+ return '新手目标 · 合成篝火并放置(天黑前必须有火!)'
+ case 4:
+ return '新手目标 · 吃点东西(点击物品栏里的浆果/萝卜)'
+ case 5:
+ return '新手目标 · 建造科学机器,解锁高级配方'
+ case 6:
+ return '新手目标 · 守着火堆熬过这一夜'
+ default:
+ return ''
+ }
+}
diff --git a/src/games/starve/mobs.js b/src/games/starve/mobs.js
index 3f579e5..3620e08 100644
--- a/src/games/starve/mobs.js
+++ b/src/games/starve/mobs.js
@@ -5,6 +5,7 @@
// addEntity(type,x,y,extra) · removeEntity(ent) · addLoot(x,y,code,n)
// removeMob(m) · killMob(m,credit) · smashStructure(ent) · tip(t) · ptip(p,t) · shake(amp)
import { MON } from './data.js'
+import { spawnFx } from './fx.js'
function isLaborMob(m) {
const k = m?.kind
@@ -14,10 +15,12 @@ function isFenceLike(e) {
return e.type === 'fence' || e.type === 'gate' || e.type === 'campfence' || e.type === 'campgate'
}
// 固体栅栏/关着的门挡路(劳工穿栅栏;开着的门、兔子洞不挡)
+// 用 g.queryEnts(空间网格)取附近候选,替代全实体线性扫描;无网格时回退全量
function blockedBySolid(g, x, y, m = null) {
if (!g.entities) return false
const labor = isLaborMob(m)
- for (const e of g.entities) {
+ const cands = typeof g.queryEnts === 'function' ? g.queryEnts(x, y, 64) : g.entities
+ for (const e of cands) {
if (!e.solid) continue
// 旧档兔子洞可能仍带 solid,一律不挡路
if (e.type === 'rabbithole') continue
@@ -38,7 +41,8 @@ function canStand(g, x, y, m = null) {
return g.isLand(x, y) && !blockedBySolid(g, x, y, m)
}
function nearCampWall(g, m) {
- return (g.entities || []).some((e) => {
+ const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 64) : g.entities || []
+ return cands.some((e) => {
if (e.type === 'campfence') return Math.hypot(e.x - m.x, e.y - m.y) < 44
if (e.type === 'campgate' && !e.mx?.open) return Math.hypot(e.x - m.x, e.y - m.y) < 52
return false
@@ -154,6 +158,10 @@ export function updateMob(m, dt, g) {
else if (m.kind === 'bee') updBee(m, conf, dt, g)
else if (m.kind === 'frog') updFrog(m, conf, dt, g)
else if (m.kind === 'deerclops') updDeerclops(m, conf, dt, g)
+ else if (m.kind === 'moosegoose') updMoosegoose(m, conf, dt, g)
+ else if (m.kind === 'dragonfly') updDragonfly(m, conf, dt, g)
+ else if (m.kind === 'slurtle') updSlurtle(m, conf, dt, g)
+ else if (m.kind === 'beequeen') updBeequeen(m, conf, dt, g)
m.x = Math.max(24, Math.min(g.worldW - 24, m.x))
m.y = Math.max(24, Math.min(g.worldH - 24, m.y))
}
@@ -178,7 +186,7 @@ function updShadow(m, conf, dt, g) {
// 蜘蛛:巢边领地怪,白天守巢、夜晚游荡范围扩大;入侵者靠近即追击
function updSpider(m, conf, dt, g) {
- const home = m.nest && g.entities.includes(m.nest) ? m.nest : null
+ const home = m.nest && !m.nest._gone ? m.nest : null
const ax = home ? home.x : m.x
const ay = home ? home.y : m.y
const aggroR = g.phase === 'night' ? 280 : 190
@@ -249,6 +257,8 @@ function updTreeguard(m, conf, dt, g) {
m.state = 'smash'
m.t = 0.35
g.shake(6)
+ spawnFx('dust', m.x + 30, m.y - 4, { n: 6, dir: 0.4, spread: 0.8 })
+ spawnFx('leaf', m.x - 20, m.y - 40, { n: 4 })
g.players.forEach((p) => {
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 70) {
g.damagePlayer(p, conf.dmg, '被树精拍中了!')
@@ -275,7 +285,7 @@ function updPig(m, conf, dt, g) {
return
}
// 2) 与敌对生物作战(结盟时主动搜索主人周边的敌人)
- if (m.targetMob && (!g.monsters.includes(m.targetMob) || m.targetMob.hp <= 0)) m.targetMob = null
+ if (m.targetMob && (m.targetMob._gone || m.targetMob.hp <= 0)) m.targetMob = null
if (!m.targetMob && m.loyalT > 0) {
const owner = m.owner && !m.owner.dead ? m.owner : null
let best = null
@@ -409,18 +419,18 @@ function updFrog(m, conf, dt, g) {
}
// 独眼巨鹿:优先拆毁玩家附近建筑,其次追人;范围砸击;冬季结束自行离去
-function updDeerclops(m, conf, dt, g) {
- m.t -= dt
+function updDeerclops(m, conf, dt, g) { m.t -= dt
if (g.season !== 'winter') {
g.removeMob(m)
g.tip('冬天过去了,独眼巨鹿转身离开……')
return
}
- // 目标:先找 500 内建筑,否则最近的玩家
- if (!m.targetEnt || !g.entities.includes(m.targetEnt)) {
+ // 目标:先找 500 内建筑,否则最近的玩家(空间网格查询)
+ if (!m.targetEnt || m.targetEnt._gone) {
m.targetEnt = null
let bd = 500
- g.entities.forEach((e) => {
+ const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 520) : g.entities
+ cands.forEach((e) => {
if (!(e.struct || e.pot)) return
const d = Math.hypot(e.x - m.x, e.y - m.y)
if (d < bd) { bd = d; m.targetEnt = e }
@@ -450,6 +460,8 @@ function updDeerclops(m, conf, dt, g) {
m.state = 'smash'
m.t = 0.4
g.shake(10)
+ spawnFx('dust', m.x + 40, m.y - 6, { n: 8, dir: 0.3, spread: 0.9, power: 1.4 })
+ spawnFx('dust', m.x - 40, m.y - 6, { n: 8, dir: Math.PI - 0.3, spread: 0.9, power: 1.4 })
// 范围砸击:玩家/猪人受伤,建筑直接摧毁
g.players.forEach((p) => {
if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 95) {
@@ -474,3 +486,177 @@ function updDeerclops(m, conf, dt, g) {
if (m.t <= 0) m.state = 'chase'
}
}
+
+// 麋鹿鹅:春季袭击;逼近后扑翅摔砸(范围震击);春季结束振翅离去
+function updMoosegoose(m, conf, dt, g) {
+ m.t -= dt
+ if (g.season !== 'spring') {
+ g.removeMob(m)
+ g.tip('春天过去了,麋鹿鹅振翅离开……')
+ return
+ }
+ const near = nearestVictim(m, g, true)
+ if (!near) return
+ const tp = near.v.ref
+ const dist = near.d
+ if (m.state === 'chase' || !m.state) {
+ if (dist > 60) toward(m, tp.x, tp.y, conf.speed, dt, g)
+ else {
+ m.state = 'windup'
+ m.t = 0.6
+ }
+ } else if (m.state === 'windup') {
+ if (m.t <= 0) {
+ m.state = 'smash'
+ m.t = 0.35
+ g.shake(9)
+ spawnFx('dust', m.x + 30, m.y - 4, { n: 7, dir: 0.4, spread: 0.8 })
+ spawnFx('leaf', m.x - 20, m.y - 30, { n: 5 })
+ // 扑砸范围伤害
+ g.players.forEach((p) => {
+ if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 85) {
+ g.damagePlayer(p, conf.dmg, '被麋鹿鹅扑砸中了!')
+ }
+ })
+ g.monsters.forEach((q) => {
+ if (q.kind === 'pig' && Math.hypot(q.x - m.x, q.y - m.y) < 85) {
+ q.hp -= conf.dmg
+ if (q.hp <= 0) g.killMob(q, null)
+ }
+ })
+ }
+ } else if (m.state === 'smash') {
+ if (m.t <= 0) { m.state = 'recover'; m.t = 1.0 }
+ } else if (m.state === 'recover') {
+ if (m.t <= 0) m.state = 'chase'
+ }
+}
+
+// 龙蝇:夏季袭击;优先焚烧可燃目标(树/建筑/植物);夏季结束钻回地底
+function updDragonfly(m, conf, dt, g) {
+ m.t -= dt
+ if (g.season !== 'summer') {
+ g.removeMob(m)
+ g.tip('夏天过去了,龙蝇钻回了地底……')
+ return
+ }
+ // 目标:先找附近可燃物,否则最近玩家
+ if (!m.targetEnt || m.targetEnt._gone) {
+ m.targetEnt = null
+ let bd = 460
+ const cands = typeof g.queryEnts === 'function' ? g.queryEnts(m.x, m.y, 480) : g.entities
+ cands.forEach((e) => {
+ const flammable = e.type === 'tree' || e.struct || e.pot || e.type === 'grass' || e.type === 'sapling' || e.type === 'berry'
+ if (!flammable) return
+ const d = Math.hypot(e.x - m.x, e.y - m.y)
+ if (d < bd) { bd = d; m.targetEnt = e }
+ })
+ }
+ const near = nearestVictim(m, g)
+ let tx
+ let ty
+ let dist
+ if (m.targetEnt) {
+ tx = m.targetEnt.x
+ ty = m.targetEnt.y
+ dist = Math.hypot(tx - m.x, ty - m.y)
+ } else if (near) {
+ tx = near.v.ref.x
+ ty = near.v.ref.y
+ dist = near.d
+ } else return
+ if (m.state === 'chase' || !m.state) {
+ if (dist > 70) toward(m, tx, ty, conf.speed, dt, g)
+ else {
+ m.state = 'windup'
+ m.t = 0.8
+ }
+ } else if (m.state === 'windup') {
+ if (m.t <= 0) {
+ m.state = 'smash'
+ m.t = 0.4
+ g.shake(10)
+ spawnFx('dust', m.x + 34, m.y - 8, { n: 8, dir: 0.3, spread: 0.9, power: 1.4 })
+ // 喷烧:玩家/猪人受伤,可燃物焚毁
+ g.players.forEach((p) => {
+ if (!p.dead && Math.hypot(p.x - m.x, p.y - m.y) < 95) {
+ g.damagePlayer(p, conf.dmg, '被龙蝇喷烧了!')
+ }
+ })
+ g.monsters.forEach((q) => {
+ if (q.kind === 'pig' && Math.hypot(q.x - m.x, q.y - m.y) < 95) {
+ q.hp -= conf.dmg
+ if (q.hp <= 0) g.killMob(q, null)
+ }
+ })
+ g.entities.slice().forEach((e) => {
+ if (Math.hypot(e.x - m.x, e.y - m.y) >= 95) return
+ if (e.struct || e.pot) {
+ g.smashStructure(e)
+ } else if (e.type === 'tree') {
+ g.removeEntity(e)
+ g.addLoot(e.x, e.y, 'ash', 1)
+ } else if (e.type === 'grass' || e.type === 'sapling' || e.type === 'berry') {
+ g.removeEntity(e)
+ }
+ })
+ }
+ } else if (m.state === 'smash') {
+ if (m.t <= 0) { m.state = 'recover'; m.t = 1.2 }
+ } else if (m.state === 'recover') {
+ if (m.t <= 0) m.state = 'chase'
+ }
+}
+
+// 石虾:洞穴中立生物,壳厚皮慢;被打会缓慢反击
+function updSlurtle(m, conf, dt, g) {
+ if (m.angryT > 0) {
+ m.angryT -= dt
+ if (m.angryAt && !m.angryAt.dead) {
+ const d = Math.hypot(m.angryAt.x - m.x, m.angryAt.y - m.y)
+ chaseBite(m, conf, { kind: 'p', ref: m.angryAt }, d, dt, g, '被石虾撞了', (ref) => {
+ g.ptip(ref, '石虾缩头再撞! · 生命 -' + conf.dmg)
+ })
+ return
+ }
+ m.angryT = 0
+ }
+ m.wanderT -= dt
+ if (m.wanderT <= 0) {
+ m.wanderT = 3 + Math.random() * 5
+ const ang = Math.random() * Math.PI * 2
+ m.tx = m.x + Math.cos(ang) * Math.random() * 90
+ m.ty = m.y + Math.sin(ang) * Math.random() * 90
+ }
+ if (m.tx !== undefined && Math.hypot(m.tx - m.x, m.ty - m.y) > 10) {
+ toward(m, m.tx, m.ty, conf.speed * 0.5, dt, g)
+ }
+}
+
+// 蜂王:养蜂线的终极 Boss;追蜇 + 定期召唤杀人蜂护卫
+function updBeequeen(m, conf, dt, g) {
+ m.t -= dt
+ m.summonCd = (m.summonCd || 4) - dt
+ const near = nearestVictim(m, g, true)
+ if (!near) return
+ chaseBite(m, conf, near.v, near.d, dt, g, '被蜂王蜇了', (ref) => {
+ if (near.v.kind === 'p') {
+ ref.san = Math.max(0, ref.san - 8)
+ g.ptip(ref, '蜂王的毒刺! · 生命 -' + conf.dmg + ' 理智 -8')
+ }
+ })
+ // 召唤护卫:场上蜂群不超过 6 只时补 2 只
+ if (m.summonCd <= 0) {
+ m.summonCd = 8
+ const bees = g.monsters.filter((q) => q.kind === 'bee').length
+ if (bees < 6) {
+ for (let i = 0; i < 2; i++) {
+ const ang = Math.random() * Math.PI * 2
+ const sx = m.x + Math.cos(ang) * 50
+ const sy = m.y + Math.sin(ang) * 50
+ g.spawnMob('bee', sx, sy, { angryAt: near.v.kind === 'p' ? near.v.ref : null, life: 25 })
+ }
+ g.tip('蜂王召唤了护卫蜂群!')
+ }
+ }
+}
diff --git a/src/games/starve/mods/agri.js b/src/games/starve/mods/agri.js
index cbf6219..266f0a8 100644
--- a/src/games/starve/mods/agri.js
+++ b/src/games/starve/mods/agri.js
@@ -249,6 +249,291 @@ export function agriYieldAt(x, y, zoneId) {
return zoneYieldMul(z?.level || 1)
}
+// ---- 地下生态圈(子地图):堆肥造肥 / 滴灌加速 / 地热越冬 / 蚯蚓松土 ----
+import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
+import { makeSubmap } from './submap.js'
+
+const ROOT_MAX = 260 // 与地窖联动的农田半径
+
+const cellarSub = makeSubmap({
+ W: 480,
+ H: 300,
+ wallRatio: 0.34,
+ speed: 150,
+ avatarR: 13,
+ reach: 46,
+ skins: { skinByCode, drawPlayerFig },
+ title: () => '地下生态圈',
+ enter() {
+ return { x: 240, y: 264 }
+ },
+ bounds() {
+ return { x0: 26, y0: 118, x1: 454, y1: 282 }
+ },
+ objects(g, host, st) {
+ const mx = host.mx || {}
+ const objs = [
+ { id: 'compost', x: 58, y: 196, w: 66, h: 56, solid: true, prompt: (mx.compost || 0) >= 5 ? '取出肥料' : `投料堆肥 ${mx.compost || 0}/5` },
+ { id: 'valve', x: 214, y: 112, w: 52, h: 42, solid: true, prompt: mx.irrigOn ? '关闭滴灌' : '开启滴灌' },
+ { id: 'heater', x: 354, y: 196, w: 64, h: 56, solid: true, prompt: mx.heatOn ? '关闭地热' : '开启地热' },
+ { id: 'ladder', x: 214, y: 248, w: 52, h: 46, solid: false, prompt: '爬回地面' },
+ ]
+ ;(st.rt.worms || []).forEach((w, i) => {
+ objs.push({ id: 'worm:' + i, x: w.x - 13, y: w.y - 9, w: 26, h: 18, prompt: '捉住蚯蚓', reach: 34 })
+ })
+ return objs
+ },
+ tick(g, dt, host, st) {
+ const mx = host.mx
+ // 蚯蚓每日刷新(数据记 mx.wormDay,入档)
+ if (st.rt.worms == null) {
+ st.rt.worms = []
+ if ((mx.wormDay || 0) !== g.day) {
+ const n = 2 + (g.rand ? g.rand(0, 1) : 0)
+ for (let i = 0; i < n; i++) {
+ st.rt.worms.push({
+ x: 120 + Math.random() * 240,
+ y: 170 + Math.random() * 70,
+ ph: Math.random() * 6,
+ vx: (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 10),
+ })
+ }
+ mx.wormDay = g.day
+ g.markDirty(host)
+ }
+ }
+ ;(st.rt.worms || []).forEach((w) => {
+ w.x += w.vx * dt
+ w.ph += dt * 6
+ if (w.x < 70 || w.x > 410) w.vx *= -1
+ w.y = 190 + Math.sin(w.ph * 0.35) * 46
+ })
+ },
+ interact(g, p, host, id, st) {
+ const mx = host.mx
+ if (id === 'leave' || id === 'ladder') {
+ cellarSub.close(g)
+ g.ptip(p, '顺着梯子回到了地面')
+ return
+ }
+ if (id === 'compost') {
+ if ((mx.compost || 0) >= 5) {
+ mx.compost -= 5
+ g.addItem(p, 'fertilizer', 1)
+ g.markDirty(host)
+ g.swing(p, 'dig')
+ g.ptip(p, '堆肥熟成了!肥料 ×1')
+ } else if (g.countItem(p, 'grass') >= 2) {
+ g.payCost(p, { grass: 2 })
+ mx.compost = Math.min(5, (mx.compost || 0) + 1)
+ g.markDirty(host)
+ g.swing(p, 'dig')
+ g.ptip(p, `埋进两把草,堆肥发酵中…… ${mx.compost}/5`)
+ } else {
+ g.ptip(p, '堆肥需要 2 份草(割草就有),攒满 5 层可取出肥料')
+ }
+ return
+ }
+ if (id === 'valve') {
+ mx.irrigOn = !mx.irrigOn
+ g.markDirty(host)
+ g.ptip(p, mx.irrigOn ? '滴灌开启:周边农田生长 +25%' : '滴灌已关闭')
+ return
+ }
+ if (id === 'heater') {
+ mx.heatOn = !mx.heatOn
+ g.markDirty(host)
+ g.ptip(p, mx.heatOn ? '地热开启:冬季作物也会缓慢生长' : '地热已关闭')
+ return
+ }
+ if (id.startsWith && id.startsWith('worm:')) {
+ const i = +id.slice(5)
+ if (st.rt.worms?.[i]) st.rt.worms.splice(i, 1)
+ mx.compost = Math.min(5, (mx.compost || 0) + 1)
+ g.addScore(2, p)
+ g.markDirty(host)
+ g.swing(p, 'dig')
+ g.ptip(p, '捉到一条肥蚯蚓,丢进了堆肥箱 +1')
+ return
+ }
+ },
+ draw(g, ctx, v, host, st, env) {
+ const t = env.time || 0
+ const mx = host.mx || {}
+ ctx.save()
+ ctx.translate(v.px, v.py)
+ ctx.scale(v.scale, v.scale)
+ // —— 土壤分层 ——
+ const layers = [[0, '#5d4228'], [56, '#543a22'], [118, '#4a321e'], [180, '#3f2a1a'], [242, '#362316']]
+ layers.forEach(([y0, col], i) => {
+ const y1 = layers[i + 1]?.[0] ?? 300
+ ctx.fillStyle = col
+ ctx.fillRect(0, y0, 480, y1 - y0 + 1)
+ ctx.strokeStyle = 'rgba(20,12,6,0.55)'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(0, y1 - 1); ctx.lineTo(480, y1 - 1); ctx.stroke()
+ })
+ ctx.fillStyle = 'rgba(28,17,7,0.5)'
+ for (let i = 0; i < 46; i++) {
+ ctx.beginPath()
+ ctx.arc((i * 97 + 31) % 470, (i * 53 + 17) % 292, 1.7, 0, TAU)
+ ctx.fill()
+ }
+ // —— 地表草皮带 + 垂须 ——
+ ctx.fillStyle = '#5d7a34'
+ ctx.fillRect(0, 0, 480, 12)
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(0, 12); ctx.lineTo(480, 12); ctx.stroke()
+ ctx.strokeStyle = 'rgba(140,170,90,0.4)'
+ ctx.lineWidth = 1.2
+ for (let i = 0; i < 13; i++) {
+ const x = 18 + i * 37
+ ctx.beginPath()
+ ctx.moveTo(x, 12)
+ ctx.quadraticCurveTo(x + 4, 26, x - 2, 38)
+ ctx.stroke()
+ }
+ // —— 上方农田根系(生长进度可视化)——
+ ctx.lineCap = 'round'
+ const farms = g.entities.filter((e) => e.type === 'agrifarm' && Math.hypot(e.x - host.x, e.y - host.y) < ROOT_MAX)
+ farms.slice(0, 6).forEach((e, i) => {
+ const gx = 66 + i * 68
+ const prog = e.state === 'ready' ? 1 : Math.min(1, (e.mx?.t || 0) / GROW_TOTAL)
+ const depth = 26 + prog * 74
+ ctx.strokeStyle = e.state === 'ready' ? 'rgba(255,220,120,0.9)' : 'rgba(196,158,104,0.8)'
+ ctx.lineWidth = 3
+ ctx.beginPath()
+ ctx.moveTo(gx, 40)
+ ctx.quadraticCurveTo(gx + 6, 40 + depth * 0.5, gx, 40 + depth)
+ ctx.stroke()
+ ctx.lineWidth = 1.6
+ ;[-1, 1].forEach((s) => {
+ for (let k = 1; k <= 3; k++) {
+ const y2 = 40 + depth * (0.25 + k * 0.22)
+ ctx.beginPath()
+ ctx.moveTo(gx, y2)
+ ctx.quadraticCurveTo(gx + s * 8, y2 + 5, gx + s * 13, y2 + 3)
+ ctx.stroke()
+ }
+ })
+ if (e.state === 'ready') {
+ ctx.fillStyle = '#ffd76a'
+ ctx.beginPath(); ctx.arc(gx, 40 + depth, 3.4, 0, TAU); ctx.fill()
+ }
+ })
+ ctx.fillStyle = 'rgba(240,230,200,0.55)'
+ ctx.font = '11px sans-serif'
+ ctx.textAlign = 'left'
+ if (!farms.length) ctx.fillText('附近还没有农田,先去地面开垦吧', 14, 50)
+ else ctx.fillText(`上方农田 ${farms.length} 块 · 成熟 ${farms.filter((e) => e.state === 'ready').length}`, 14, 22)
+ // —— 滴灌管道 ——
+ ctx.strokeStyle = '#7e8187'
+ ctx.lineWidth = 5
+ ctx.beginPath(); ctx.moveTo(70, 128); ctx.lineTo(410, 128); ctx.stroke()
+ ctx.strokeStyle = '#54565c'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.moveTo(70, 125); ctx.lineTo(410, 125); ctx.stroke()
+ if (mx.irrigOn) {
+ for (let i = 0; i < 3; i++) {
+ const ph = (t * 0.9 + i * 0.33) % 1
+ ctx.fillStyle = `rgba(120,190,230,${0.85 * (1 - ph)})`
+ ctx.beginPath()
+ ctx.arc(100 + i * 120, 132 + ph * 56, 2.2, 0, TAU)
+ ctx.fill()
+ }
+ ctx.strokeStyle = 'rgba(90,150,180,0.45)'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.moveTo(240, 128); ctx.lineTo(240, 246); ctx.stroke()
+ }
+ // 阀门轮盘
+ ctx.strokeStyle = mx.irrigOn ? '#7fb069' : '#8d9097'
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.arc(240, 128, 9, 0, TAU); ctx.stroke()
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(231, 128); ctx.lineTo(249, 128)
+ ctx.moveTo(240, 119); ctx.lineTo(240, 137)
+ ctx.stroke()
+ // —— 堆肥箱 ——
+ const comp = mx.compost || 0
+ ctx.fillStyle = '#7a5230'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(58, 206, 66, 42, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(62, 210, 58, 9, 2); ctx.fill(); ctx.stroke()
+ if (comp > 0) {
+ ctx.fillStyle = '#6a4a26'
+ ctx.beginPath(); ctx.roundRect(63, 245 - comp * 6, 56, comp * 6, 2); ctx.fill()
+ ctx.strokeStyle = 'rgba(220,200,160,0.35)'
+ ctx.lineWidth = 1.2
+ for (let i = 0; i < 2; i++) {
+ const ph = (t * 0.6 + i * 0.5) % 1
+ ctx.beginPath()
+ ctx.arc(80 + i * 22, 236 - comp * 5 - ph * 12, 2.4 + ph * 3, 0, TAU)
+ ctx.stroke()
+ }
+ }
+ ctx.fillStyle = '#c9b48c'
+ ctx.font = 'bold 10px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('堆肥箱', 91, 256)
+ // —— 地热暖石 ——
+ const pulse = mx.heatOn ? 0.6 + Math.sin(t * 3) * 0.25 : 0.15
+ if (mx.heatOn) {
+ const gl = ctx.createRadialGradient(386, 222, 4, 386, 222, 62)
+ gl.addColorStop(0, `rgba(255,120,50,${0.3 * pulse})`)
+ gl.addColorStop(1, 'rgba(255,120,50,0)')
+ ctx.fillStyle = gl
+ ctx.beginPath(); ctx.arc(386, 222, 62, 0, TAU); ctx.fill()
+ }
+ ctx.fillStyle = mx.heatOn ? '#8a4438' : '#6f7278'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(356, 242); ctx.lineTo(362, 212); ctx.lineTo(382, 202); ctx.lineTo(406, 212); ctx.lineTo(412, 242)
+ ctx.closePath()
+ ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = mx.heatOn ? 'rgba(255,150,80,0.8)' : 'rgba(30,30,36,0.5)'
+ ctx.lineWidth = 1.6
+ ctx.beginPath()
+ ctx.moveTo(368, 218); ctx.lineTo(396, 218)
+ ctx.moveTo(364, 228); ctx.lineTo(402, 228)
+ ctx.stroke()
+ ctx.fillStyle = '#c9b48c'
+ ctx.fillText('地热暖石', 386, 256)
+ // —— 蚯蚓 ——
+ ;(st.rt.worms || []).forEach((w) => {
+ ctx.strokeStyle = '#e8a0a8'
+ ctx.lineWidth = 3.4
+ ctx.beginPath()
+ for (let k = 0; k <= 6; k++) {
+ const px2 = w.x + (k - 3) * 3.4
+ const py2 = w.y + Math.sin(w.ph + k * 0.9) * 2.6
+ if (k === 0) ctx.moveTo(px2, py2)
+ else ctx.lineTo(px2, py2)
+ }
+ ctx.stroke()
+ ctx.fillStyle = '#c97880'
+ ctx.beginPath(); ctx.arc(w.x + 11, w.y + Math.sin(w.ph + 5.4) * 2.6, 1.4, 0, TAU); ctx.fill()
+ })
+ // —— 出口梯子 ——
+ ctx.strokeStyle = '#8a5f34'
+ ctx.lineWidth = 3.4
+ ;[216, 258].forEach((x) => {
+ ctx.beginPath(); ctx.moveTo(x, 214); ctx.lineTo(x, 292); ctx.stroke()
+ })
+ ctx.lineWidth = 2.4
+ ;[224, 240, 256, 272, 288].forEach((y) => {
+ ctx.beginPath(); ctx.moveTo(216, y); ctx.lineTo(258, y); ctx.stroke()
+ })
+ ctx.fillStyle = 'rgba(255,240,200,0.14)'
+ ctx.beginPath(); ctx.ellipse(238, 290, 34, 8, 0, 0, TAU); ctx.fill()
+ ctx.restore()
+ },
+})
+
export default {
id: 'agri',
name: '农业文明',
@@ -271,9 +556,31 @@ export default {
{ code: 'well', name: '水井', tab: 'agri', tech: 1, cost: { rock: 12, rope: 2 }, build: true, desc: '灌溉周边良田,生长加速' },
{ code: 'granary', name: '粮仓', tab: 'agri', tech: 1, cost: { log: 8, rope: 2 }, build: true, desc: '存储作物,防潮保鲜' },
{ code: 'mill', name: '磨坊', tab: 'agri', tech: 2, cost: { log: 4, rock: 6, gold: 2 }, build: true, desc: '小麦磨面粉' },
+ { code: 'agricellar', name: '地下生态圈', tab: 'agri', tech: 2, cost: { log: 6, rock: 8, rope: 2 }, build: true, desc: '钻进地下:堆肥造肥 · 滴灌加速 · 地热越冬' },
{ code: 'fertilizer', name: '肥料', tab: 'agri', tech: 1, cost: { rot: 2, ash: 1 }, desc: '施于良田,生长 +50%' },
],
icons: {
+ agricellar: (c) => {
+ c.fillStyle = '#6a4a2e'
+ c.strokeStyle = '#1d1409'
+ c.lineWidth = 1.8
+ c.beginPath()
+ c.moveTo(-13, 10)
+ c.quadraticCurveTo(-12, -6, 0, -9)
+ c.quadraticCurveTo(12, -6, 13, 10)
+ c.closePath()
+ c.fill(); c.stroke()
+ c.fillStyle = '#5d3f24'
+ c.beginPath(); c.roundRect(-7, -4, 14, 14, 2); c.fill(); c.stroke()
+ c.strokeStyle = '#8a5f34'
+ c.lineWidth = 1.2
+ c.beginPath(); c.moveTo(0, -4); c.lineTo(0, 10); c.stroke()
+ c.strokeStyle = '#5d8a45'
+ c.lineWidth = 2
+ c.beginPath(); c.moveTo(0, -9); c.quadraticCurveTo(3, -15, 7, -16); c.stroke()
+ c.fillStyle = '#7fb069'
+ c.beginPath(); c.ellipse(8, -16, 3.4, 2, 0.4, 0, TAU); c.fill(); c.stroke()
+ },
agrihoe: (c) => {
c.strokeStyle = '#7a5b33'
c.lineWidth = 3
@@ -445,11 +752,13 @@ export default {
},
update(e, dt, g) {
if (!e.mx || e.state === 'empty' || e.state === 'ready') return
- if (g.season === 'winter') return // 冬季暂停生长(与官方一致)
+ const cell = g.entities.find((o) => o.type === 'agricellar' && Math.hypot(o.x - e.x, o.y - e.y) < 220)
+ const winter = g.season === 'winter'
+ if (winter && !(cell?.mx?.heatOn)) return // 冬季暂停(地窖开地热可越冬慢长)
const watered = g.entities.some((o) => o.type === 'well' && Math.hypot(o.x - e.x, o.y - e.y) < 140)
const z = zoneOfPlot(e)
const lvMul = 1 + 0.06 * Math.max(0, (z?.level || 1) - 1)
- e.mx.t += dt * (watered ? 1.6 : 1) * lvMul
+ e.mx.t += dt * (watered ? 1.6 : 1) * (cell?.mx?.irrigOn ? 1.25 : 1) * lvMul * (winter ? 0.5 : 1)
const st = stageOf(e.mx.t)
if (st !== e.state) {
e.state = st
@@ -484,7 +793,7 @@ export default {
return
}
const pct = Math.min(99, Math.floor(((e.mx?.t || 0) / GROW_TOTAL) * 100))
- g.ptip(p, `作物生长中…… ${pct}%(靠近水井更快)`)
+ g.ptip(p, `作物生长中…… ${pct}%(水井/滴灌更快)`)
},
draw(ctx, e) {
const z = zoneOfPlot(e)
@@ -794,6 +1103,52 @@ export default {
ctx.beginPath(); ctx.arc(0, -32, 3, 0, TAU); ctx.fill()
},
},
+ agricellar: {
+ name: '地下生态圈',
+ conf: { size: 42, struct: true, submap: true },
+ init(e) {
+ if (!e.mx) e.mx = {}
+ if (e.mx.irrigOn == null) e.mx.irrigOn = false
+ if (e.mx.heatOn == null) e.mx.heatOn = false
+ if (e.mx.compost == null) e.mx.compost = 0
+ if (e.mx.wormDay == null) e.mx.wormDay = 0
+ },
+ interact(p, e, g) {
+ cellarSub.open(g, e)
+ g.ptip(p, '钻进了地下生态圈 · WASD 走动 · E 互动 · Esc 爬回地面')
+ },
+ draw(ctx, e, env) {
+ drawNameplate(ctx, '地下生态圈', -46)
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'
+ ctx.beginPath(); ctx.ellipse(0, 6, 24, 8, 0, 0, TAU); ctx.fill()
+ // 土丘
+ ctx.fillStyle = '#6a4a2e'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(-26, 6)
+ ctx.quadraticCurveTo(-24, -20, 0, -24)
+ ctx.quadraticCurveTo(24, -20, 26, 6)
+ ctx.closePath()
+ ctx.fill(); ctx.stroke()
+ // 石门框 + 木门
+ ctx.fillStyle = '#8d9097'
+ ctx.beginPath(); ctx.roundRect(-16, -18, 32, 24, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(-11, -13, 22, 19, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#8a5f34'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.moveTo(0, -13); ctx.lineTo(0, 6); ctx.stroke()
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(7, -3, 1.6, 0, TAU); ctx.fill()
+ // 门顶新芽
+ ctx.strokeStyle = '#5d8a45'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(0, -24); ctx.quadraticCurveTo(3, -30, 7, -31); ctx.stroke()
+ ctx.fillStyle = '#7fb069'
+ ctx.beginPath(); ctx.ellipse(8, -31, 3.4, 2, 0.4, 0, TAU); ctx.fill()
+ },
+ },
},
hooks: {
save() {
@@ -828,6 +1183,13 @@ export default {
agriZones.forEach((z) => fillFarmsInZone(g, z, { clear: false }))
agriZones.forEach((z) => ensureAgriWarehouse(g, z))
},
+ onTick(g, dt) {
+ // 地下生态圈子地图:移动/动画/交互
+ if (cellarSub.active()) cellarSub.update(g, dt)
+ },
+ allowsPlayerMove(g, p) {
+ return !cellarSub.active()
+ },
onUseItem(g, p, s, area, i) {
if (s.code === 'agrihoe') {
const spot = g.findLand(p.x + p.dir * 46, p.y + 8, 0, 24)
@@ -927,6 +1289,8 @@ export default {
return agriZoneRightAction(g, z)
},
onClick(g, wx, wy) {
+ // 地下生态圈打开期间吞掉点击
+ if (cellarSub.handleClick(g, g.me(), wx - uiCamX, wy - uiCamY)) return true
if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
@@ -1041,6 +1405,8 @@ export default {
uiCancel = null
}
ctx.restore()
+ // 地下生态圈子地图面板
+ cellarSub.drawFrame(g, ctx, env)
drawZoneMenu(ctx, env)
},
},
diff --git a/src/games/starve/mods/camp.js b/src/games/starve/mods/camp.js
index 3bef56a..fa14f27 100644
--- a/src/games/starve/mods/camp.js
+++ b/src/games/starve/mods/camp.js
@@ -3,7 +3,7 @@
// - 划定宅基:框选区域自动铺房屋地基,建筑工按计划盖房
// - 守卫 / 仓储 / 食堂 / 营地升级
import { rebuildFencePerimeter, normalizeRect, clearZoneClutter, pointInAabb, drawNameplate, openZoneMenu, drawZoneMenu, handleZoneMenuClick, closeZoneMenu, isZoneMenuOpen, zoneFloors } from './zoneUtil.js'
-import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
+import { skinByCode, drawPlayerFig, drawGuard } from '../../starveSkins.js'
const TAU = Math.PI * 2
const CAMP_R0 = 420 // 1 级营地安全半径
@@ -38,6 +38,36 @@ const ROOM_H = 280
const AVATAR_R = 14
const INTERIOR_SPEED = 150
+// —— 屋内小猫(纯视觉彩蛋 + 撸猫回理智;不入档)——
+const cat = { x: ROOM_W / 2, y: 200, tx: ROOM_W / 2, ty: 200, dir: 1, walkT: 0, moving: false, waitT: 2 }
+let catPetDay = 0
+let readDay = 0
+let medDay = 0
+let phonoDay = 0
+function updateCat(dt) {
+ if (cat.moving) {
+ const dx = cat.tx - cat.x
+ const dy = cat.ty - cat.y
+ const d = Math.hypot(dx, dy)
+ if (d < 4) {
+ cat.moving = false
+ cat.waitT = 1.5 + Math.random() * 3
+ } else {
+ cat.x += (dx / d) * 46 * dt
+ cat.y += (dy / d) * 46 * dt
+ cat.walkT += dt
+ if (dx) cat.dir = dx > 0 ? 1 : -1
+ }
+ } else {
+ cat.waitT -= dt
+ if (cat.waitT <= 0) {
+ cat.tx = 40 + Math.random() * (ROOM_W - 80)
+ cat.ty = 150 + Math.random() * 100
+ cat.moving = true
+ }
+ }
+}
+
const HOUSE_MODELS = {
hut: { label: '茅屋', wall: '#b07f47', wall2: '#9a6a38', roof: '#6a4a28', trim: '#8a5f30', cap: 1 },
timber: { label: '木屋', wall: '#c49a5a', wall2: '#a87840', roof: '#7a5230', trim: '#8a5f30', cap: 2 },
@@ -164,6 +194,73 @@ function doHouseRest(g, p, e, viaBed) {
return true
}
+// ---- 房屋外观绘制(4 种模型细节化)----
+// 墙体纹理:hut 编木抹泥 / timber 木板 / stone 砖石 / manor 都铎木架
+function houseWallTex(ctx, model, skin, x, y, w, h, seed) {
+ ctx.fillStyle = skin.wall
+ if (model === 'hut') {
+ // 抹泥墙 + 编木痕 + 补丁
+ ctx.beginPath(); ctx.roundRect(x, y, w, h, 3); ctx.fill()
+ ctx.strokeStyle = 'rgba(90,60,30,0.4)'
+ ctx.lineWidth = 1
+ for (let i = 0; i < 4; i++) {
+ const yy = y + 6 + i * (h - 12) / 3
+ ctx.beginPath()
+ ctx.moveTo(x + 3, yy + (i % 2) * 2)
+ ctx.quadraticCurveTo(x + w / 2, yy - 2, x + w - 3, yy + (i % 2 ? 1 : -1))
+ ctx.stroke()
+ }
+ ctx.fillStyle = 'rgba(120,85,45,0.35)'
+ ;[[x + 7, y + h * 0.28, 5, 3.4], [x + w - 12, y + h * 0.6, 6, 3], [x + w * 0.4, y + h - 9, 5, 3.2]].forEach(([px, py, pw, ph], i) => {
+ ctx.beginPath()
+ ctx.ellipse(px + (seed % (3 + i)), py, pw, ph, 0, 0, TAU)
+ ctx.fill()
+ })
+ } else if (model === 'timber') {
+ // 横向木板 + 钉
+ ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
+ ctx.strokeStyle = 'rgba(90,58,28,0.55)'
+ ctx.lineWidth = 1.2
+ for (let py = y + 7; py < y + h; py += 8) {
+ ctx.beginPath(); ctx.moveTo(x + 1, py); ctx.lineTo(x + w - 1, py + 0.6); ctx.stroke()
+ }
+ ctx.fillStyle = 'rgba(60,40,20,0.5)'
+ ;[[x + 4, y + 4], [x + w - 4, y + 4], [x + 4, y + h - 4], [x + w - 4, y + h - 4]].forEach(([px, py]) => {
+ ctx.beginPath(); ctx.arc(px, py, 0.9, 0, TAU); ctx.fill()
+ })
+ } else if (model === 'stone') {
+ // 错缝砖
+ ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
+ ctx.strokeStyle = 'rgba(70,74,82,0.6)'
+ ctx.lineWidth = 1.1
+ const bh = 8
+ for (let row = 0; row * bh < h; row++) {
+ const yy = y + row * bh
+ ctx.beginPath(); ctx.moveTo(x, yy); ctx.lineTo(x + w, yy); ctx.stroke()
+ const off = row % 2 ? 0 : 8
+ for (let bx = x + off; bx < x + w; bx += 16) {
+ ctx.beginPath(); ctx.moveTo(bx, yy); ctx.lineTo(bx, Math.min(yy + bh, y + h)); ctx.stroke()
+ }
+ }
+ } else {
+ // manor:灰泥 + 都铎深木架
+ ctx.beginPath(); ctx.roundRect(x, y, w, h, 2); ctx.fill()
+ ctx.strokeStyle = '#6a5238'
+ ctx.lineWidth = 2.4
+ ctx.beginPath()
+ ctx.moveTo(x + 2, y + 1); ctx.lineTo(x + 2, y + h - 1)
+ ctx.moveTo(x + w - 2, y + 1); ctx.lineTo(x + w - 2, y + h - 1)
+ ctx.moveTo(x, y + 2); ctx.lineTo(x + w, y + 2)
+ ctx.moveTo(x, y + h - 2); ctx.lineTo(x + w, y + h - 2)
+ ctx.stroke()
+ ctx.lineWidth = 1.8
+ ctx.beginPath()
+ ctx.moveTo(x + w * 0.34, y + 3); ctx.lineTo(x + w * 0.26, y + h - 3)
+ ctx.moveTo(x + w * 0.66, y + 3); ctx.lineTo(x + w * 0.74, y + h - 3)
+ ctx.stroke()
+ }
+}
+
function drawHouseExterior(ctx, e, env) {
const mx = normalizeHouseMx(e)
const floors = Math.max(1, Math.min(4, mx.floors || 1))
@@ -172,89 +269,237 @@ function drawHouseExterior(ctx, e, env) {
const name = mx.name || '房屋'
const resN = (mx.residents || []).length
const cap = houseCapacity(e)
+ const t = env?.time || 0
const label = `${name} · ${skin.label}`
- ctx.fillStyle = 'rgba(20,16,10,0.78)'
- const tw = Math.max(48, label.length * 8 + 14)
- ctx.beginPath(); ctx.roundRect(-tw / 2, -88 - (floors - 1) * 28, tw, 16, 4); ctx.fill()
+ const topY = -46 - (floors - 1) * 28
+ const seed = e.id | 0
+ // 底部投影 + 墙角 AO
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'
+ ctx.beginPath(); ctx.ellipse(0, 8, 36, 9, 0, 0, TAU); ctx.fill()
+ // 地基
+ ctx.fillStyle = model === 'hut' ? '#8a7050' : '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-32, -4, 64, 13, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(40,40,46,0.5)'
+ ctx.lineWidth = 1
+ for (let i = 0; i < 4; i++) {
+ ctx.beginPath(); ctx.moveTo(-28 + i * 15, -2); ctx.lineTo(-24 + i * 15, 7); ctx.stroke()
+ }
+ // 一层门前的石阶
+ ctx.fillStyle = '#9a9da4'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(-14, 2, 26, 5, 1.5); ctx.fill(); ctx.stroke()
+ // 每层墙体 + 窗 + 门
+ for (let f = 0; f < floors; f++) {
+ const baseY = -46 - f * 28
+ // 左右侧板(伪 2.5D 体积)
+ ctx.fillStyle = skin.wall2
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-28, baseY, 56, 44, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = 'rgba(0,0,0,0.14)'
+ ctx.beginPath(); ctx.roundRect(20, baseY + 1, 7, 42, 2); ctx.fill()
+ ctx.fillStyle = 'rgba(255,255,255,0.1)'
+ ctx.beginPath(); ctx.roundRect(-27, baseY + 1, 6, 42, 2); ctx.fill()
+ houseWallTex(ctx, model, skin, -28, baseY, 56, 44, seed + f)
+ // 窗(带框、窗台、反光;manor 双窗)
+ const win = (wx, wy, ww, wh) => {
+ ctx.fillStyle = model === 'manor' ? '#a8d8f4' : '#ffd98a'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath(); ctx.roundRect(wx, wy, ww, wh, 1.5); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(255,255,255,0.55)'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.moveTo(wx + 2, wy + wh - 3); ctx.lineTo(wx + ww * 0.45, wy + 2); ctx.stroke()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ ctx.moveTo(wx + ww / 2, wy); ctx.lineTo(wx + ww / 2, wy + wh)
+ ctx.moveTo(wx, wy + wh / 2); ctx.lineTo(wx + ww, wy + wh / 2)
+ ctx.stroke()
+ ctx.fillStyle = skin.trim
+ ctx.beginPath(); ctx.roundRect(wx - 2, wy + wh + 1, ww + 4, 2.6, 1); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ }
+ if (f === 0) {
+ // 门(按模型分款)
+ if (model === 'stone') {
+ ctx.fillStyle = '#3a2a16'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(-12, -2)
+ ctx.lineTo(-12, -18)
+ ctx.quadraticCurveTo(-1, -28, 10, -18)
+ ctx.lineTo(10, -2)
+ ctx.closePath()
+ ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(200,170,120,0.4)'
+ ctx.lineWidth = 1.2
+ for (let i = 1; i < 3; i++) {
+ ctx.beginPath(); ctx.moveTo(-12 + i * 7.3, -4); ctx.lineTo(-12 + i * 7.3, -19); ctx.stroke()
+ }
+ } else if (model === 'manor') {
+ ctx.fillStyle = '#5d3f24'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-11, -27, 22, 25, [4, 4, 0, 0]); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#c9a86a'
+ ctx.lineWidth = 1.3
+ ctx.beginPath(); ctx.roundRect(-7.5, -23, 15, 8, 1.5); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(-7.5, -12, 15, 7, 1.5); ctx.stroke()
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(6.5, -14, 1.5, 0, TAU); ctx.fill()
+ // 门灯
+ ctx.fillStyle = '#3a3f48'
+ ctx.beginPath(); ctx.roundRect(16, -24, 4, 6, 1); ctx.fill()
+ ctx.fillStyle = '#ffd98a'
+ ctx.beginPath(); ctx.arc(18, -21, 1.6, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#1d1409'; ctx.lineWidth = 1; ctx.stroke()
+ } else {
+ ctx.fillStyle = '#3a2a16'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(160,120,70,0.5)'
+ ctx.lineWidth = 1.1
+ for (let i = 1; i < 3; i++) {
+ ctx.beginPath(); ctx.moveTo(-11 + i * 6.6, -26); ctx.lineTo(-11 + i * 6.6, -2); ctx.stroke()
+ }
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(4.5, -14, 1.5, 0, TAU); ctx.fill()
+ }
+ if (model === 'manor') win(-24, baseY + 6, 14, 13)
+ else win(7, baseY + 6, 15, 13)
+ } else {
+ win(7, baseY + 6, 15, 13)
+ if (model === 'manor') win(-22, baseY + 6, 14, 13)
+ }
+ }
+ // 屋顶(按模型分款)
+ const roofY = -46 - (floors - 1) * 28
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ if (model === 'hut') {
+ // 茅草:三层垂檐
+ for (let i = 0; i < 3; i++) {
+ const ry = roofY - i * 9
+ const rw = 36 + i * 4
+ ctx.fillStyle = i % 2 ? skin.roof : '#7a5628'
+ ctx.beginPath()
+ ctx.moveTo(-rw, ry + 2)
+ ctx.quadraticCurveTo(0, ry - 14, rw, ry + 2)
+ ctx.quadraticCurveTo(rw * 0.6, ry - 2, 0, ry - 1)
+ ctx.quadraticCurveTo(-rw * 0.6, ry - 2, -rw, ry + 2)
+ ctx.closePath()
+ ctx.fill()
+ ctx.lineWidth = 1.6
+ ctx.stroke()
+ }
+ // 檐口垂草
+ ctx.strokeStyle = '#8a6a34'
+ ctx.lineWidth = 1.4
+ for (let i = -5; i <= 5; i++) {
+ const x = i * 7
+ ctx.beginPath()
+ ctx.moveTo(x, roofY + 1)
+ ctx.quadraticCurveTo(x + 1, roofY + 5, x + i * 0.4, roofY + 7)
+ ctx.stroke()
+ }
+ } else if (model === 'timber') {
+ ctx.fillStyle = skin.roof
+ ctx.beginPath(); ctx.moveTo(-38, roofY); ctx.lineTo(38, roofY); ctx.lineTo(0, roofY - 32); ctx.closePath(); ctx.fill(); ctx.stroke()
+ // 瓦片行
+ ctx.strokeStyle = 'rgba(40,26,14,0.55)'
+ ctx.lineWidth = 1.2
+ for (let r2 = 1; r2 <= 3; r2++) {
+ const yy = roofY - r2 * 8
+ const half = 38 * (1 - r2 * 0.25)
+ ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
+ for (let x = -half + 4; x < half; x += 9) {
+ ctx.beginPath(); ctx.arc(x, yy, 3.4, Math.PI, 0); ctx.stroke()
+ }
+ }
+ } else if (model === 'stone') {
+ ctx.fillStyle = skin.roof
+ ctx.beginPath(); ctx.moveTo(-36, roofY); ctx.lineTo(36, roofY); ctx.lineTo(0, roofY - 30); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(30,30,36,0.5)'
+ ctx.lineWidth = 1.2
+ for (let r2 = 1; r2 <= 3; r2++) {
+ const yy = roofY - r2 * 7.5
+ const half = 36 * (1 - r2 * 0.25)
+ ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
+ }
+ // 屋脊
+ ctx.strokeStyle = '#3a343c'
+ ctx.lineWidth = 2.4
+ ctx.beginPath(); ctx.moveTo(-4, roofY - 29.4); ctx.lineTo(4, roofY - 29.4); ctx.stroke()
+ } else {
+ // manor:双坡顶 + 山墙封檐
+ ctx.fillStyle = skin.roof
+ ctx.beginPath(); ctx.moveTo(-38, roofY); ctx.lineTo(38, roofY); ctx.lineTo(30, roofY - 16); ctx.lineTo(-30, roofY - 16); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#3a2820'
+ ctx.beginPath(); ctx.moveTo(-30, roofY - 16); ctx.lineTo(30, roofY - 16); ctx.lineTo(0, roofY - 38); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(20,12,8,0.45)'
+ ctx.lineWidth = 1.2
+ for (let r2 = 1; r2 <= 2; r2++) {
+ const yy = roofY - r2 * 5
+ const half = 38 - r2 * 2.4
+ ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
+ }
+ }
+ // 烟囱 + 烟
+ const chx = model === 'manor' ? -14 : 14
+ const chTop = roofY - (model === 'manor' ? 34 : 28)
+ ctx.fillStyle = model === 'hut' ? '#9a6a3c' : '#7a8088'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath(); ctx.roundRect(chx - 5, chTop + 4, 11, 20, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = model === 'hut' ? '#7a5230' : '#8d9097'
+ ctx.beginPath(); ctx.roundRect(chx - 6.5, chTop, 14, 6, 1.5); ctx.fill(); ctx.stroke()
+ for (let i = 0; i < 3; i++) {
+ const ph = (t * 0.42 + i * 0.45 + (seed % 10) * 0.1) % 1.5
+ const px = chx + Math.sin(t * 0.9 + i * 2) * (3 + ph * 4)
+ ctx.fillStyle = `rgba(214,214,214,${0.4 * (1 - ph / 1.5)})`
+ ctx.beginPath()
+ ctx.arc(px, chTop - 4 - ph * 24, 2.6 + ph * 3.4, 0, TAU)
+ ctx.fill()
+ }
+ // 木质名字牌 + 入住徽章
+ const tw = Math.max(52, label.length * 8 + 18)
+ ctx.save()
+ ctx.translate(0, topY - 70)
+ ctx.fillStyle = '#8a5f34'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(-tw / 2, -9, tw, 18, 3); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(60,40,20,0.45)'
+ ctx.lineWidth = 1
+ ctx.beginPath(); ctx.moveTo(-tw / 2 + 7, -8); ctx.lineTo(-tw / 2 + 7, 8); ctx.stroke()
+ ctx.fillStyle = '#c9a03c'
+ ;[-tw / 2 + 4, tw / 2 - 4].forEach((x) => {
+ ctx.beginPath(); ctx.arc(x, 0, 1.4, 0, TAU); ctx.fill()
+ })
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
- ctx.fillText(label, 0, -80 - (floors - 1) * 28)
+ ctx.fillText(label, 1, 0.5)
if (resN > 0) {
- ctx.fillStyle = 'rgba(40,60,30,0.75)'
- ctx.beginPath(); ctx.roundRect(-22, -72 - (floors - 1) * 28, 44, 12, 3); ctx.fill()
+ ctx.fillStyle = 'rgba(40,60,30,0.78)'
+ ctx.beginPath(); ctx.roundRect(-24, 12, 48, 13, 4); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
ctx.fillStyle = '#c8e8a0'
- ctx.font = '9px sans-serif'
- ctx.fillText(`住 ${resN}/${cap}`, 0, -66 - (floors - 1) * 28)
+ ctx.font = 'bold 9px sans-serif'
+ ctx.fillText(`住 ${resN}/${cap}`, 0, 18.5)
}
- ctx.fillStyle = 'rgba(0,0,0,0.25)'
- ctx.beginPath(); ctx.ellipse(0, 8, 34, 9, 0, 0, TAU); ctx.fill()
- // 地基:石屋/庄园用厚石基
- ctx.fillStyle = model === 'hut' ? '#8a7050' : '#8d9097'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-30, -4, 60, 12, 2); ctx.fill(); ctx.stroke()
- for (let f = 0; f < floors; f++) {
- const baseY = -46 - f * 28
- ctx.fillStyle = f % 2 ? skin.wall2 : skin.wall
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-26, baseY, 52, 44, model === 'manor' ? 2 : 3); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = skin.trim
- ctx.lineWidth = 1.2
- if (model === 'stone' || model === 'manor') {
- // 砖缝
- for (let row = 0; row < 4; row++) {
- const y = baseY + 8 + row * 9
- ctx.beginPath(); ctx.moveTo(-24, y); ctx.lineTo(24, y); ctx.stroke()
- }
- } else {
- ;[-38, -30, -22, -14, -6].forEach((y) => {
- ctx.beginPath(); ctx.moveTo(-26, baseY + 46 + y); ctx.lineTo(26, baseY + 46 + y); ctx.stroke()
- })
- }
- ctx.fillStyle = model === 'manor' ? '#a8d8ff' : '#ffd98a'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(7, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke()
- if (model === 'manor') {
- ctx.beginPath(); ctx.roundRect(-22, baseY + 6, 15, 13, 1.5); ctx.fill(); ctx.stroke()
- }
- if (f === 0) {
- ctx.fillStyle = '#3a2a16'
- ctx.beginPath(); ctx.roundRect(-11, -26, 20, 24, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#e0c060'
- ctx.beginPath(); ctx.arc(4, -14, 1.6, 0, TAU); ctx.fill()
- }
- }
- const roofY = -46 - (floors - 1) * 28
- ctx.fillStyle = skin.roof
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- if (model === 'manor') {
- ctx.beginPath(); ctx.moveTo(-36, roofY); ctx.lineTo(36, roofY); ctx.lineTo(28, roofY - 18); ctx.lineTo(-28, roofY - 18); ctx.closePath(); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#3a2820'
- ctx.beginPath(); ctx.moveTo(-28, roofY - 18); ctx.lineTo(28, roofY - 18); ctx.lineTo(0, roofY - 36); ctx.closePath(); ctx.fill(); ctx.stroke()
- } else {
- ctx.beginPath(); ctx.moveTo(-34, roofY); ctx.lineTo(34, roofY); ctx.lineTo(0, roofY - 30); ctx.closePath(); ctx.fill(); ctx.stroke()
- }
- ctx.fillStyle = model === 'stone' || model === 'manor' ? '#7a8088' : '#9a6a3c'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.8
- ctx.beginPath(); ctx.roundRect(12, roofY - 26, 11, 20, 2); ctx.fill(); ctx.stroke()
- const t = env?.time || 0
- ctx.fillStyle = 'rgba(220,220,220,0.4)'
- for (let i = 0; i < 3; i++) {
- const ph = (t * 0.5 + i * 0.5 + e.id * 0.1) % 1.5
- ctx.beginPath()
- ctx.arc(17 + Math.sin(t + i) * 3, roofY - 34 - ph * 22, 3 + ph * 3, 0, TAU)
- ctx.fill()
- }
- ctx.fillStyle = '#9a6a3c'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.6
- ctx.beginPath(); ctx.roundRect(-13, -2, 24, 5, 1.5); ctx.fill(); ctx.stroke()
+ ctx.restore()
}
function houseFloorCount(e) {
@@ -271,6 +516,12 @@ function roomFurniture(floor, floors) {
{ id: 'table', x: ROOM_W / 2 - 36, y: ROOM_H - 108, w: 72, h: 44, solid: true, interact: true },
{ id: 'shelf', x: ROOM_W - 108, y: 48, w: 72, h: 86, solid: true, interact: true },
{ id: 'plant', x: 28, y: 52, w: 42, h: 52, solid: true, interact: true },
+ { id: 'fireplace', x: 292, y: ROOM_H - 106, w: 84, h: 66, solid: true, interact: true },
+ { id: 'wallclock', x: 150, y: 76, w: 38, h: 38, solid: false, interact: true },
+ { id: 'medicine', x: 112, y: 82, w: 30, h: 34, solid: false, interact: true },
+ { id: 'phonograph', x: 306, y: ROOM_H - 40, w: 44, h: 40, solid: true, interact: true },
+ { id: 'diary', x: 252, y: ROOM_H - 102, w: 28, h: 22, solid: false, interact: true },
+ { id: 'painting', x: 228, y: 50, w: 54, h: 38, solid: false, interact: false },
{ id: 'door', x: ROOM_W / 2 - 40, y: ROOM_H - 28, w: 80, h: 28, solid: false, interact: true },
]
}
@@ -288,12 +539,22 @@ function roomFurniture(floor, floors) {
furn.push({ id: 'plant', x: 40, y: 48, w: 44, h: 54, solid: true, interact: true })
furn.push({ id: 'shelf', x: 160, y: 44, w: 70, h: 80, solid: true, interact: true })
furn.push({ id: 'crate', x: 250, y: ROOM_H - 100, w: 56, h: 48, solid: true, interact: true })
+ furn.push({ id: 'fireplace', x: 316, y: ROOM_H - 104, w: 78, h: 64, solid: true, interact: true })
+ furn.push({ id: 'wallclock', x: 250, y: 74, w: 38, h: 38, solid: false, interact: true })
+ furn.push({ id: 'medicine', x: 286, y: 78, w: 30, h: 34, solid: false, interact: true })
+ furn.push({ id: 'phonograph', x: 320, y: ROOM_H - 38, w: 44, h: 38, solid: true, interact: true })
+ furn.push({ id: 'diary', x: 258, y: ROOM_H - 30, w: 28, h: 22, solid: false, interact: true })
} else if (f === floors - 1) {
// 顶层卧室
furn.push({ id: 'bed', x: 32, y: ROOM_H - 120, w: 120, h: 64, solid: true, interact: true })
furn.push({ id: 'bed2', x: 170, y: ROOM_H - 120, w: 110, h: 64, solid: true, interact: true })
furn.push({ id: 'shelf', x: 40, y: 44, w: 72, h: 86, solid: true, interact: true })
furn.push({ id: 'plant', x: 280, y: 50, w: 42, h: 52, solid: true, interact: true })
+ furn.push({ id: 'painting', x: 130, y: 48, w: 54, h: 38, solid: false, interact: false })
+ furn.push({ id: 'wallclock', x: 206, y: 76, w: 38, h: 38, solid: false, interact: true })
+ furn.push({ id: 'medicine', x: 250, y: 52, w: 28, h: 34, solid: false, interact: true })
+ furn.push({ id: 'phonograph', x: 300, y: ROOM_H - 40, w: 44, h: 38, solid: true, interact: true })
+ furn.push({ id: 'diary', x: 286, y: ROOM_H - 152, w: 28, h: 22, solid: false, interact: true })
} else {
// 中间层:起居 / 储物
furn.push({ id: 'sofa', x: 40, y: ROOM_H - 108, w: 130, h: 56, solid: true, interact: true })
@@ -301,6 +562,7 @@ function roomFurniture(floor, floors) {
furn.push({ id: 'crate', x: 40, y: 48, w: 60, h: 52, solid: true, interact: true })
furn.push({ id: 'crate2', x: 120, y: 48, w: 60, h: 52, solid: true, interact: true })
furn.push({ id: 'plant', x: 260, y: 50, w: 42, h: 52, solid: true, interact: true })
+ furn.push({ id: 'fireplace', x: 316, y: ROOM_H - 104, w: 78, h: 64, solid: true, interact: true })
}
return furn
}
@@ -308,7 +570,10 @@ function roomFurniture(floor, floors) {
function currentFurn(e, av) {
const floors = houseFloorCount(e)
const floor = Math.max(0, Math.min(floors - 1, av?.floor ?? 0))
- return { floors, floor, furn: roomFurniture(floor, floors) }
+ const furn = roomFurniture(floor, floors)
+ // 小猫作为可交互对象动态加入(走到哪都能撸)
+ furn.push({ id: 'cat', x: cat.x - 15, y: cat.y - 12, w: 30, h: 22, solid: false, interact: true })
+ return { floors, floor, furn }
}
function interiorView(W, H) {
@@ -418,8 +683,73 @@ function tryInteriorInteract(g, p, id) {
return true
}
if (id === 'bed' || id === 'bed2') { doHouseRest(g, p, e, true); return true }
+ if (id === 'fireplace') {
+ // 优先烤食物:背包有小肉就烤成烤串;否则烤手回状态
+ if (g.countItem(p, 'morsel') > 0) {
+ g.payCost(p, { morsel: 1 })
+ g.addItem(p, 'roastmorsel', 1)
+ g.swing(p, 'chop')
+ g.ptip(p, '架在壁炉上烤了串 · 烤串 +1(趁热吃更补)')
+ } else {
+ p.hp = Math.min(150, p.hp + 2)
+ p.san = Math.min(200, p.san + 6)
+ g.ptip(p, '在壁炉边烤了烤手 · 生命 +2 理智 +6(有小肉可以来烤串)')
+ }
+ return true
+ }
+ if (id === 'medicine') {
+ if (medDay !== g.day) {
+ medDay = g.day
+ p.hp = Math.min(150, p.hp + 15)
+ g.swing(p, 'eat')
+ g.ptip(p, '用药箱包扎了一下 · 生命 +15(每日一次)')
+ } else {
+ g.ptip(p, '药箱里的纱布用完了,明天再来')
+ }
+ return true
+ }
+ if (id === 'phonograph') {
+ if (phonoDay !== g.day) {
+ phonoDay = g.day
+ p.san = Math.min(200, p.san + 6)
+ g.swing(p, 'eat')
+ g.ptip(p, '唱片沙沙转着,屋子里响起旧时代的歌 · 理智 +6(每日一次)')
+ } else {
+ g.ptip(p, '唱片已经转到头了,明天再换一张')
+ }
+ return true
+ }
+ if (id === 'diary') {
+ g.ptip(p, `日记:来到这里已经 ${g.day} 天 · 生命 ${Math.round(p.hp)} · 饥饿 ${Math.round(p.hunger)} · 理智 ${Math.round(p.san)} · 得分 ${p.score || 0}`)
+ return true
+ }
+ if (id === 'cat') {
+ if (catPetDay !== g.day) {
+ catPetDay = g.day
+ p.san = Math.min(200, p.san + 4)
+ g.ptip(p, '小猫蹭了蹭你的裤脚 · 理智 +4')
+ } else {
+ g.ptip(p, '小猫打了个哈欠,蜷成一团睡着了')
+ }
+ return true
+ }
+ if (id === 'wallclock') {
+ const frac = Math.max(0, Math.min(1, (g.dayTime || 0) / 160))
+ const hh = Math.floor(frac * 24)
+ g.ptip(p, `挂钟指向 ${String(hh).padStart(2, '0')}:00(游戏内时间)`)
+ return true
+ }
+ if (id === 'shelf') {
+ if (readDay !== g.day) {
+ readDay = g.day
+ p.san = Math.min(200, p.san + 8)
+ g.ptip(p, '靠在书架旁读了一会书 · 理智 +8')
+ } else {
+ g.ptip(p, '今天的书都读完了,明天再来')
+ }
+ return true
+ }
if (id === 'table') { g.ptip(p, '一张结实的木桌'); return true }
- if (id === 'shelf') { g.ptip(p, '橱柜里放着日用品'); return true }
if (id === 'plant') { g.ptip(p, '一盆绿意盎然的盆栽'); return true }
if (id === 'sofa') { g.ptip(p, '柔软的沙发,适合歇脚'); return true }
if (id === 'crate' || id === 'crate2') { g.ptip(p, '堆放杂物的木箱'); return true }
@@ -478,6 +808,8 @@ function updateInterior(g, dt) {
const wantLeave = !!g.keyDown?.('Escape')
if (wantLeave && !av.escapeLatch) leaveInterior(g)
av.escapeLatch = wantLeave
+ // 屋内小猫散步
+ updateCat(dt)
}
function drawFurnPiece(ctx, view, f, env) {
@@ -485,103 +817,417 @@ function drawFurnPiece(ctx, view, f, env) {
const w = f.w * view.scale
const h = f.h * view.scale
const id = f.id
+ const s = view.scale
+ const t = env.time || 0
ctx.save()
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- if (id === 'door') {
+ ctx.lineJoin = 'round'
+ const wood = (x, y, ww, hh, col = '#a07040', colLo = '#7a5030', r = 3) => {
+ ctx.fillStyle = col
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(x, y, ww, hh, r); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = 'rgba(255,255,255,0.14)'
+ ctx.beginPath(); ctx.roundRect(x + 2, y + 2, ww - 4, Math.max(2, hh * 0.28), 2); ctx.fill()
+ ctx.fillStyle = 'rgba(0,0,0,0.16)'
+ ctx.beginPath(); ctx.roundRect(x + 2, y + hh - Math.max(2, hh * 0.22) - 2, ww - 4, Math.max(2, hh * 0.22), 2); ctx.fill()
+ ctx.fillStyle = colLo
+ }
+ if (id === 'fireplace') {
+ // 石砌壁炉:摇曳火焰 + 暖光 + 炉柴 + 火星
+ ctx.fillStyle = '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(a.x + 4, a.y + 6, w - 8, h - 8, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#6f7278'
+ ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 2, w - 16, 10, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#2a1c10'
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.22, a.y + h * 0.4, w * 0.56, h * 0.46, 3); ctx.fill()
+ const vigor = 0.7 + Math.sin(t * 9) * 0.2
+ const fx = a.x + w / 2
+ const fy = a.y + h * 0.88
+ const gl = ctx.createRadialGradient(fx, fy - h * 0.18, 2, fx, fy - h * 0.18, w * 0.75)
+ gl.addColorStop(0, `rgba(255,170,70,${0.32 * vigor})`)
+ gl.addColorStop(1, 'rgba(255,170,70,0)')
+ ctx.fillStyle = gl
+ ctx.beginPath(); ctx.arc(fx, fy - h * 0.18, w * 0.75, 0, TAU); ctx.fill()
+ const flame = (fh, fw, col) => {
+ ctx.beginPath()
+ ctx.moveTo(fx, fy - fh * vigor)
+ ctx.quadraticCurveTo(fx + fw, fy - fh * 0.4, fx, fy)
+ ctx.quadraticCurveTo(fx - fw, fy - fh * 0.4, fx, fy - fh * vigor)
+ ctx.closePath()
+ ctx.fillStyle = col
+ ctx.fill()
+ }
+ flame(h * 0.5, w * 0.16, 'rgba(236,110,36,0.9)')
+ flame(h * 0.34, w * 0.11, 'rgba(255,180,55,0.9)')
+ flame(h * 0.2, w * 0.06, 'rgba(255,238,160,0.95)')
+ ctx.strokeStyle = '#5b4128'
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.moveTo(fx - 12, fy - 2); ctx.lineTo(fx + 12, fy - 6); ctx.stroke()
+ ctx.fillStyle = 'rgba(255,190,90,0.8)'
+ for (let i = 0; i < 2; i++) {
+ const ph = (t * 0.8 + i * 0.5) % 1
+ ctx.beginPath()
+ ctx.arc(fx + Math.sin(t * 3 + i * 4) * 4, fy - h * 0.6 - ph * 22, Math.max(0.2, 1.6 - ph), 0, TAU)
+ ctx.fill()
+ }
+ } else if (id === 'wallclock') {
+ // 挂钟:指针按游戏内时间(dayTime/160)走
+ const frac = Math.max(0, Math.min(1, (env.dayTime || 0) / 160))
+ const cx2 = a.x + w / 2
+ const cy2 = a.y + h / 2
+ const rr = Math.min(w, h) * 0.46
+ ctx.fillStyle = '#e8dcc0'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.arc(cx2, cy2, rr, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#8a5f34'
+ ctx.beginPath(); ctx.roundRect(cx2 - rr * 0.2, a.y - 4, rr * 0.4, 6, 2); ctx.fill()
+ ctx.strokeStyle = '#5d4432'
+ ctx.lineWidth = 1
+ for (let i = 0; i < 4; i++) {
+ const ang = (i / 4) * TAU
+ ctx.beginPath()
+ ctx.moveTo(cx2 + Math.cos(ang) * rr * 0.66, cy2 + Math.sin(ang) * rr * 0.66)
+ ctx.lineTo(cx2 + Math.cos(ang) * rr * 0.82, cy2 + Math.sin(ang) * rr * 0.82)
+ ctx.stroke()
+ }
+ const ang = frac * TAU - Math.PI / 2
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(cx2, cy2)
+ ctx.lineTo(cx2 + Math.cos(ang) * rr * 0.62, cy2 + Math.sin(ang) * rr * 0.62)
+ ctx.stroke()
+ ctx.fillStyle = '#c43a3a'
+ ctx.beginPath(); ctx.arc(cx2, cy2, 2, 0, TAU); ctx.fill()
+ } else if (id === 'painting') {
+ // 墙上小画:篝火夜景
+ ctx.fillStyle = '#3a4a5e'
+ ctx.strokeStyle = '#8a5f34'
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + 2, w - 4, h - 4, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#2a3448'
+ ctx.beginPath(); ctx.ellipse(a.x + w * 0.5, a.y + h * 0.74, w * 0.42, h * 0.2, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#e8e2d4'
+ ;[[a.x + w * 0.2, a.y + h * 0.28], [a.x + w * 0.76, a.y + h * 0.22], [a.x + w * 0.55, a.y + h * 0.36]].forEach(([sx2, sy2]) => {
+ ctx.beginPath(); ctx.arc(sx2, sy2, 1, 0, TAU); ctx.fill()
+ })
+ ctx.fillStyle = '#ffd76a'
+ ctx.beginPath(); ctx.arc(a.x + w * 0.5, a.y + h * 0.6, 3, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#e0a030'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.arc(a.x + w * 0.5, a.y + h * 0.6, 5.6, 0.4, 2.7); ctx.stroke()
+ } else if (id === 'medicine') {
+ // 壁挂药箱:白箱红十字
+ ctx.fillStyle = '#e8e2d4'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + 4, w - 4, h - 8, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#c95a4a'
+ const mw = w * 0.14
+ ctx.fillRect(a.x + w / 2 - mw / 2, a.y + h * 0.3, mw, h * 0.42)
+ ctx.fillRect(a.x + w * 0.28, a.y + h * 0.44, w * 0.44, mw)
+ ctx.strokeStyle = '#8a8a94'
+ ctx.lineWidth = 1
+ ctx.strokeRect(a.x + w * 0.28, a.y + h * 0.44, w * 0.44, mw)
+ } else if (id === 'phonograph') {
+ // 留声机:木箱 + 旋转唱片 + 唱臂(今天听过歌会冒音符)
+ ctx.fillStyle = '#7a5230'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(a.x + 4, a.y + h * 0.42, w - 8, h * 0.52, 3); ctx.fill(); ctx.stroke()
+ const discX = a.x + w * 0.36
+ const discY = a.y + h * 0.36
+ ctx.fillStyle = '#241a10'
+ ctx.beginPath(); ctx.arc(discX, discY, w * 0.3, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#c9a86a'
+ ctx.lineWidth = 1.1
+ const discRot = (phonoDay === g.day ? t : 0) * 1.4
+ for (let i = 0; i < 3; i++) {
+ ctx.beginPath()
+ ctx.arc(discX, discY, w * (0.1 + i * 0.075), discRot + i, discRot + i + 4.4)
+ ctx.stroke()
+ }
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(discX, discY, 2, 0, TAU); ctx.fill()
+ // 唱臂
+ ctx.strokeStyle = '#c9a86a'
+ ctx.lineWidth = 1.8
+ ctx.beginPath()
+ ctx.moveTo(a.x + w * 0.78, a.y + h * 0.2)
+ ctx.quadraticCurveTo(a.x + w * 0.86, a.y + h * 0.34, discX + w * 0.2, discY)
+ ctx.stroke()
+ if (phonoDay === g.day) {
+ // 音符飘出
+ ctx.fillStyle = 'rgba(240,220,150,0.85)'
+ for (let i = 0; i < 2; i++) {
+ const ph = (t * 0.7 + i * 0.5) % 1
+ const nx = a.x + w * 0.6 + Math.sin(t * 2 + i * 3) * 6 + ph * 10
+ const ny = a.y + h * 0.3 - ph * 22
+ ctx.beginPath()
+ ctx.ellipse(nx, ny, 2.4, 1.7, -0.4, 0, TAU)
+ ctx.fill()
+ ctx.fillRect(nx + 1.6, ny - 9 * (1 - ph * 0.3) - 2, 1, 8)
+ }
+ }
+ } else if (id === 'diary') {
+ // 桌上的日记本(摊开 + 摇曳笔迹)
+ ctx.fillStyle = '#8a5f34'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(a.x, a.y + h * 0.3, w, h * 0.6, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.beginPath(); ctx.roundRect(a.x + 2, a.y + h * 0.36, w / 2 - 2.4, h * 0.48, 1.5); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(a.x + w / 2 + 0.8, a.y + h * 0.36, w / 2 - 2.4, h * 0.48, 1.5); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(90,60,30,0.55)'
+ ctx.lineWidth = 0.9
+ const wig = Math.sin(t * 2.4) * 0.6
+ ;[0.16, 0.36, 0.56].forEach((k) => {
+ ctx.beginPath()
+ ctx.moveTo(a.x + 3, a.y + h * (0.42 + k * 0.5))
+ ctx.lineTo(a.x + w / 2 - 3, a.y + h * (0.44 + k * 0.5) + wig)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(a.x + w / 2 + 2, a.y + h * (0.44 + k * 0.5))
+ ctx.lineTo(a.x + w - 3, a.y + h * (0.42 + k * 0.5) - wig)
+ ctx.stroke()
+ })
+ } else if (id === 'door') {
ctx.fillStyle = 'rgba(255, 220, 140, 0.28)'
ctx.fillRect(a.x, a.y, w, h)
- ctx.fillStyle = '#3a2a16'
- ctx.fillRect(a.x + 4, a.y + 2, w - 8, 6)
+ wood(a.x + 4, a.y + 2, w - 8, 6, '#5d3f24', '#3a2a16')
ctx.fillStyle = '#f4ead2'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('大门', a.x + w / 2, a.y - 4)
} else if (id === 'stairs_up' || id === 'stairs_down') {
const up = id === 'stairs_up'
- ctx.fillStyle = '#6a6e78'
- ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
- // 坡道斑马线
- ctx.strokeStyle = 'rgba(240,220,140,0.55)'
+ // 木质台阶(越靠上越窄的透视阶梯)
+ ctx.fillStyle = '#8a6a44'
+ ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
- const steps = 6
+ ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 5); ctx.fill(); ctx.stroke()
+ const steps = 5
for (let i = 0; i < steps; i++) {
- const t = i / steps
- const y = up ? a.y + h * (1 - t) : a.y + h * t
- ctx.beginPath()
- ctx.moveTo(a.x + 4, y)
- ctx.lineTo(a.x + w - 4, y - (up ? 8 : -8) * view.scale)
+ const k = up ? 1 - i / steps : i / steps
+ const sy = a.y + 8 + k * (h - 26)
+ const inset = 6 + (up ? i : steps - 1 - i) * 3
+ ctx.fillStyle = i % 2 ? '#a07040' : '#946639'
+ ctx.beginPath(); ctx.roundRect(a.x + inset, sy, w - inset * 2, 9, 2); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.2
ctx.stroke()
}
ctx.fillStyle = '#f0d79a'
- ctx.font = `bold ${Math.max(10, 12 * view.scale)}px sans-serif`
+ ctx.font = `bold ${Math.max(10, 12 * s)}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(up ? '↑ 上楼' : '↓ 下楼', a.x + w / 2, a.y + h / 2)
} else if (id === 'bed' || id === 'bed2') {
- ctx.fillStyle = id === 'bed2' ? '#4a6a8a' : '#6a4a8a'
- ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 6); ctx.fill(); ctx.stroke()
+ // 床:木框 + 床垫 + 枕头 + 被子
+ wood(a.x, a.y, w, h, id === 'bed2' ? '#4a6a8a' : '#6a4a8a', '#3a2a4a', 5)
ctx.fillStyle = '#e8e0f0'
- ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 6, w - 16, h * 0.42, 4); ctx.fill()
- ctx.fillStyle = '#f4ead2'
- ctx.font = `bold ${Math.max(10, 12 * view.scale)}px sans-serif`
- ctx.textAlign = 'center'
- ctx.fillText('床', a.x + w / 2, a.y + h - 10)
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(a.x + 5, a.y + 5, w - 10, h - 10, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f8f6ee'
+ ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + 7, w * 0.3, h * 0.34, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = id === 'bed2' ? '#5d80a4' : '#7d5aa4'
+ ctx.beginPath(); ctx.roundRect(a.x + 8 + w * 0.36, a.y + 7, w - 16 - w * 0.36, h - 14, 3); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(255,255,255,0.4)'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.moveTo(a.x + 10 + w * 0.4, a.y + h * 0.55); ctx.quadraticCurveTo(a.x + w * 0.6, a.y + h * 0.7, a.x + w - 12, a.y + h * 0.5); ctx.stroke()
} else if (id === 'table') {
- ctx.fillStyle = '#a07040'
- ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h * 0.45, 4); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#7a5030'
- ctx.fillRect(a.x + 8, a.y + h * 0.45, 6, h * 0.55)
- ctx.fillRect(a.x + w - 14, a.y + h * 0.45, 6, h * 0.55)
- ctx.fillStyle = '#f4ead2'
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('木桌', a.x + w / 2, a.y - 6)
- } else if (id === 'shelf') {
- ctx.fillStyle = '#8a6038'
- ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#5a3a20'
- ;[0.33, 0.66].forEach((k) => {
- ctx.beginPath(); ctx.moveTo(a.x + 5, a.y + h * k); ctx.lineTo(a.x + w - 5, a.y + h * k); ctx.stroke()
+ // 木桌:桌面 + 四腿 + 桌上摆件
+ ctx.fillStyle = '#5d3f24'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ;[[a.x + 8, a.y + h * 0.4], [a.x + w - 14, a.y + h * 0.4]].forEach(([lx, ly]) => {
+ ctx.beginPath(); ctx.roundRect(lx, ly, 6, h * 0.58, 1.5); ctx.fill(); ctx.stroke()
})
+ wood(a.x, a.y, w, h * 0.45, '#a07040', '#7a5030', 4)
+ // 桌上:蜡烛 + 木杯
ctx.fillStyle = '#f4ead2'
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('橱柜', a.x + w / 2, a.y - 6)
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.2, a.y - 8 * s, 4, 9 * s, 1.5); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.fillStyle = `rgba(255, 200, 90, ${0.75 + Math.sin(t * 6) * 0.2})`
+ ctx.beginPath(); ctx.ellipse(a.x + w * 0.2 + 2, a.y - 10 * s, 2, 3, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#8a6238'
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.62, a.y - 5 * s, 7, 6 * s, 1.5); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ } else if (id === 'shelf') {
+ // 橱柜:双门 + 格层 + 罐子/书
+ wood(a.x, a.y, w, h, '#8a6038', '#5d3f24', 4)
+ ctx.strokeStyle = '#5a3a20'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.moveTo(a.x + w / 2, a.y + 4); ctx.lineTo(a.x + w / 2, a.y + h - 4); ctx.stroke()
+ ;[0.35, 0.7].forEach((k) => {
+ ctx.beginPath(); ctx.moveTo(a.x + 4, a.y + h * k); ctx.lineTo(a.x + w - 4, a.y + h * k); ctx.stroke()
+ })
+ // 上层罐子、下层书脊
+ ctx.fillStyle = '#c9a36b'
+ ctx.beginPath(); ctx.roundRect(a.x + 8, a.y + h * 0.35 - 8 * s, 7, 8 * s, 1.5); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ;['#a84c38', '#3a5a8a', '#6a7a3d'].forEach((bc, i) => {
+ ctx.fillStyle = bc
+ ctx.beginPath(); ctx.roundRect(a.x + 10 + i * 8, a.y + h * 0.72, 6, h * 0.24, 1); ctx.fill()
+ ctx.stroke()
+ })
+ ctx.fillStyle = '#c9a03c'
+ ctx.beginPath(); ctx.arc(a.x + w / 2 - 3, a.y + h / 2, 1.4, 0, TAU); ctx.fill()
+ ctx.beginPath(); ctx.arc(a.x + w / 2 + 3, a.y + h / 2, 1.4, 0, TAU); ctx.fill()
} else if (id === 'plant') {
+ // 陶盆 + 三层叶丛(轻摆)
+ const sway = Math.sin(t * 2.2) * 2 * s
ctx.fillStyle = '#8a5a3a'
- ctx.beginPath(); ctx.roundRect(a.x + w * 0.2, a.y + h * 0.55, w * 0.6, h * 0.4, 3); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#4a8a3a'
- ctx.beginPath(); ctx.ellipse(a.x + w / 2, a.y + h * 0.4, w * 0.38, h * 0.28, 0, 0, TAU); ctx.fill()
- const sway = Math.sin((env.time || 0) * 2.2) * 2
- ctx.fillStyle = '#3a7a2a'
- ctx.beginPath(); ctx.ellipse(a.x + w / 2 + sway, a.y + h * 0.28, w * 0.22, h * 0.18, 0, 0, TAU); ctx.fill()
- ctx.fillStyle = '#f4ead2'
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('盆栽', a.x + w / 2, a.y - 4)
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(a.x + w * 0.24, a.y + h * 0.6)
+ ctx.lineTo(a.x + w * 0.76, a.y + h * 0.6)
+ ctx.lineTo(a.x + w * 0.68, a.y + h)
+ ctx.lineTo(a.x + w * 0.32, a.y + h)
+ ctx.closePath()
+ ctx.fill(); ctx.stroke()
+ ctx.fillStyle = 'rgba(255,255,255,0.12)'
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.28, a.y + h * 0.63, w * 0.14, h * 0.3, 2); ctx.fill()
+ ctx.strokeStyle = '#3f6b35'
+ ctx.lineWidth = 2 * s
+ ctx.beginPath()
+ ctx.moveTo(a.x + w / 2, a.y + h * 0.62)
+ ctx.quadraticCurveTo(a.x + w / 2 + sway, a.y + h * 0.4, a.x + w / 2 + sway * 1.4, a.y + h * 0.26)
+ ctx.stroke()
+ ;[[a.x + w * 0.3, a.y + h * 0.44, 0], [a.x + w * 0.7, a.y + h * 0.42, 0.6], [a.x + w * 0.5 + sway, a.y + h * 0.24, 0]].forEach(([cx2, cy2, rot]) => {
+ ctx.fillStyle = '#4a8a3a'
+ ctx.beginPath()
+ ctx.ellipse(cx2 + sway * 0.4, cy2, w * 0.2, h * 0.13, rot, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.2
+ ctx.stroke()
+ })
+ ctx.fillStyle = '#6aa84a'
+ ctx.beginPath()
+ ctx.ellipse(a.x + w * 0.5 + sway * 1.2, a.y + h * 0.18, w * 0.14, h * 0.09, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
} else if (id === 'sofa') {
- ctx.fillStyle = '#6a4a3a'
- ctx.beginPath(); ctx.roundRect(a.x, a.y + h * 0.25, w, h * 0.75, 6); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#8a6a50'
- ctx.beginPath(); ctx.roundRect(a.x + 6, a.y, w - 12, h * 0.4, 5); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#f4ead2'
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('沙发', a.x + w / 2, a.y - 4)
+ // 布艺沙发:座垫 + 靠背 + 扶手 + 抱枕
+ wood(a.x + 4, a.y + h * 0.3, w - 8, h * 0.66, '#6a4a3a', '#4a3030', 6)
+ wood(a.x + 6, a.y, w - 12, h * 0.42, '#8a6a50', '#5d4432', 5)
+ ctx.fillStyle = '#a8825e'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(a.x + 12, a.y + h * 0.36, w * 0.38, h * 0.3, 3); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(a.x + 12 + w * 0.44, a.y + h * 0.36, w * 0.36, h * 0.3, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#c96a5a'
+ ctx.beginPath(); ctx.roundRect(a.x + w * 0.16, a.y + h * 0.18, w * 0.2, h * 0.26, 3); ctx.fill(); ctx.stroke()
+ // 木脚
+ ctx.fillStyle = '#5d3f24'
+ ;[[a.x + 8, a.y + h - 6], [a.x + w - 14, a.y + h - 6]].forEach(([lx, ly]) => {
+ ctx.beginPath(); ctx.roundRect(lx, ly, 6, 6, 1.5); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ })
} else if (id === 'crate' || id === 'crate2') {
- ctx.fillStyle = '#9a7a48'
- ctx.beginPath(); ctx.roundRect(a.x, a.y, w, h, 4); ctx.fill(); ctx.stroke()
+ // 木箱:板条 + 交叉加固条
+ wood(a.x, a.y, w, h, '#9a7a48', '#6a4a28', 3)
ctx.strokeStyle = '#6a4a28'
- ctx.beginPath(); ctx.moveTo(a.x + 4, a.y + h / 2); ctx.lineTo(a.x + w - 4, a.y + h / 2); ctx.stroke()
- ctx.fillStyle = '#f4ead2'
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('木箱', a.x + w / 2, a.y - 4)
+ ctx.lineWidth = 3
+ ctx.beginPath()
+ ctx.moveTo(a.x + 3, a.y + 3); ctx.lineTo(a.x + w - 3, a.y + h - 3)
+ ctx.moveTo(a.x + w - 3, a.y + 3); ctx.lineTo(a.x + 3, a.y + h - 3)
+ ctx.stroke()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.2
+ ctx.strokeRect(a.x + 4, a.y + 4, w - 8, h - 8)
+ ctx.fillStyle = '#c9a03c'
+ ctx.beginPath(); ctx.arc(a.x + w / 2, a.y + h / 2, 2, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
}
ctx.restore()
}
+/** 屋内小猫:四足小步跑 / 坐下摇尾,纯装饰可撸 */
+function drawHouseCat(ctx, view, env) {
+ const t = env.time || 0
+ const pos = roomToScreen(view, cat.x, cat.y)
+ ctx.save()
+ ctx.translate(pos.x, pos.y)
+ ctx.scale(view.scale, view.scale)
+ ctx.scale(cat.dir, 1)
+ const bob = cat.moving ? Math.sin(cat.walkT * 12) : 0
+ // 影子
+ ctx.fillStyle = 'rgba(0,0,0,0.18)'
+ ctx.beginPath(); ctx.ellipse(0, 3, 9, 2.4, 0, 0, TAU); ctx.fill()
+ // 尾巴(走时轻摆,坐时大幅摇)
+ ctx.strokeStyle = '#c98a44'
+ ctx.lineWidth = 2.4
+ ctx.lineCap = 'round'
+ const tailAmp = cat.moving ? 2 : 4.5
+ const tail = Math.sin(t * (cat.moving ? 6 : 2.4)) * tailAmp
+ ctx.beginPath()
+ ctx.moveTo(-10, -4)
+ ctx.quadraticCurveTo(-16, -8 - tail, -14 - tail * 0.5, -13 - tail)
+ ctx.stroke()
+ // 身体
+ ctx.fillStyle = '#d8a860'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.5
+ ctx.beginPath(); ctx.ellipse(0, -6 + bob * 0.4, 10, 5.4, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ // 虎斑纹
+ ctx.strokeStyle = 'rgba(160,110,50,0.7)'
+ ctx.lineWidth = 1.2
+ ;[-4, 0, 4].forEach((bx2) => {
+ ctx.beginPath(); ctx.moveTo(bx2, -10 + bob * 0.4); ctx.lineTo(bx2 + 1.4, -5 + bob * 0.4); ctx.stroke()
+ })
+ // 头 + 耳
+ ctx.fillStyle = '#d8a860'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.5
+ ctx.beginPath(); ctx.arc(9, -11 + bob * 0.5, 4.6, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(6.2, -13.8 + bob * 0.5); ctx.lineTo(7, -18.4 + bob * 0.5); ctx.lineTo(9.6, -14.6 + bob * 0.5)
+ ctx.moveTo(10.4, -14.6 + bob * 0.5); ctx.lineTo(12.4, -18 + bob * 0.5); ctx.lineTo(13.4, -13.8 + bob * 0.5)
+ ctx.closePath(); ctx.fill(); ctx.stroke()
+ // 脸(绿眼 + 鼻须)
+ ctx.fillStyle = '#5a8a3a'
+ ctx.beginPath(); ctx.arc(10.6, -11.4 + bob * 0.5, 1.1, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#1d1409'
+ ctx.beginPath(); ctx.arc(10.9, -11.4 + bob * 0.5, 0.55, 0, TAU); ctx.fill()
+ ctx.strokeStyle = 'rgba(30,20,10,0.6)'
+ ctx.lineWidth = 0.8
+ ctx.beginPath()
+ ctx.moveTo(13, -10.4 + bob * 0.5); ctx.lineTo(15.4, -10 + bob * 0.5)
+ ctx.moveTo(13, -9.6 + bob * 0.5); ctx.lineTo(15.4, -9 + bob * 0.5)
+ ctx.stroke()
+ // 腿(走动交替)
+ ctx.strokeStyle = '#b88844'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ if (cat.moving) {
+ ctx.moveTo(-5, -2); ctx.lineTo(-5 + Math.sin(cat.walkT * 12) * 2.6, 3)
+ ctx.moveTo(5, -2); ctx.lineTo(5 - Math.sin(cat.walkT * 12) * 2.6, 3)
+ } else {
+ ctx.moveTo(-5, -2); ctx.lineTo(-5, 3)
+ ctx.moveTo(5, -2); ctx.lineTo(5, 3)
+ }
+ ctx.stroke()
+ ctx.restore()
+}
+
function drawInterior(g, ctx, env) {
const e = g.entities.find((x) => x.id === interiorOpen && x.type === 'camphouse')
if (!e) { leaveInterior(g); return }
@@ -620,17 +1266,113 @@ function drawInterior(g, ctx, env) {
ctx.globalAlpha = Math.min(1, 0.92 * floorTint)
ctx.fillRect(view.px, view.py, view.pw, view.ph * 0.42)
ctx.globalAlpha = 1
+ // 墙面木架横梁 + 挂画/壁灯点缀
+ ctx.strokeStyle = 'rgba(70,45,25,0.4)'
+ ctx.lineWidth = 2
+ for (let bx = view.px + 30; bx < view.px + view.pw; bx += 90) {
+ ctx.beginPath(); ctx.moveTo(bx, view.py + 6); ctx.lineTo(bx, view.py + view.ph * 0.42 - 6); ctx.stroke()
+ }
+ // 窗光斜照(左上窗投下的暖光带)
+ const winX = view.px + view.pw * 0.12
+ const winY = view.py + view.ph * 0.08
+ ctx.save()
+ ctx.beginPath()
+ ctx.roundRect(winX, winY, 56 * view.scale, 42 * view.scale, 3)
+ ctx.clip()
+ // 窗外天色随昼夜相位变化(夜=星空 / 黄昏=暖橙 / 白天=天蓝)
+ const ph2 = g.phase || 'day'
+ if (ph2 === 'night') {
+ ctx.fillStyle = '#141d33'
+ ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
+ ctx.fillStyle = '#e8ecf4'
+ ;[[winX + 10 * view.scale, winY + 10 * view.scale], [winX + 40 * view.scale, winY + 16 * view.scale], [winX + 24 * view.scale, winY + 30 * view.scale], [winX + 48 * view.scale, winY + 8 * view.scale]].forEach(([sx2, sy2], i) => {
+ ctx.beginPath(); ctx.arc(sx2, sy2, (i % 2 ? 1 : 1.4) * view.scale, 0, TAU); ctx.fill()
+ })
+ ctx.fillStyle = '#f0e8c8'
+ ctx.beginPath(); ctx.arc(winX + 46 * view.scale, winY + 30 * view.scale, 5 * view.scale, 0, TAU); ctx.fill()
+ } else if (ph2 === 'dusk') {
+ const skyG = ctx.createLinearGradient(winX, winY, winX, winY + 42 * view.scale)
+ skyG.addColorStop(0, '#5a3a5e')
+ skyG.addColorStop(1, '#e8a05a')
+ ctx.fillStyle = skyG
+ ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
+ ctx.fillStyle = 'rgba(255,220,140,0.9)'
+ ctx.beginPath(); ctx.arc(winX + 40 * view.scale, winY + 30 * view.scale, 5 * view.scale, 0, TAU); ctx.fill()
+ } else {
+ const skyG = ctx.createLinearGradient(winX, winY, winX, winY + 42 * view.scale)
+ skyG.addColorStop(0, '#a8d4f0')
+ skyG.addColorStop(1, '#e8d8a8')
+ ctx.fillStyle = skyG
+ ctx.fillRect(winX, winY, 56 * view.scale, 42 * view.scale)
+ ctx.fillStyle = 'rgba(255,255,255,0.75)'
+ ctx.beginPath()
+ ctx.ellipse(winX + (20 + Math.sin((env.time || 0) * 0.3) * 10) * view.scale, winY + 14 * view.scale, 9 * view.scale, 4 * view.scale, 0, 0, TAU)
+ ctx.fill()
+ }
+ ctx.strokeStyle = '#5d3f24'
+ ctx.lineWidth = 3
+ ctx.beginPath()
+ ctx.moveTo(winX + 28 * view.scale, winY)
+ ctx.lineTo(winX + 28 * view.scale, winY + 42 * view.scale)
+ ctx.moveTo(winX, winY + 21 * view.scale)
+ ctx.lineTo(winX + 56 * view.scale, winY + 21 * view.scale)
+ ctx.stroke()
+ ctx.restore()
+ ctx.strokeStyle = '#5d3f24'
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.roundRect(winX, winY, 56 * view.scale, 42 * view.scale, 3); ctx.stroke()
+ // 窗光斜带(落到地板)
+ ctx.save()
+ ctx.globalAlpha = 0.14
+ ctx.fillStyle = '#ffe8b0'
+ ctx.beginPath()
+ ctx.moveTo(winX + 4, winY + 42 * view.scale)
+ ctx.lineTo(winX + 52 * view.scale, winY + 42 * view.scale)
+ ctx.lineTo(winX + 96 * view.scale, view.py + view.ph)
+ ctx.lineTo(winX - 18 * view.scale, view.py + view.ph)
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+ // 墙裙
ctx.fillStyle = skin.wall2
ctx.fillRect(view.px, view.py + view.ph * 0.42 - 6, view.pw, 8)
+ ctx.fillStyle = 'rgba(0,0,0,0.18)'
+ ctx.fillRect(view.px, view.py + view.ph * 0.42 + 2, view.pw, 3)
+ // 地板(拼花木板 + 接缝错位)
const floorCols = ['#8a6a40', '#7a7248', '#6a6a50', '#5a5a58']
ctx.fillStyle = floorCols[floor % floorCols.length]
ctx.fillRect(view.px, view.py + view.ph * 0.42, view.pw, view.ph * 0.58)
ctx.strokeStyle = 'rgba(60,40,20,0.35)'
ctx.lineWidth = 1
const plank = 16 * view.scale
+ let rowIdx = 0
for (let y = view.py + view.ph * 0.42 + 4; y < view.py + view.ph - 4; y += plank) {
ctx.beginPath(); ctx.moveTo(view.px + 4, y); ctx.lineTo(view.px + view.pw - 4, y); ctx.stroke()
+ // 每行错缝竖线
+ const off = (rowIdx++ % 3) * 26 * view.scale
+ for (let x = view.px + 10 + off; x < view.px + view.pw - 6; x += 78 * view.scale) {
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, Math.min(y + plank, view.py + view.ph - 4)); ctx.stroke()
+ }
}
+ // 中央地毯
+ const rugW = view.pw * 0.34
+ const rugH = view.ph * 0.26
+ const rugX = view.px + view.pw / 2 - rugW / 2
+ const rugY = view.py + view.ph * 0.6
+ ctx.fillStyle = '#8a4438'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(rugX, rugY, rugW, rugH, 8); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#d8b478'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(rugX + 6, rugY + 6, rugW - 12, rugH - 12, 6); ctx.stroke()
+ ctx.strokeStyle = 'rgba(216,180,120,0.55)'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(rugX + 12, rugY + 12, rugW - 24, rugH - 24, 5); ctx.stroke()
+ ctx.fillStyle = 'rgba(0,0,0,0.12)'
+ ;[[rugX + rugW * 0.5, rugY + rugH * 0.5, rugW * 0.1]].forEach(([cx2, cy2, r2]) => {
+ ctx.beginPath(); ctx.arc(cx2, cy2, r2, 0, TAU); ctx.fill()
+ })
// 停车楼式楼层指示条
const chipW = 28
@@ -664,7 +1406,11 @@ function drawInterior(g, ctx, env) {
: 'WASD 走动 · E 互动 · Esc 离开'
ctx.fillText(`住人 ${(mx.residents || []).length}/${houseCapacity(e)} · ${floorHint}`, view.px + view.pw / 2, view.py - 4)
- furn.forEach((f) => drawFurnPiece(ctx, view, f, env))
+ // 小猫走在地板上、家具之下
+ drawHouseCat(ctx, view, env)
+ // 家具环境注入昼夜信息(挂钟/窗户用)
+ const fenv = { ...env, dayTime: g.dayTime || 0, phase: g.phase || 'day' }
+ furn.forEach((f) => drawFurnPiece(ctx, view, f, fenv))
if (av) {
const pos = roomToScreen(view, av.x, av.y)
@@ -1077,6 +1823,10 @@ export default {
factor: 0.75, // 大幅降低难度
tag: 'easy',
tabs: [{ code: 'camp', name: '营地', icon: 'camphouse' }],
+ items: {
+ // 壁炉烤出来的热食:拿小肉在屋里壁炉烤制
+ roastmorsel: { name: '烤串', food: { hunger: 25, hp: 3, san: 2 }, perish: 8 },
+ },
recipes: [
{ code: 'campstore', name: '营地仓储', tab: 'camp', tech: 1, cost: { log: 10, rock: 4 }, build: true, desc: '8 个箱子,可建造多个' },
{ code: 'camphouse', name: '营地房屋', tab: 'camp', tech: 1, cost: { log: 12, rock: 8 }, build: true, desc: '亲手盖房(或划定宅基让建筑工建)' },
@@ -1085,6 +1835,21 @@ export default {
{ code: 'campgate', name: '营地大门', tab: 'camp', tech: 2, cost: { log: 6, rock: 4, rope: 2 }, build: true, desc: '可开关的营地大门' },
],
icons: {
+ roastmorsel: (c) => {
+ // 竹签 + 烤肉块
+ c.strokeStyle = '#8a6a3a'
+ c.lineWidth = 2
+ c.beginPath(); c.moveTo(-11, 9); c.lineTo(9, -9); c.stroke()
+ c.fillStyle = '#b06a3a'
+ c.strokeStyle = '#1d1409'
+ c.lineWidth = 1.5
+ c.beginPath(); c.ellipse(-1, 1, 6, 5, -0.78, 0, TAU); c.fill(); c.stroke()
+ c.fillStyle = '#8a4a26'
+ c.beginPath(); c.ellipse(4, -4, 5, 4, -0.78, 0, TAU); c.fill(); c.stroke()
+ c.strokeStyle = 'rgba(255,220,150,0.7)'
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(-3, 3); c.lineTo(0, 0); c.stroke()
+ },
camphouse: (c) => {
// 石砌地基
c.fillStyle = '#8d9097'
@@ -1195,14 +1960,44 @@ export default {
draw(ctx, e) {
ctx.fillStyle = 'rgba(0,0,0,0.2)'
ctx.beginPath(); ctx.ellipse(0, 6, 28, 8, 0, 0, TAU); ctx.fill()
+ // 夯土工地:虚线边界 + 四角木桩 + 蓝图小屋
ctx.strokeStyle = '#c9a06a'
- ctx.fillStyle = 'rgba(180, 140, 80, 0.25)'
+ ctx.fillStyle = 'rgba(180, 140, 80, 0.22)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.beginPath(); ctx.roundRect(-28, -36, 56, 42, 3); ctx.fill(); ctx.stroke()
ctx.setLineDash([])
ctx.strokeStyle = '#8a6a34'
+ ctx.lineWidth = 1.6
+ ;[[-28, -36], [28, -36], [-28, 6], [28, 6]].forEach(([px, py]) => {
+ ctx.beginPath()
+ ctx.moveTo(px > 0 ? px - 6 : px + 6, py)
+ ctx.lineTo(px, py + (py < 0 ? 8 : -8))
+ ctx.stroke()
+ ctx.fillStyle = '#a8763e'
+ ctx.beginPath(); ctx.arc(px, py, 1.6, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#8a6a34'
+ })
+ ctx.strokeStyle = '#c9a06a'
+ ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(20, -28); ctx.lineTo(0, -48); ctx.closePath(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(201,160,106,0.5)'
+ ctx.lineWidth = 1
+ ctx.beginPath(); ctx.moveTo(-20, -28); ctx.lineTo(-6, -38); ctx.moveTo(0, -48); ctx.lineTo(0, -28); ctx.stroke()
+ // 角落的沙堆与石料
+ ctx.fillStyle = '#c9b083'
+ ctx.beginPath(); ctx.ellipse(20, 2, 6, 3.4, 0, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#8a6a34'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.fillStyle = '#9a9da4'
+ ;[[16, -4], [22, -6]].forEach(([px, py]) => {
+ ctx.beginPath()
+ ctx.moveTo(px - 3, py + 2); ctx.lineTo(px - 2, py - 2); ctx.lineTo(px + 2, py - 2.4); ctx.lineTo(px + 3, py + 2)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ })
const name = e.mx?.name || '地基'
ctx.fillStyle = 'rgba(20,16,10,0.75)'
const tw = Math.max(36, name.length * 8 + 12)
@@ -1248,54 +2043,91 @@ export default {
boxesOf(e).forEach((b) => {
Object.values(b.items || {}).forEach((k) => { stored += k || 0 })
})
- drawNameplate(ctx, stored > 0 ? `营地仓储 · ${stored}件` : '营地仓储', -74)
- // 底部投影
+ drawNameplate(ctx, stored > 0 ? `营地仓储 · ${stored}件` : '营地仓储', -78)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
- // 木石仓房
+ const t = env?.time || 0
+ // 仓房主体:石基 + 竖板墙
+ ctx.fillStyle = '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-27, -8, 54, 14, 2); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#a9773c'
+ ctx.beginPath(); ctx.roundRect(-25, -42, 50, 36, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(90,58,28,0.55)'
+ ctx.lineWidth = 1.2
+ for (let x = -19; x < 25; x += 8) {
+ ctx.beginPath(); ctx.moveTo(x, -41); ctx.lineTo(x, -7); ctx.stroke()
+ }
+ ctx.fillStyle = 'rgba(60,40,20,0.5)'
+ ;[[-21, -38], [21, -38], [-21, -11], [21, -11]].forEach(([x, y]) => {
+ ctx.beginPath(); ctx.arc(x, y, 1, 0, TAU); ctx.fill()
+ })
+ // 大谷仓门(分叉滑门 + 铁件)
+ ctx.fillStyle = '#5d3f24'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-24, -40, 48, 46, 3); ctx.fill(); ctx.stroke()
- // 竖向木板纹
- ctx.strokeStyle = '#8a5f30'
- ctx.lineWidth = 1.2
- ;[-14, -4, 6, 16].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x, 6); ctx.stroke() })
- // 大门
- ctx.fillStyle = '#3a2a16'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-12, -22, 24, 28, [3, 3, 0, 0]); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#5d3f24'
- ctx.lineWidth = 1.2
- ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(0, 6); ctx.stroke()
- // 斜屋顶
+ ctx.beginPath(); ctx.roundRect(-13, -30, 26, 28, [2, 2, 0, 0]); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#3a2818'
+ ctx.lineWidth = 2.4
+ ctx.beginPath()
+ ctx.moveTo(-13, -30); ctx.lineTo(13, -2)
+ ctx.moveTo(13, -30); ctx.lineTo(-13, -2)
+ ctx.stroke()
+ ctx.strokeStyle = '#c9a03c'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.moveTo(-13, -18); ctx.lineTo(13, -18); ctx.stroke()
+ ctx.fillStyle = '#e0c060'
+ ctx.beginPath(); ctx.arc(0, -18, 1.8, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#1d1409'; ctx.lineWidth = 1; ctx.stroke()
+ // 挑檐坡顶 + 阴影
+ ctx.fillStyle = 'rgba(0,0,0,0.2)'
+ ctx.beginPath(); ctx.moveTo(-31, -40); ctx.lineTo(31, -40); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill()
ctx.fillStyle = '#7a5230'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
- ctx.beginPath(); ctx.moveTo(-30, -40); ctx.lineTo(30, -40); ctx.lineTo(0, -64); ctx.closePath(); ctx.fill(); ctx.stroke()
- ctx.strokeStyle = '#5d3f24'
- ctx.lineWidth = 1
- ;[-22, -12, 0, 12, 22].forEach((x) => { ctx.beginPath(); ctx.moveTo(x, -40); ctx.lineTo(x * 0.4, -61); ctx.stroke() })
- // 门口码放的木箱(3~4 个叠放,体现"很多箱子")
+ ctx.beginPath(); ctx.moveTo(-31, -40); ctx.lineTo(31, -40); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(40,26,14,0.55)'
+ ctx.lineWidth = 1.2
+ for (let r2 = 1; r2 <= 2; r2++) {
+ const yy = -40 - r2 * 8
+ const half = 31 * (1 - r2 * 0.24)
+ ctx.beginPath(); ctx.moveTo(-half, yy); ctx.lineTo(half, yy); ctx.stroke()
+ }
+ // 门口码放的货箱(带斜撑木条)
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.beginPath(); ctx.ellipse(24, 8, 20, 6, 0, 0, TAU); ctx.fill()
const crate = (x, y, w, h) => {
ctx.fillStyle = '#c9a36b'
ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
+ ctx.lineWidth = 1.8
ctx.beginPath(); ctx.roundRect(x, y, w, h, 1.5); ctx.fill(); ctx.stroke()
ctx.strokeStyle = '#8a5f30'
- ctx.lineWidth = 1.1
+ ctx.lineWidth = 2
ctx.beginPath()
- ctx.moveTo(x, y); ctx.lineTo(x + w, y + h)
- ctx.moveTo(x + w, y); ctx.lineTo(x, y + h)
+ ctx.moveTo(x + 2, y + 2); ctx.lineTo(x + w - 2, y + h - 2)
+ ctx.moveTo(x + w - 2, y + 2); ctx.lineTo(x + 2, y + h - 2)
ctx.stroke()
+ ctx.strokeStyle = '#6b4420'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.roundRect(x + 1, y + 1, w - 2, h - 2, 1); ctx.stroke()
}
- crate(-30, -8, 16, 16) // 左前
- crate(-14, -4, 16, 16) // 中间偏前
- crate(-30, -24, 16, 16) // 左后上叠
- crate(14, -10, 18, 18) // 右侧一只
+ crate(-31, -9, 16, 16)
+ crate(-14, -5, 16, 16)
+ crate(-31, -25, 16, 16)
+ crate(15, -11, 18, 18)
+ // 空中悬吊的油灯(微光摆动)
+ const sw = Math.sin(t * 1.8) * 2
+ ctx.strokeStyle = '#3a2a18'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.moveTo(26, -44); ctx.quadraticCurveTo(26 + sw, -40, 26 + sw, -36); ctx.stroke()
+ ctx.fillStyle = '#3a3f48'
+ ctx.beginPath(); ctx.roundRect(24 + sw, -36, 4.6, 7, 1.5); ctx.fill()
+ ctx.fillStyle = '#ffd98a'
+ ctx.beginPath(); ctx.arc(26.3 + sw, -32.5, 1.8, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
},
},
canteen: {
@@ -1314,24 +2146,104 @@ export default {
},
draw(ctx, e, env) {
const n = Object.values(e.mx?.menu || {}).reduce((a, b) => a + (b || 0), 0)
- drawNameplate(ctx, n > 0 ? `食堂 · ${n}份` : '食堂', -68)
+ drawNameplate(ctx, n > 0 ? `食堂 · ${n}份` : '食堂', -76)
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.beginPath(); ctx.ellipse(0, 8, 28, 8, 0, 0, TAU); ctx.fill()
+ const t = env?.time || 0
+ // 木屋小馆:圆木墙 + 大锅烟囱
ctx.fillStyle = '#b07f47'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-26, -36, 52, 40, 3); ctx.fill(); ctx.stroke()
- ctx.fillStyle = '#7a5230'
- ctx.beginPath(); ctx.moveTo(-30, -36); ctx.lineTo(0, -58); ctx.lineTo(30, -36); ctx.closePath(); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(-26, -38, 52, 42, 3); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(90,58,28,0.55)'
+ ctx.lineWidth = 1.2
+ for (let y = -32; y < 2; y += 7) {
+ ctx.beginPath(); ctx.moveTo(-25, y); ctx.lineTo(25, y + 0.5); ctx.stroke()
+ }
+ // 服务窗口 + 吊板
ctx.fillStyle = '#3a2a16'
- ctx.beginPath(); ctx.roundRect(-10, -18, 20, 22, 2); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(-14, -30, 28, 15, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#8a6a3a'
+ ctx.beginPath(); ctx.roundRect(-17, -16, 34, 4, 1.5); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#e8c070'
- ctx.beginPath(); ctx.ellipse(0, -22, 10, 5, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.ellipse(0, -22, 9, 3.6, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ // 门
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(12, -14, 11, 16, [2, 2, 0, 0]); ctx.fill(); ctx.stroke()
+ // 条纹遮阳棚(波浪边)
+ ctx.save()
+ ctx.beginPath()
+ ctx.moveTo(-32, -40)
+ ctx.lineTo(32, -40)
+ ctx.lineTo(28, -50)
+ ctx.lineTo(-28, -50)
+ ctx.closePath()
+ ctx.fillStyle = '#c45a3a'
+ ctx.fill()
+ ctx.clip()
+ ctx.fillStyle = '#f4ead2'
+ for (let x = -32; x < 32; x += 12) {
+ ctx.fillRect(x + ((x / 12) % 2 ? 6 : 0), -52, 6, 14)
+ }
+ ctx.restore()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(-32, -40)
+ ctx.lineTo(32, -40)
+ ctx.lineTo(28, -50)
+ ctx.lineTo(-28, -50)
+ ctx.closePath()
+ ctx.stroke()
+ // 棚边波浪
+ ctx.fillStyle = '#c45a3a'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ for (let x = -32; x < 30; x += 8) {
+ ctx.beginPath()
+ ctx.arc(x + 4, -40, 4, 0, Math.PI)
+ ctx.fill()
+ ctx.stroke()
+ }
+ // 屋顶
+ ctx.fillStyle = '#7a5230'
+ ctx.beginPath(); ctx.moveTo(-28, -50); ctx.lineTo(28, -50); ctx.lineTo(0, -66); ctx.closePath(); ctx.fill(); ctx.stroke()
+ // 烟囱 + 烟
+ ctx.fillStyle = '#8d9097'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(14, -62, 9, 14, 1.5); ctx.fill(); ctx.stroke()
+ for (let i = 0; i < 2; i++) {
+ const ph = (t * 0.4 + i * 0.5) % 1.4
+ ctx.fillStyle = `rgba(220,220,220,${0.35 * (1 - ph / 1.4)})`
+ ctx.beginPath()
+ ctx.arc(18.5 + Math.sin(t + i * 2) * 3, -66 - ph * 18, 2.4 + ph * 3, 0, TAU)
+ ctx.fill()
+ }
+ // 门口汤锅(冒着热气)
+ ctx.fillStyle = '#4a4f55'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(-24, -10, 14, 9, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#6a7078'
+ ctx.beginPath(); ctx.ellipse(-17, -10.5, 7.5, 2.6, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ for (let i = 0; i < 2; i++) {
+ const ph = (t * 0.7 + i * 0.5) % 1
+ ctx.fillStyle = `rgba(240,240,240,${0.4 * (1 - ph)})`
+ ctx.beginPath()
+ ctx.arc(-17 + Math.sin(t * 2 + i * 3) * 2.4, -14 - ph * 9, 1.6 + ph * 1.8, 0, TAU)
+ ctx.fill()
+ }
if (n > 0) {
+ ctx.fillStyle = 'rgba(20,16,10,0.75)'
+ ctx.beginPath(); ctx.roundRect(-18, 6, 36, 13, 4); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.stroke()
ctx.fillStyle = '#f4ead2'
ctx.font = 'bold 11px system-ui, sans-serif'
ctx.textAlign = 'center'
- ctx.fillText(`菜×${n}`, 0, 14)
+ ctx.fillText(`菜×${n}`, 0, 14.5)
}
},
},
@@ -1350,14 +2262,51 @@ export default {
ctx.fill()
ctx.save()
if (vert) ctx.rotate(Math.PI / 2)
- ctx.fillStyle = '#8d9097'
- ctx.strokeStyle = '#1d1409'
+ // 三根削尖圆木桩 + 两道横木 + 绑绳
+ const logPost = (x) => {
+ ctx.fillStyle = '#8a6238'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath()
+ ctx.moveTo(x - 4.5, 2)
+ ctx.lineTo(x - 4.5, -26)
+ ctx.lineTo(x, -34)
+ ctx.lineTo(x + 4.5, -26)
+ ctx.lineTo(x + 4.5, 2)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.strokeStyle = 'rgba(60,40,20,0.5)'
+ ctx.lineWidth = 1
+ ctx.beginPath(); ctx.moveTo(x - 1.5, -24); ctx.lineTo(x - 1.5, 0); ctx.stroke()
+ ctx.fillStyle = '#a8763e'
+ ctx.beginPath(); ctx.arc(x, -30, 1.6, 0, TAU); ctx.fill()
+ }
+ logPost(-11)
+ logPost(11)
+ const rail = (y) => {
+ ctx.fillStyle = '#9a6a3c'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.8
+ ctx.beginPath(); ctx.roundRect(-16, y, 32, 5, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(60,40,20,0.4)'
+ ctx.lineWidth = 0.8
+ ctx.beginPath(); ctx.moveTo(-14, y + 1.6); ctx.lineTo(14, y + 1.6); ctx.stroke()
+ }
+ rail(-20)
+ rail(-8)
+ ctx.strokeStyle = '#c9a86a'
ctx.lineWidth = 2
;[-11, 11].forEach((x) => {
- ctx.beginPath(); ctx.roundRect(x - 4, -28, 8, 32, 2); ctx.fill(); ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(x - 5, -22)
+ ctx.quadraticCurveTo(x, -19, x + 5, -22)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(x - 5, -9)
+ ctx.quadraticCurveTo(x, -6, x + 5, -9)
+ ctx.stroke()
})
- ctx.beginPath(); ctx.roundRect(-16, -20, 32, 5, 2); ctx.fill(); ctx.stroke()
- ctx.beginPath(); ctx.roundRect(-16, -8, 32, 5, 2); ctx.fill(); ctx.stroke()
ctx.restore()
},
},
@@ -1390,18 +2339,48 @@ export default {
ctx.beginPath(); ctx.ellipse(0, 4, 20, 5, 0, 0, TAU); ctx.fill()
ctx.save()
if (open) ctx.rotate(-0.7)
- ctx.fillStyle = '#8d9097'
+ // 门柱(更高更粗,顶部尖头)
+ ;[-14, 14].forEach((x) => {
+ ctx.fillStyle = '#7a5230'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(x - 5.5, 4)
+ ctx.lineTo(x - 5.5, -30)
+ ctx.lineTo(x, -39)
+ ctx.lineTo(x + 5.5, -30)
+ ctx.lineTo(x + 5.5, 4)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = '#a8763e'
+ ctx.beginPath(); ctx.arc(x, -34, 2, 0, TAU); ctx.fill()
+ })
+ // 双层横木门扇 + 斜撑
+ ctx.fillStyle = '#9a6a3c'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-20, -24, 40, 6, 2); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(-20, -11, 40, 6, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(60,40,20,0.45)'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.moveTo(-16, -20); ctx.lineTo(12, -13); ctx.stroke()
+ // 铰链 + 绑绳
+ ctx.strokeStyle = '#c9a86a'
+ ctx.lineWidth = 2
;[-14, 14].forEach((x) => {
- ctx.beginPath(); ctx.roundRect(x - 4, -30, 8, 34, 2); ctx.fill(); ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(x - 5.5, -26)
+ ctx.quadraticCurveTo(x, -23, x + 5.5, -26)
+ ctx.stroke()
})
- ctx.beginPath(); ctx.roundRect(-20, -22, 40, 6, 2); ctx.fill(); ctx.stroke()
- ctx.beginPath(); ctx.roundRect(-20, -10, 40, 6, 2); ctx.fill(); ctx.stroke()
+ // 门闩铜扣
ctx.fillStyle = '#e0c060'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 1.4
- ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -18, 10, 8, 2); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(open ? 8 : 10, -19, 10, 8, 2); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = 'rgba(255,255,255,0.35)'
+ ctx.beginPath(); ctx.arc((open ? 8 : 10) + 3, -17, 1.4, 0, TAU); ctx.fill()
ctx.restore()
},
},
@@ -1438,6 +2417,7 @@ export default {
} else if ((m.cd || 0) <= 0) {
m.cd = 0.35
g.damageMob(target, conf.dmg, null)
+ g.fx?.('spark', target.x, target.y - 12, { dir: Math.atan2(target.y - m.y, target.x - m.x), spread: 0.7, n: 6 })
}
return
}
@@ -1460,64 +2440,15 @@ export default {
g.ptip(p, `守卫:发现敌对会立刻迎击(当前编制 ${desiredGuardCount()} 人)`)
},
draw(ctx, m, time) {
- ctx.save()
- ctx.scale(m.dir || 1, 1)
- const hop = Math.sin(time * 7 + (m.bob || 0)) * (m.moving ? 1.8 : 0.5)
- // 底部投影
- ctx.fillStyle = 'rgba(0,0,0,0.25)'
- ctx.beginPath(); ctx.ellipse(0, 5, 15, 6, 0, 0, TAU); ctx.fill()
- // 腿(皮靴)
- ctx.strokeStyle = '#5b3a1e'
- ctx.lineWidth = 4.6
- const leg = m.moving ? Math.sin(time * 9) * 2.6 : 0
- ctx.beginPath()
- ctx.moveTo(-5, -10); ctx.lineTo(-6 + leg, 3)
- ctx.moveTo(5, -10); ctx.lineTo(6 - leg, 3)
- ctx.stroke()
- // 身体:皮甲(棕褐)
- ctx.fillStyle = '#8a5f34'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(-11, -32 + hop * 0.3, 22, 24, 5); ctx.fill(); ctx.stroke()
- // 皮甲护胸横条
- ctx.strokeStyle = '#6b4a2a'
- ctx.lineWidth = 1.4
- ;[-27, -22, -17].forEach((y) => { ctx.beginPath(); ctx.moveTo(-9, y); ctx.lineTo(9, y); ctx.stroke() })
- // 肩甲
- ctx.fillStyle = '#a97b47'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.6
- ctx.beginPath(); ctx.arc(-11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
- ctx.beginPath(); ctx.arc(11, -30 + hop * 0.3, 5, 0, TAU); ctx.fill(); ctx.stroke()
- // 长矛(斜握)
- ctx.strokeStyle = '#7a5230'
- ctx.lineWidth = 3
- ctx.beginPath(); ctx.moveTo(8, 2); ctx.lineTo(24, -40); ctx.stroke()
- ctx.fillStyle = '#c6ccd4'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.4
- ctx.beginPath(); ctx.moveTo(22, -38); ctx.lineTo(27, -47); ctx.lineTo(30, -40); ctx.closePath(); ctx.fill(); ctx.stroke()
- // 头
- ctx.fillStyle = '#e8c9a0'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 2
- ctx.beginPath(); ctx.arc(0, -40 + hop * 0.5, 7, 0, TAU); ctx.fill(); ctx.stroke()
- // 金属头盔
- ctx.fillStyle = '#9aa0a8'
- ctx.strokeStyle = '#1d1409'
- ctx.lineWidth = 1.8
- ctx.beginPath(); ctx.arc(0, -42 + hop * 0.5, 8, Math.PI, TAU); ctx.fill(); ctx.stroke()
- ctx.beginPath(); ctx.roundRect(-8, -43 + hop * 0.5, 16, 3, 1); ctx.fill(); ctx.stroke()
- // 头盔红缨
- ctx.fillStyle = '#cf3b34'
- ctx.beginPath()
- ctx.moveTo(0, -50 + hop * 0.5); ctx.quadraticCurveTo(4, -56 + hop * 0.5, 1, -60 + hop * 0.5)
- ctx.quadraticCurveTo(-2, -56 + hop * 0.5, -2, -50 + hop * 0.5)
- ctx.closePath(); ctx.fill(); ctx.stroke()
- // 目光
- ctx.fillStyle = '#1d1409'
- ctx.beginPath(); ctx.arc(3, -40 + hop * 0.5, 1.2, 0, TAU); ctx.fill()
- ctx.restore()
+ // 骨骼化守卫:巡逻步态 / 待机呼吸 / 突刺由 m.cd 驱动(starveSkins.drawGuard)
+ drawGuard(ctx, {
+ dir: m.dir || 1,
+ moving: !!m.moving,
+ walkT: time * 1,
+ cd: m.cd > 0 ? m.cd : 0,
+ bob: m.bob || 0,
+ time,
+ })
},
},
},
@@ -1649,6 +2580,37 @@ export default {
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
+ // 夜晚:房屋窗光与门灯(世界坐标,画在实体之上、夜晚压暗层之下)
+ if (g.phase === 'night') {
+ g.entities.forEach((he) => {
+ if (he.type !== 'camphouse') return
+ const mx2 = normalizeHouseMx(he)
+ const floors2 = Math.max(1, Math.min(4, mx2.floors || 1))
+ const model2 = mx2.model || 'hut'
+ const sx = he.x - env.camX
+ const sy = he.y - env.camY
+ if (sx < -120 || sx > env.W + 120 || sy < -260 || sy > env.H + 60) return
+ for (let f2 = 0; f2 < floors2; f2++) {
+ const baseY2 = -46 - f2 * 28
+ const wins = model2 === 'manor' ? [[-15, 12.5], [14.5, 12.5]] : [[14.5, 12.5]]
+ wins.forEach(([wx, wy]) => {
+ const gx = sx + wx
+ const gy = sy + baseY2 + wy
+ const grd = ctx.createRadialGradient(gx, gy, 2, gx, gy, 26)
+ grd.addColorStop(0, 'rgba(255,200,110,0.32)')
+ grd.addColorStop(1, 'rgba(255,200,110,0)')
+ ctx.fillStyle = grd
+ ctx.beginPath()
+ ctx.arc(gx, gy, 26, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = 'rgba(255,214,130,0.5)'
+ ctx.beginPath()
+ ctx.roundRect(gx - 7, gy - 6, 14, 12, 1.5)
+ ctx.fill()
+ })
+ }
+ })
+ }
// 各块宅基规划虚线框
houseZones.forEach((z) => {
ctx.save()
@@ -1733,23 +2695,25 @@ export default {
const W = env.W
const H = env.H
- // 营地等级 HUD
+ // 营地等级 HUD(面板宽度按文字实测自适应,防溢出)
ctx.save()
+ ctx.font = 'bold 12px system-ui, sans-serif'
+ const need = xpNeed(campState.level)
+ const line = `营地 Lv.${campState.level} XP ${campState.xp}/${need} 守卫 ${guardCount(g)}/${desiredGuardCount()} 篝火 ${eternalFireCount(g)}`
+ const lineW = ctx.measureText(line).width
ctx.fillStyle = 'rgba(40,28,14,0.75)'
ctx.strokeStyle = '#1d1409'
ctx.lineWidth = 2
- ctx.beginPath(); ctx.roundRect(10, 48, 168, 36, 6); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.roundRect(10, 64, lineW + 26, 36, 6); ctx.fill(); ctx.stroke()
ctx.fillStyle = '#f4ead2'
- ctx.font = 'bold 12px system-ui, sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
- const need = xpNeed(campState.level)
- ctx.fillText(`营地 Lv.${campState.level} XP ${campState.xp}/${need} 守卫 ${guardCount(g)}/${desiredGuardCount()} 篝火 ${eternalFireCount(g)}`, 18, 66)
+ ctx.fillText(line, 24, 82)
// 划定宅基按钮
const bw = 92
const bh = 28
const bx = 10
- const by = 92
+ const by = 110
uiHouseBtn = { x: bx, y: by, w: bw, h: bh }
ctx.fillStyle = houseZoneMode ? '#5a4a7a' : '#3a2a4a'
ctx.strokeStyle = '#1d1409'
diff --git a/src/games/starve/mods/husbandry.js b/src/games/starve/mods/husbandry.js
index 661885d..c72a9e9 100644
--- a/src/games/starve/mods/husbandry.js
+++ b/src/games/starve/mods/husbandry.js
@@ -296,6 +296,285 @@ function wander(m, dt, g, speed) {
}
}
+// ---- 畜棚(子地图):食槽投喂 / 剪毛台 / 繁殖栏,靠近鸡舍牛群提升产出 ----
+import { skinByCode, drawPlayerFig } from '../../starveSkins.js'
+import { makeSubmap } from './submap.js'
+
+const BARN_FEEDS = ['wheat', 'pumpkin', 'carrot', 'berry']
+const PEN_KINDS = ['pig', 'beefalo', 'sheep', 'rabbit', 'chicken']
+function penCap(kind, area) {
+ if (kind === 'pig') return Math.min(4, 1 + Math.floor(area / 80000))
+ if (kind === 'beefalo') return Math.min(3, 1 + Math.floor(area / 100000))
+ if (kind === 'sheep') return Math.min(3, 1 + Math.floor(area / 90000))
+ if (kind === 'rabbit') return Math.min(4, 1 + Math.floor(area / 70000))
+ return Math.min(6, 2 + Math.floor(area / 60000))
+}
+function nearestZone(host) {
+ return pastureZones.find((z) => pointInAabb(host.x, host.y, z))
+ || pastureZones.find((z) => Math.hypot((z.x0 + z.x1) / 2 - host.x, (z.y0 + z.y1) / 2 - host.y) < 500)
+ || null
+}
+
+const barnSub = makeSubmap({
+ W: 460,
+ H: 300,
+ wallRatio: 0.4,
+ speed: 150,
+ avatarR: 13,
+ reach: 46,
+ skins: { skinByCode, drawPlayerFig },
+ title: (host) => `畜棚 · 饲料 ${host.mx?.feed || 0}`,
+ enter() {
+ return { x: 230, y: 262 }
+ },
+ bounds() {
+ return { x0: 26, y0: 126, x1: 434, y1: 282 }
+ },
+ objects(g, host) {
+ const feed = host.mx?.feed || 0
+ return [
+ { id: 'feed', x: 46, y: 200, w: 124, h: 52, solid: true, prompt: feed > 0 ? `添饲料(现有 ${feed})` : '投喂食槽' },
+ { id: 'shear', x: 196, y: 196, w: 84, h: 54, solid: true, prompt: '剪毛台' },
+ { id: 'breed', x: 324, y: 182, w: 100, h: 82, solid: true, prompt: '繁殖栏' },
+ { id: 'door', x: 196, y: 252, w: 68, h: 44, solid: false, prompt: '走出畜棚' },
+ ]
+ },
+ tick(g, dt, host, st) {
+ // 栖架上的鸡:啄食小动画
+ st.rt.peck = st.rt.peck || { ph: Math.random() * 6 }
+ st.rt.peck.ph += dt * 2.2
+ },
+ interact(g, p, host, id, st) {
+ const mx = host.mx
+ if (id === 'leave' || id === 'door') {
+ barnSub.close(g)
+ g.ptip(p, '走出了畜棚')
+ return
+ }
+ if (id === 'feed') {
+ const food = BARN_FEEDS.find((code) => g.countItem(p, code) > 0)
+ if (!food) {
+ g.ptip(p, '需要作物当饲料(小麦/南瓜/胡萝卜/浆果)')
+ return
+ }
+ g.payCost(p, { [food]: 1 })
+ mx.feed = Math.min(9, (mx.feed || 0) + 1)
+ g.markDirty(host)
+ g.swing(p, 'eat')
+ g.ptip(p, `添了饲料(${mx.feed}/9)· 畜棚周边产出更旺`)
+ return
+ }
+ if (id === 'shear') {
+ if (g.countItem(p, 'husbshears') <= 0) {
+ g.ptip(p, '剪毛台需要背包里有剪刀')
+ return
+ }
+ const z = nearestZone(host)
+ if (!z) {
+ g.ptip(p, '先把畜棚建在牧场附近')
+ return
+ }
+ const tag = 'sheared:' + g.day
+ const sheepList = g.monsters.filter((m) => m.kind === 'sheep' && m.pen && m.zoneId === z.id && m.state !== tag)
+ if (!sheepList.length) {
+ g.ptip(p, '圈里的绵羊今天都剪过了')
+ return
+ }
+ let n = 0
+ sheepList.forEach((m) => {
+ m.state = tag
+ g.dropLoot(m.x, m.y + 18, 'wool', 2)
+ n++
+ })
+ g.addScore(6 * n, p)
+ g.swing(p, 'chop')
+ g.ptip(p, `一次剪了 ${n} 只羊 · 羊毛 ×${n * 2}`)
+ return
+ }
+ if (id === 'breed') {
+ if ((mx.feed || 0) < 3) {
+ g.ptip(p, '繁殖需要 3 份饲料(先添食槽)')
+ return
+ }
+ const z = nearestZone(host)
+ if (!z) {
+ g.ptip(p, '先把畜棚建在牧场附近')
+ return
+ }
+ const area = aabbArea(z)
+ const pick = PEN_KINDS.find((kind) => {
+ if (kind === 'rabbit') {
+ const have = g.entities.filter((e) => e.type === 'rabbit' && e.mx?.zoneId === z.id).length
+ return have < penCap('rabbit', area)
+ }
+ const have = g.monsters.filter((m) => m.kind === kind && m.pen && m.zoneId === z.id).length
+ return have < penCap(kind, area)
+ })
+ if (!pick) {
+ g.ptip(p, '牧场各栏都住满了')
+ return
+ }
+ mx.feed -= 3
+ g.markDirty(host)
+ const cx = (z.x0 + z.x1) / 2
+ const cy = (z.y0 + z.y1) / 2
+ const spot = g.findLand ? g.findLand(cx, cy, 0, 60) : { x: cx, y: cy }
+ if (pick === 'rabbit') {
+ g.addEntity('rabbit', spot.x + g.rand(-30, 30), spot.y + g.rand(-20, 20), { mx: { pen: true, zoneId: z.id } })
+ } else {
+ g.spawnMob(pick, spot.x + g.rand(-30, 30), spot.y + g.rand(-20, 20), { pen: true, zoneId: z.id, home: { x: cx, y: cy }, zone: z })
+ }
+ g.swing(p, 'eat')
+ g.ptip(p, `消耗饲料 ×3 · 小${g.mon[pick]?.name || pick}出生了!`)
+ return
+ }
+ },
+ draw(g, ctx, v, host, st, env) {
+ const t = env.time || 0
+ ctx.save()
+ ctx.translate(v.px, v.py)
+ ctx.scale(v.scale, v.scale)
+ // —— 木墙 + 干草地板 ——
+ ctx.fillStyle = '#8a6a44'
+ ctx.fillRect(0, 0, 460, 120)
+ ctx.strokeStyle = 'rgba(60,40,20,0.45)'
+ ctx.lineWidth = 1.4
+ for (let x = 0; x < 460; x += 46) {
+ ctx.beginPath(); ctx.moveTo(x, 4); ctx.lineTo(x, 118); ctx.stroke()
+ }
+ for (let y = 34; y < 120; y += 42) {
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(460, y); ctx.stroke()
+ }
+ ctx.fillStyle = '#5d4432'
+ ctx.fillRect(0, 108, 460, 12)
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(0, 120); ctx.lineTo(460, 120); ctx.stroke()
+ // 干草地板块
+ ctx.fillStyle = '#c9a86a'
+ ctx.fillRect(0, 120, 460, 180)
+ ctx.strokeStyle = 'rgba(150,110,60,0.6)'
+ ctx.lineWidth = 1.2
+ for (let i = 0; i < 16; i++) {
+ const x = (i * 61) % 440
+ const y = 132 + ((i * 37) % 150)
+ ctx.beginPath()
+ ctx.moveTo(x, y); ctx.lineTo(x + 16, y + 3); ctx.moveTo(x + 4, y + 6); ctx.lineTo(x + 14, y + 9)
+ ctx.stroke()
+ }
+ // —— 窗户(天光 + 流云)——
+ ctx.fillStyle = '#a8d4f0'
+ ctx.strokeStyle = '#5d3f24'
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.roundRect(182, 24, 96, 58, 3); ctx.fill(); ctx.stroke()
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(230, 24); ctx.lineTo(230, 82)
+ ctx.moveTo(182, 53); ctx.lineTo(278, 53)
+ ctx.stroke()
+ ctx.fillStyle = 'rgba(255,255,255,0.7)'
+ ctx.beginPath(); ctx.ellipse(206 + Math.sin(t * 0.4) * 16, 40, 12, 5, 0, 0, TAU); ctx.fill()
+ // —— 挂着的工具 ——
+ ctx.strokeStyle = '#5d3f24'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.moveTo(320, 26); ctx.lineTo(380, 26); ctx.stroke()
+ ;[[332, '#9aa4ae'], [362, '#b06a3a']].forEach(([hx, col]) => {
+ ctx.strokeStyle = col
+ ctx.lineWidth = 3
+ ctx.beginPath(); ctx.moveTo(hx, 28); ctx.lineTo(hx, 58); ctx.stroke()
+ ctx.fillStyle = col
+ ctx.beginPath(); ctx.arc(hx, 30, 3.4, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1
+ ctx.beginPath(); ctx.arc(hx, 30, 3.4, 0, TAU); ctx.stroke()
+ })
+ // —— 草料垛 ——
+ ;[[26, 96], [416, 96]].forEach(([bx, by]) => {
+ ctx.fillStyle = '#d8b478'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.roundRect(bx - 16, by, 32, 24, 4); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#b08a4a'
+ ctx.lineWidth = 1
+ for (let i = 0; i < 3; i++) {
+ ctx.beginPath(); ctx.moveTo(bx - 12, by + 6 + i * 6); ctx.lineTo(bx + 12, by + 4 + i * 6); ctx.stroke()
+ }
+ })
+ // —— 食槽 ——
+ ctx.fillStyle = '#8a5f34'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(46, 214, 124, 26, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(52, 210, 112, 10, 3); ctx.fill(); ctx.stroke()
+ const feedN = host.mx?.feed || 0
+ if (feedN > 0) {
+ ctx.fillStyle = '#e0b060'
+ for (let i = 0; i < Math.min(6, feedN); i++) {
+ ctx.beginPath()
+ ctx.ellipse(62 + i * 16, 216, 7, 4, 0.2, 0, TAU)
+ ctx.fill()
+ }
+ }
+ ctx.fillStyle = '#5d4432'
+ ctx.beginPath(); ctx.roundRect(58, 240, 8, 14, 2); ctx.fill()
+ ctx.beginPath(); ctx.roundRect(150, 240, 8, 14, 2); ctx.fill()
+ // —— 剪毛台 ——
+ ctx.fillStyle = '#a07040'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(196, 210, 84, 20, 3); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f0e8dc'
+ ctx.beginPath(); ctx.ellipse(238, 206, 26, 8, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#8a6a50'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.arc(238, 206, 16, 0.4, 2.6); ctx.stroke()
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(204, 230, 7, 16, 2); ctx.fill()
+ ctx.beginPath(); ctx.roundRect(264, 230, 7, 16, 2); ctx.fill()
+ // —— 繁殖栏 ——
+ ctx.strokeStyle = '#8a5f34'
+ ctx.lineWidth = 3
+ ctx.strokeRect(324, 190, 100, 74)
+ ctx.lineWidth = 1.4
+ ;[210, 230, 250].forEach((y) => {
+ ctx.beginPath(); ctx.moveTo(326, y); ctx.lineTo(422, y); ctx.stroke()
+ })
+ ctx.fillStyle = '#d8b478'
+ ctx.beginPath(); ctx.ellipse(374, 246, 30, 12, 0, 0, TAU); ctx.fill()
+ ctx.strokeStyle = '#b08a4a'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.arc(374, 246, 22, 0.3, 2.7); ctx.stroke()
+ if ((host.mx?.feed || 0) < 3 && nearestZone(host)) {
+ ctx.fillStyle = 'rgba(240,220,180,0.55)'
+ ctx.font = '10px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.fillText('需要 3 饲料', 374, 218)
+ }
+ // —— 栖架上的鸡(装饰动画)——
+ ctx.strokeStyle = '#8a5f34'
+ ctx.lineWidth = 4
+ ctx.beginPath(); ctx.moveTo(60, 84); ctx.lineTo(160, 84); ctx.stroke()
+ for (let i = 0; i < 3; i++) {
+ const px = 78 + i * 38
+ const peck = Math.sin(st.rt.peck?.ph + i * 2) > 0.6 ? 4 : 0
+ ctx.fillStyle = i === 1 ? '#e8e2d4' : '#d8c8a8'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.ellipse(px, 70, 9, 7, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.beginPath(); ctx.arc(px + 8, 62 + peck, 4.6, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#c45a28'
+ ctx.beginPath(); ctx.arc(px + 8, 56 + peck, 2, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#e0a030'
+ ctx.beginPath(); ctx.moveTo(px + 12.4, 62 + peck); ctx.lineTo(px + 16, 63 + peck); ctx.lineTo(px + 12.4, 64.4 + peck); ctx.closePath(); ctx.fill()
+ ctx.strokeStyle = '#b8a888'
+ ctx.lineWidth = 1.6
+ ctx.beginPath(); ctx.moveTo(px - 4, 78); ctx.lineTo(px - 4, 84); ctx.moveTo(px + 4, 78); ctx.lineTo(px + 4, 84); ctx.stroke()
+ }
+ ctx.restore()
+ },
+})
+
export default {
id: 'husbandry',
name: '牧业文明',
@@ -318,10 +597,24 @@ export default {
{ code: 'gate', name: '牧场门', tab: 'husbandry', tech: 1, cost: { twig: 6, log: 2, grass: 2 }, build: true, desc: '可开关的牧场大门' },
{ code: 'trough', name: '饲料槽', tab: 'husbandry', tech: 1, cost: { log: 4, rock: 2 }, build: true, desc: '投喂作物,提升牧场产出' },
{ code: 'coop', name: '鸡舍', tab: 'husbandry', tech: 2, cost: { log: 6, silk: 4, rope: 2 }, build: true, desc: '每日自动囤蛋' },
+ { code: 'pasturebarn', name: '畜棚', tab: 'husbandry', tech: 2, cost: { log: 10, rock: 4, grass: 6 }, build: true, desc: '走进畜棚:投喂/剪毛/繁殖,周边产出加成' },
{ code: 'woolhat', name: '羊毛帽', tab: 'husbandry', tech: 2, cost: { wool: 6, rope: 1 }, desc: '保暖 60(弱于冬帽但便宜)' },
{ code: 'cheese', name: '奶酪', tab: 'husbandry', tech: 1, cost: { milk: 2 }, desc: '牛奶发酵而成' },
],
icons: {
+ pasturebarn: (c) => {
+ c.fillStyle = '#a9773c'
+ c.strokeStyle = '#1d1409'
+ c.lineWidth = 1.8
+ c.beginPath(); c.roundRect(-11, -4, 22, 14, 2); c.fill(); c.stroke()
+ c.fillStyle = '#7a5230'
+ c.beginPath(); c.moveTo(-15, -4); c.lineTo(0, -14); c.lineTo(15, -4); c.closePath(); c.fill(); c.stroke()
+ c.fillStyle = '#2a1c10'
+ c.beginPath(); c.roundRect(-4, 0, 8, 10, [4, 4, 0, 0]); c.fill()
+ c.strokeStyle = '#b08a4a'
+ c.lineWidth = 1.2
+ c.beginPath(); c.moveTo(-8, -12); c.lineTo(-2, -7); c.moveTo(8, -12); c.lineTo(2, -7); c.stroke()
+ },
husbshears: (c) => {
// 指环
c.strokeStyle = '#6f767d'
@@ -681,6 +974,59 @@ export default {
}
},
},
+ pasturebarn: {
+ name: '畜棚',
+ conf: { size: 44, struct: true, submap: true },
+ init(e) {
+ if (!e.mx) e.mx = {}
+ if (e.mx.feed == null) e.mx.feed = 0
+ },
+ interact(p, e, g) {
+ barnSub.open(g, e)
+ g.ptip(p, '走进了畜棚 · WASD 走动 · E 互动 · Esc 离开')
+ },
+ draw(ctx, e, env) {
+ drawNameplate(ctx, '畜棚', -52)
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'
+ ctx.beginPath(); ctx.ellipse(0, 6, 26, 8, 0, 0, TAU); ctx.fill()
+ // 主体(谷仓式红墙 + 白木条)
+ ctx.fillStyle = '#a8563c'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-24, -26, 48, 32, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = 'rgba(244,234,210,0.55)'
+ ctx.lineWidth = 1.4
+ ;[-12, 0, 12].forEach((x) => {
+ ctx.beginPath(); ctx.moveTo(x, -25); ctx.lineTo(x, 5); ctx.stroke()
+ })
+ // 谷仓大门
+ ctx.fillStyle = '#5d3f24'
+ ctx.beginPath(); ctx.roundRect(-10, -14, 20, 20, 2); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#c9a86a'
+ ctx.lineWidth = 1.4
+ ctx.beginPath()
+ ctx.moveTo(-10, -14); ctx.lineTo(10, 6)
+ ctx.moveTo(10, -14); ctx.lineTo(-10, 6)
+ ctx.stroke()
+ // 复折屋顶
+ ctx.fillStyle = '#7a4a30'
+ ctx.beginPath()
+ ctx.moveTo(-30, -26); ctx.lineTo(-16, -40); ctx.lineTo(16, -40); ctx.lineTo(30, -26)
+ ctx.closePath()
+ ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#5d3a24'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.moveTo(-28, -30); ctx.lineTo(28, -30); ctx.stroke()
+ // 干草垛点缀
+ ctx.fillStyle = '#d8b478'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.4
+ ctx.beginPath(); ctx.ellipse(26, 2, 8, 5, 0, 0, TAU); ctx.fill(); ctx.stroke()
+ ctx.strokeStyle = '#b08a4a'
+ ctx.lineWidth = 1
+ ctx.beginPath(); ctx.arc(26, 2, 5, 0.3, 2.6); ctx.stroke()
+ },
+ },
},
mobs: {
sheep: {
@@ -854,16 +1200,22 @@ export default {
onTick(g, dt) {
keepPenMobs(g, dt)
tickGates(g, dt)
+ // 畜棚子地图:移动/动画/交互
+ if (barnSub.active()) barnSub.update(g, dt)
+ },
+ allowsPlayerMove(g, p) {
+ return !barnSub.active()
},
onNewDay(g) {
// 每日按容量补齐圈养(死亡/跑丢后恢复)
pastureZones.forEach((z) => stockPasture(g, z))
- // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽则翻倍;牧场等级提高上限与产出
+ // 结算每座鸡舍的囤蛋:附近有鸡或喂了饲料槽/畜棚则翻倍;牧场等级提高上限与产出
+ const fedSource = (x, y, r) => g.entities.find((t) => (t.type === 'trough' || t.type === 'pasturebarn') && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - x, t.y - y) < r)
g.entities.filter((e) => e.type === 'coop').forEach((coop) => {
if (!coop.mx) coop.mx = {}
if (coop.mx.stored == null) coop.mx.stored = 0
const chickenNear = g.monsters.some((m) => m.kind === 'chicken' && Math.hypot(m.x - coop.x, m.y - coop.y) < 180)
- const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - coop.x, t.y - coop.y) < 160)
+ const trough = fedSource(coop.x, coop.y, 160)
let inc = 1
if (chickenNear || trough) inc = 2
const mul = pastureYieldAt(coop.x, coop.y)
@@ -876,9 +1228,9 @@ export default {
coop.mx.stored = Math.min(cap, (coop.mx.stored || 0) + inc)
g.markDirty(coop)
})
- // 牧场牛产奶:靠近有饲料的饲料槽(高等级多掉)
+ // 牧场牛产奶:靠近有饲料的饲料槽或畜棚(高等级多掉)
g.monsters.filter((m) => m.kind === 'beefalo' && m.pen).forEach((cow) => {
- const trough = g.entities.find((t) => t.type === 'trough' && (t.mx?.feed || 0) > 0 && Math.hypot(t.x - cow.x, t.y - cow.y) < 140)
+ const trough = fedSource(cow.x, cow.y, 140)
if (!trough) return
trough.mx.feed -= 1
g.markDirty(trough)
@@ -929,6 +1281,8 @@ export default {
return pastureZoneRightAction(g, z)
},
onClick(g, wx, wy) {
+ // 畜棚打开期间吞掉点击
+ if (barnSub.handleClick(g, g.me(), wx - uiCamX, wy - uiCamY)) return true
if (handleZoneMenuClick(wx, wy)) return true
const sx = wx - uiCamX
const sy = wy - uiCamY
@@ -1056,6 +1410,8 @@ export default {
uiCancel = null
}
ctx.restore()
+ // 畜棚子地图面板
+ barnSub.drawFrame(g, ctx, env)
drawZoneMenu(ctx, env)
},
},
diff --git a/src/games/starve/mods/loyaldog.js b/src/games/starve/mods/loyaldog.js
index 79fbafb..2973f29 100644
--- a/src/games/starve/mods/loyaldog.js
+++ b/src/games/starve/mods/loyaldog.js
@@ -43,9 +43,14 @@ export default {
}
return
}
- // 目标:主人当前攻击目标,或靠近主人的敌对生物
+ // 目标:主人当前攻击目标,或靠近主人的敌对生物(不打自己/友军,防误伤)
let target = null
- if (owner.target?.kind === 'mon' && g.monsters.includes(owner.target.ref)) {
+ if (
+ owner.target?.kind === 'mon' &&
+ g.monsters.includes(owner.target.ref) &&
+ owner.target.ref !== m &&
+ g.mon[owner.target.ref.kind]?.hostile
+ ) {
target = owner.target.ref
}
if (!target) {
diff --git a/src/games/starve/mods/manual.js b/src/games/starve/mods/manual.js
new file mode 100644
index 0000000..911e1ab
--- /dev/null
+++ b/src/games/starve/mods/manual.js
@@ -0,0 +1,183 @@
+// mods/manual.js — Mod 手册文案:每个 mod 的玩法要点与注意事项
+// 与 mods/index.js 的顺序保持一致;GamePlay 的 Mod 装载面板「手册」页读取本表
+// 只增不改语义:新 mod 上线时在数组末尾追加一节即可
+export const MOD_MANUAL = [
+ {
+ id: 'camp',
+ name: '营地',
+ tag: '内容',
+ features: [
+ '出生自带主屋、长明篝火与两名无敌守卫,野怪来犯守卫会迎击',
+ '主屋可进入室内:床睡觉跳过夜晚/回状态,随营地等级升级外观与楼层',
+ '营地仓储(8 箱共享仓库)与食堂(点菜吃饭)',
+ '营地有经验与等级:收获/圈养/建造都会攒 XP,升级扩大安全圈',
+ '划定宅基:框选区域自动铺地基,建筑工会来盖房',
+ '夜晚窗户透光、屋内可撸猫读书烤火',
+ ],
+ tips: [
+ '右键宅基或房屋可升级/改名/删除',
+ '守卫无敌但不会追出安全圈太远,别引太多怪',
+ ],
+ },
+ {
+ id: 'agri',
+ name: '农业文明',
+ tag: '内容',
+ features: [
+ '犁锄开垦良田,播种小麦/南瓜/浆果,200 秒三阶段成熟',
+ '水井:140px 内生长 ×1.6,打水回理智(60 秒冷却)',
+ '粮仓储粮防潮,磨坊把小麦磨成面粉做面包',
+ '肥料直接推进生长 50%',
+ '地下生态圈(地窖):捉蚯蚓堆肥造肥料、滴灌 +25%、地热越冬',
+ ],
+ tips: [
+ '框选农业区自动铺田,右键区域可升级提高产出',
+ '冬季农田会停产,记得提前囤粮或开地窖地热',
+ ],
+ },
+ {
+ id: 'farm',
+ name: '农场',
+ tag: '内容',
+ features: [
+ '锄头开垦农田,三阶段 320 秒生长(与农业文明二选一即可)',
+ '采草掉胡萝卜种子、采浆果丛掉浆果种子',
+ ],
+ tips: ['与农业文明同时开启会各铺各的田,建议二选一'],
+ },
+ {
+ id: 'husbandry',
+ name: '牧业文明',
+ tag: '内容',
+ features: [
+ '框选牧场自动围栏圈养:猪/牛/羊/兔/鸡按面积补栏',
+ '剪刀给绵羊剪毛(每只每天一次,羊毛 ×2)',
+ '饲料槽投喂作物:鸡舍每日囤蛋翻倍、牛每日产奶',
+ '畜棚子地图:投喂/剪毛台/繁殖栏(3 饲料繁殖一只)',
+ '羊毛帽保暖 60,鸡蛋牛奶奶酪补食物',
+ ],
+ tips: [
+ '门会自动开关,劳工进出免费',
+ '右键牧场可升级提高产出上限',
+ ],
+ },
+ {
+ id: 'industry',
+ name: '工业文明',
+ tag: '内容',
+ features: [
+ '进阶链:熔炉炼锭 → 蒸汽机供能 → 工厂产齿轮 → 矿机自动开采',
+ '蒸汽机烧木头,为 300px 内的工厂/矿机供能',
+ '矿机每日自动产矿石,蒸汽供能下翻倍',
+ ],
+ tips: ['蒸汽机要烧木头才供能,记得补燃料'],
+ },
+ {
+ id: 'labor',
+ name: '劳务市场',
+ tag: '内容',
+ features: [
+ '招聘所可雇:农民/牧工/建筑工/厨师/伐木工/矿工(工资从大仓库扣)',
+ '自然村民可点选分配职业,工资更低',
+ '劳工自动干活:收获入仓、播种、喂养、盖房、砍树、采矿',
+ '大仓库统一存取,劳工夜间回员工宿舍',
+ ],
+ tips: [
+ '发不出工资劳工会离职,仓库里常备粮食',
+ '与营地 mod 同开时劳工可入驻房屋',
+ ],
+ },
+ {
+ id: 'chest',
+ name: '宝藏',
+ tag: '内容',
+ features: ['世界各处散落 6~10 个宝箱,开箱按战利品表随机获得物资(每箱一次)'],
+ tips: ['宝箱在地图各处,多探索'],
+ },
+ {
+ id: 'chester',
+ name: '切斯特',
+ tag: '内容',
+ features: [
+ '拾到眼骨后切斯特现身跟随,自动吞掉身边掉落物(9 格随身仓)',
+ '点击切斯特把肚子里的东西全部吐出',
+ ],
+ tips: ['切斯特死亡后眼骨冷却会再召出来'],
+ },
+ {
+ id: 'loyaldog',
+ name: '最忠诚的伙伴',
+ tag: '内容',
+ features: [
+ '地图散落骨肉/肉,找到小狗喂食结为永久伙伴',
+ '小狗会攻击你攻击的目标和保护你',
+ ],
+ tips: ['喂食要拿在手上走近按 E;小狗不会打友军'],
+ },
+ {
+ id: 'lucky',
+ name: '幸运矿工',
+ tag: '增益',
+ features: ['采矿收益提高,石头更值钱'],
+ },
+ {
+ id: 'magic',
+ name: '魔法武器',
+ tag: '内容',
+ features: ['火魔杖:对最近敌人范围灼烧;回旋镖:直线飞出命中后折返'],
+ },
+ {
+ id: 'nightmare',
+ name: '噩梦模式',
+ tag: '难度',
+ features: ['全体怪物血量伤害 ×1.5,猎犬波周期缩短且每波 +1'],
+ tips: ['得分系数更高,与和平模式互斥'],
+ },
+ {
+ id: 'peaceful',
+ name: '和平模式',
+ tag: '难度',
+ features: ['取消猎犬波与独眼巨鹿袭击排程,其余生物照常'],
+ tips: ['适合纯种田建造玩法'],
+ },
+ {
+ id: 'qol',
+ name: '便利包',
+ tag: '便利',
+ features: ['全图显示(无战争迷雾)+ 快速采集 + 堆叠上限 99'],
+ },
+ {
+ id: 'revive',
+ name: '复活雕像',
+ tag: '内容',
+ features: [
+ '二级科技建造石像;任何玩家死亡时自动消耗一座原地复活(半血半饥)',
+ '单人模式等于多一条命,保住死亡删档前的存档',
+ ],
+ },
+ {
+ id: 'startchest',
+ name: '文明火种',
+ tag: '便利',
+ features: ['出生点旁放一只系红绸的木箱,首次开启一次性发齐开局物资'],
+ },
+ {
+ id: 'traveler',
+ name: '旅者行囊',
+ tag: '便利',
+ features: ['更大的随身背包空间'],
+ },
+ {
+ id: 'wideworld',
+ name: '辽阔世界',
+ tag: '便利',
+ features: ['世界放大 1.5 倍,多出近一倍土地'],
+ tips: ['与开始面板的地图倍率取更大值生效'],
+ },
+ {
+ id: 'chill',
+ name: '悠闲时光',
+ tag: '难度',
+ features: ['降低生存压力,适合轻松体验'],
+ },
+]
diff --git a/src/games/starve/mods/submap.js b/src/games/starve/mods/submap.js
new file mode 100644
index 0000000..4860438
--- /dev/null
+++ b/src/games/starve/mods/submap.js
@@ -0,0 +1,244 @@
+// mods/submap.js — mod 通用子地图引擎(室内 / 地下生态圈共用)
+// 复刻 camp.js 内室系统的成熟套路:
+// 模块级状态不入档 · allowsPlayerMove 屏蔽世界移动 · onTick 走移动与交互 ·
+// onDrawUI 画外框并记录相机 · onClick 命中按钮(世界坐标 → 屏幕坐标)
+// 用法:
+// const sub = makeSubmap(cfg)
+// 钩子接线:onTick -> sub.update(g, dt)
+// onDrawUI -> 先记录相机不必须(引擎自己记),调 sub.drawFrame(g, ctx, env)
+// onClick -> if (sub.handleClick(g, p, wx - uiCamX, wy - uiCamY)) return true
+// allowsPlayerMove -> return !sub.active()
+// cfg = {
+// W, H 逻辑尺寸(面板内容区)
+// wallRatio = 0.45 墙占上部比例(地下图可更高)
+// speed = 150 走动速度 px/s
+// avatarR = 14 碰撞半径
+// reach = 48 默认交互半径
+// title(host) 面板标题
+// enter(g, host) -> { x, y } 入场点
+// bounds(g, host) -> { x0, y0, x1, y1 } 活动范围(默认留边)
+// objects(g, host, st) -> [{ id, x, y, w, h, solid?, prompt?, reach? }]
+// draw(g, ctx, v, host, st, env) 内容绘制(墙地/装饰/交互物;v=几何,st.rt 存动画态)
+// interact(g, p, host, id, st) 交互回调
+// tick?(g, dt, host, st) 每帧内容逻辑(动画/资源刷新)
+// onLeave?(g, host)
+// }
+
+const TAU = Math.PI * 2
+
+// 全局互斥:同一时间只允许一张子地图打开(开新关旧)
+let current = null
+
+export function makeSubmap(cfg) {
+ const W = cfg.W
+ const H = cfg.H
+ const st = {
+ hostId: null,
+ av: null, // { x, y, dir, walking, walkT, prompt }
+ uiCamX: 0,
+ uiCamY: 0,
+ btns: [], // 屏幕坐标交互按钮(含离开)
+ latch: false,
+ escLatch: false,
+ rt: {}, // 内容动画态(不入档)
+ }
+
+ function active() {
+ return st.hostId != null
+ }
+ function hostOf(g) {
+ return g.entities.find((e) => e.id === st.hostId) || null
+ }
+ function view(env) {
+ const pad = 24
+ const maxW = Math.min(env.W - pad * 2, 660)
+ const maxH = Math.min(env.H - pad * 2, 460)
+ const scale = Math.min(maxW / W, maxH / H)
+ return { px: (env.W - W * scale) / 2, py: (env.H - H * scale) / 2, pw: W * scale, ph: H * scale, scale }
+ }
+ function roomToScreen(v, rx, ry) {
+ return { x: v.px + rx * v.scale, y: v.py + ry * v.scale }
+ }
+ function boundsOf(g, host) {
+ if (cfg.bounds) return cfg.bounds(g, host)
+ return { x0: 22, y0: H * cfg.wallRatio + 14, x1: W - 22, y1: H - 14 }
+ }
+ function circleHitsRect(cx, cy, r, rect) {
+ const nx = Math.max(rect.x, Math.min(cx, rect.x + rect.w))
+ const ny = Math.max(rect.y, Math.min(cy, rect.y + rect.h))
+ return Math.hypot(cx - nx, cy - ny) < r
+ }
+
+ function open(g, host) {
+ if (current && current !== api) current.close(g)
+ current = api
+ st.hostId = host.id
+ const spot = cfg.enter ? cfg.enter(g, host) : { x: W / 2, y: H - 48 }
+ st.av = { x: spot.x, y: spot.y, dir: 1, walking: false, walkT: 0, prompt: null }
+ st.rt = {}
+ st.latch = true // 入场瞬间按住的 E 不立即触发
+ g.invalidate?.()
+ }
+ function close(g) {
+ const host = st.hostId != null ? g?.entities?.find((e) => e.id === st.hostId) : null
+ st.hostId = null
+ st.av = null
+ st.btns = []
+ if (current === api) current = null
+ if (host && cfg.onLeave) cfg.onLeave(g, host)
+ g?.invalidate?.()
+ }
+
+ function update(g, dt) {
+ if (!active()) return
+ const host = hostOf(g)
+ const p = g.me()
+ if (!host || !p || p.dead) { close(g); return }
+ cfg.tick?.(g, dt, host, st)
+ const av = st.av
+ const objs = cfg.objects ? cfg.objects(g, host, st) : []
+ // WASD / 方向键 / 联机输入 移动(X/Y 分离碰撞固体)
+ let dx = 0
+ let dy = 0
+ if (g.keyDown) {
+ dx = (g.keyDown('ArrowRight') || g.keyDown('d') || g.keyDown('D') ? 1 : 0)
+ - (g.keyDown('ArrowLeft') || g.keyDown('a') || g.keyDown('A') ? 1 : 0)
+ dy = (g.keyDown('ArrowDown') || g.keyDown('s') || g.keyDown('S') ? 1 : 0)
+ - (g.keyDown('ArrowUp') || g.keyDown('w') || g.keyDown('W') ? 1 : 0)
+ }
+ if (!dx && !dy && p.input) {
+ dx = p.input.dx || 0
+ dy = p.input.dy || 0
+ }
+ const len = Math.hypot(dx, dy)
+ if (len > 1e-3) {
+ dx /= len
+ dy /= len
+ const b = boundsOf(g, host)
+ const step = (cfg.speed || 150) * dt
+ const solids = objs.filter((o) => o.solid)
+ const nx = Math.max(b.x0, Math.min(b.x1, av.x + dx * step))
+ const ny = Math.max(b.y0, Math.min(b.y1, av.y + dy * step))
+ if (!solids.some((o) => circleHitsRect(nx, av.y, cfg.avatarR || 14, o))) av.x = nx
+ if (!solids.some((o) => circleHitsRect(av.x, ny, cfg.avatarR || 14, o))) av.y = ny
+ if (dx) av.dir = dx > 0 ? 1 : -1
+ av.walking = true
+ av.walkT += dt
+ } else {
+ av.walking = false
+ }
+ // 最近可交互物
+ let best = null
+ let bd = Infinity
+ for (const o of objs) {
+ if (!o.prompt) continue
+ const cx = o.x + o.w / 2
+ const cy = o.y + o.h / 2
+ const d = Math.hypot(cx - av.x, cy - av.y)
+ const r = o.reach || cfg.reach || 48
+ if (d < r && d < bd) { bd = d; best = o }
+ }
+ av.prompt = best
+ // E 交互(边沿触发)
+ const wantE = !!(g.keyDown?.('e') || g.keyDown?.('E') || g.keyDown?.(' ') || g.keyDown?.('Enter'))
+ if (wantE && !st.latch && av.prompt) cfg.interact(g, p, host, av.prompt.id, st)
+ st.latch = wantE
+ // Escape 离开
+ const wantEsc = !!g.keyDown?.('Escape')
+ if (wantEsc && !st.escLatch) close(g)
+ st.escLatch = wantEsc
+ }
+
+ function drawFrame(g, ctx, env) {
+ if (!active()) return
+ const host = hostOf(g)
+ if (!host) return
+ const v = view(env)
+ st.uiCamX = env.camX
+ st.uiCamY = env.camY
+ ctx.save()
+ // 全屏暗罩 + 木框
+ ctx.fillStyle = 'rgba(8, 6, 4, 0.74)'
+ ctx.fillRect(0, 0, env.W, env.H)
+ ctx.fillStyle = '#4a3224'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 4
+ ctx.beginPath(); ctx.roundRect(v.px - 6, v.py - 6, v.pw + 12, v.ph + 12, 10); ctx.fill(); ctx.stroke()
+ // 内容
+ cfg.draw(g, ctx, v, host, st, env)
+ // 头像
+ const p = g.me()
+ if (p && st.av) {
+ const { skinByCode, drawPlayerFig } = cfg.skins
+ const a = roomToScreen(v, st.av.x, st.av.y)
+ ctx.save()
+ ctx.translate(a.x, a.y)
+ const s = v.scale
+ ctx.scale(s, s)
+ drawPlayerFig(ctx, skinByCode(p?.skin || 'wilson'), {
+ dir: st.av.dir, walking: st.av.walking, walkT: st.av.walkT,
+ swing: p.swing || 0, act: p.act || '',
+ toolCode: p.equip?.hand?.code || '',
+ breathe: st.av.walking ? 0 : Math.sin((env.time || 0) * 2.2) * 1.2,
+ time: env.time || 0,
+ })
+ ctx.restore()
+ // 交互气泡(头顶上方指向交互物)
+ if (st.av.prompt) {
+ const o = st.av.prompt
+ const ox = v.px + (o.x + o.w / 2) * v.scale
+ const oy = v.py + o.y * v.scale - 12
+ const label = `E · ${o.prompt}`
+ ctx.font = 'bold 11px sans-serif'
+ const tw = ctx.measureText(label).width + 14
+ ctx.fillStyle = 'rgba(30,24,14,0.88)'
+ ctx.beginPath(); ctx.roundRect(ox - tw / 2, oy - 16, tw, 18, 5); ctx.fill()
+ ctx.strokeStyle = '#c9a86a'; ctx.lineWidth = 1.2; ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, ox, oy - 7)
+ }
+ }
+ // 标题
+ ctx.fillStyle = 'rgba(30,22,12,0.85)'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 1.6
+ const title = cfg.title ? cfg.title(host) : ''
+ const tw2 = Math.max(90, title.length * 13 + 20)
+ ctx.beginPath(); ctx.roundRect(v.px + v.pw / 2 - tw2 / 2, v.py - 26, tw2, 20, 6); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 12px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(title, v.px + v.pw / 2, v.py - 16)
+ // 交互按钮(供 onClick 命中)
+ st.btns = []
+ const leave = { id: 'leave', x: v.px + v.pw / 2 - 50, y: v.py + v.ph + 12, w: 100, h: 28 }
+ ctx.fillStyle = '#5d4432'
+ ctx.strokeStyle = '#1d1409'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(leave.x, leave.y, leave.w, leave.h, 8); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 12px sans-serif'
+ ctx.fillText('离开(Esc)', leave.x + leave.w / 2, leave.y + leave.h / 2 + 1)
+ st.btns.push(leave)
+ ctx.restore()
+ }
+
+ function handleClick(g, p, sx, sy) {
+ if (!active()) return false
+ for (const b of st.btns) {
+ if (sx >= b.x && sx <= b.x + b.w && sy >= b.y && sy <= b.y + b.h) {
+ const host = hostOf(g)
+ if (!host) { close(g); return true }
+ cfg.interact(g, p, host, b.id, st)
+ return true
+ }
+ }
+ return true // 面板打开期间吞掉所有点击,防点穿世界
+ }
+
+ const api = { active, open, close, update, drawFrame, handleClick, st, view, roomToScreen }
+ return api
+}
diff --git a/src/games/starve/paint.js b/src/games/starve/paint.js
index f7ad9c4..6110bc6 100644
--- a/src/games/starve/paint.js
+++ b/src/games/starve/paint.js
@@ -2,8 +2,13 @@
// 约定:实体/生物画师在"已 translate 到脚底原点"的 ctx 上绘制;静态实体先离屏缓存再 blit,
// 摇摆动画通过整体旋转缓存图完成(性能远优于逐帧矢量重绘);生物保持逐帧矢量(动作丰富)
import { ITEMS, ITEM_CODES } from './data.js'
+import {
+ defineSkeleton, computePose, sampleClip, clipPose, blob, boneStroke, taper,
+ ellipsePts, boxPts, wobblePts, qTime, linGrad, shade, rgba, addPose,
+} from './skel.js'
const TAU = Math.PI * 2
+const OUT = '#1d1409'
function fs(c, fill, stroke = '#1d1409', lw = 2) {
if (fill) { c.fillStyle = fill; c.fill() }
@@ -263,6 +268,84 @@ export function drawItemIcon(c, code) {
c.beginPath(); c.rect(-10, 4, 20, 5); c.fill()
c.strokeStyle = '#241812'; c.lineWidth = 1.6; c.strokeRect(-10, 4, 20, 5)
c.beginPath(); c.arc(0, -11, 2.6, 0, TAU); fs(c, '#dfe6f0', '#241812', 1.2)
+ } else if (code === 'strawhat') {
+ // 草编宽檐夏帽
+ c.beginPath(); c.ellipse(0, 6, 13, 4.6, 0, 0, TAU); fs(c, '#d8b04a', '#241812', 1.5)
+ c.beginPath(); c.moveTo(-7, 6); c.quadraticCurveTo(-6, -7, 0, -8); c.quadraticCurveTo(6, -7, 7, 6); c.closePath(); fs(c, '#e6c668', '#241812', 1.5)
+ c.strokeStyle = '#b8933a'; c.lineWidth = 1.3
+ c.beginPath(); c.moveTo(-6, 1); c.quadraticCurveTo(0, -2, 6, 1); c.stroke()
+ } else if (code === 'gem') {
+ // 切面宝石
+ c.beginPath(); c.moveTo(0, -10); c.lineTo(8, -4); c.lineTo(6, 8); c.lineTo(-6, 8); c.lineTo(-8, -4); c.closePath(); fs(c, '#7fd8f0', '#1d3440', 1.4)
+ c.beginPath(); c.moveTo(0, -10); c.lineTo(3, -2); c.lineTo(0, 8); c.lineTo(-3, -2); c.closePath(); fs(c, '#b8ecf8', null)
+ c.strokeStyle = '#1d3440'; c.lineWidth = 1
+ c.beginPath(); c.moveTo(-8, -4); c.lineTo(8, -4); c.moveTo(-3, -2); c.lineTo(3, -2); c.stroke()
+ } else if (code === 'lightbulb') {
+ // 荧光果:发光球茎
+ c.beginPath(); c.arc(0, 0, 8.4, 0, TAU); fs(c, 'rgba(190,245,205,0.95)', '#2a5a38', 1.5)
+ c.fillStyle = '#f0fff2'
+ c.beginPath(); c.arc(-2.6, -2.6, 2.8, 0, TAU); c.fill()
+ c.strokeStyle = '#3f6b46'; c.lineWidth = 1.6
+ c.beginPath(); c.moveTo(0, 8.4); c.lineTo(0, 11); c.moveTo(0, 9.4); c.lineTo(3.4, 11); c.stroke()
+ } else if (code === 'eyebrella') {
+ // 眼球伞:伞面 + 眼球纹
+ c.beginPath(); c.arc(0, -2, 11, Math.PI, 0); c.closePath(); fs(c, '#d8dbe0', '#241812', 1.5)
+ c.beginPath(); c.arc(0, -6, 4.4, 0, TAU); fs(c, '#f4f6f8', '#241812', 1.2)
+ c.fillStyle = '#4a8cc9'; c.beginPath(); c.arc(0, -6, 2.2, 0, TAU); c.fill()
+ c.strokeStyle = '#6e5a3a'; c.lineWidth = 2
+ c.beginPath(); c.moveTo(0, -2); c.lineTo(0, 10); c.stroke()
+ } else if (code === 'minerhat') {
+ // 矿工帽:黄盔 + 前灯
+ c.beginPath(); c.arc(0, 3, 10, Math.PI, 0); c.closePath(); fs(c, '#e0b83a', '#241812', 1.5)
+ c.fillStyle = '#f0c94a'
+ c.beginPath(); c.roundRect(-11, 1, 22, 4, 2); c.fill()
+ c.beginPath(); c.arc(0, -4, 3.4, 0, TAU); fs(c, '#fff7d8', '#241812', 1.2)
+ c.strokeStyle = '#8a6a10'; c.lineWidth = 1
+ c.beginPath(); c.moveTo(3, -4); c.lineTo(7, -6); c.stroke()
+ } else if (code === 'beecrown') {
+ // 蜂王冠
+ c.beginPath(); c.moveTo(-9, 6); c.lineTo(-9, -4); c.lineTo(-4.5, 0); c.lineTo(0, -8); c.lineTo(4.5, 0); c.lineTo(9, -4); c.lineTo(9, 6); c.closePath(); fs(c, '#f0c94a', '#8a6a10', 1.4)
+ c.fillStyle = '#e0b83a'; c.beginPath(); c.roundRect(-10, 5, 20, 3.4, 1.4); c.fill()
+ } else if (code === 'stinger') {
+ // 蜂刺
+ c.beginPath(); c.moveTo(-8, 4); c.quadraticCurveTo(2, 6, 10, -6); c.lineTo(6, -8); c.quadraticCurveTo(-2, 2, -8, 0); c.closePath(); fs(c, '#3a2c14', '#241812', 1.2)
+ } else if (code === 'cavemushroom' || code === 'cookedcavemushroom') {
+ // 洞穴菇(烤制后变棕)
+ const cap = code === 'cavemushroom' ? '#c8d4dc' : '#b89058'
+ c.beginPath(); c.arc(0, -1, 9, Math.PI, 0); c.closePath(); fs(c, cap, '#241812', 1.4)
+ c.fillStyle = '#eef4f8'; c.fillRect(-2, -1.4, 4, 9)
+ } else if (code === 'icebox') {
+ // 冰箱:冰白双门柜
+ c.beginPath(); c.roundRect(-9, -11, 18, 22, 2.5); fs(c, '#c9d6de', '#2a3540', 1.5)
+ c.fillStyle = '#aebec9'
+ c.beginPath(); c.roundRect(-7, -9, 14, 7, 1.5); c.fill()
+ c.beginPath(); c.roundRect(-7, 0, 14, 7, 1.5); c.fill()
+ c.fillStyle = '#5c6b76'
+ c.fillRect(4, -7, 2, 4)
+ c.fillRect(4, 1, 2, 4)
+ } else if (code === 'fishingrod') {
+ c.strokeStyle = '#8a6238'; c.lineWidth = 2.4
+ c.beginPath(); c.moveTo(-10, 10); c.quadraticCurveTo(2, -14, 12, -10); c.stroke()
+ c.strokeStyle = '#d8d8d8'; c.lineWidth = 1
+ c.beginPath(); c.moveTo(12, -10); c.quadraticCurveTo(14, 0, 10, 6); c.stroke()
+ c.beginPath(); c.arc(0, 6, 10, 0, TAU); c.fillStyle = '#57a486'; c.fill()
+ } else if (code === 'fish') {
+ c.beginPath(); c.ellipse(0, 0, 10, 5, -0.25, 0, TAU); fs(c, '#6fa8c9', '#241812', 1.4)
+ c.beginPath(); c.moveTo(8, -1); c.lineTo(14, -5); c.lineTo(14, 4); c.closePath(); fs(c, '#6fa8c9', '#241812', 1.2)
+ c.fillStyle = '#241812'; c.beginPath(); c.arc(-4, -1.5, 1.1, 0, TAU); c.fill()
+ } else if (code === 'cookedfish') {
+ c.beginPath(); c.ellipse(0, 0, 10, 5, -0.25, 0, TAU); fs(c, '#c98f4e', '#241812', 1.4)
+ c.beginPath(); c.moveTo(8, -1); c.lineTo(14, -5); c.lineTo(14, 4); c.closePath(); fs(c, '#c98f4e', '#241812', 1.2)
+ c.fillStyle = '#8a5a28'; c.beginPath(); c.arc(-4, -1.5, 1.1, 0, TAU); c.fill()
+ } else if (code === 'honeycomb') {
+ c.beginPath(); c.moveTo(0, -9); c.lineTo(8, -4.5); c.lineTo(8, 4.5); c.lineTo(0, 9); c.lineTo(-8, 4.5); c.lineTo(-8, -4.5); c.closePath(); fs(c, '#e0b83a', '#241812', 1.4)
+ c.fillStyle = '#f0923c'
+ ;[[0, -3], [0, 3], [-4, 0], [4, 0]].forEach(([x, y]) => { c.beginPath(); c.arc(x, y, 1.6, 0, TAU); c.fill() })
+ } else if (code === 'beebox') {
+ c.beginPath(); c.roundRect(-10, -8, 20, 17, 3); fs(c, '#e0b83a', '#241812', 1.5)
+ c.fillStyle = '#8a5f38'
+ c.beginPath(); c.roundRect(-10, -8, 20, 5, 2); c.fill()
+ c.fillStyle = '#f0923c'; c.beginPath(); c.arc(0, 1, 2.2, 0, TAU); c.fill()
} else if (code === 'trap') {
c.strokeStyle = '#a8894c'; c.lineWidth = 2.2
c.beginPath(); c.arc(0, 6, 10, Math.PI, 0); c.stroke()
@@ -313,7 +396,7 @@ export function drawItemIcon(c, code) {
export function buildIcons() {
const url = {}
const cv = {}
- const extra = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest']
+ const extra = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot', 'woodchest', 'icebox']
;[...ITEM_CODES, ...extra.filter((e) => !ITEMS[e])].forEach((code) => {
const canvas = document.createElement('canvas')
canvas.width = 40
@@ -668,6 +751,24 @@ export function drawEnt(ctx, ent, env) {
drawCrockpot(ctx, ent, time)
} else if (ent.type === 'woodchest') {
drawWoodchest(ctx, ent)
+ } else if (ent.type === 'icebox') {
+ drawIcebox(ctx, ent, time)
+ } else if (ent.type === 'beebox') {
+ drawBeebox(ctx, ent, time)
+ } else if (ent.type === 'pond') {
+ drawPond(ctx, ent, time)
+ } else if (ent.type === 'cavein') {
+ drawCaveHole(ctx, ent, time, true)
+ } else if (ent.type === 'caveout') {
+ drawCaveHole(ctx, ent, time, false)
+ } else if (ent.type === 'lightflower') {
+ drawLightflower(ctx, ent, time)
+ } else if (ent.type === 'gemrock') {
+ drawGemrock(ctx, ent, time)
+ } else if (ent.type === 'cavemushroom') {
+ drawCavemushroom(ctx, ent, time)
+ } else if (ent.type === 'gianthive') {
+ drawGianthive(ctx, ent, time)
} else if (ent.type === 'trap') {
drawTrap(ctx, ent, time)
} else if (ent.type === 'rabbit') {
@@ -720,6 +821,92 @@ function drawWoodchest(ctx, ent) {
}
}
+// 冰箱:冰白双门柜 + 霜光闪点,上方物品数量标签(样式对齐木箱)
+function drawIcebox(ctx, ent, time) {
+ const n = (ent.mx?.slots || []).reduce((a, s) => a + (s?.n || 0), 0)
+ const label = n > 0 ? `冰箱 · ${n}` : '冰箱'
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = Math.max(40, label.length * 9 + 14)
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, -46, tw, 16, 4)
+ else ctx.rect(-tw / 2, -46, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -38)
+ shadow(ctx, 0, 7, 19, 5)
+ ctx.fillStyle = '#c9d6de'
+ ctx.strokeStyle = '#2a3540'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-15, -16, 30, 24, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#aebec9'
+ ctx.beginPath(); ctx.roundRect(-13, -14, 26, 9, 2); ctx.fill()
+ ctx.beginPath(); ctx.roundRect(-13, -3, 26, 9, 2); ctx.fill()
+ ctx.fillStyle = '#5c6b76'
+ ctx.fillRect(8, -10, 2.4, 5)
+ ctx.fillRect(8, 1, 2.4, 5)
+ ctx.fillStyle = `rgba(255,255,255,${0.5 + Math.sin(time * 3) * 0.25})`
+ ;[[-9, -9], [4, -1], [-5, 3]].forEach(([x, y]) => {
+ ctx.beginPath(); ctx.arc(x, y, 1.1, 0, TAU); ctx.fill()
+ })
+}
+
+// 蜂箱:木框蜡黄蜂房 + 蜜量标签,酿满时滴蜜
+function drawBeebox(ctx, ent, time) {
+ const honey = ent.mx?.honey || 0
+ const label = honey > 0 ? `蜂箱 · 蜜 ${honey}/4` : '蜂箱 · 酿蜜中'
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = Math.max(40, label.length * 9 + 14)
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, -44, tw, 16, 4)
+ else ctx.rect(-tw / 2, -44, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -36)
+ shadow(ctx, 0, 6, 17, 5)
+ ctx.fillStyle = '#e0b83a'
+ ctx.strokeStyle = '#241812'
+ ctx.lineWidth = 2
+ ctx.beginPath(); ctx.roundRect(-13, -12, 26, 20, 4); ctx.fill(); ctx.stroke()
+ ctx.fillStyle = '#8a5f38'
+ ctx.beginPath(); ctx.roundRect(-13, -12, 26, 7, 3); ctx.fill()
+ if (honey > 0) {
+ // 蜜孔按蜜量点亮 + 轻微呼吸
+ const glow = 0.6 + Math.sin(time * 2.4) * 0.2
+ for (let i = 0; i < honey; i++) {
+ ctx.fillStyle = `rgba(240,146,60,${glow})`
+ ctx.beginPath(); ctx.arc(-7.5 + i * 5, 2, 2.1, 0, TAU); ctx.fill()
+ }
+ } else {
+ ctx.strokeStyle = '#b8933a'
+ ctx.lineWidth = 1.2
+ ctx.beginPath(); ctx.moveTo(-6, 2); ctx.lineTo(6, 2); ctx.stroke()
+ }
+}
+
+// 池塘:水面 + 扩散涟漪(钓鱼点)
+function drawPond(ctx, ent, time) {
+ ctx.fillStyle = '#25525e'
+ ctx.beginPath(); ctx.ellipse(0, 0, 36, 25, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#2e6b78'
+ ctx.beginPath(); ctx.ellipse(0, 0, 30, 20, 0, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#3d8494'
+ ctx.beginPath(); ctx.ellipse(-4, -3, 18, 11, 0, 0, TAU); ctx.fill()
+ ctx.strokeStyle = 'rgba(220,240,250,0.6)'
+ ctx.lineWidth = 1.3
+ for (let i = 0; i < 3; i++) {
+ const ph = (time * 0.8 + i * 0.7) % 1
+ ctx.globalAlpha = 0.7 * (1 - ph)
+ ctx.beginPath(); ctx.ellipse(Math.sin(i * 2.3) * 8, Math.cos(i * 1.7) * 4, 4 + ph * 16, 3 + ph * 10, 0, 0, TAU); ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+}
+
// 陷阱:armed 张开支起 / caught 扣地晃动
function drawTrap(ctx, ent, time) {
const wob = ent.state === 'caught' && ent.shake > 0 ? Math.sin(time * 40) * 2 : 0
@@ -877,49 +1064,124 @@ function drawCrockpot(ctx, ent, time) {
export function drawFirePainter(ctx, f, time) {
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
+ const tq = qTime(time, 9)
if (f.pit) {
- ctx.fillStyle = '#7e8187'
+ // 石圈:每块石头独立明暗
for (let i = 0; i < 8; i++) {
const a = (i / 8) * TAU
+ const sx = Math.cos(a) * 17
+ const sy = 4 + Math.sin(a) * 8
+ const k = 0.5 + Math.abs(Math.cos(a)) * 0.25
+ ctx.fillStyle = `rgb(${(126 * k + 40) | 0},${(129 * k + 40) | 0},${(135 * k + 42) | 0})`
ctx.beginPath()
- ctx.arc(Math.cos(a) * 17, 4 + Math.sin(a) * 8, 4, 0, TAU)
+ ctx.ellipse(sx, sy, 4.6, 3.4, a, 0, TAU)
ctx.fill()
- ctx.strokeStyle = '#54565c'
+ ctx.strokeStyle = '#3c3e44'
ctx.lineWidth = 1.2
ctx.stroke()
+ ctx.fillStyle = 'rgba(255,255,255,0.22)'
+ ctx.beginPath()
+ ctx.ellipse(sx - 1.2, sy - 1.2, 1.8, 1.1, a, 0, TAU)
+ ctx.fill()
}
ctx.fillStyle = '#2a2018'
ctx.beginPath()
ctx.ellipse(0, 4, 12, 5.4, 0, 0, TAU)
ctx.fill()
+ ctx.fillStyle = 'rgba(90,70,50,0.6)'
+ ctx.beginPath()
+ ctx.ellipse(0, 3.4, 9, 3.6, 0, 0, TAU)
+ ctx.fill()
} else {
- ctx.fillStyle = '#7e8187'
;[-14, -7, 0, 7, 14].forEach((sx, i) => {
+ ctx.fillStyle = i % 2 ? '#74777d' : '#878a90'
ctx.beginPath()
ctx.arc(sx, 6 + (i % 2 ? 2 : 0), 3.4, 0, TAU)
ctx.fill()
+ ctx.strokeStyle = '#4a4c52'
+ ctx.lineWidth = 1
+ ctx.stroke()
})
}
- if (f.ttl <= 0) return
- ctx.strokeStyle = '#5b4128'
- ctx.lineWidth = 4
- ctx.beginPath()
- ctx.moveTo(-9, 5); ctx.lineTo(9, 0); ctx.moveTo(-9, 0); ctx.lineTo(9, 5)
- ctx.stroke()
+ if (f.ttl <= 0) {
+ // 熄灭的焦黑木堆 + 一缕余烟
+ ctx.strokeStyle = '#241a12'
+ ctx.lineWidth = 4.4
+ ctx.beginPath()
+ ctx.moveTo(-9, 5); ctx.lineTo(9, 0)
+ ctx.moveTo(-9, 0); ctx.lineTo(9, 5)
+ ctx.stroke()
+ ctx.strokeStyle = '#3a2c1e'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(-9, 5); ctx.lineTo(9, 0)
+ ctx.moveTo(-9, 0); ctx.lineTo(9, 5)
+ ctx.stroke()
+ const p = (time * 0.4) % 1
+ ctx.fillStyle = `rgba(180,180,180,${0.3 * (1 - p)})`
+ ctx.beginPath()
+ ctx.arc(Math.sin(time + 2) * 3, -8 - p * 26, 3 + p * 4, 0, TAU)
+ ctx.fill()
+ return
+ }
+ // 交叠原木(带端面年轮)
+ const log = (x0, y0, x1, y1, w) => {
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = w + 1.6
+ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke()
+ ctx.strokeStyle = '#5b4128'
+ ctx.lineWidth = w
+ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke()
+ ctx.strokeStyle = '#6f5233'
+ ctx.lineWidth = w * 0.4
+ ctx.beginPath(); ctx.moveTo(x0 + 1, y0 - 1); ctx.lineTo(x1 - 1, y1 - 1); ctx.stroke()
+ }
+ log(-10, 5, 10, -1, 4.2)
+ log(-10, -1, 10, 5, 4.2)
+ log(-6, 6, 7, -4, 3.4)
+ // 火光光晕
const vigor = 0.45 + 0.55 * Math.min(1, f.ttl / 45)
+ const glow = ctx.createRadialGradient(0, -8, 2, 0, -8, 44 * vigor)
+ glow.addColorStop(0, `rgba(255,170,70,${0.24 * vigor})`)
+ glow.addColorStop(1, 'rgba(255,170,70,0)')
+ ctx.fillStyle = glow
+ ctx.beginPath()
+ ctx.arc(0, -8, 44 * vigor, 0, TAU)
+ ctx.fill()
const flick = Math.sin(time * 9) * 2.5 * vigor
const scale = f.pit ? 1.2 : 1
+ // 三层抖动火焰(沸腾线):沿两条对称贝塞尔曲线取点
+ const qBez = (p0, p1, p2, t) => {
+ const u = 1 - t
+ return [
+ u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0],
+ u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1],
+ ]
+ }
const flame = (h, w, col) => {
+ const tip = [0, (-h + flick * 0.4) * scale]
+ const base = [0, 2]
+ const pts = []
+ for (let i = 0; i <= 4; i++) pts.push(qBez(tip, [w * scale, -h * 0.45 * scale], base, i / 4))
+ for (let i = 1; i <= 4; i++) pts.push(qBez(base, [-w * scale, -h * 0.45 * scale], tip, i / 4))
+ blob(ctx, pts, { fill: col, amp: 1.3, seed: 5 + h, tq })
+ }
+ flame(31 * vigor + flick, 13 * vigor, 'rgba(236,110,36,0.9)')
+ flame(21 * vigor + flick * 0.7, 9 * vigor, 'rgba(255,180,55,0.92)')
+ flame(12 * vigor + flick * 0.4, 5.2 * vigor, 'rgba(255,238,160,0.95)')
+ // 内芯
+ ctx.fillStyle = 'rgba(255,252,230,0.85)'
+ ctx.beginPath()
+ ctx.ellipse(Math.sin(time * 7) * 1.2, -5, 2.6 * vigor, 4.4 * vigor, 0, 0, TAU)
+ ctx.fill()
+ // 上升火星
+ for (let i = 0; i < 2; i++) {
+ const p = ((time * (0.8 + i * 0.35) + i * 0.5) % 1)
+ ctx.fillStyle = `rgba(255,190,90,${0.85 * (1 - p)})`
ctx.beginPath()
- ctx.moveTo(0, (-h + flick * 0.4) * scale)
- ctx.quadraticCurveTo(w * scale, -h * 0.45 * scale, 0, 2)
- ctx.quadraticCurveTo(-w * scale, -h * 0.45 * scale, 0, (-h + flick * 0.4) * scale)
- ctx.fillStyle = col
+ ctx.arc(Math.sin(time * 3 + i * 4) * (4 + p * 8), -14 - p * 26 * vigor, 1.4 - p * 0.8, 0, TAU)
ctx.fill()
}
- flame(30 * vigor + flick, 13 * vigor, 'rgba(240,120,40,0.88)')
- flame(21 * vigor + flick * 0.7, 9 * vigor, 'rgba(255,185,60,0.9)')
- flame(12 * vigor + flick * 0.4, 5.4 * vigor, 'rgba(255,238,160,0.95)')
}
// =====================================================================
@@ -936,35 +1198,304 @@ export function drawMob(ctx, m, time) {
else if (m.kind === 'bee') drawBee(ctx, m, time)
else if (m.kind === 'frog') drawFrog(ctx, m, time)
else if (m.kind === 'deerclops') drawDeerclops(ctx, m, time)
+ else if (m.kind === 'moosegoose') drawMoosegoose(ctx, m, time)
+ else if (m.kind === 'dragonfly') drawDragonfly(ctx, m, time)
+ else if (m.kind === 'slurtle') drawSlurtle(ctx, m, time)
+ else if (m.kind === 'beequeen') drawBeequeen(ctx, m, time)
}
+// =====================================================================
+// 生物骨骼与动画片段(ctx 已平移到脚底,+x 为面朝方向)
+// =====================================================================
+// —— 猎犬 / 冰猎犬(四足)——
+const HOUND_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -11 },
+ { name: 'spine', parent: 'root', len: 15 },
+ { name: 'chest', parent: 'spine', len: 4 },
+ { name: 'neck', parent: 'chest', len: 6, ang: -0.35 },
+ { name: 'head', parent: 'neck', len: 5, ang: 0.3 },
+ { name: 'jawU', parent: 'head', x: 3.5, y: -0.5, len: 8, ang: 0.06 },
+ { name: 'jawL', parent: 'head', x: 3.5, y: 1.5, len: 7.5, ang: 0.32 },
+ { name: 'tail', parent: 'root', x: -4, y: -2, len: 10, ang: Math.PI * 0.72 },
+ { name: 'legFA.u', parent: 'root', x: 10, y: 2, len: 6, ang: 1.25 },
+ { name: 'legFA.f', parent: 'legFA.u', len: 6.5, ang: 0.25 },
+ { name: 'legFF.u', parent: 'root', x: 11.5, y: 2.5, len: 6, ang: 1.32 },
+ { name: 'legFF.f', parent: 'legFF.u', len: 6.5, ang: 0.18 },
+ { name: 'legBA.u', parent: 'root', x: -8, y: 2, len: 6, ang: 1.85 },
+ { name: 'legBA.f', parent: 'legBA.u', len: 6.5, ang: -0.25 },
+ { name: 'legBF.u', parent: 'root', x: -9.5, y: 2.5, len: 6, ang: 1.78 },
+ { name: 'legBF.f', parent: 'legBF.u', len: 6.5, ang: -0.18 },
+])
+const CLIP_HOUND_RUN = { dur: 0.5, loop: true, tracks: {
+ 'root.y': [[0, 0], [0.25, -2.2], [0.5, 0], [0.75, -2.2], [1, 0]],
+ spine: [[0, -0.05], [0.5, 0.05], [1, -0.05]],
+ 'legFA.u': [[0, -0.6], [0.5, 0.55], [1, -0.6]],
+ 'legFA.f': [[0, 0.5], [0.5, -0.15], [1, 0.5]],
+ 'legFF.u': [[0, -0.5], [0.5, 0.6], [1, -0.5]],
+ 'legFF.f': [[0, 0.42], [0.5, -0.2], [1, 0.42]],
+ 'legBA.u': [[0, 0.5], [0.5, -0.55], [1, 0.5]],
+ 'legBA.f': [[0, -0.2], [0.5, 0.45], [1, -0.2]],
+ 'legBF.u': [[0, 0.6], [0.5, -0.5], [1, 0.6]],
+ 'legBF.f': [[0, -0.15], [0.5, 0.5], [1, -0.15]],
+ tail: [[0, 0.25], [0.5, -0.25], [1, 0.25]],
+ neck: [[0, -0.06], [0.5, 0.06], [1, -0.06]],
+} }
+const CLIP_HOUND_BITE = { dur: 1, loop: false, tracks: {
+ 'root.x': [[0, 2], [0.45, 8, 'out'], [0.7, 5], [1, 0]],
+ neck: [[0, -0.2], [0.45, 0.3, 'out'], [1, 0]],
+ jawU: [[0, -0.45], [0.3, -0.62, 'out'], [0.5, -0.06, 'in'], [1, 0]],
+ jawL: [[0, 0.45], [0.3, 0.66, 'out'], [0.5, 0.14, 'in'], [1, 0.32]],
+} }
+// 咬击进度:m.cd 从 1.15 开始倒数,前 0.5 秒是扑咬演出
+function biteT(m) {
+ return m.cd > 0.6 ? (1.15 - m.cd) / 0.55 : 0
+}
+
+// —— 猪人(两足胖子)——
+const PIG_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -13 },
+ { name: 'spine', parent: 'root', len: 8, ang: -Math.PI / 2 + 0.35 },
+ { name: 'chest', parent: 'spine', len: 5 },
+ { name: 'neck', parent: 'chest', len: 3, ang: 0.3 },
+ { name: 'head', parent: 'neck', len: 4, ang: 0.25 },
+ { name: 'armA', parent: 'chest', x: -1.5, y: 2, len: 7, ang: 1.4 },
+ { name: 'armF', parent: 'chest', x: 2, y: 2, len: 7, ang: 1.22 },
+ { name: 'legA.u', parent: 'root', x: -3.5, len: 7, ang: 1.7 },
+ { name: 'legA.f', parent: 'legA.u', len: 6, ang: -0.12 },
+ { name: 'legF.u', parent: 'root', x: 3.5, len: 7, ang: 1.44 },
+ { name: 'legF.f', parent: 'legF.u', len: 6, ang: 0.08 },
+])
+const CLIP_PIG_WALK = { dur: 0.7, loop: true, tracks: {
+ 'root.y': [[0, 0.8], [0.25, -0.6], [0.5, 0.8], [0.75, -0.6], [1, 0.8]],
+ 'root.rot': [[0, -0.05], [0.5, 0.05], [1, -0.05]],
+ 'legF.u': [[0, -0.45], [0.5, 0.4], [1, -0.45]],
+ 'legF.f': [[0, 0.12], [0.3, 0.4], [0.5, 0.55], [0.75, 0.85], [1, 0.12]],
+ 'legA.u': [[0, 0.4], [0.5, -0.45], [1, 0.4]],
+ 'legA.f': [[0, 0.55], [0.25, 0.85], [0.5, 0.12], [0.7, 0.4], [1, 0.55]],
+ 'armF': [[0, 0.35], [0.5, -0.4], [1, 0.35]],
+ 'armA': [[0, -0.4], [0.5, 0.35], [1, -0.4]],
+ head: [[0, 0.04], [0.5, -0.04], [1, 0.04]],
+} }
+const CLIP_PIG_BITE = { dur: 1, loop: false, tracks: {
+ 'root.x': [[0, 2], [0.45, 7, 'out'], [0.7, 4], [1, 0]],
+ spine: [[0, -0.08], [0.45, 0.22, 'out'], [1, 0]],
+ head: [[0, -0.15], [0.45, 0.3, 'out'], [1, 0]],
+ 'armF': [[0, 0.3], [0.45, -0.7, 'out'], [1, 0]],
+} }
+
+// —— 皮弗娄牛(四足,低头吃草)——
+const BEEF_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -16 },
+ { name: 'spine', parent: 'root', len: 20 },
+ { name: 'neck', parent: 'spine', len: 8, ang: 0.5 },
+ { name: 'head', parent: 'neck', len: 7, ang: 0.18 },
+ { name: 'tail', parent: 'root', x: -8, y: -4, len: 12, ang: Math.PI * 0.68 },
+ { name: 'legFA.u', parent: 'root', x: 14, y: 3, len: 8, ang: 1.4 },
+ { name: 'legFA.f', parent: 'legFA.u', len: 8, ang: 0.12 },
+ { name: 'legFF.u', parent: 'root', x: 15.5, y: 3.5, len: 8, ang: 1.46 },
+ { name: 'legFF.f', parent: 'legFF.u', len: 8, ang: 0.08 },
+ { name: 'legBA.u', parent: 'root', x: -12, y: 3, len: 8, ang: 1.66 },
+ { name: 'legBA.f', parent: 'legBA.u', len: 8, ang: -0.1 },
+ { name: 'legBF.u', parent: 'root', x: -13.5, y: 3.5, len: 8, ang: 1.6 },
+ { name: 'legBF.f', parent: 'legBF.u', len: 8, ang: -0.06 },
+])
+const CLIP_BEEF_WALK = { dur: 1.1, loop: true, tracks: {
+ 'root.y': [[0, 0.6], [0.25, -0.5], [0.5, 0.6], [0.75, -0.5], [1, 0.6]],
+ 'legFA.u': [[0, -0.35], [0.5, 0.32], [1, -0.35]],
+ 'legFA.f': [[0, 0.3], [0.5, -0.1], [1, 0.3]],
+ 'legFF.u': [[0, -0.28], [0.5, 0.36], [1, -0.28]],
+ 'legFF.f': [[0, 0.26], [0.5, -0.14], [1, 0.26]],
+ 'legBA.u': [[0, 0.32], [0.5, -0.35], [1, 0.32]],
+ 'legBA.f': [[0, -0.12], [0.5, 0.28], [1, -0.12]],
+ 'legBF.u': [[0, 0.36], [0.5, -0.28], [1, 0.36]],
+ 'legBF.f': [[0, -0.1], [0.5, 0.26], [1, -0.1]],
+ tail: [[0, 0.2], [0.5, -0.2], [1, 0.2]],
+} }
+const CLIP_BEEF_GRAZE = { dur: 6, loop: true, tracks: {
+ neck: [[0, 0], [0.2, 0], [0.35, 0.75, 'inOut'], [0.55, 0.8], [0.7, 0, 'inOut'], [1, 0]],
+ head: [[0, 0], [0.35, 0.3], [0.55, 0.32], [0.7, 0], [1, 0]],
+} }
+
+// —— 青蛙(蹲跳)——
+const FROG_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -8 },
+ { name: 'spine', parent: 'root', len: 8, ang: -Math.PI / 2 + 0.5 },
+ { name: 'head', parent: 'spine', len: 5 },
+ { name: 'legA.u', parent: 'root', x: -3, y: 3, len: 7, ang: 2.3 },
+ { name: 'legA.f', parent: 'legA.u', len: 7, ang: -1.1 },
+ { name: 'legF.u', parent: 'root', x: 3, y: 3, len: 7, ang: 2.1 },
+ { name: 'legF.f', parent: 'legF.u', len: 7, ang: -1.0 },
+ { name: 'armA', parent: 'spine', x: -3.5, y: 4, len: 5, ang: 1.15 },
+ { name: 'armF', parent: 'spine', x: 3.5, y: 4, len: 5, ang: 1.0 },
+])
+const CLIP_FROG_HOP = { dur: 0.55, loop: true, tracks: {
+ 'root.y': [[0, 1.5], [0.3, -7, 'out'], [0.7, -7], [0.88, 1.5, 'in'], [1, 1.5]],
+ 'root.sy': [[0, 0.94], [0.18, 1.1], [0.5, 1], [0.88, 0.82, 'in'], [1, 0.94]],
+ 'legA.u': [[0, 2.6], [0.25, 1.75, 'out'], [0.8, 1.7], [1, 2.6, 'in']],
+ 'legA.f': [[0, -1.35], [0.25, -0.25, 'out'], [0.8, -0.3], [1, -1.35, 'in']],
+ 'legF.u': [[0, 2.45], [0.25, 1.65, 'out'], [0.8, 1.6], [1, 2.45, 'in']],
+ 'legF.f': [[0, -1.28], [0.25, -0.2, 'out'], [0.8, -0.25], [1, -1.28, 'in']],
+ 'armA': [[0, 1.3], [0.25, 0.9], [1, 1.3, 'in']],
+ 'armF': [[0, 1.15], [0.25, 0.8], [1, 1.15, 'in']],
+ spine: [[0, 0.12], [0.3, -0.1], [1, 0.12, 'in']],
+} }
+const CLIP_FROG_BITE = { dur: 1, loop: false, tracks: {
+ 'root.x': [[0, 1.5], [0.4, 7, 'out'], [0.65, 4], [1, 0]],
+ 'root.y': [[0, 0], [0.4, -3, 'out'], [1, 0]],
+ spine: [[0, -0.1], [0.4, 0.24, 'out'], [1, 0]],
+} }
+
+// —— 树精(大体型双足,状态机 chase/windup/smash/recover)——
+const TREE_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -14 },
+ { name: 'trunk', parent: 'root', len: 34, ang: -Math.PI / 2 },
+ { name: 'chest', parent: 'trunk', len: 26 },
+ { name: 'head', parent: 'chest', len: 10, ang: -0.15 },
+ { name: 'armA.u', parent: 'chest', x: -13, len: 18, ang: 2.2 },
+ { name: 'armA.f', parent: 'armA.u', len: 16, ang: 0.35 },
+ { name: 'armF.u', parent: 'chest', x: 13, len: 18, ang: 0.95 },
+ { name: 'armF.f', parent: 'armF.u', len: 16, ang: -0.2 },
+ { name: 'legA.u', parent: 'root', x: -9, len: 12, ang: 1.75 },
+ { name: 'legA.f', parent: 'legA.u', len: 11, ang: -0.1 },
+ { name: 'legF.u', parent: 'root', x: 9, len: 12, ang: 1.4 },
+ { name: 'legF.f', parent: 'legF.u', len: 11, ang: 0.06 },
+])
+const CLIP_TREE_WALK = { dur: 1.6, loop: true, tracks: {
+ 'root.y': [[0, 0], [0.25, -2.4], [0.5, 0], [0.75, -2.4], [1, 0]],
+ trunk: [[0, -0.03], [0.5, 0.03], [1, -0.03]],
+ 'legF.u': [[0, -0.3], [0.5, 0.28], [1, -0.3]],
+ 'legF.f': [[0, 0.1], [0.5, 0.35], [1, 0.1]],
+ 'legA.u': [[0, 0.28], [0.5, -0.3], [1, 0.28]],
+ 'legA.f': [[0, 0.35], [0.5, 0.1], [1, 0.35]],
+ 'armF.u': [[0, 0.12], [0.5, -0.12], [1, 0.12]],
+ 'armA.u': [[0, -0.12], [0.5, 0.12], [1, -0.12]],
+} }
+const CLIP_TREE_WINDUP = { dur: 0.55, loop: false, tracks: {
+ 'armF.u': [[0, 0.95], [1, -1.35, 'inOut']],
+ 'armF.f': [[0, -0.2], [0.5, -0.45], [1, -0.55]],
+ 'armA.u': [[0, 2.2], [1, -1.0, 'inOut']],
+ trunk: [[0, 0], [1, -0.16, 'inOut']],
+ 'root.y': [[0, 0], [1, -2.5, 'inOut']],
+ head: [[0, 0], [1, -0.2, 'inOut']],
+} }
+const CLIP_TREE_SMASH = { dur: 0.35, loop: false, tracks: {
+ 'armF.u': [[0, -1.35], [0.6, 1.35, 'in'], [1, 1.5, 'out']],
+ 'armF.f': [[0, -0.55], [0.6, 0.1], [1, 0.15]],
+ 'armA.u': [[0, -1.0], [0.6, 1.1, 'in'], [1, 1.25, 'out']],
+ trunk: [[0, -0.16], [0.6, 0.26, 'in'], [1, 0.2, 'out']],
+ 'root.y': [[0, -2.5], [0.6, 3, 'in'], [1, 2, 'out']],
+ head: [[0, -0.2], [0.6, 0.3, 'in'], [1, 0.22, 'out']],
+} }
+const CLIP_TREE_RECOVER = { dur: 0.8, loop: false, tracks: {
+ 'armF.u': [[0, 1.5], [1, 0.95, 'inOut']],
+ 'armA.u': [[0, 1.25], [1, 2.2, 'inOut']],
+ trunk: [[0, 0.2], [1, 0, 'inOut']],
+ 'root.y': [[0, 2], [1, 0, 'inOut']],
+ head: [[0, 0.22], [1, 0, 'inOut']],
+} }
+
+// —— 独眼巨鹿(冬季 Boss,状态机同树精)——
+const CLOPS_SKEL = defineSkeleton([
+ { name: 'root', x: 0, y: -30 },
+ { name: 'trunk', parent: 'root', len: 36, ang: -Math.PI / 2 },
+ { name: 'chest', parent: 'trunk', len: 26 },
+ { name: 'head', parent: 'chest', len: 16, ang: -0.1 },
+ { name: 'armA.u', parent: 'chest', x: -24, len: 24, ang: 2.0 },
+ { name: 'armA.f', parent: 'armA.u', len: 22, ang: 0.3 },
+ { name: 'armF.u', parent: 'chest', x: 24, len: 24, ang: 1.1 },
+ { name: 'armF.f', parent: 'armF.u', len: 22, ang: -0.22 },
+ { name: 'legA.u', parent: 'root', x: -14, len: 20, ang: 1.72 },
+ { name: 'legA.f', parent: 'legA.u', len: 16, ang: -0.08 },
+ { name: 'legF.u', parent: 'root', x: 14, len: 20, ang: 1.42 },
+ { name: 'legF.f', parent: 'legF.u', len: 16, ang: 0.05 },
+])
+const CLIP_CLOPS_WALK = { dur: 2, loop: true, tracks: {
+ 'root.y': [[0, 0], [0.25, -3], [0.5, 0], [0.75, -3], [1, 0]],
+ trunk: [[0, -0.02], [0.5, 0.02], [1, -0.02]],
+ 'legF.u': [[0, -0.26], [0.5, 0.24], [1, -0.26]],
+ 'legF.f': [[0, 0.08], [0.5, 0.3], [1, 0.08]],
+ 'legA.u': [[0, 0.24], [0.5, -0.26], [1, 0.24]],
+ 'legA.f': [[0, 0.3], [0.5, 0.08], [1, 0.3]],
+ 'armF.u': [[0, 0.1], [0.5, -0.1], [1, 0.1]],
+ 'armA.u': [[0, -0.1], [0.5, 0.1], [1, -0.1]],
+ head: [[0, 0.03], [0.5, -0.03], [1, 0.03]],
+} }
+const CLIP_CLOPS_WINDUP = { dur: 0.8, loop: false, tracks: {
+ 'armF.u': [[0, 1.1], [1, -1.5, 'inOut']],
+ 'armF.f': [[0, -0.22], [0.5, -0.5], [1, -0.6]],
+ 'armA.u': [[0, 2.0], [1, -1.1, 'inOut']],
+ trunk: [[0, 0], [1, -0.14, 'inOut']],
+ 'root.y': [[0, 0], [1, -4, 'inOut']],
+ head: [[0, 0], [1, -0.18, 'inOut']],
+} }
+const CLIP_CLOPS_SMASH = { dur: 0.4, loop: false, tracks: {
+ 'armF.u': [[0, -1.5], [0.6, 1.4, 'in'], [1, 1.55, 'out']],
+ 'armF.f': [[0, -0.6], [0.6, 0.1], [1, 0.15]],
+ 'armA.u': [[0, -1.1], [0.6, 1.15, 'in'], [1, 1.3, 'out']],
+ trunk: [[0, -0.14], [0.6, 0.24, 'in'], [1, 0.18, 'out']],
+ 'root.y': [[0, -4], [0.6, 4, 'in'], [1, 2.5, 'out']],
+ head: [[0, -0.18], [0.6, 0.28, 'in'], [1, 0.2, 'out']],
+} }
+const CLIP_CLOPS_RECOVER = { dur: 1.1, loop: false, tracks: {
+ 'armF.u': [[0, 1.55], [1, 1.1, 'inOut']],
+ 'armA.u': [[0, 1.3], [1, 2.0, 'inOut']],
+ trunk: [[0, 0.18], [1, 0, 'inOut']],
+ 'root.y': [[0, 2.5], [1, 0, 'inOut']],
+ head: [[0, 0.2], [1, 0, 'inOut']],
+} }
+
+// —— 蜘蛛(体节 + 8 足程序摆动)——
+const SPIDER_SKEL = defineSkeleton([
+ { name: 'body', x: 0, y: -8 },
+ { name: 'abd', parent: 'body', x: -3.5 },
+ { name: 'head', parent: 'body', x: 7 },
+])
+// 8 足:[挂点x, 挂点y, 上段静息角, 下段相对角, 上段长, 下段长]
+const SPIDER_LEGS = [
+ [-2, -2, 2.35, 0.55, 7, 8],
+ [0, -3, 1.95, 0.5, 7.5, 8.5],
+ [2.5, -3.5, 1.5, 0.45, 7.5, 8.5],
+ [5, -3, 1.05, 0.4, 7, 8],
+]
+
function drawShadow(ctx, m, time) {
const wob = Math.sin(time * 2.2 + m.bob)
ctx.save()
- ctx.translate(0, wob * 4)
- ctx.globalAlpha = 0.85
- ctx.beginPath()
- ctx.moveTo(0, -30)
- ctx.bezierCurveTo(14 + wob * 3, -26, 17, -6, 12, 6)
- ctx.bezierCurveTo(9, 14, -9, 14, -12, 6)
- ctx.bezierCurveTo(-17, -6, -14 - wob * 3, -26, 0, -30)
- ctx.fillStyle = '#120a1e'
- ctx.fill()
+ ctx.translate(0, -14 + wob * 3)
+ ctx.globalAlpha = 0.9
+ const tq = qTime(time, 8)
+ // 底层雾影(更大更淡)
+ blob(ctx, ellipsePts(0, 0, 20, 24, 14), { fill: 'rgba(18,10,30,0.45)', amp: 1.4, seed: 7, tq })
+ // 主体
+ blob(ctx, [
+ [0, -16], [10 + wob * 2.4, -12], [14, -2], [10, 8],
+ [4 + wob * 1.5, 13], [-4 - wob * 1.5, 13], [-10, 8], [-14, -2], [-10 - wob * 2.4, -12],
+ ], { fill: '#120a1e', amp: 0.8, seed: 3, tq })
+ // 下缘飘带触须(4 条,随时间流动)
ctx.strokeStyle = '#120a1e'
- ctx.lineWidth = 4
- ;[-8, 0, 8].forEach((tx, i) => {
- const sw = Math.sin(time * 3 + i * 2 + m.bob) * 4
+ ctx.lineCap = 'round'
+ for (let i = 0; i < 4; i++) {
+ const bx = -9 + i * 6
+ const sw = Math.sin(time * 3.2 + i * 1.9 + m.bob) * 5
+ ctx.lineWidth = 4 - i * 0.4
ctx.beginPath()
- ctx.moveTo(tx, 8)
- ctx.quadraticCurveTo(tx + sw, 16, tx + sw * 1.4, 22)
+ ctx.moveTo(bx, 9)
+ ctx.quadraticCurveTo(bx + sw * 0.6, 16, bx + sw * 1.5, 22 + Math.sin(time * 2 + i) * 2)
ctx.stroke()
- })
- ctx.strokeStyle = '#e8e5f2'
+ }
+ // 两只斜切白瞳(微闪)
+ const flick = 0.7 + Math.sin(time * 7.3) * 0.3
+ ctx.strokeStyle = `rgba(238,235,248,${flick})`
ctx.lineWidth = 2.4
ctx.beginPath()
- ctx.moveTo(-8, -18); ctx.lineTo(-3, -14)
- ctx.moveTo(8, -18); ctx.lineTo(3, -14)
+ ctx.moveTo(-8.5, -12); ctx.lineTo(-3, -8)
+ ctx.moveTo(8.5, -12); ctx.lineTo(3, -8)
ctx.stroke()
+ // 幽蓝内芯
+ ctx.fillStyle = `rgba(90,70,150,${0.22 * flick})`
+ ctx.beginPath()
+ ctx.ellipse(0, -2, 7, 9, 0, 0, TAU)
+ ctx.fill()
ctx.globalAlpha = 1
ctx.restore()
}
@@ -972,98 +1503,234 @@ function drawShadow(ctx, m, time) {
function drawSpider(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
- shadow(ctx, 0, 8, 14, 4.6)
- const crawl = Math.sin(time * 10 + m.bob)
- ctx.strokeStyle = '#17151c'
- ctx.lineWidth = 2.2
- for (let s = -1; s <= 1; s += 2) {
- for (let i = 0; i < 4; i++) {
- const lift = Math.sin(time * 10 + m.bob + i * 1.6 + (s > 0 ? 0 : Math.PI)) * 2.4
- const bx = s * (6 + i * 1.2)
- const by = -4 + i * 2.4
- ctx.beginPath()
- ctx.moveTo(s * 3, -4)
- ctx.lineTo(bx + s * 4, by - 4 + lift)
- ctx.lineTo(bx + s * 8, 7 + lift * 0.4)
- ctx.stroke()
- }
+ const moving = !!m.moving
+ const atk = biteT(m)
+ shadow(ctx, 0, 8, 15, 4.8)
+ const tq = qTime(time, 8)
+ const gait = time * (moving ? 11 : 2.2) + (m.bob || 0)
+ const pose = {
+ 'root.y': Math.sin(gait * 0.5) * (moving ? 1.2 : 0.4) - atk * 1.5,
+ body: atk * -0.28,
+ head: atk * -0.12,
}
- ctx.beginPath(); ctx.ellipse(-4, -6, 10, 8, 0, 0, TAU); fsx(ctx, '#211f28')
- ctx.beginPath(); ctx.arc(8, -5 + crawl * 0.6, 5.6, 0, TAU); fsx(ctx, '#2b2934')
- ctx.strokeStyle = '#3a3744'
+ const P = computePose(SPIDER_SKEL, pose)
+ const bodyP = P.body
+ const legDraw = (far) => {
+ ctx.lineCap = 'round'
+ SPIDER_LEGS.forEach(([lx, ly, ua, fa, lu, lf], i) => {
+ const ph = gait + i * 1.7 + (far ? Math.PI : 0)
+ const swing = moving ? Math.sin(ph) * 0.3 : Math.sin(ph * 0.8) * 0.05
+ const lift = moving ? Math.max(0, Math.sin(ph + 0.6)) * 2.2 : 0
+ const front = i >= 2 ? 1 : 0
+ const uA = ua + swing + (front ? atk * -0.9 : 0)
+ const ax = bodyP.x + lx
+ const ay = bodyP.y + ly
+ const mx = ax + Math.cos(uA) * lu
+ const my = ay + Math.sin(uA) * lu - lift
+ const fA = uA + fa + Math.sin(ph + 1.2) * (moving ? 0.24 : 0.04)
+ const ex2 = mx + Math.cos(fA) * lf
+ const ey2 = Math.min(7, my + Math.sin(fA) * lf)
+ const col = far ? '#14121a' : '#26242e'
+ ctx.strokeStyle = col
+ ctx.lineWidth = far ? 2 : 2.4
+ ctx.beginPath()
+ ctx.moveTo(ax, ay)
+ ctx.lineTo(mx, my)
+ ctx.stroke()
+ ctx.lineWidth = far ? 1.5 : 1.9
+ ctx.beginPath()
+ ctx.moveTo(mx, my)
+ ctx.lineTo(ex2, ey2)
+ ctx.stroke()
+ ctx.fillStyle = col
+ ctx.beginPath()
+ ctx.arc(ex2, ey2, 1.05, 0, TAU)
+ ctx.fill()
+ })
+ }
+ legDraw(true)
+ // 腹部(大而垂)+ 沙漏纹 + 绒刺
+ const abd = P.abd
+ blob(ctx, ellipsePts(abd.x, abd.y - 1, 10.5, 8.4, 12), {
+ fill: linGrad(ctx, 'spabd', -10, -10, 8, 6, [[0, '#302d3a'], [1, '#1b1922']]),
+ stroke: OUT, lw: 1.8, amp: 0.5, seed: 11, tq,
+ })
+ ctx.strokeStyle = '#3f3c4c'
ctx.lineWidth = 1.2
- ;[[-10, -12], [-5, -14], [0, -13]].forEach(([hx, hy]) => {
- ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx - 1, hy - 3); ctx.stroke()
+ ;[[-9, -11], [-4, -13.5], [1, -12.5]].forEach(([hx, hy]) => {
+ ctx.beginPath()
+ ctx.moveTo(abd.x + hx * 0.62, abd.y + hy * 0.68)
+ ctx.lineTo(abd.x + hx * 0.78, abd.y + hy * 0.68 - 3.2)
+ ctx.stroke()
})
+ ctx.fillStyle = 'rgba(224,72,64,0.4)'
+ ctx.beginPath()
+ ctx.ellipse(abd.x - 4.5, abd.y - 3, 2.1, 3.1, 0.3, 0, TAU)
+ ctx.fill()
+ ctx.beginPath()
+ ctx.ellipse(abd.x - 1.8, abd.y + 1.6, 2.7, 1.9, 0.3, 0, TAU)
+ ctx.fill()
+ // 头胸部
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x, hd.y, 6.4, 5.4, 10), {
+ fill: linGrad(ctx, 'sphd', -6, -6, 7, 5, [[0, '#3a3744'], [1, '#24222c']]),
+ stroke: OUT, lw: 1.6, amp: 0.4, seed: 12, tq,
+ })
+ // 螯肢(咬合时张开)
+ ctx.strokeStyle = '#17151c'
+ ctx.lineWidth = 2.1
+ const fang = 0.25 + atk * 0.55
+ ctx.beginPath()
+ ctx.moveTo(hd.x + 4, hd.y + 1)
+ ctx.quadraticCurveTo(hd.x + 8, hd.y + 2 + fang, hd.x + 9, hd.y + 4.5)
+ ctx.stroke()
+ // 四只红瞳 + 高光
ctx.fillStyle = '#e04840'
- ;[[6.5, -7], [9.5, -7], [7, -4.4], [10, -4.6]].forEach(([ex, ey]) => {
- ctx.beginPath(); ctx.arc(ex, ey + crawl * 0.6, 1.1, 0, TAU); ctx.fill()
+ ;[[4.5, -2.5], [6.6, -2.2], [4.8, -0.2], [6.9, 0.1]].forEach(([ex, ey]) => {
+ ctx.beginPath()
+ ctx.arc(hd.x + ex, hd.y + ey, 1.05, 0, TAU)
+ ctx.fill()
})
+ ctx.fillStyle = 'rgba(255,220,200,0.55)'
+ ;[[4.3, -2.9], [6.4, -2.6]].forEach(([ex, ey]) => {
+ ctx.beginPath()
+ ctx.arc(hd.x + ex, hd.y + ey, 0.4, 0, TAU)
+ ctx.fill()
+ })
+ legDraw(false)
ctx.restore()
}
function drawHoundFig(ctx, m, time, ice) {
- const body = ice ? '#7fa8c8' : '#33313a'
- const dark = ice ? '#5a86a8' : '#26242a'
+ const body = ice ? '#7fa8c8' : '#37343f'
+ const bodyLo = ice ? '#5a86a8' : '#28252e'
+ const bodyHi = ice ? '#a8cce0' : '#4a4754'
const chest = ice ? '#dfe8f0' : '#8f8c96'
- const eye = ice ? '#7fd8f0' : '#e04840'
+ const eye = ice ? '#7fd8f0' : '#ffb43c'
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 9, 16, 5)
- const run = time * 14 + m.bob
- ctx.strokeStyle = dark
- ctx.lineWidth = 3.4
- ;[[-9, 0], [-5, Math.PI], [7, Math.PI * 0.9], [11, Math.PI * 1.9]].forEach(([lx, ph]) => {
- const sw = Math.sin(run + ph) * 5
+ const tq = qTime(time, 9)
+ let pose = clipPose(CLIP_HOUND_RUN, time * 1 + (m.bob || 0))
+ if (!m.moving) {
+ // 待机:呼吸起伏 + 尾巴慢摆
+ pose = {
+ 'root.y': Math.sin(time * 2.4 + m.bob) * 0.6,
+ tail: Math.sin(time * 2.1 + m.bob) * 0.12,
+ spine: Math.sin(time * 2.4) * 0.015,
+ }
+ }
+ const bt = biteT(m)
+ if (bt > 0) pose = addPose(pose, sampleClip(CLIP_HOUND_BITE, bt))
+ const P = computePose(HOUND_SKEL, pose)
+ // 腿通用画法(后腿 = 髋部大腿粗、小腿细)
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 4.4, 3.2, shade(body, k), OUT, 1.2)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 3.2, 2, shade(body, k - 0.1), OUT, 1.1)
+ ctx.fillStyle = shade(body, k - 0.15)
ctx.beginPath()
- ctx.moveTo(lx, -6)
- ctx.lineTo(lx + sw, 8)
+ ctx.ellipse(f2.ex, f2.ey + 0.4, 2.3, 1.5, 0, 0, TAU)
+ ctx.fill()
+ }
+ // 远侧腿先画
+ leg('legBA.u', 'legBA.f', -0.3)
+ leg('legFA.u', 'legFA.f', -0.3)
+ // 躯干(双段 Blob:臀 + 胸腹)
+ const r0 = P.root
+ const ch = P.chest
+ blob(ctx, ellipsePts(r0.x - 5, r0.y - 1, 9.5, 7.2, 11, -0.08), {
+ fill: linGrad(ctx, 'hnb' + (ice ? 1 : 0), -14, -18, 4, -2, [[0, bodyHi], [0.5, body], [1, bodyLo]]),
+ stroke: OUT, lw: 1.8, amp: 0.5, seed: 21, tq,
+ })
+ blob(ctx, ellipsePts((r0.x + ch.ex) / 2 + 2, r0.y - 1.5, 10.5, 7, 11, 0.02), {
+ fill: linGrad(ctx, 'hnc' + (ice ? 1 : 0), -4, -18, 14, -2, [[0, bodyHi], [0.5, body], [1, bodyLo]]),
+ stroke: OUT, lw: 1.8, amp: 0.5, seed: 22, tq,
+ })
+ // 胸口浅毛
+ ctx.fillStyle = chest
+ ctx.beginPath()
+ ctx.ellipse(ch.ex + 1, r0.y + 2.5, 4, 3, 0.2, 0, TAU)
+ ctx.fill()
+ // 背脊鬃毛
+ ctx.strokeStyle = bodyLo
+ ctx.lineWidth = 1.6
+ ;[-10, -5, 0, 5].forEach((sx, i) => {
+ ctx.beginPath()
+ ctx.moveTo(r0.x + sx, r0.y - 7 - Math.sin(i) * 0.6)
+ ctx.lineTo(r0.x + sx - 1.5, r0.y - 10.5)
ctx.stroke()
})
- ctx.beginPath(); ctx.ellipse(0, -9, 14, 7.4, -0.08, 0, TAU); fsx(ctx, body)
- ctx.beginPath(); ctx.ellipse(6, -6, 4.4, 3, -0.3, 0, TAU)
- ctx.fillStyle = chest
- ctx.fill()
- ctx.strokeStyle = dark
+ // 尾巴(弯钩 + 尾尖毛)
+ const tl = P.tail
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 4
+ ctx.beginPath()
+ ctx.moveTo(tl.x, tl.y)
+ ctx.quadraticCurveTo(tl.x - 5, tl.y - 4 + Math.sin(time * 3) * 1, tl.ex, tl.ey)
+ ctx.stroke()
+ ctx.strokeStyle = body
ctx.lineWidth = 2.6
ctx.beginPath()
- ctx.moveTo(-13, -11)
- ctx.quadraticCurveTo(-19, -15 + Math.sin(run) * 1.4, -21, -11)
+ ctx.moveTo(tl.x, tl.y)
+ ctx.quadraticCurveTo(tl.x - 5, tl.y - 4 + Math.sin(time * 3) * 1, tl.ex, tl.ey)
ctx.stroke()
- const jaw = (Math.sin(run * 0.7) + 1) * 0.16
- ctx.save()
- ctx.translate(13, -13)
- ctx.beginPath(); ctx.arc(0, 0, 6.2, 0, TAU); fsx(ctx, body)
- ctx.beginPath()
- ctx.moveTo(-4, -4); ctx.lineTo(-2, -10); ctx.lineTo(1, -5)
- ctx.closePath()
- fsx(ctx, dark, '#1d1409', 1.4)
- ctx.save()
- ctx.rotate(-jaw)
- ctx.beginPath()
- ctx.moveTo(2, -2); ctx.lineTo(13, 0); ctx.lineTo(3, 2.6)
- ctx.closePath()
- fsx(ctx, body, '#1d1409', 1.4)
+ // 近侧腿
+ leg('legBF.u', 'legBF.f', 0)
+ leg('legFF.u', 'legFF.f', 0)
+ // 颈鬃
+ const nk = P.neck
+ ctx.fillStyle = bodyLo
+ for (let i = 0; i < 3; i++) {
+ const t = 0.25 + i * 0.25
+ const nx = nk.x + (nk.ex - nk.x) * t
+ const ny = nk.y + (nk.ey - nk.y) * t
+ ctx.beginPath()
+ ctx.moveTo(nx - 1, ny - 3)
+ ctx.lineTo(nx - 3.5, ny - 7)
+ ctx.lineTo(nx + 1.5, ny - 3.5)
+ ctx.closePath()
+ ctx.fill()
+ }
+ // 头 + 耳 + 双颌
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x + 1, hd.y, 6.2, 5.4, 10, 0.1), {
+ fill: linGrad(ctx, 'hnhd' + (ice ? 1 : 0), hd.x - 5, hd.y - 6, hd.x + 7, hd.y + 5, [[0, bodyHi], [0.55, body], [1, bodyLo]]),
+ stroke: OUT, lw: 1.6, amp: 0.35, seed: 23, tq,
+ })
+ blob(ctx, [[hd.x - 2, hd.y - 4], [hd.x + 1, hd.y - 9.5], [hd.x + 3.5, hd.y - 3.5]], { fill: bodyLo, stroke: OUT, lw: 1.2, amp: 0.25, seed: 24, tq })
+ // 上颌 + 下颌(张合由咬击片段驱动)
+ const ju = P.jawU
+ taper(ctx, ju.x, ju.y, ju.ex, ju.ey, 4.6, 2.6, body, OUT, 1.3)
+ taper(ctx, P.jawL.x, P.jawL.y, P.jawL.ex, P.jawL.ey, 3.8, 2, bodyLo, OUT, 1.2)
+ // 牙
ctx.fillStyle = '#e8e5da'
+ ;[[ju.ex - 2, ju.ey + 1], [ju.ex - 5, ju.ey + 1.4]].forEach(([tx, ty]) => {
+ ctx.beginPath()
+ ctx.moveTo(tx, ty)
+ ctx.lineTo(tx + 1.2, ty + 2.6)
+ ctx.lineTo(tx + 2.4, ty)
+ ctx.closePath()
+ ctx.fill()
+ })
+ // 鼻 + 眼
+ ctx.fillStyle = '#1a1610'
ctx.beginPath()
- ctx.moveTo(8, 0.4); ctx.lineTo(9.6, 2.8); ctx.lineTo(11, 0.6)
- ctx.closePath()
+ ctx.arc(ju.ex + 0.8, ju.ey - 0.6, 1.5, 0, TAU)
ctx.fill()
- ctx.restore()
- ctx.save()
- ctx.rotate(jaw)
- ctx.beginPath()
- ctx.moveTo(2, 2); ctx.lineTo(11, 4.4); ctx.lineTo(3, 6)
- ctx.closePath()
- fsx(ctx, dark, '#1d1409', 1.4)
- ctx.restore()
ctx.fillStyle = eye
- ctx.beginPath(); ctx.arc(1, -2.6, 1.4, 0, TAU); ctx.fill()
- ctx.restore()
+ ctx.beginPath()
+ ctx.arc(hd.x + 2.5, hd.y - 1.6, 1.5, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = 'rgba(255,255,255,0.6)'
+ ctx.beginPath()
+ ctx.arc(hd.x + 2.9, hd.y - 2, 0.5, 0, TAU)
+ ctx.fill()
// 冰犬霜气
if (ice) {
ctx.fillStyle = 'rgba(200,236,248,0.5)'
- const p = (time * 1.2 + m.bob) % 1
+ const p = (time * 1.2 + (m.bob || 0)) % 1
ctx.beginPath()
ctx.arc(m.dir * 16, -18 - p * 8, 2.4 * (1 - p), 0, TAU)
ctx.fill()
@@ -1075,116 +1742,234 @@ function drawTreeguard(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 6, 30, 9)
- const breathe = Math.sin(time * 1.6) * 1.5
- ctx.strokeStyle = '#4a3421'
- ctx.lineWidth = 5
- ;[[-12, -4], [-4, 2], [6, 0], [13, -3]].forEach(([rx, ry]) => {
- ctx.beginPath()
- ctx.moveTo(rx * 0.5, -14)
- ctx.quadraticCurveTo(rx, ry - 6, rx + (rx > 0 ? 4 : -4), 4)
- ctx.stroke()
+ const tq = qTime(time, 7)
+ let pose = { 'root.y': Math.sin(time * 1.6) * 1.2, trunk: Math.sin(time * 1.3) * 0.02, 'armF.u': Math.sin(time * 1.6) * 0.06, 'armA.u': -Math.sin(time * 1.6) * 0.05 }
+ if (m.moving) pose = clipPose(CLIP_TREE_WALK, time + (m.bob || 0))
+ // 状态机演出:windup 抬臂 → smash 砸地 → recover 喘息
+ if (m.state === 'windup') pose = sampleClip(CLIP_TREE_WINDUP, 1 - Math.max(0, m.t) / 0.55)
+ else if (m.state === 'smash') pose = sampleClip(CLIP_TREE_SMASH, 1 - Math.max(0, m.t) / 0.35)
+ else if (m.state === 'recover') pose = sampleClip(CLIP_TREE_RECOVER, 1 - Math.max(0, m.t) / 0.8)
+ const P = computePose(TREE_SKEL, pose)
+ const bark = '#5b4128'
+ const barkLo = '#43301d'
+ const barkHi = '#75583a'
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 9, 7, shade(bark, k), OUT, 1.8)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 7, 5.5, shade(bark, k), OUT, 1.7)
+ // 根须脚
+ ctx.strokeStyle = shade(bark, k - 0.12)
+ ctx.lineWidth = 3.4
+ ctx.lineCap = 'round'
+ ;[-4, 0, 4].forEach((dx) => {
+ ctx.beginPath()
+ ctx.moveTo(f2.ex, f2.ey)
+ ctx.quadraticCurveTo(f2.ex + dx, f2.ey + 2, f2.ex + dx * 2.2, f2.ey + 3.4)
+ ctx.stroke()
+ })
+ }
+ leg('legA.u', 'legA.f', -0.2)
+ // 主干躯干
+ const tk = P.trunk
+ const ch = P.chest
+ blob(ctx, [
+ [tk.x - 17, tk.y + 2], [tk.x - 14, tk.y - 30], [ch.x - 16, ch.y - 20],
+ [ch.ex - 15, ch.ey - 2], [ch.ex + 15, ch.ey - 2], [ch.x + 16, ch.y - 20],
+ [tk.x + 14, tk.y - 30], [tk.x + 17, tk.y + 2],
+ ], {
+ fill: linGrad(ctx, 'trg', tk.x - 16, tk.y - 60, tk.x + 16, tk.y + 4, [[0, barkHi], [0.5, bark], [1, barkLo]]),
+ stroke: OUT, lw: 2.4, amp: 0.7, seed: 31, tq,
})
- ctx.beginPath()
- ctx.moveTo(-17, -12); ctx.lineTo(-14, -74 + breathe); ctx.lineTo(14, -74 + breathe); ctx.lineTo(17, -12)
- ctx.closePath()
- fsx(ctx, '#5b4128', '#1d1409', 2.6)
- ctx.strokeStyle = '#43301d'
+ // 树皮沟壑
+ ctx.strokeStyle = barkLo
ctx.lineWidth = 2
;[[-8, -20, -10, -58], [0, -16, 2, -66], [9, -22, 8, -54]].forEach(([x1, y1, x2, y2]) => {
ctx.beginPath()
- ctx.moveTo(x1, y1)
- ctx.quadraticCurveTo((x1 + x2) / 2 + 3, (y1 + y2) / 2, x2, y2 + breathe)
+ ctx.moveTo(tk.x + x1, tk.y + y1 + 14)
+ ctx.quadraticCurveTo(tk.x + (x1 + x2) / 2 + 3, tk.y + (y1 + y2) / 2 + 14, tk.x + x2, tk.y + y2 + 14 + Math.sin(time * 1.6) * 1.5)
ctx.stroke()
})
- const crown = (yTop, w, col) => {
- ctx.beginPath()
- ctx.moveTo(-w, yTop + 14)
- for (let i = 0; i <= 4; i++) {
- ctx.lineTo(-w + (i * 2 * w) / 4, yTop + 14 - (i % 2 === 0 ? 0 : 7))
- }
- ctx.lineTo(0, yTop)
- ctx.closePath()
- fsx(ctx, col, '#1d1409', 2)
+ // 树瘤节疤
+ ctx.fillStyle = barkLo
+ ctx.beginPath()
+ ctx.ellipse(tk.x + 10, tk.y - 34, 3, 4.2, 0.3, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = barkHi
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ ctx.ellipse(tk.x + 10, tk.y - 34, 3, 4.2, 0.3, 0, TAU)
+ ctx.stroke()
+ leg('legF.u', 'legF.f', 0)
+ // 手臂(粗枝 + 叉状指)
+ const arm = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 7.5, 5.5, shade(bark, k), OUT, 1.8)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 5.5, 4, shade(bark, k), OUT, 1.7)
+ ctx.strokeStyle = shade(bark, k - 0.1)
+ ctx.lineWidth = 3
+ const dirx = f2.ex - f2.x
+ const diry = f2.ey - f2.y
+ const dl = Math.hypot(dirx, diry) || 1
+ const ux = dirx / dl
+ const uy = diry / dl
+ ;[-0.5, 0, 0.5].forEach((sp) => {
+ ctx.beginPath()
+ ctx.moveTo(f2.ex, f2.ey)
+ ctx.lineTo(f2.ex + (ux - uy * sp) * 8, f2.ey + (uy + ux * sp) * 8)
+ ctx.stroke()
+ })
}
- crown(-102 + breathe, 22, '#27411f')
- crown(-88 + breathe, 27, '#1f3619')
- const armAng = m.state === 'windup' ? -2.2 : m.state === 'smash' ? 0.7 : -0.5 + Math.sin(time * 1.6) * 0.12
- ;[1, -1].forEach((side) => {
- ctx.save()
- ctx.translate(side * 15, -58 + breathe)
- ctx.rotate(side === 1 ? armAng : -armAng * 0.55 - 0.4)
- ctx.strokeStyle = '#5b4128'
- ctx.lineWidth = 7
- ctx.beginPath()
- ctx.moveTo(0, 0)
- ctx.quadraticCurveTo(side * 16, 10, side * 26, 26)
- ctx.stroke()
- ctx.lineWidth = 4
- ctx.beginPath()
- ctx.moveTo(side * 26, 26); ctx.lineTo(side * 34, 34)
- ctx.moveTo(side * 26, 26); ctx.lineTo(side * 22, 38)
- ctx.stroke()
- ctx.restore()
- })
+ arm('armA.u', 'armA.f', -0.18)
+ arm('armF.u', 'armF.f', 0)
+ // 树冠(两层锯齿,挂头/胸顶)
+ const crown = (cx, cy, w, h, col, seed) => {
+ const pts = []
+ const n = 7
+ for (let i = 0; i <= n; i++) {
+ pts.push([cx - w + (i * 2 * w) / n, cy + h * (i % 2 === 0 ? 0.25 : 1) * 0.4])
+ }
+ pts.push([cx, cy - h])
+ blob(ctx, pts, { fill: col, stroke: OUT, lw: 2, amp: 1, seed, tq })
+ }
+ const hd = P.head
+ crown(hd.x, hd.y - 16, 24, 20, '#27411f', 41)
+ crown(ch.x, ch.y - 34, 29, 16, '#1f3619', 42)
+ // 发光双眼(windup 时更亮)
const glow = m.state === 'windup' ? 1 : 0.6 + Math.sin(time * 4) * 0.2
ctx.fillStyle = `rgba(224,64,48,${glow})`
ctx.beginPath()
- ctx.arc(-6, -62 + breathe, 3, 0, TAU)
- ctx.arc(7, -62 + breathe, 3, 0, TAU)
+ ctx.arc(hd.x - 5, hd.y - 2, 3, 0, TAU)
+ ctx.arc(hd.x + 8, hd.y - 2, 3, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = `rgba(255,200,160,${glow * 0.5})`
+ ctx.beginPath()
+ ctx.arc(hd.x - 5.8, hd.y - 2.8, 1, 0, TAU)
+ ctx.arc(hd.x + 7.2, hd.y - 2.8, 1, 0, TAU)
ctx.fill()
ctx.restore()
}
-// 猪人:粉色双足胖子,步行摆腿;结盟时眼神朝向主人
+// 猪人:粉色双足胖子,步行摇摆 + 咬人前扑;结盟时头顶爱心
function drawPig(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
- shadow(ctx, 0, 8, 14, 5)
- const walkSwing = m.moving ? Math.sin(time * 9 + m.bob) : 0
- // 腿
- ctx.strokeStyle = '#c98a80'
- ctx.lineWidth = 5
- ctx.beginPath()
- ctx.moveTo(-5, -6); ctx.lineTo(-5 + walkSwing * 4, 7)
- ctx.moveTo(5, -6); ctx.lineTo(5 - walkSwing * 4, 7)
- ctx.stroke()
- // 圆身
- ctx.beginPath(); ctx.ellipse(0, -16, 13, 12, 0, 0, TAU); fsx(ctx, '#e8a8a0')
- ctx.beginPath(); ctx.ellipse(0, -11, 8, 6, 0, 0, TAU)
- ctx.fillStyle = '#f2c4bc'
- ctx.fill()
- // 手
- ctx.strokeStyle = '#e8a8a0'
- ctx.lineWidth = 4
- ctx.beginPath()
- ctx.moveTo(-10, -18); ctx.lineTo(-14 - walkSwing * 2, -10)
- ctx.moveTo(10, -18); ctx.lineTo(14 + walkSwing * 2, -10)
- ctx.stroke()
- // 头
- ctx.beginPath(); ctx.arc(2, -32, 9, 0, TAU); fsx(ctx, '#e8a8a0')
- // 耳朵
- ;[-1, 1].forEach((s) => {
+ shadow(ctx, 0, 8, 15, 5)
+ const tq = qTime(time, 8)
+ let pose = {
+ 'root.y': Math.sin(time * 2.2 + m.bob) * 0.7,
+ spine: Math.sin(time * 2.2 + m.bob) * 0.02,
+ head: Math.sin(time * 1.7 + m.bob) * 0.05,
+ }
+ if (m.moving) pose = clipPose(CLIP_PIG_WALK, time * 1.1 + (m.bob || 0))
+ const bt = biteT(m)
+ if (bt > 0) pose = addPose(pose, sampleClip(CLIP_PIG_BITE, bt))
+ const P = computePose(PIG_SKEL, pose)
+ const pink = '#e8a8a0'
+ const pinkLo = '#c9837a'
+ const pinkHi = '#f4c4bc'
+ // 腿(猪蹄)
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 5.4, 4, shade(pink, k - 0.06), OUT, 1.3)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 4, 3, shade(pink, k - 0.1), OUT, 1.2)
+ ctx.fillStyle = shade('#d88a80', k)
ctx.beginPath()
- ctx.moveTo(2 + s * 6, -39)
- ctx.lineTo(2 + s * 10, -45)
- ctx.lineTo(2 + s * 12, -38)
- ctx.closePath()
- fsx(ctx, '#d88a80', '#1d1409', 1.4)
+ ctx.ellipse(f2.ex + 0.5, f2.ey + 0.5, 2.6, 1.6, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1.1
+ ctx.stroke()
+ }
+ leg('legA.u', 'legA.f', -0.25)
+ // 手臂(小短手)
+ const arm = (name, k) => {
+ const a = P[name]
+ taper(ctx, a.x, a.y, a.ex, a.ey, 4, 2.8, shade(pink, k), OUT, 1.2)
+ ctx.fillStyle = shade(pink, k - 0.06)
+ ctx.beginPath()
+ ctx.arc(a.ex, a.ey, 2, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1.1
+ ctx.stroke()
+ }
+ arm('armA', -0.25)
+ // 圆肚子(大椭圆 + 浅肚皮)
+ const r0 = P.root
+ blob(ctx, ellipsePts(r0.x, r0.y - 3, 13.5, 12, 13), {
+ fill: linGrad(ctx, 'pgb', r0.x - 12, r0.y - 16, r0.x + 10, r0.y + 8, [[0, pinkHi], [0.5, pink], [1, pinkLo]]),
+ stroke: OUT, lw: 1.8, amp: 0.5, seed: 51, tq,
})
- // 猪鼻 + 眼
- ctx.beginPath(); ctx.ellipse(8, -30, 4.4, 3.4, 0, 0, TAU); fsx(ctx, '#d88a80', '#1d1409', 1.6)
- ctx.fillStyle = '#1d1409'
+ ctx.fillStyle = 'rgba(255,228,222,0.7)'
ctx.beginPath()
- ctx.arc(7, -30.5, 0.8, 0, TAU)
- ctx.arc(9.5, -30.5, 0.8, 0, TAU)
- ctx.arc(4, -35, 1.4, 0, TAU)
+ ctx.ellipse(r0.x + 1, r0.y + 2, 7.5, 5.5, 0, 0, TAU)
ctx.fill()
+ // 肚脐点
+ ctx.strokeStyle = pinkLo
+ ctx.lineWidth = 1
+ ctx.beginPath()
+ ctx.arc(r0.x + 2, r0.y + 1, 1.6, 0.4, 2.2)
+ ctx.stroke()
+ leg('legF.u', 'legF.f', 0)
+ arm('armF', 0)
+ // 头(大猪头:双叠圆)
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x + 1, hd.y, 9, 8.4, 12, 0.05), {
+ fill: linGrad(ctx, 'pgh', hd.x - 8, hd.y - 9, hd.x + 8, hd.y + 8, [[0, pinkHi], [0.5, pink], [1, pinkLo]]),
+ stroke: OUT, lw: 1.7, amp: 0.45, seed: 52, tq,
+ })
+ // 耳朵( floppy 三角)
+ ;[[hd.x - 2.5, hd.y - 7, -0.5], [hd.x + 5, hd.y - 7.5, 0.3]].forEach(([ex, ey, rot]) => {
+ ctx.save()
+ ctx.translate(ex, ey)
+ ctx.rotate(rot + Math.sin(time * 2.4 + m.bob) * 0.06)
+ blob(ctx, [[-2.5, 1], [0.5, -6], [4, 0.5]], { fill: pinkLo, stroke: OUT, lw: 1.3, amp: 0.25, seed: 53, tq })
+ ctx.restore()
+ })
+ // 猪鼻
+ const snx = hd.x + 7.5
+ const sny = hd.y + 1.5
+ ctx.beginPath()
+ ctx.ellipse(snx, sny, 4.4, 3.4, 0.1, 0, TAU)
+ fsx(ctx, '#d88a80', OUT, 1.5)
+ ctx.fillStyle = '#b06a60'
+ ctx.beginPath()
+ ctx.arc(snx - 1.2, sny, 0.9, 0, TAU)
+ ctx.arc(snx + 1.4, sny, 0.9, 0, TAU)
+ ctx.fill()
+ // 眼睛(咬人时眯眼)
+ const bt2 = biteT(m)
+ ctx.fillStyle = '#241a10'
+ if (bt2 > 0.3) {
+ ctx.strokeStyle = '#241a10'
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ ctx.moveTo(hd.x + 2, hd.y - 3.5); ctx.lineTo(hd.x + 4.4, hd.y - 2.6)
+ ctx.stroke()
+ } else {
+ ctx.beginPath()
+ ctx.arc(hd.x + 3, hd.y - 3, 1.4, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#fff'
+ ctx.beginPath()
+ ctx.arc(hd.x + 3.4, hd.y - 3.4, 0.5, 0, TAU)
+ ctx.fill()
+ }
+ // 尾卷
+ ctx.strokeStyle = pinkLo
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.arc(r0.x - 12, r0.y - 6 + Math.sin(time * 3 + m.bob) * 1.2, 2.6, -0.6, 4.2)
+ ctx.stroke()
// 结盟标记:头顶小红心
if (m.loyalT > 0) {
ctx.fillStyle = 'rgba(220,80,90,0.9)'
const s = 1 + Math.sin(time * 4) * 0.12
ctx.save()
- ctx.translate(2, -50)
+ ctx.translate(hd.x, hd.y - 16)
ctx.scale(s, s)
ctx.beginPath()
ctx.moveTo(0, 3)
@@ -1196,237 +1981,508 @@ function drawPig(ctx, m, time) {
ctx.restore()
}
-// 皮弗娄牛:棕色蓬毛大块头 + 弯角,低头吃草/走动
+// 皮弗娄牛:棕色蓬毛大块头 + 弯角 + 驼峰,闲时低头吃草,暴怒低头冲撞
function drawBeefalo(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 10, 24, 7)
- const walk = m.moving ? Math.sin(time * 6 + m.bob) : 0
- const graze = m.moving ? 0 : Math.sin(time * 0.8 + m.bob) * 2
- // 四腿
- ctx.strokeStyle = '#4a3828'
- ctx.lineWidth = 5
- ;[[-14, 0], [-7, Math.PI], [8, Math.PI * 0.8], [15, Math.PI * 1.8]].forEach(([lx, ph]) => {
- const sw = walk * Math.sin(ph + 1) * 4
+ const tq = qTime(time, 7)
+ const angry = (m.angryT || 0) > 0
+ let pose = clipPose(CLIP_BEEF_GRAZE, time * 1 + (m.bob || 0))
+ if (m.moving) pose = clipPose(CLIP_BEEF_WALK, time + (m.bob || 0))
+ if (angry) {
+ // 冲撞姿态:压低脖子前顶
+ pose = addPose(pose, { neck: 0.5, head: 0.25, spine: 0.05, tail: -0.3 })
+ }
+ const P = computePose(BEEF_SKEL, pose)
+ const fur = '#6b5340'
+ const furLo = '#54402e'
+ const furHi = '#82684f'
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 6.2, 4.4, shade(furLo, k), OUT, 1.4)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 4.4, 3, shade('#4a3828', k), OUT, 1.3)
+ ctx.fillStyle = shade('#3a2c1e', k)
ctx.beginPath()
- ctx.moveTo(lx, -10)
- ctx.lineTo(lx + sw, 9)
+ ctx.ellipse(f2.ex, f2.ey + 0.6, 3, 1.7, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1.1
ctx.stroke()
+ }
+ leg('legBA.u', 'legBA.f', -0.22)
+ leg('legFA.u', 'legFA.f', -0.22)
+ // 蓬毛躯干:臀 + 大肚 + 驼峰三团圆叠
+ const r0 = P.root
+ const sp = P.spine
+ blob(ctx, ellipsePts(r0.x - 7, r0.y - 2, 13, 11, 12), {
+ fill: linGrad(ctx, 'bf0', r0.x - 20, r0.y - 14, r0.x, r0.y + 8, [[0, furHi], [0.5, fur], [1, furLo]]),
+ stroke: OUT, lw: 2, amp: 0.8, seed: 61, tq,
})
- // 蓬毛躯干(几团圆叠出蓬松感)
- ;[[0, -20, 21, 15], [-12, -16, 13, 11], [12, -18, 12, 10]].forEach(([x, y, rx, ry]) => {
- ctx.beginPath()
- ctx.ellipse(x, y, rx, ry, 0, 0, TAU)
- fsx(ctx, '#6b5340')
+ blob(ctx, ellipsePts((r0.x + sp.ex) / 2, r0.y - 4, 15, 12, 13, 0.04), {
+ fill: linGrad(ctx, 'bf1', sp.x - 6, r0.y - 16, sp.x + 14, r0.y + 8, [[0, furHi], [0.5, fur], [1, furLo]]),
+ stroke: OUT, lw: 2, amp: 0.8, seed: 62, tq,
})
- // 毛发笔触
- ctx.strokeStyle = '#54402e'
+ blob(ctx, ellipsePts(sp.ex + 1, r0.y - 10, 9, 8, 10, 0.1), {
+ fill: linGrad(ctx, 'bf2', sp.ex - 8, r0.y - 18, sp.ex + 8, r0.y - 2, [[0, shade(fur, 0.14)], [1, furLo]]),
+ stroke: OUT, lw: 1.8, amp: 0.6, seed: 63, tq,
+ })
+ // 蓬毛笔触
+ ctx.strokeStyle = furLo
ctx.lineWidth = 1.6
- ;[[-16, -8], [-6, -6], [6, -7], [14, -9]].forEach(([x, y]) => {
+ ;[[-16, -8], [-6, -6], [6, -7], [15, -10]].forEach(([hx, hy]) => {
ctx.beginPath()
- ctx.moveTo(x, y)
- ctx.lineTo(x - 2, y + 6)
+ ctx.moveTo(r0.x + hx, r0.y + hy)
+ ctx.lineTo(r0.x + hx - 2.5, r0.y + hy + 6)
ctx.stroke()
})
- // 头(低垂)
- ctx.save()
- ctx.translate(20, -14 + graze)
- ctx.beginPath(); ctx.ellipse(2, 0, 9, 7.4, 0.2, 0, TAU); fsx(ctx, '#5c4634')
- // 弯角
+ leg('legBF.u', 'legBF.f', 0)
+ leg('legFF.u', 'legFF.f', 0)
+ // 头(低垂)+ 弯角 + 鼻环
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x + 2, hd.y, 9.5, 8, 11, 0.15), {
+ fill: linGrad(ctx, 'bfh', hd.x - 6, hd.y - 9, hd.x + 10, hd.y + 8, [[0, shade(fur, 0.08)], [1, furLo]]),
+ stroke: OUT, lw: 2, amp: 0.6, seed: 64, tq,
+ })
+ // 额毛
+ ctx.strokeStyle = furLo
+ ctx.lineWidth = 2.4
+ ctx.lineCap = 'round'
+ ;[-2, 1, 4].forEach((dx) => {
+ ctx.beginPath()
+ ctx.moveTo(hd.x + dx, hd.y - 7)
+ ctx.lineTo(hd.x + dx - 1, hd.y - 11)
+ ctx.stroke()
+ })
+ // 弯角(带环纹)
ctx.strokeStyle = '#d8c8a8'
+ ctx.lineWidth = 3.6
+ ctx.beginPath()
+ ctx.moveTo(hd.x + 2, hd.y - 6)
+ ctx.quadraticCurveTo(hd.x - 7, hd.y - 12, hd.x - 3, hd.y - 17)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(hd.x + 8, hd.y - 6)
+ ctx.quadraticCurveTo(hd.x + 16, hd.y - 10, hd.x + 12, hd.y - 16)
+ ctx.stroke()
+ ctx.strokeStyle = '#b8a888'
+ ctx.lineWidth = 1.2
+ ;[[-5.2, -9.6], [-4, -13.4], [11.4, -8.4], [12.4, -12]].forEach(([ox, oy]) => {
+ ctx.beginPath()
+ ctx.arc(hd.x + ox, hd.y + oy, 1.6, 0, TAU)
+ ctx.stroke()
+ })
+ // 鼻环 + 鼻孔 + 眼
+ ctx.strokeStyle = '#c9a03c'
+ ctx.lineWidth = 1.6
+ ctx.beginPath()
+ ctx.arc(hd.x + 10.5, hd.y + 3.5, 2.2, 0.4, 4.6)
+ ctx.stroke()
+ ctx.fillStyle = '#241a10'
+ ctx.beginPath()
+ ctx.arc(hd.x + 9, hd.y + 4.5, 1.2, 0, TAU)
+ ctx.fill()
+ ctx.beginPath()
+ ctx.arc(hd.x + 4.5, hd.y - 2.5, 1.5, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#fff'
+ ctx.beginPath()
+ ctx.arc(hd.x + 4.9, hd.y - 2.9, 0.5, 0, TAU)
+ ctx.fill()
+ // 尾巴(甩动 + 毛穗)
+ const tl = P.tail
+ ctx.strokeStyle = OUT
ctx.lineWidth = 3.4
ctx.beginPath()
- ctx.moveTo(-2, -6)
- ctx.quadraticCurveTo(-8, -12, -4, -16)
+ ctx.moveTo(tl.x, tl.y)
+ ctx.quadraticCurveTo(tl.x - 6, tl.y + 3, tl.ex, tl.ey)
ctx.stroke()
+ ctx.strokeStyle = furLo
+ ctx.lineWidth = 2.2
ctx.beginPath()
- ctx.moveTo(6, -5)
- ctx.quadraticCurveTo(12, -10, 9, -15)
+ ctx.moveTo(tl.x, tl.y)
+ ctx.quadraticCurveTo(tl.x - 6, tl.y + 3, tl.ex, tl.ey)
ctx.stroke()
- // 鼻孔与眼
- ctx.fillStyle = '#1d1409'
+ ctx.fillStyle = furLo
ctx.beginPath()
- ctx.arc(8, 2, 1.2, 0, TAU)
- ctx.arc(1, -3, 1.4, 0, TAU)
+ ctx.ellipse(tl.ex, tl.ey + 1.5, 2.4, 3.4, 0, 0, TAU)
ctx.fill()
- ctx.restore()
- // 尾巴
- ctx.strokeStyle = '#4a3828'
- ctx.lineWidth = 2.4
- ctx.beginPath()
- ctx.moveTo(-20, -20)
- ctx.quadraticCurveTo(-26, -14 + Math.sin(time * 2 + m.bob) * 2, -24, -8)
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1
ctx.stroke()
+ // 暴怒红气
+ if (angry) {
+ const p = (time * 1.6) % 1
+ ctx.fillStyle = `rgba(224,72,64,${0.7 * (1 - p)})`
+ ctx.beginPath()
+ ctx.arc(hd.x + 6, hd.y - 14 - p * 6, 1.8 + p * 2, 0, TAU)
+ ctx.fill()
+ }
ctx.restore()
}
-// 杀人蜂:黄黑条纹小球 + 高频振翅
+// 杀人蜂:绒毛胸 + 条纹腹 + 高频振翅(拖影),蜇击时前倾冲刺
function drawBee(ctx, m, time) {
const hover = Math.sin(time * 6 + m.bob) * 3
+ const atk = m.cd > 0.9 ? (1.4 - m.cd) / 0.5 : 0
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 4, 7, 2.6)
- ctx.translate(0, -18 + hover)
- // 翅膀
- const wing = Math.sin(time * 40) * 0.5
- ctx.fillStyle = 'rgba(230,240,248,0.75)'
+ ctx.translate(atk * 4, -18 + hover - atk * 2)
+ ctx.rotate(atk * 0.3)
+ const tq = qTime(time, 10)
+ // 翅膀(高频 + 双层残影)
+ const wing = Math.sin(time * 40)
+ for (let layer = 0; layer < 2; layer++) {
+ const wa = 0.5 + wing * (0.55 - layer * 0.22)
+ ctx.fillStyle = `rgba(230,240,248,${0.75 - layer * 0.3})`
+ ;[-1, 1].forEach((s) => {
+ ctx.save()
+ ctx.rotate(s * wa)
+ ctx.beginPath()
+ ctx.ellipse(0, -7 - layer, 3.2, 6.8, 0, 0, TAU)
+ ctx.fill()
+ ctx.restore()
+ })
+ }
+ ctx.strokeStyle = 'rgba(190,205,225,0.5)'
+ ctx.lineWidth = 0.8
;[-1, 1].forEach((s) => {
- ctx.save()
- ctx.rotate(s * (0.5 + wing))
ctx.beginPath()
- ctx.ellipse(0, -7, 3, 6.5, 0, 0, TAU)
- ctx.fill()
- ctx.restore()
+ ctx.moveTo(s * 1.5, -6)
+ ctx.lineTo(s * 3.4, -12)
+ ctx.stroke()
+ })
+ // 腹部(条纹 + 蜇针)
+ blob(ctx, ellipsePts(-1.5, 1.5, 7, 5.4, 10), {
+ fill: linGrad(ctx, 'bee', -8, -4, 4, 7, [[0, '#f0ca55'], [1, '#c9971e']]),
+ stroke: OUT, lw: 1.5, amp: 0.3, seed: 71, tq,
})
- // 身体条纹
- ctx.beginPath(); ctx.ellipse(0, 0, 7.5, 5.6, 0, 0, TAU); fsx(ctx, '#e0b83a')
ctx.save()
ctx.beginPath()
- ctx.ellipse(0, 0, 7.5, 5.6, 0, 0, TAU)
+ ctx.ellipse(-1.5, 1.5, 7, 5.4, 0, 0, TAU)
ctx.clip()
ctx.fillStyle = '#2a2418'
- ctx.fillRect(-2.4, -6, 2.6, 12)
- ctx.fillRect(2.6, -6, 2.4, 12)
+ ctx.fillRect(-4, -4.5, 2.4, 12)
+ ctx.fillRect(0.4, -4.5, 2.2, 12)
+ ctx.fillStyle = 'rgba(255,255,255,0.3)'
+ ctx.beginPath()
+ ctx.ellipse(-4.5, -1.5, 2, 3, 0.4, 0, TAU)
+ ctx.fill()
ctx.restore()
- ctx.strokeStyle = '#1d1409'
+ ctx.strokeStyle = OUT
ctx.lineWidth = 1.4
ctx.beginPath()
- ctx.ellipse(0, 0, 7.5, 5.6, 0, 0, TAU)
+ ctx.ellipse(-1.5, 1.5, 7, 5.4, 0, 0, TAU)
ctx.stroke()
- // 蜇针 + 眼
+ // 蜇针
ctx.beginPath()
- ctx.moveTo(-7, 1); ctx.lineTo(-11, 2.4); ctx.lineTo(-7, 3.4)
+ ctx.moveTo(-8, 2)
+ ctx.lineTo(-12.5, 3.2)
+ ctx.lineTo(-8, 4.2)
ctx.closePath()
- fsx(ctx, '#e8e2d4', '#1d1409', 1)
+ fsx(ctx, '#e8e2d4', OUT, 1)
+ // 绒毛胸
+ blob(ctx, ellipsePts(3.4, -1, 4.6, 4.2, 9), { fill: '#8a6a3a', stroke: OUT, lw: 1.3, amp: 0.35, seed: 72, tq })
+ ctx.strokeStyle = '#6a4e28'
+ ctx.lineWidth = 0.9
+ ;[[1.6, -3.4], [3.4, -4.4], [5.2, -3.6]].forEach(([fx, fy]) => {
+ ctx.beginPath()
+ ctx.moveTo(fx, fy)
+ ctx.lineTo(fx - 0.8, fy - 2.2)
+ ctx.stroke()
+ })
+ // 触角 + 大眼 + 悬垂小足
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1.1
+ ctx.beginPath()
+ ctx.moveTo(5.4, -4)
+ ctx.quadraticCurveTo(7.5, -8.5, 9.5, -8)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.moveTo(6.2, -3)
+ ctx.quadraticCurveTo(9, -6.5, 11, -5.4)
+ ctx.stroke()
ctx.fillStyle = '#1d1409'
- ctx.beginPath(); ctx.arc(5.4, -1.6, 1.2, 0, TAU); ctx.fill()
+ ctx.beginPath()
+ ctx.arc(6, -1.8, 1.35, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#fff'
+ ctx.beginPath()
+ ctx.arc(6.4, -2.2, 0.45, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = '#241a10'
+ ctx.lineWidth = 1
+ ;[[2, 4], [4.2, 4.6]].forEach(([lx, ly]) => {
+ ctx.beginPath()
+ ctx.moveTo(lx, ly)
+ ctx.lineTo(lx + 0.6, ly + 3.2)
+ ctx.stroke()
+ })
ctx.restore()
}
-// 青蛙:绿团蹲跳 + 大嘴
+// 青蛙:绿团蹲跳(起蹲挤压拉伸)+ 鼓喉 + 弹舌
function drawFrog(ctx, m, time) {
- const hop = m.moving ? Math.abs(Math.sin(time * 8 + m.bob)) : 0
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 6, 11, 3.6)
- ctx.translate(0, -hop * 8)
- ctx.scale(1 + hop * 0.1, 1 - hop * 0.12)
- // 后腿
- ctx.strokeStyle = '#5c7a34'
- ctx.lineWidth = 4
+ const tq = qTime(time, 8)
+ const bt = biteT(m)
+ let pose = {}
+ if (m.moving) {
+ pose = clipPose(CLIP_FROG_HOP, time * 1.4 + (m.bob || 0))
+ } else {
+ pose = {
+ 'root.y': Math.sin(time * 2.6 + m.bob) * 0.5,
+ spine: Math.sin(time * 2.2 + m.bob) * 0.03,
+ }
+ }
+ if (bt > 0) pose = addPose(pose, sampleClip(CLIP_FROG_BITE, bt))
+ const P = computePose(FROG_SKEL, pose)
+ const body = '#7a9a44'
+ const bodyLo = '#5c7a34'
+ const bodyHi = '#9cb85c'
+ // 后腿(折叠 Z 形)
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 5, 3.6, shade(bodyLo, k), OUT, 1.3)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 3.6, 2.4, shade(bodyLo, k), OUT, 1.2)
+ // 蹼足
+ ctx.fillStyle = shade(bodyLo, k)
+ ctx.beginPath()
+ ctx.ellipse(f2.ex, f2.ey + 0.4, 3, 1.5, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1
+ ctx.stroke()
+ }
+ leg('legA.u', 'legA.f', -0.22)
+ // 身体(豆形 + 渐变 + 斑点)
+ const sp = P.spine
+ blob(ctx, ellipsePts(sp.x + 1, sp.y - 1, 10.5, 8, 12, 0.15), {
+ fill: linGrad(ctx, 'frb', sp.x - 9, sp.y - 9, sp.x + 8, sp.y + 7, [[0, bodyHi], [0.5, body], [1, bodyLo]]),
+ stroke: OUT, lw: 1.8, amp: 0.5, seed: 81, tq,
+ })
+ ctx.fillStyle = 'rgba(255,255,255,0.22)'
ctx.beginPath()
- ctx.moveTo(-7, -3)
- ctx.quadraticCurveTo(-12, 2, -9, 6)
- ctx.stroke()
- // 身体
- ctx.beginPath(); ctx.ellipse(0, -7, 10, 7.4, 0, 0, TAU); fsx(ctx, '#7a9a44')
- ctx.beginPath(); ctx.ellipse(2, -4, 6, 4, 0, 0, TAU)
- ctx.fillStyle = '#a8c070'
+ ctx.ellipse(sp.x - 2, sp.y - 4, 4.5, 2.6, 0.3, 0, TAU)
ctx.fill()
+ ctx.fillStyle = 'rgba(60,90,30,0.5)'
+ ;[[sp.x - 4, sp.y + 2], [sp.x + 2, sp.y + 4], [sp.x - 1, sp.y - 1]].forEach(([dx, dy]) => {
+ ctx.beginPath()
+ ctx.ellipse(dx, dy, 1.3, 0.9, 0.4, 0, TAU)
+ ctx.fill()
+ })
+ leg('legF.u', 'legF.f', 0)
// 前脚
- ctx.strokeStyle = '#5c7a34'
- ctx.lineWidth = 3
+ const arm = (name, k) => {
+ const a = P[name]
+ taper(ctx, a.x, a.y, a.ex, a.ey, 3.2, 2.2, shade(bodyLo, k), OUT, 1.2)
+ ctx.fillStyle = shade(bodyLo, k)
+ ctx.beginPath()
+ ctx.ellipse(a.ex, a.ey + 0.4, 2.2, 1.3, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = OUT
+ ctx.lineWidth = 1
+ ctx.stroke()
+ }
+ arm('armA', -0.22)
+ arm('armF', 0)
+ // 头 + 鼓喉
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x + 2, hd.y + 1, 8, 7, 11, 0.1), {
+ fill: linGrad(ctx, 'frh', hd.x - 6, hd.y - 7, hd.x + 9, hd.y + 8, [[0, bodyHi], [0.5, body], [1, bodyLo]]),
+ stroke: OUT, lw: 1.7, amp: 0.45, seed: 82, tq,
+ })
+ // 鸣囊(呼吸鼓起)
+ const throat = 3.2 + Math.sin(time * 3.2 + m.bob) * 1.6 + bt * 2
+ ctx.fillStyle = 'rgba(240,220,150,0.75)'
ctx.beginPath()
- ctx.moveTo(5, -3); ctx.lineTo(7, 5)
- ctx.moveTo(1, -2); ctx.lineTo(1, 6)
+ ctx.ellipse(hd.x + 4, hd.y + 5.5, throat, throat * 0.8, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = 'rgba(90,70,30,0.5)'
+ ctx.lineWidth = 1
ctx.stroke()
// 眼睛鼓包
- ;[[3, -14], [8, -13]].forEach(([x, y]) => {
- ctx.beginPath(); ctx.arc(x, y, 3, 0, TAU); fsx(ctx, '#7a9a44', '#1d1409', 1.4)
- ctx.fillStyle = '#1d1409'
- ctx.beginPath(); ctx.arc(x + 1, y, 1.1, 0, TAU); ctx.fill()
+ ;[[hd.x + 1, hd.y - 6], [hd.x + 6.5, hd.y - 5.4]].forEach(([ex, ey]) => {
+ ctx.beginPath()
+ ctx.arc(ex, ey, 3, 0, TAU)
+ fsx(ctx, body, OUT, 1.3)
+ ctx.fillStyle = '#f4e8a0'
+ ctx.beginPath()
+ ctx.ellipse(ex + 0.8, ey, 1.7, 2, 0, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#241a10'
+ ctx.beginPath()
+ ctx.ellipse(ex + 1, ey + 0.2, 0.8, 1.5, 0, 0, TAU)
+ ctx.fill()
})
- // 嘴线
- ctx.strokeStyle = '#1d1409'
+ // 大嘴线
+ ctx.strokeStyle = OUT
ctx.lineWidth = 1.4
ctx.beginPath()
- ctx.moveTo(4, -8)
- ctx.quadraticCurveTo(9, -7, 11, -9)
+ ctx.moveTo(hd.x - 1, hd.y + 2.5)
+ ctx.quadraticCurveTo(hd.x + 7, hd.y + 4.5, hd.x + 10.5, hd.y + 1.5)
ctx.stroke()
ctx.restore()
}
-// 独眼巨鹿:极巨蓝灰毛怪 + 鹿角 + 单眼,windup 抬双臂 / smash 砸地
+// 独眼巨鹿:极巨蓝灰毛怪 + 鹿角 + 单眼,windup 抬双臂 / smash 砸地,寒冬白气
function drawDeerclops(ctx, m, time) {
ctx.save()
ctx.scale(m.dir || 1, 1)
shadow(ctx, 0, 8, 40, 12)
- const step = m.moving ? Math.sin(time * 4 + m.bob) : 0
- const breathe = Math.sin(time * 1.4) * 2
- // 粗腿
- ctx.strokeStyle = '#3a4450'
- ctx.lineWidth = 10
- ctx.beginPath()
- ctx.moveTo(-14, -34); ctx.lineTo(-16 + step * 5, 4)
- ctx.moveTo(14, -34); ctx.lineTo(16 - step * 5, 4)
- ctx.stroke()
- // 蓬毛巨躯
- ;[[0, -62 + breathe, 30, 34], [-18, -50 + breathe, 16, 20], [18, -50 + breathe, 16, 20]].forEach(([x, y, rx, ry]) => {
+ const tq = qTime(time, 6)
+ let pose = {
+ 'root.y': Math.sin(time * 1.4) * 1.6,
+ trunk: Math.sin(time * 1.1) * 0.015,
+ 'armF.u': Math.sin(time * 1.4) * 0.05,
+ 'armA.u': -Math.sin(time * 1.4) * 0.04,
+ }
+ if (m.moving) pose = clipPose(CLIP_CLOPS_WALK, time + (m.bob || 0))
+ if (m.state === 'windup') pose = sampleClip(CLIP_CLOPS_WINDUP, 1 - Math.max(0, m.t) / 0.8)
+ else if (m.state === 'smash') pose = sampleClip(CLIP_CLOPS_SMASH, 1 - Math.max(0, m.t) / 0.4)
+ else if (m.state === 'recover') pose = sampleClip(CLIP_CLOPS_RECOVER, 1 - Math.max(0, m.t) / 1.1)
+ const P = computePose(CLOPS_SKEL, pose)
+ const fur = '#4c5866'
+ const furLo = '#3a4450'
+ const furHi = '#5e6c7c'
+ // 粗腿(兽足)
+ const leg = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 13, 10, shade(furLo, k), '#1d1420', 2.2)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 10, 8, shade(furLo, k), '#1d1420', 2)
+ ctx.fillStyle = shade('#2c333c', k)
ctx.beginPath()
- ctx.ellipse(x, y, rx, ry, 0, 0, TAU)
- fsx(ctx, '#4c5866', '#1d1420', 2.4)
+ ctx.ellipse(f2.ex + 1, f2.ey + 1, 6.5, 3.4, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = '#1d1420'
+ ctx.lineWidth = 1.6
+ ctx.stroke()
+ }
+ leg('legA.u', 'legA.f', -0.18)
+ // 蓬毛巨躯(三团圆叠 + 毛尖)
+ const r0 = P.root
+ const tk = P.trunk
+ blob(ctx, ellipsePts(r0.x - 16, r0.y + 6, 18, 16, 12), {
+ fill: linGrad(ctx, 'dc0', r0.x - 32, r0.y - 8, r0.x - 4, r0.y + 22, [[0, furHi], [1, furLo]]),
+ stroke: '#1d1420', lw: 2.4, amp: 1.1, seed: 91, tq,
})
- // 毛发笔触
- ctx.strokeStyle = '#3a4450'
- ctx.lineWidth = 2
- ;[[-20, -40], [-8, -34], [6, -36], [18, -42]].forEach(([x, y]) => {
+ blob(ctx, ellipsePts(tk.x + 2, tk.y + 4, 27, 30, 14, 0.04), {
+ fill: linGrad(ctx, 'dc1', tk.x - 24, tk.y - 24, tk.x + 20, tk.y + 30, [[0, furHi], [0.5, fur], [1, furLo]]),
+ stroke: '#1d1420', lw: 2.6, amp: 1.2, seed: 92, tq,
+ })
+ blob(ctx, ellipsePts(tk.x + 22, tk.y + 2, 15, 17, 11, 0.15), {
+ fill: linGrad(ctx, 'dc2', tk.x + 8, tk.y - 12, tk.x + 34, tk.y + 16, [[0, shade(fur, 0.1)], [1, furLo]]),
+ stroke: '#1d1420', lw: 2.2, amp: 1, seed: 93, tq,
+ })
+ // 垂毛笔触
+ ctx.strokeStyle = furLo
+ ctx.lineWidth = 2.4
+ ;[[-20, -30], [-8, -22], [6, -26], [18, -34]].forEach(([hx, hy]) => {
ctx.beginPath()
- ctx.moveTo(x, y + breathe)
- ctx.lineTo(x - 3, y + 10 + breathe)
+ ctx.moveTo(r0.x + hx, r0.y + hy)
+ ctx.lineTo(r0.x + hx - 3, r0.y + hy + 12)
ctx.stroke()
})
- // 手臂:windup 高举 / smash 砸地
- const armAng = m.state === 'windup' ? -2.4 : m.state === 'smash' ? 0.8 : -0.4 + Math.sin(time * 1.4) * 0.1
- ;[1, -1].forEach((side) => {
- ctx.save()
- ctx.translate(side * 26, -72 + breathe)
- ctx.rotate(side === 1 ? armAng : -armAng * 0.6 - 0.3)
- ctx.strokeStyle = '#46525e'
- ctx.lineWidth = 9
- ctx.beginPath()
- ctx.moveTo(0, 0)
- ctx.quadraticCurveTo(side * 18, 14, side * 28, 34)
- ctx.stroke()
- // 爪
+ leg('legF.u', 'legF.f', 0)
+ // 手臂(长毛臂 + 白爪)
+ const arm = (uName, fName, k) => {
+ const u = P[uName]
+ const f2 = P[fName]
+ taper(ctx, u.x, u.y, u.ex, u.ey, 11, 8.5, shade(fur, k), '#1d1420', 2)
+ taper(ctx, f2.x, f2.y, f2.ex, f2.ey, 8.5, 6.5, shade(furLo, k), '#1d1420', 1.9)
ctx.strokeStyle = '#dfe6f0'
- ctx.lineWidth = 3
- ctx.beginPath()
- ctx.moveTo(side * 28, 34); ctx.lineTo(side * 34, 40)
- ctx.moveTo(side * 28, 34); ctx.lineTo(side * 24, 42)
- ctx.stroke()
- ctx.restore()
+ ctx.lineWidth = 3.2
+ ctx.lineCap = 'round'
+ const dirx = f2.ex - f2.x
+ const diry = f2.ey - f2.y
+ const dl = Math.hypot(dirx, diry) || 1
+ const ux = dirx / dl
+ const uy = diry / dl
+ ;[-0.45, 0.1, 0.55].forEach((sp2) => {
+ ctx.beginPath()
+ ctx.moveTo(f2.ex, f2.ey)
+ ctx.lineTo(f2.ex + (ux - uy * sp2) * 8, f2.ey + (uy + ux * sp2) * 8)
+ ctx.stroke()
+ })
+ }
+ arm('armA.u', 'armA.f', -0.15)
+ arm('armF.u', 'armF.f', 0)
+ // 头 + 鹿角 + 獠牙
+ const hd = P.head
+ blob(ctx, ellipsePts(hd.x + 2, hd.y + 2, 18, 15, 13, 0.06), {
+ fill: linGrad(ctx, 'dch', hd.x - 15, hd.y - 12, hd.x + 18, hd.y + 16, [[0, furHi], [0.5, fur], [1, furLo]]),
+ stroke: '#1d1420', lw: 2.4, amp: 1, seed: 94, tq,
})
- // 头 + 鹿角 + 单眼
- ctx.save()
- ctx.translate(0, -92 + breathe)
- ctx.beginPath(); ctx.ellipse(0, 0, 18, 15, 0, 0, TAU); fsx(ctx, '#4c5866', '#1d1420', 2.4)
- // 鹿角
- ctx.strokeStyle = '#c9b891'
- ctx.lineWidth = 4
- ;[-1, 1].forEach((s) => {
+ // 鹿角(主干 + 分叉,带环纹)
+ const antler = (s) => {
+ ctx.strokeStyle = '#1d1420'
+ ctx.lineWidth = 5.4
ctx.beginPath()
- ctx.moveTo(s * 10, -10)
- ctx.quadraticCurveTo(s * 22, -22, s * 18, -32)
- ctx.moveTo(s * 16, -22)
- ctx.lineTo(s * 24, -26)
+ ctx.moveTo(hd.x + s * 8, hd.y - 10)
+ ctx.quadraticCurveTo(hd.x + s * 20, hd.y - 22, hd.x + s * 16, hd.y - 34)
ctx.stroke()
- })
- // 单眼(愤怒发光)
+ ctx.strokeStyle = '#c9b891'
+ ctx.lineWidth = 4
+ ctx.beginPath()
+ ctx.moveTo(hd.x + s * 8, hd.y - 10)
+ ctx.quadraticCurveTo(hd.x + s * 20, hd.y - 22, hd.x + s * 16, hd.y - 34)
+ ctx.stroke()
+ ctx.lineWidth = 2.8
+ ctx.beginPath()
+ ctx.moveTo(hd.x + s * 14, hd.y - 20)
+ ctx.lineTo(hd.x + s * 23, hd.y - 25)
+ ctx.moveTo(hd.x + s * 15.5, hd.y - 28)
+ ctx.lineTo(hd.x + s * 22, hd.y - 33)
+ ctx.stroke()
+ ctx.strokeStyle = 'rgba(120,100,70,0.5)'
+ ctx.lineWidth = 1
+ ;[[s * 11, -14], [s * 16, -22], [s * 17, -29]].forEach(([ox, oy]) => {
+ ctx.beginPath()
+ ctx.arc(hd.x + ox, hd.y + oy, 2.2, 0, TAU)
+ ctx.stroke()
+ })
+ }
+ antler(-1)
+ antler(1)
+ // 单眼(windup 愤怒发光)
const glow = m.state === 'windup' ? 1 : 0.7 + Math.sin(time * 5) * 0.15
- ctx.beginPath(); ctx.arc(2, -2, 7.4, 0, TAU); fsx(ctx, '#ece8dc', '#1d1420', 2)
+ ctx.beginPath()
+ ctx.arc(hd.x + 2, hd.y, 8, 0, TAU)
+ fsx(ctx, '#ece8dc', '#1d1420', 2)
ctx.fillStyle = `rgba(224,64,48,${glow})`
- ctx.beginPath(); ctx.arc(2, -2, 4, 0, TAU); ctx.fill()
+ ctx.beginPath()
+ ctx.arc(hd.x + 2, hd.y, 4.4, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#ffce9c'
+ ctx.beginPath()
+ ctx.arc(hd.x + 2, hd.y, 2, 0, TAU)
+ ctx.fill()
ctx.fillStyle = '#1d1420'
- ctx.beginPath(); ctx.arc(2, -2, 1.6, 0, TAU); ctx.fill()
+ ctx.beginPath()
+ ctx.arc(hd.x + 2, hd.y, 1.1, 0, TAU)
+ ctx.fill()
// 嘴獠牙
ctx.fillStyle = '#dfe6f0'
- ;[[-8, 10], [-2, 12], [5, 11]].forEach(([x, y]) => {
+ ;[[-9, 10], [-2, 12], [6, 11]].forEach(([ox, oy]) => {
ctx.beginPath()
- ctx.moveTo(x, y); ctx.lineTo(x + 3, y + 5); ctx.lineTo(x + 6, y)
+ ctx.moveTo(hd.x + ox, hd.y + oy)
+ ctx.lineTo(hd.x + ox + 3, hd.y + oy + 5)
+ ctx.lineTo(hd.x + ox + 6, hd.y + oy)
ctx.closePath()
ctx.fill()
})
- ctx.restore()
+ // 寒冬呼出的白气
+ const p = ((time * 0.9 + (m.bob || 0)) % 1)
+ ctx.fillStyle = `rgba(220,235,245,${0.4 * (1 - p)})`
+ ctx.beginPath()
+ ctx.arc(hd.x + 20 + p * 12, hd.y + 6 - p * 5, 3 + p * 5, 0, TAU)
+ ctx.fill()
ctx.restore()
}
@@ -1462,3 +2518,455 @@ export function drawDecor(ctx, d, x, y) {
ctx.fill()
}
}
+
+// 麋鹿鹅:棕躯鹅颈鹿角,双翅扑扇;windup 后仰 / smash 前倾
+function drawMoosegoose(ctx, m, time) {
+ ctx.save()
+ ctx.scale(m.dir || 1, 1)
+ shadow(ctx, 0, 8, 34, 10)
+ const bob = m.moving ? Math.sin(time * 9) * 3 : Math.sin(time * 1.8) * 1.6
+ const wind = m.state === 'windup' ? Math.max(0, m.t) / 0.6 : 0
+ const smash = m.state === 'smash' ? Math.max(0, m.t) / 0.35 : 0
+ const lean = wind * -0.3 + smash * 0.4
+ ctx.translate(0, -bob)
+ // 腿(橙蹼交替)
+ const step = m.moving ? Math.sin(time * 9) * 6 : 0
+ ;[-8 + step, 8 - step].forEach((x, i) => {
+ ctx.strokeStyle = '#b5651d'
+ ctx.lineWidth = 5
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(x, 2)
+ ctx.lineTo(x + (i ? 2 : -2), 16)
+ ctx.stroke()
+ ctx.fillStyle = '#d8842e'
+ ctx.beginPath()
+ ctx.moveTo(x + (i ? 2 : -2) - 5, 16)
+ ctx.lineTo(x + (i ? 2 : -2) + 6, 16)
+ ctx.lineTo(x + (i ? 2 : -2), 20)
+ ctx.closePath()
+ ctx.fill()
+ })
+ ctx.rotate(lean)
+ // 躯干
+ ctx.beginPath()
+ ctx.ellipse(0, -6, 26, 19, 0, 0, TAU)
+ fs(ctx, '#6e4a2f', '#241812', 2.2)
+ // 扑扇双翅
+ const flap = m.state === 'smash' ? 1.0 : Math.sin(time * (m.moving ? 11 : 4)) * 0.5
+ ;[-1, 1].forEach((s) => {
+ ctx.save()
+ ctx.translate(-4, -12)
+ ctx.rotate(s * (0.55 + flap * 0.7))
+ ctx.beginPath()
+ ctx.ellipse(0, s * 13, 8, 16, 0, 0, TAU)
+ fs(ctx, '#5d3f28', '#241812', 1.8)
+ ctx.restore()
+ })
+ // 鹅颈 + 白头
+ ctx.strokeStyle = '#241c14'
+ ctx.lineWidth = 7
+ ctx.lineCap = 'round'
+ ctx.beginPath()
+ ctx.moveTo(16, -14)
+ ctx.quadraticCurveTo(30, -26, 30, -36)
+ ctx.stroke()
+ ctx.beginPath()
+ ctx.arc(30, -40, 7.5, 0, TAU)
+ fs(ctx, '#f0ead8', '#241812', 1.8)
+ // 鹿角
+ ctx.strokeStyle = '#c9b08a'
+ ctx.lineWidth = 3
+ ;[[26, -46], [34, -46]].forEach(([x, y], i) => {
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ ctx.lineTo(x - 3 + i * 6, y - 9)
+ ctx.moveTo(x - 3 + i * 6, y - 9)
+ ctx.lineTo(x - 6 + i * 9, y - 13)
+ ctx.stroke()
+ })
+ // 喙 + 眼
+ ctx.fillStyle = '#f0923c'
+ ctx.beginPath()
+ ctx.moveTo(37, -41)
+ ctx.lineTo(44, -39)
+ ctx.lineTo(37, -37)
+ ctx.closePath()
+ ctx.fill()
+ ctx.fillStyle = '#241812'
+ ctx.beginPath()
+ ctx.arc(32, -42, 1.4, 0, TAU)
+ ctx.fill()
+ ctx.restore()
+}
+
+// 龙蝇:浮空暗红巨躯,六足双翼,windup 时口器火光渐亮
+function drawDragonfly(ctx, m, time) {
+ ctx.save()
+ ctx.scale(m.dir || 1, 1)
+ shadow(ctx, 0, 10, 30, 9)
+ const hover = Math.sin(time * 2.6) * 3
+ ctx.translate(0, -16 - hover)
+ const wind = m.state === 'windup' ? Math.max(0, m.t) / 0.8 : 0
+ // 六足
+ ctx.strokeStyle = '#3a1f18'
+ ctx.lineWidth = 3
+ ctx.lineCap = 'round'
+ ;[-1, 1].forEach((s) => {
+ ;[0, 1, 2].forEach((i) => {
+ const bx = s * (10 - i * 8)
+ ctx.beginPath()
+ ctx.moveTo(bx, 4)
+ ctx.lineTo(bx + s * 9, 12 + i * 2)
+ ctx.stroke()
+ })
+ })
+ // 双翼(高频振动)
+ const wf = Math.sin(time * 40) * 0.5
+ ;[-1, 1].forEach((s) => {
+ ctx.save()
+ ctx.translate(-2, -10)
+ ctx.rotate(s * (0.9 + wf))
+ ctx.fillStyle = 'rgba(210,230,240,0.32)'
+ ctx.beginPath()
+ ctx.ellipse(0, s * 14, 6.5, 15, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = 'rgba(220,240,250,0.45)'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.restore()
+ })
+ // 分节长腹
+ ctx.beginPath()
+ ctx.ellipse(-14, 2, 20, 9, 0.15, 0, TAU)
+ fs(ctx, '#8c2f1e', '#241812', 2.2)
+ ctx.fillStyle = '#6e2416'
+ for (let i = 0; i < 4; i++) {
+ ctx.beginPath()
+ ctx.ellipse(-26 + i * 7, 3.4 - i * 0.6, 3.4, 6.4 - i * 0.5, 0.15, 0, TAU)
+ ctx.fill()
+ }
+ // 头 + 口器火光
+ ctx.beginPath()
+ ctx.ellipse(16, -2, 12, 10.5, 0, 0, TAU)
+ fs(ctx, '#a03822', '#241812', 2.2)
+ ctx.fillStyle = `rgba(255,${Math.round(120 + wind * 90)},40,${0.55 + wind * 0.45})`
+ ctx.beginPath()
+ ctx.ellipse(24, -2, 4.5 + wind * 2.5, 3.5 + wind * 2.5, 0, 0, TAU)
+ ctx.fill()
+ // 复眼
+ ctx.fillStyle = '#e8c23a'
+ ctx.beginPath(); ctx.arc(14, -7, 3.4, 0, TAU); ctx.fill()
+ ctx.beginPath(); ctx.arc(19, -6, 3, 0, TAU); ctx.fill()
+ ctx.fillStyle = '#241812'
+ ctx.beginPath(); ctx.arc(14.6, -7.6, 1.2, 0, TAU); ctx.fill()
+ ctx.beginPath(); ctx.arc(19.4, -6.6, 1.1, 0, TAU); ctx.fill()
+ ctx.restore()
+}
+
+// 洞口/出口:黑暗洞穴口 + 碎石拱 + 木质支架
+function drawCaveHole(ctx, ent, time, isEntrance) {
+ shadow(ctx, 0, 8, 34, 9)
+ // 洞口黑斑
+ ctx.fillStyle = '#0c0a08'
+ ctx.beginPath()
+ ctx.ellipse(0, 2, 30, 20, 0, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#1a1410'
+ ctx.beginPath()
+ ctx.ellipse(0, -2, 24, 14, 0, 0, TAU)
+ ctx.fill()
+ // 碎石拱
+ ctx.fillStyle = '#6e6a5e'
+ ctx.strokeStyle = '#241c14'
+ ctx.lineWidth = 2
+ ;[[-24, -6], [-12, -14], [2, -18], [16, -13], [26, -5]].forEach(([x, y]) => {
+ ctx.beginPath()
+ ctx.arc(x, y, 6.5, 0, TAU)
+ ctx.fill()
+ ctx.stroke()
+ })
+ // 木质支架
+ ctx.strokeStyle = '#6b4826'
+ ctx.lineWidth = 4
+ ctx.beginPath()
+ ctx.moveTo(-22, 12)
+ ctx.lineTo(-16, -20)
+ ctx.moveTo(22, 12)
+ ctx.lineTo(16, -20)
+ ctx.moveTo(-18, -20)
+ ctx.lineTo(18, -20)
+ ctx.stroke()
+ // 标签
+ const label = isEntrance ? '洞穴入口' : '洞穴出口'
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = label.length * 12 + 14
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, -52, tw, 16, 4)
+ else ctx.rect(-tw / 2, -52, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = isEntrance ? '#9fd8f0' : '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -44)
+ // 入口下坠气流线(微微动画)
+ if (isEntrance) {
+ ctx.strokeStyle = 'rgba(160,200,230,0.35)'
+ ctx.lineWidth = 1.2
+ for (let i = 0; i < 2; i++) {
+ const ph = (time * 0.6 + i * 0.5) % 1
+ ctx.globalAlpha = 0.5 * (1 - ph)
+ ctx.beginPath()
+ ctx.arc(0, -4, 10 + ph * 14, 0.2, Math.PI - 0.2)
+ ctx.stroke()
+ }
+ ctx.globalAlpha = 1
+ }
+}
+
+// 荧光草:球茎发光花 + 呼吸光晕(洞穴光岛)
+function drawLightflower(ctx, ent, time) {
+ const glow = 0.6 + Math.sin(time * 2.2 + (ent.id || 0)) * 0.18
+ shadow(ctx, 0, 6, 11, 4)
+ // 光晕
+ const g = ctx.createRadialGradient(0, -12, 2, 0, -12, 34)
+ g.addColorStop(0, `rgba(150,230,190,${glow * 0.5})`)
+ g.addColorStop(1, 'rgba(150,230,190,0)')
+ ctx.fillStyle = g
+ ctx.beginPath()
+ ctx.arc(0, -12, 34, 0, TAU)
+ ctx.fill()
+ // 茎叶
+ ctx.strokeStyle = '#3f6b46'
+ ctx.lineWidth = 2.4
+ ctx.beginPath()
+ ctx.moveTo(0, 0)
+ ctx.quadraticCurveTo(2, -8, 0, -12)
+ ctx.moveTo(0, -6)
+ ctx.lineTo(-6, -10)
+ ctx.moveTo(0, -8)
+ ctx.lineTo(6, -12)
+ ctx.stroke()
+ // 球茎
+ ctx.beginPath()
+ ctx.arc(0, -13, 5.5, 0, TAU)
+ fs(ctx, `rgba(190,245,205,${0.75 + glow * 0.25})`, '#2a5a38', 1.6)
+ ctx.fillStyle = `rgba(255,255,255,${glow})`
+ ctx.beginPath()
+ ctx.arc(-1.6, -14.5, 1.8, 0, TAU)
+ ctx.fill()
+}
+
+// 宝石矿:岩块 + 彩色晶簇(微闪)
+function drawGemrock(ctx, ent, time) {
+ shadow(ctx, 0, 7, 17, 5)
+ ctx.fillStyle = '#6e6a5e'
+ ctx.strokeStyle = '#241c14'
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ ctx.moveTo(-15, 8)
+ ctx.lineTo(-17, -4)
+ ctx.lineTo(-8, -12)
+ ctx.lineTo(8, -13)
+ ctx.lineTo(16, -3)
+ ctx.lineTo(14, 8)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = '#5c584e'
+ ctx.beginPath()
+ ctx.moveTo(-17, -4)
+ ctx.lineTo(-8, -12)
+ ctx.lineTo(2, -8)
+ ctx.lineTo(-6, 0)
+ ctx.closePath()
+ ctx.fill()
+ // 晶簇
+ const gl = 0.65 + Math.sin(time * 3 + (ent.id || 0)) * 0.3
+ ;[[-6, -4, '#7fd8f0'], [3, -7, '#e88ab0'], [8, -1, '#9fd86a']].forEach(([x, y, col]) => {
+ ctx.fillStyle = col
+ ctx.globalAlpha = gl
+ ctx.beginPath()
+ ctx.moveTo(x, y - 6)
+ ctx.lineTo(x + 3.4, y)
+ ctx.lineTo(x, y + 2)
+ ctx.lineTo(x - 3.4, y)
+ ctx.closePath()
+ ctx.fill()
+ ctx.globalAlpha = 1
+ ctx.strokeStyle = '#241c14'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ })
+}
+
+// 洞穴菇:灰蓝小蘑菇丛
+function drawCavemushroom(ctx, ent, time) {
+ shadow(ctx, 0, 5, 10, 3.4)
+ ;[[-6, 0, 5], [3, 2, 6.4], [8, -2, 4]].forEach(([x, y, s]) => {
+ ctx.fillStyle = '#c8d4dc'
+ ctx.strokeStyle = '#241c14'
+ ctx.lineWidth = 1.4
+ ctx.beginPath()
+ ctx.arc(x, y - s * 0.7, s, Math.PI, 0)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.fillStyle = '#eef4f8'
+ ctx.fillRect(x - 1.6, y - s * 0.7, 3.2, s * 0.7)
+ })
+}
+
+// 巨型蜂巢:蜡黄大蜂箱 + 蜜光 + 环绕蜂
+function drawGianthive(ctx, ent, time) {
+ const honey = ent.mx?.honey || 0
+ const label = '巨型蜂巢'
+ ctx.fillStyle = 'rgba(20,16,10,0.78)'
+ const tw = label.length * 12 + 14
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(-tw / 2, -58, tw, 16, 4)
+ else ctx.rect(-tw / 2, -58, tw, 16)
+ ctx.fill()
+ ctx.fillStyle = '#f4ead2'
+ ctx.font = 'bold 11px "PingFang SC","Microsoft YaHei",sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(label, 0, -50)
+ shadow(ctx, 0, 9, 26, 7)
+ // 蜂巢主体(三层蜡黄)
+ ctx.fillStyle = '#e0b83a'
+ ctx.strokeStyle = '#241812'
+ ctx.lineWidth = 2.2
+ ;[[-20, 8, 40, 12], [-17, -4, 34, 12], [-13, -16, 26, 12]].forEach(([x, y, w, h]) => {
+ ctx.beginPath()
+ if (ctx.roundRect) ctx.roundRect(x, y, w, h, 5)
+ else ctx.rect(x, y, w, h)
+ ctx.fill()
+ ctx.stroke()
+ })
+ // 入巢孔 + 蜜光
+ ctx.fillStyle = '#5c3a10'
+ ctx.beginPath()
+ ctx.ellipse(0, 10, 6, 4, 0, 0, TAU)
+ ctx.fill()
+ const glow = 0.5 + Math.sin(time * 2.6) * 0.25
+ ctx.fillStyle = `rgba(240,146,60,${glow})`
+ ctx.beginPath()
+ ctx.ellipse(0, 10, 3.4, 2.2, 0, 0, TAU)
+ ctx.fill()
+ // 环绕守卫蜂
+ for (let i = 0; i < 3; i++) {
+ const a = time * (1.6 + i * 0.4) + i * 2.1
+ ctx.fillStyle = '#e0b83a'
+ ctx.beginPath()
+ ctx.arc(Math.cos(a) * 32, -14 + Math.sin(a * 1.6) * 10, 2.4, 0, TAU)
+ ctx.fill()
+ }
+}
+
+// 石虾:灰壳圆龟,小头短腿
+function drawSlurtle(ctx, m, time) {
+ ctx.save()
+ ctx.scale(m.dir || 1, 1)
+ shadow(ctx, 0, 7, 20, 6)
+ const bob = m.moving ? Math.sin(time * 7) * 1.6 : Math.sin(time * 1.6) * 0.9
+ ctx.translate(0, -bob)
+ // 腿
+ ctx.strokeStyle = '#5a5348'
+ ctx.lineWidth = 3.6
+ ctx.lineCap = 'round'
+ const step = m.moving ? Math.sin(time * 7) * 3 : 0
+ ;[-8 + step, 8 - step].forEach((x) => {
+ ctx.beginPath()
+ ctx.moveTo(x, 6)
+ ctx.lineTo(x, 11)
+ ctx.stroke()
+ })
+ // 壳(大圆顶)
+ ctx.beginPath()
+ ctx.arc(0, -2, 15, Math.PI, 0)
+ ctx.closePath()
+ fs(ctx, '#8a8478', '#241c14', 2)
+ // 壳纹
+ ctx.strokeStyle = '#6e6a5e'
+ ctx.lineWidth = 1.6
+ ;[-6, 0, 6].forEach((x) => {
+ ctx.beginPath()
+ ctx.arc(x * 1.4, 4, 12, Math.PI * 1.15, Math.PI * 1.85)
+ ctx.stroke()
+ })
+ // 头(缩头感)
+ ctx.beginPath()
+ ctx.ellipse(15, -1, 6, 5, 0, 0, TAU)
+ fs(ctx, '#a89e8e', '#241c14', 1.8)
+ ctx.fillStyle = '#241c14'
+ ctx.beginPath()
+ ctx.arc(17.4, -2.6, 1.1, 0, TAU)
+ ctx.fill()
+ ctx.restore()
+}
+
+// 蜂王:放大蜂躯 + 金冠 + 透明大翼,怒态红纹
+function drawBeequeen(ctx, m, time) {
+ ctx.save()
+ ctx.scale(m.dir || 1, 1)
+ shadow(ctx, 0, 10, 28, 8)
+ const hover = Math.sin(time * 3) * 2.6
+ ctx.translate(0, -14 - hover)
+ const wf = Math.sin(time * 34) * 0.4
+ ;[-1, 1].forEach((s) => {
+ ctx.save()
+ ctx.translate(-2, -12)
+ ctx.rotate(s * (0.7 + wf))
+ ctx.fillStyle = 'rgba(215,235,245,0.4)'
+ ctx.beginPath()
+ ctx.ellipse(0, s * 15, 7, 17, 0, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = 'rgba(230,245,252,0.55)'
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.restore()
+ })
+ ctx.beginPath()
+ ctx.ellipse(-6, 6, 20, 14, 0.1, 0, TAU)
+ fs(ctx, '#e0b83a', '#241812', 2.2)
+ ctx.fillStyle = '#241812'
+ ;[-16, -6, 4].forEach((x) => {
+ ctx.beginPath()
+ ctx.ellipse(x, 5 + Math.abs(x) * 0.12, 2.6, 11 - Math.abs(x) * 0.2, 0.1, 0, TAU)
+ ctx.fill()
+ })
+ ctx.beginPath()
+ ctx.ellipse(12, -2, 11, 10, 0, 0, TAU)
+ fs(ctx, '#3a2c14', '#241812', 2)
+ ctx.fillStyle = '#e04840'
+ ctx.beginPath()
+ ctx.arc(15, -5, 2.6, 0, TAU)
+ ctx.fill()
+ ctx.fillStyle = '#241812'
+ ctx.beginPath()
+ ctx.arc(15, -5, 1.1, 0, TAU)
+ ctx.fill()
+ ctx.strokeStyle = '#241812'
+ ctx.lineWidth = 2.4
+ ctx.beginPath()
+ ctx.moveTo(-25, 9)
+ ctx.lineTo(-32, 11)
+ ctx.stroke()
+ ctx.fillStyle = '#f0c94a'
+ ctx.strokeStyle = '#8a6a10'
+ ctx.lineWidth = 1.2
+ ctx.beginPath()
+ ctx.moveTo(6, -12)
+ ctx.lineTo(6, -18)
+ ctx.lineTo(9.4, -14.6)
+ ctx.lineTo(12.4, -19)
+ ctx.lineTo(15.4, -14.6)
+ ctx.lineTo(18.6, -18)
+ ctx.lineTo(18.6, -12)
+ ctx.closePath()
+ ctx.fill()
+ ctx.stroke()
+ ctx.restore()
+}
diff --git a/src/games/starve/skel.js b/src/games/starve/skel.js
new file mode 100644
index 0000000..34757ea
--- /dev/null
+++ b/src/games/starve/skel.js
@@ -0,0 +1,285 @@
+// starve/skel.js — 骨骼动画引擎(零依赖)
+// 供 starveSkins(主角)、paint.js(野怪)、mod(camp.js 守卫)共用的小型骨骼-关键帧系统。
+// 坐标约定:脚底为原点,+x 为面朝方向,+y 向下;角度为 canvas 标准弧度(0 = +x,π/2 = 向下)。
+// 骨骼定义 = { name, parent(可 null), len, ang(相对父骨的静息角), x, y(相对父骨末端的静息偏移,屏幕轴) }
+// —— 偏移不随父骨姿态旋转(肩/髋偏移只有几像素,误差可忽略,换来简单可预测)。
+// 姿势 pose = 扁平对象:{ [boneName]: Δ角(弧度), 'root.x': Δx, 'root.y': Δy, 'root.rot': Δ整体角 }
+// 只有出现键的骨骼会偏离静息角,其余保持静息 —— 片段只需声明关心的轨道。
+// 关键帧片段 clip = { dur: 秒, loop: bool, tracks: { 键: [[t, 值, 缓动名?], ...] } },t ∈ [0,1]。
+
+export const TAU = Math.PI * 2
+
+// =====================================================================
+// 缓动
+// =====================================================================
+export const EASE = {
+ linear: (t) => t,
+ in: (t) => t * t,
+ out: (t) => 1 - (1 - t) * (1 - t),
+ inOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
+ outBack: (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2),
+ inBack: (t) => 2.7 * t * t * t - 1.7 * t * t,
+}
+
+// =====================================================================
+// 骨架
+// =====================================================================
+/** def 必须按「父在子前」的拓扑序给出 */
+export function defineSkeleton(def) {
+ const bones = {}
+ const order = []
+ def.forEach((b) => {
+ bones[b.name] = {
+ name: b.name,
+ parent: b.parent || null,
+ len: b.len || 0,
+ ang: b.ang || 0,
+ x: b.x || 0,
+ y: b.y || 0,
+ }
+ order.push(b.name)
+ })
+ return { bones, order }
+}
+
+/**
+ * 由姿势计算每个骨骼的世界状态。
+ * 返回 { [boneName]: { x, y, ang, cos, sin, ex, ey } }(起点/绝对角/末端)。
+ * root.x/root.y 平移整副骨架,root.rot 绕根骨骼原点旋转。
+ */
+export function computePose(skel, pose = {}) {
+ const out = {}
+ const rx = pose['root.x'] || 0
+ const ry = pose['root.y'] || 0
+ const rot = pose['root.rot'] || 0
+ for (const name of skel.order) {
+ const b = skel.bones[name]
+ const p = b.parent ? out[b.parent] : null
+ const ang = (p ? p.ang : rot) + b.ang + (pose[name] || 0)
+ const cos = Math.cos(ang)
+ const sin = Math.sin(ang)
+ const x = (p ? p.ex + b.x : b.x) + (p ? 0 : rx)
+ const y = (p ? p.ey + b.y : b.y) + (p ? 0 : ry)
+ out[name] = { x, y, ang, cos, sin, ex: x + b.len * cos, ey: y + b.len * sin }
+ }
+ return out
+}
+
+// =====================================================================
+// 关键帧采样 / 混合
+// =====================================================================
+/** 按 [0,1] 归一化进度采样片段,返回姿势对象(只含该片段声明的轨道) */
+export function sampleClip(clip, tNorm) {
+ const pose = {}
+ if (!clip) return pose
+ const t = clip.loop
+ ? tNorm - Math.floor(tNorm)
+ : Math.max(0, Math.min(1, tNorm))
+ for (const key in clip.tracks) {
+ const ks = clip.tracks[key]
+ if (!ks || !ks.length) continue
+ let v
+ if (t <= ks[0][0]) {
+ v = ks[0][1]
+ } else if (t >= ks[ks.length - 1][0]) {
+ v = ks[ks.length - 1][1]
+ } else {
+ let i = 0
+ while (i < ks.length - 2 && ks[i + 1][0] <= t) i++
+ const t0 = ks[i][0]
+ const v0 = ks[i][1]
+ const t1 = ks[i + 1][0]
+ const v1 = ks[i + 1][1]
+ const ease = EASE[ks[i][2]] || EASE.inOut
+ v = v0 + (v1 - v0) * ease((t - t0) / ((t1 - t0) || 1e-6))
+ }
+ pose[key] = v
+ }
+ return pose
+}
+
+/** 按实际秒数采样(内部换算 dur) */
+export function clipPose(clip, timeSec) {
+ if (!clip) return {}
+ return sampleClip(clip, timeSec / (clip.dur || 1))
+}
+
+/**
+ * 交叉淡化两个姿势:w=0 完全 a,w=1 完全 b。
+ * 只在单侧出现的轨道会淡入/淡出到 0(对静息角)。
+ */
+export function blendPose(a, b, w) {
+ const out = {}
+ for (const k in a) out[k] = a[k] * (1 - w)
+ for (const k in b) out[k] = (out[k] || 0) + b[k] * w
+ return out
+}
+
+/** 加性叠加(呼吸、眨眼等微动层) */
+export function addPose(a, b) {
+ const out = {}
+ for (const k in a) out[k] = a[k]
+ for (const k in b) out[k] = (out[k] || 0) + b[k]
+ return out
+}
+
+// =====================================================================
+// 手绘抖动(沸腾线)
+// =====================================================================
+function fract(x) {
+ return x - Math.floor(x)
+}
+function noise2(i, seed, tq) {
+ return fract(Math.sin(i * 127.1 + seed * 311.7 + tq * 74.7) * 43758.5453) - 0.5
+}
+/**
+ * 确定性抖动折线点:seed 相同 + tq 相同 → 结果稳定;
+ * tq 用 qTime(time) 量化后,跨帧按低帧率跳变,产生手绘动画的"沸腾线"质感。
+ */
+export function wobblePts(pts, seed = 1, amp = 0.7, tq = 0) {
+ return pts.map(([x, y], i) => [
+ x + noise2(i, seed, tq) * amp * 2,
+ y + noise2(i + 57, seed, tq) * amp * 2,
+ ])
+}
+/** 量化时间:fps 越低手绘卡顿感越强(常用 8~10) */
+export function qTime(time, fps = 9) {
+ return Math.floor(time * fps)
+}
+
+/** 椭圆 → 多边形顶点(供 blob 抖动填充),n 为边数 */
+export function ellipsePts(cx, cy, rx, ry, n = 14, rot = 0) {
+ const pts = []
+ const cr = Math.cos(rot)
+ const sr = Math.sin(rot)
+ for (let i = 0; i < n; i++) {
+ const a = (i / n) * TAU
+ const px = Math.cos(a) * rx
+ const py = Math.sin(a) * ry
+ pts.push([cx + px * cr - py * sr, cy + px * sr + py * cr])
+ }
+ return pts
+}
+
+/** 圆角矩形 → 多边形顶点 */
+export function boxPts(x, y, w, h, r = 0, n = 3) {
+ const pts = []
+ const cs = [
+ [x + w - r, y + r, -Math.PI / 2, 0],
+ [x + w - r, y + h - r, 0, Math.PI / 2],
+ [x + r, y + h - r, Math.PI / 2, Math.PI],
+ [x + r, y + r, Math.PI, Math.PI * 1.5],
+ ]
+ cs.forEach(([cx, cy, a0, a1]) => {
+ for (let i = 0; i <= n; i++) {
+ const a = a0 + ((a1 - a0) * i) / n
+ pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
+ }
+ })
+ return pts
+}
+
+/**
+ * 抖动多边形填充+描边(描边色默认 #1d1409 与全局风格一致)。
+ * 返回实际绘制的点(可继续叠加细节)。
+ */
+export function blob(c, pts, o = {}) {
+ const wp = o.amp ? wobblePts(pts, o.seed || 1, o.amp, o.tq || 0) : pts
+ c.beginPath()
+ c.moveTo(wp[0][0], wp[0][1])
+ for (let i = 1; i < wp.length; i++) c.lineTo(wp[i][0], wp[i][1])
+ c.closePath()
+ if (o.fill) {
+ c.fillStyle = o.fill
+ c.fill()
+ }
+ if (o.stroke) {
+ c.strokeStyle = o.stroke
+ c.lineWidth = o.lw || 1.6
+ c.stroke()
+ }
+ return wp
+}
+
+/** 骨骼肢体:圆帽线段 */
+export function boneStroke(c, x, y, ex, ey, w, color) {
+ c.strokeStyle = color
+ c.lineWidth = w
+ c.lineCap = 'round'
+ c.lineJoin = 'round'
+ c.beginPath()
+ c.moveTo(x, y)
+ c.lineTo(ex, ey)
+ c.stroke()
+}
+
+/** 锥形胶囊肢体:起点宽 w0 → 末端宽 w1,可填充+描边 */
+export function taper(c, x, y, ex, ey, w0, w1, fill, stroke, lw = 1.3) {
+ const dx = ex - x
+ const dy = ey - y
+ const d = Math.hypot(dx, dy) || 1
+ const nx = -dy / d
+ const ny = dx / d
+ const a0 = Math.atan2(ny, nx)
+ c.beginPath()
+ c.moveTo(x + nx * w0 * 0.5, y + ny * w0 * 0.5)
+ c.lineTo(ex + nx * w1 * 0.5, ey + ny * w1 * 0.5)
+ c.arc(ex, ey, w1 * 0.5, a0, a0 - Math.PI, true)
+ c.lineTo(x - nx * w0 * 0.5, y - ny * w0 * 0.5)
+ c.arc(x, y, w0 * 0.5, a0 + Math.PI, a0, true)
+ c.closePath()
+ if (fill) {
+ c.fillStyle = fill
+ c.fill()
+ }
+ if (stroke) {
+ c.strokeStyle = stroke
+ c.lineWidth = lw || 1.3
+ c.stroke()
+ }
+}
+
+// =====================================================================
+// 颜色工具(不改原始皮肤数据,绘制时做明暗/混色)
+// =====================================================================
+export function hex2rgb(hex) {
+ let h = hex.replace('#', '')
+ if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('')
+ const n = parseInt(h, 16)
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
+}
+/** k > 0 往白混(提亮),k < 0 往黑混(压暗) */
+export function shade(hex, k) {
+ const [r, g, b] = hex2rgb(hex)
+ const f = (v) => Math.round(k >= 0 ? v + (255 - v) * k : v * (1 + k))
+ return `rgb(${f(r)},${f(g)},${f(b)})`
+}
+export function mixc(h1, h2, t) {
+ const a = hex2rgb(h1)
+ const b = hex2rgb(h2)
+ const f = (i) => Math.round(a[i] + (b[i] - a[i]) * t)
+ return `rgb(${f(0)},${f(1)},${f(2)})`
+}
+export function rgba(hex, a) {
+ const [r, g, b] = hex2rgb(hex)
+ return `rgba(${r},${g},${b},${a})`
+}
+
+// =====================================================================
+// 渐变缓存(CanvasGradient 按调用方 ctx 分开缓存)
+// =====================================================================
+const gradCache = new WeakMap()
+export function linGrad(c, key, x0, y0, x1, y1, stops) {
+ let m = gradCache.get(c)
+ if (!m) {
+ m = new Map()
+ gradCache.set(c, m)
+ }
+ let g = m.get(key)
+ if (!g) {
+ g = c.createLinearGradient(x0, y0, x1, y1)
+ stops.forEach(([o, col]) => g.addColorStop(o, col))
+ m.set(key, g)
+ }
+ return g
+}
diff --git a/src/games/starve/spatial.js b/src/games/starve/spatial.js
new file mode 100644
index 0000000..65ea4c0
--- /dev/null
+++ b/src/games/starve/spatial.js
@@ -0,0 +1,94 @@
+// 空间散列网格:把高频的「附近实体」查询从 O(实体总数) 线性扫描降为 O(附近实体数)。
+// 只索引静止实体(树/石/建筑/掉落物等);mobile 实体(兔子等会动的)不入网格,
+// 由 query() 附加线性补扫(数量少)。实体在网格中的位置不随移动更新——这正是
+// 只收静止实体的原因;带 e.mobile 标记的实体一律走补扫。
+// 典型调用方:blockedBySolid / nearCampWall(mobs.js)、点击与光标检测、
+// refreshNearActions / doGather / doAttack / pickAutoInteractTarget(StarveGame)。
+export function createEntGrid(cell = 160) {
+ let cells = new Map() // cellKey -> ent[]
+ let mobile = [] // 会动的实体(e.mobile),线性补扫
+ let count = 0
+
+ const keyOf = (cx, cy) => cx * 100003 + cy
+
+ function add(e) {
+ if (e._inGrid) return
+ if (e.mobile) {
+ if (!mobile.includes(e)) mobile.push(e)
+ e._inGrid = true
+ return
+ }
+ const r = Math.max(8, e.size || 16)
+ const x0 = Math.floor((e.x - r) / cell)
+ const x1 = Math.floor((e.x + r) / cell)
+ const y0 = Math.floor((e.y - r) / cell)
+ const y1 = Math.floor((e.y + r) / cell)
+ for (let cx = x0; cx <= x1; cx++) {
+ for (let cy = y0; cy <= y1; cy++) {
+ const k = keyOf(cx, cy)
+ let arr = cells.get(k)
+ if (!arr) cells.set(k, (arr = []))
+ arr.push(e)
+ }
+ }
+ e._inGrid = true
+ count++
+ }
+
+ function remove(e) {
+ if (!e._inGrid) return
+ e._inGrid = false
+ if (e.mobile) {
+ const i = mobile.indexOf(e)
+ if (i >= 0) mobile.splice(i, 1)
+ return
+ }
+ const r = Math.max(8, e.size || 16)
+ const x0 = Math.floor((e.x - r) / cell)
+ const x1 = Math.floor((e.x + r) / cell)
+ const y0 = Math.floor((e.y - r) / cell)
+ const y1 = Math.floor((e.y + r) / cell)
+ for (let cx = x0; cx <= x1; cx++) {
+ for (let cy = y0; cy <= y1; cy++) {
+ const arr = cells.get(keyOf(cx, cy))
+ if (!arr) continue
+ const i = arr.indexOf(e)
+ if (i >= 0) arr.splice(i, 1)
+ if (!arr.length) cells.delete(keyOf(cx, cy))
+ }
+ }
+ count--
+ }
+
+ // 圆范围查询:返回候选实体数组(网格命中 + mobile 补扫)。调用方自行做精确距离过滤。
+ function query(x, y, r) {
+ const out = []
+ const x0 = Math.floor((x - r) / cell)
+ const x1 = Math.floor((x + r) / cell)
+ const y0 = Math.floor((y - r) / cell)
+ const y1 = Math.floor((y + r) / cell)
+ for (let cx = x0; cx <= x1; cx++) {
+ for (let cy = y0; cy <= y1; cy++) {
+ const arr = cells.get(keyOf(cx, cy))
+ if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i])
+ }
+ }
+ for (let i = 0; i < mobile.length; i++) out.push(mobile[i])
+ return out
+ }
+
+ function rebuild(list) {
+ cells = new Map()
+ mobile = []
+ count = 0
+ for (const e of list) add(e)
+ }
+
+ return {
+ add,
+ remove,
+ query,
+ rebuild,
+ get size() { return count },
+ }
+}
diff --git a/src/games/starve/world.js b/src/games/starve/world.js
index c205835..f017f77 100644
--- a/src/games/starve/world.js
+++ b/src/games/starve/world.js
@@ -12,7 +12,7 @@ export const WORLD = { w: GW * TILE, h: GH * TILE } // 3200 x 2400
// 世界尺寸可配置:GW/GH 为模块级 let,genIsland/planEntities/genDecor/drawTiles/tileIdx
// 等函数闭包引用它们(ES module 实时绑定),改尺寸后自动跟随;WORLD 保持同一对象只改属性。
export function configureWorld(scale = 1) {
- const s = Math.max(0.5, Math.min(2, Number(scale) || 1))
+ const s = Math.max(0.5, Math.min(4, Number(scale) || 1))
GW = Math.round(64 * s)
GH = Math.round(48 * s)
WORLD.w = GW * TILE
@@ -234,6 +234,25 @@ export function planEntities(tiles, road) {
ensure('beehive', 2, [3])
ensure('goldrock', 4, [5])
ensure('rabbithole', 5, [1, 2])
+ ensure('cavein', 2, [5])
+ // 4) 池塘(钓鱼点):贴水的陆地瓦片随机挑几个
+ const pondSpots = []
+ for (let ty = 1; ty < GH - 1; ty++) {
+ for (let tx = 1; tx < GW - 1; tx++) {
+ const i = ty * GW + tx
+ if (!tiles[i] || road[i]) continue
+ if (!tiles[i - 1] || !tiles[i + 1] || !tiles[i - GW] || !tiles[i + GW]) pondSpots.push(i)
+ }
+ }
+ for (let i = pondSpots.length - 1; i > 0; i--) {
+ const j = (Math.random() * (i + 1)) | 0
+ ;[pondSpots[i], pondSpots[j]] = [pondSpots[j], pondSpots[i]]
+ }
+ const pondN = Math.min(pondSpots.length, 5)
+ for (let k = 0; k < pondN; k++) {
+ const i = pondSpots[k]
+ put('pond', (i % GW) * TILE + 20 + Math.random() * 40, Math.floor(i / GW) * TILE + 20 + Math.random() * 40)
+ }
ensure('tentacle', 4, [6])
ensure('reeds', 6, [6])
// 4) 猪人村:草地/森林挑 2 处,每处 3 座猪屋围成一圈
diff --git a/src/games/starveSkins.js b/src/games/starveSkins.js
index a0dc6ca..72c0fa5 100644
--- a/src/games/starveSkins.js
+++ b/src/games/starveSkins.js
@@ -1,9 +1,16 @@
-// 饥荒人物皮肤模块:45 款皮肤绘制参数 + 体型/服装/五官分支矢量绘制 + 离屏立绘预览
+// 饥荒人物皮肤模块:47 款皮肤绘制参数 + 骨骼动画人形绘制 + 手持工具 + 离屏立绘预览
// code 与后端 game_skins 种子数据一一对应;StarveGame(本人/队友)与 GamePlay 更衣室共用
// figure: human / bulky / robot / slim / plant
// outfit: vest / dress / plaid / coat / robe / armor / suit / stripes / apron / tank
// face: default / beard / mustache / robot / mime / glasses
// hair: spiky / flat / long / curly / crown / pigtails / braids / none / toque / bun / chef / horns / fuzzy / leafy / fins / scout
+// 绘制层基于 starve/skel.js 骨骼引擎:两段式四肢 + 关键帧动作 + 手绘沸腾线,造型向饥荒原版靠拢
+import {
+ defineSkeleton, computePose, sampleClip, clipPose, blendPose, addPose,
+ blob, boneStroke, taper, ellipsePts, boxPts, wobblePts, qTime,
+ linGrad, shade, mixc, rgba, TAU,
+} from './starve/skel.js'
+
export const SKINS = [
{ code: 'wilson', name: '威尔逊', figure: 'human', outfit: 'vest', face: 'beard', hair: 'spiky', hairColor: '#15100c', skinTone: '#f3dfc2', vestColor: '#a33d3d', shirtColor: '#e8e2d4' },
{ code: 'willow', name: '薇洛', figure: 'slim', outfit: 'dress', face: 'default', hair: 'pigtails', hairColor: '#8a2f22', skinTone: '#f3dfc2', vestColor: '#c45a3a', shirtColor: '#d8d2c4' },
@@ -83,301 +90,1062 @@ export function filterSkins(list, tab = 'all') {
return out
}
-// 通用描边填充
-function fs(c, fill, stroke = '#1d1409', lw = 2) {
+// =====================================================================
+// 通用小助手
+// =====================================================================
+const OUT = '#1d1409'
+function fs(c, fill, stroke = OUT, lw = 1.7) {
if (fill) { c.fillStyle = fill; c.fill() }
if (stroke) { c.strokeStyle = stroke; c.lineWidth = lw; c.stroke() }
}
+function hashStr(s) {
+ let h = 7
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
+ return Math.abs(h) % 9973
+}
-// 发型绘制(以头心 (0, headY) 为基准,头半径 8.4)
-function drawHair(c, skin, headY) {
- c.fillStyle = skin.hairColor
- if (skin.hair === 'spiky') {
- // 威尔逊式尖刺
+// =====================================================================
+// 人形骨架:饥荒式比例(头大、四肢细),五体型重定向
+// =====================================================================
+const FIGS = {
+ human: { hipY: -23.5, spine: 9, chest: 8.5, neck: 2.5, headR: 10.4, armU: 8.5, armF: 8, legU: 11.5, legF: 11.5, hipW: 4.6, shW: 6.6, armW: 3.3, legW: 4.1 },
+ slim: { hipY: -22, spine: 8.4, chest: 8, neck: 2.4, headR: 10.2, armU: 7.9, armF: 7.4, legU: 10.8, legF: 10.8, hipW: 4, shW: 5.7, armW: 2.8, legW: 3.5 },
+ bulky: { hipY: -26, spine: 10, chest: 9.5, neck: 2.8, headR: 10.8, armU: 9.6, armF: 9, legU: 12.8, legF: 12.8, hipW: 5.6, shW: 8.4, armW: 4.4, legW: 5.2 },
+ robot: { hipY: -23.5, spine: 9, chest: 8.5, neck: 2.5, headR: 10.2, armU: 8.5, armF: 8, legU: 11.5, legF: 11.5, hipW: 5, shW: 7, armW: 3.6, legW: 4.4 },
+ plant: { hipY: -22.5, spine: 8.6, chest: 8.2, neck: 2.4, headR: 10.8, armU: 8.1, armF: 7.6, legU: 11, legF: 11, hipW: 4.4, shW: 6, armW: 3, legW: 3.8 },
+}
+const skelCache = {}
+function skelFor(skin) {
+ const fig = skin.figure || 'human'
+ if (!skelCache[fig]) {
+ const f = FIGS[fig] || FIGS.human
+ skelCache[fig] = defineSkeleton([
+ { name: 'hips', y: f.hipY },
+ { name: 'spine', parent: 'hips', len: f.spine, ang: -Math.PI / 2 },
+ { name: 'chest', parent: 'spine', len: f.chest },
+ { name: 'neck', parent: 'chest', len: f.neck },
+ { name: 'head', parent: 'neck', y: -f.headR * 0.5 },
+ // 手臂:肩挂在颈根两侧,后臂(A)在前臂(F)之后绘制
+ // 注意 chest 链的静息角是 -π/2(脊柱向上),所以手臂要垂下需补 π
+ { name: 'armA.u', parent: 'chest', x: -f.shW * 0.52, y: f.chest * 0.2, len: f.armU, ang: Math.PI + 0.3 },
+ { name: 'armA.f', parent: 'armA.u', len: f.armF, ang: 0.12 },
+ { name: 'armF.u', parent: 'chest', x: f.shW * 0.52, y: f.chest * 0.2, len: f.armU, ang: Math.PI - 0.26 },
+ { name: 'armF.f', parent: 'armF.u', len: f.armF, ang: -0.08 },
+ // 腿:挂髋部两侧
+ { name: 'legA.u', parent: 'hips', x: -f.hipW * 0.55, len: f.legU, ang: Math.PI / 2 + 0.15 },
+ { name: 'legA.f', parent: 'legA.u', len: f.legF, ang: -0.07 },
+ { name: 'legF.u', parent: 'hips', x: f.hipW * 0.55, len: f.legU, ang: Math.PI / 2 - 0.13 },
+ { name: 'legF.f', parent: 'legF.u', len: f.legF, ang: 0.05 },
+ ])
+ }
+ return skelCache[fig]
+}
+function figOf(skin) {
+ return FIGS[skin.figure] || FIGS.human
+}
+
+// =====================================================================
+// 关键帧片段:走路 / 待机 / 各动作(t = 1 - swing,0 起手 → 1 收势)
+// 角度均为对静息角的增量;方向约定:偏小 = 向前(+x),偏大 = 向后
+// =====================================================================
+const CLIP_WALK = {
+ dur: 0.62, loop: true,
+ tracks: {
+ 'legF.u': [[0, -0.5], [0.2, -0.08], [0.5, 0.46], [0.8, -0.04], [1, -0.5]],
+ 'legF.f': [[0, 0.1], [0.2, 0.32], [0.5, 0.6], [0.72, 1.05], [1, 0.1]],
+ 'legA.u': [[0, 0.46], [0.3, -0.04], [0.5, -0.5], [0.7, -0.08], [1, 0.46]],
+ 'legA.f': [[0, 0.6], [0.22, 1.05], [0.5, 0.1], [0.8, 0.32], [1, 0.6]],
+ 'root.y': [[0, 1.1], [0.25, -0.7], [0.5, 1.1], [0.75, -0.7], [1, 1.1]],
+ spine: [[0, 0.09]],
+ head: [[0, -0.05]],
+ 'armF.u': [[0, 0.42], [0.5, -0.52], [1, 0.42]],
+ 'armF.f': [[0, -0.3], [0.5, -0.75], [1, -0.3]],
+ 'armA.u': [[0, -0.52], [0.5, 0.42], [1, -0.52]],
+ 'armA.f': [[0, -0.75], [0.5, -0.3], [1, -0.75]],
+ },
+}
+const CLIP_IDLE = {
+ dur: 3.4, loop: true,
+ tracks: {
+ spine: [[0, 0.02], [0.5, 0.055], [1, 0.02]],
+ 'armF.u': [[0, 0.02], [0.5, 0.09], [1, 0.02]],
+ 'armA.u': [[0, -0.02], [0.5, 0.06], [1, -0.02]],
+ head: [[0, 0.02], [0.35, -0.03], [0.7, 0.03], [1, 0.02]],
+ },
+}
+// 动作关键帧(带预备-发力-收势);'tool' 为手腕相对前臂的工具角
+const ACT_CLIPS = {
+ chop: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, -3.1], [0.28, -3.5, 'inOut'], [0.52, -0.55, 'in'], [0.74, -0.7, 'out'], [1, -0.12, 'inOut']],
+ 'armF.f': [[0, -0.55], [0.28, -0.65], [0.52, 0.08], [1, -0.05]],
+ spine: [[0, -0.2], [0.28, -0.26], [0.52, 0.3], [0.74, 0.22], [1, 0.02]],
+ head: [[0, -0.12], [0.52, 0.2], [1, 0]],
+ 'root.y': [[0, 0.9], [0.28, 1.2], [0.52, 1.8], [1, 0]],
+ 'root.x': [[0, -1.6], [0.28, -2.2], [0.52, 2.6], [1, 0]],
+ 'armA.u': [[0, -0.7], [0.52, 0.4], [1, 0]],
+ 'legF.u': [[0, -0.1], [0.52, -0.3], [1, 0]],
+ 'legA.u': [[0, 0.08], [0.52, 0.22], [1, 0]],
+ tool: [[0, -3.55], [0.28, -3.9], [0.52, -0.2, 'in'], [0.74, -0.35, 'out'], [1, -3.5, 'inOut']],
+ } },
+ mine: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, -3.3], [0.4, -3.65, 'inOut'], [0.62, -0.35, 'in'], [0.8, -0.55, 'out'], [1, -0.1]],
+ 'armF.f': [[0, -0.5], [0.4, -0.6], [0.62, 0.05], [1, -0.05]],
+ spine: [[0, -0.26], [0.4, -0.32], [0.62, 0.34], [0.8, 0.26], [1, 0.03]],
+ head: [[0, -0.14], [0.62, 0.22], [1, 0]],
+ 'root.y': [[0, 1.1], [0.4, 1.4], [0.62, 2.2], [1, 0]],
+ 'root.x': [[0, -1.2], [0.62, 2.2], [1, 0]],
+ 'armA.u': [[0, -0.8], [0.62, 0.45], [1, 0]],
+ 'legF.u': [[0, -0.12], [0.62, -0.32], [1, 0]],
+ 'legA.u': [[0, 0.1], [0.62, 0.24], [1, 0]],
+ tool: [[0, -3.55], [0.4, -3.9], [0.62, 0, 'in'], [0.8, -0.15, 'out'], [1, -3.5, 'inOut']],
+ } },
+ dig: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, -0.55], [0.32, 0.5, 'in'], [0.62, -1.75, 'out'], [1, -0.35]],
+ 'armF.f': [[0, -0.9], [0.32, -0.4], [0.62, -1.5], [1, -0.8]],
+ spine: [[0, 0.16], [0.32, 0.42], [0.62, 0.06], [1, 0.12]],
+ head: [[0, 0.1], [0.32, 0.24], [1, 0]],
+ 'root.y': [[0, 0.6], [0.32, 2.2], [0.62, 0.8], [1, 0]],
+ 'armA.u': [[0, 0.2], [0.32, 0.5], [0.62, -0.3], [1, 0]],
+ 'legF.u': [[0, -0.06], [0.32, -0.22], [1, 0]],
+ 'legA.u': [[0, 0.06], [0.32, 0.2], [1, 0]],
+ tool: [[0, -3.5], [0.32, 0.4, 'in'], [0.62, -0.7, 'out'], [1, -3.5, 'inOut']],
+ } },
+ attack: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, 0.12], [0.3, -0.5, 'out'], [0.52, -0.62, 'out'], [0.72, -0.5], [1, 0]],
+ 'armF.f': [[0, 0.35], [0.52, -0.05], [1, 0.12]],
+ 'root.x': [[0, 0], [0.4, 6.5, 'out'], [0.62, 5.5], [1, 0]],
+ spine: [[0, 0], [0.5, 0.16], [1, 0]],
+ 'armA.u': [[0, 0.1], [0.52, -0.35], [1, 0]],
+ tool: [[0, -2.95], [0.52, -0.65, 'out'], [1, -2.9, 'inOut']],
+ } },
+ eat: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, -0.5], [0.38, -1.35, 'out'], [0.68, -1.5, 'inOut'], [1, -0.25]],
+ 'armF.f': [[0, -1.1], [0.38, -2.05], [0.68, -1.95], [1, -0.7]],
+ head: [[0, -0.04], [0.38, -0.12], [1, 0]],
+ 'armA.u': [[0, 0.05]],
+ } },
+ swing: { dur: 0.3, loop: false, tracks: {
+ 'armF.u': [[0, -1.9], [0.3, -2.3, 'inOut'], [0.55, -0.5, 'in'], [0.75, -0.65, 'out'], [1, -0.1]],
+ 'armF.f': [[0, -0.5], [0.3, -0.7], [0.55, 0], [1, -0.1]],
+ spine: [[0, -0.14], [0.55, 0.24], [1, 0.02]],
+ 'root.x': [[0, -1], [0.55, 1.8], [1, 0]],
+ 'armA.u': [[0, -0.5], [0.55, 0.3], [1, 0]],
+ tool: [[0, -3.55], [0.3, -3.9], [0.55, -0.3, 'in'], [1, -3.5, 'inOut']],
+ } },
+}
+// 动作视觉命中点(t 归一化)——供特效延迟对齐:delay = t / SWING_RATE
+export const SWING_RATE = 3.4
+export const IMPACT_T = { chop: 0.5, mine: 0.58, dig: 0.3, attack: 0.35, swing: 0.5 }
+
+// =====================================================================
+// 皮肤派生色板(不改 SKINS 原始数据)
+// =====================================================================
+const palCache = {}
+function palOf(skin) {
+ const key = skin.code
+ if (palCache[key]) return palCache[key]
+ const st = skin.skinTone || '#f3dfc2'
+ const p = {
+ seed: hashStr(key),
+ skin: st,
+ skinLo: shade(st, -0.2),
+ skinHi: shade(st, 0.26),
+ hair: skin.hairColor || '#15100c',
+ hairLo: shade(skin.hairColor || '#15100c', -0.32),
+ hairHi: shade(skin.hairColor || '#15100c', 0.24),
+ shirt: skin.shirtColor || '#e8e2d4',
+ shirtLo: shade(skin.shirtColor || '#e8e2d4', -0.24),
+ shirtHi: shade(skin.shirtColor || '#e8e2d4', 0.26),
+ vest: skin.vestColor || '#a33d3d',
+ vestLo: shade(skin.vestColor || '#a33d3d', -0.26),
+ vestHi: shade(skin.vestColor || '#a33d3d', 0.22),
+ }
+ palCache[key] = p
+ return p
+}
+// 袖子/裤腿按 outfit 区分(full 全袖 / short 短袖 / none 无袖)
+function sleeveOf(skin) {
+ const fig = skin.figure
+ if (fig === 'robot') return { upper: '#6a7280', fore: '#59616d', hand: '#454c56', kind: 'full' }
+ if (fig === 'plant') return { upper: '#5a7a38', fore: '#4c6c30', hand: skin.skinTone, kind: 'full' }
+ const o = skin.outfit
+ if (o === 'tank') return { upper: skin.skinTone, fore: skin.skinTone, hand: skin.skinTone, kind: 'none' }
+ if (o === 'dress') return { upper: skin.vestColor, fore: skin.skinTone, hand: skin.skinTone, kind: 'short' }
+ if (o === 'vest' || o === 'apron' || o === 'stripes' || o === 'plaid') return { upper: skin.shirtColor, fore: skin.shirtColor, hand: skin.skinTone, kind: 'full' }
+ return { upper: skin.vestColor, fore: skin.vestColor, hand: skin.skinTone, kind: 'full' }
+}
+function legOf(skin) {
+ const fig = skin.figure
+ if (fig === 'robot') return { col: '#59616d', foot: '#3a4048' }
+ if (fig === 'plant') return { col: '#4a6a30', foot: '#3a5424' }
+ const o = skin.outfit
+ if (o === 'dress') return { col: skin.skinTone, foot: '#4a3040' }
+ if (o === 'robe') return { col: shade(skin.vestColor, -0.12), foot: '#3a2c20' }
+ if (o === 'suit' || o === 'coat' || o === 'armor') return { col: '#372f28', foot: '#241c15' }
+ return { col: '#463c30', foot: '#2a2118' }
+}
+
+// =====================================================================
+// 躯干(outfit 十款,脊柱局部坐标系:原点=髋,-y 沿脊柱向上)
+// =====================================================================
+function torsoShape(c, f, yTop, yBot, wTop, wBot, hem = 0) {
+ const w = f.shW
+ c.beginPath()
+ c.moveTo(-w * wTop, yTop)
+ c.quadraticCurveTo(-w * wTop - 1, (yTop + yBot) / 2, -w * wBot, yBot + hem)
+ c.quadraticCurveTo(0, yBot + hem + 2.2, w * wBot, yBot + hem)
+ c.quadraticCurveTo(w * wTop + 1, (yTop + yBot) / 2, w * wTop, yTop)
+ c.quadraticCurveTo(0, yTop - 2, -w * wTop, yTop)
+ c.closePath()
+}
+function gradFill(c, id, y0, y1, lo, mid, hi, lw = 1.7) {
+ c.fillStyle = linGrad(c, id, 0, y0, 0, y1, [[0, hi], [0.45, mid], [1, lo]])
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = lw
+ c.stroke()
+}
+const shY = (f) => -(f.spine + f.chest)
+
+function drawTorso(c, skin, pal, f, tq) {
+ const top = shY(f)
+ const o = skin.outfit || 'vest'
+ const seed = pal.seed
+ const robot = skin.figure === 'robot'
+ if (robot) {
+ // 机器人:金属胸腔 + 铆钉 + 发光核心
c.beginPath()
- c.moveTo(-8, headY - 2)
- c.lineTo(-9, headY - 12)
- c.lineTo(-4.5, headY - 6.5)
- c.lineTo(-3, headY - 15)
- c.lineTo(1, headY - 7)
- c.lineTo(4, headY - 13)
- c.lineTo(6.5, headY - 5.5)
- c.lineTo(8.4, headY - 1)
- c.quadraticCurveTo(4, headY - 7.5, -8, headY - 2)
- c.closePath()
- c.fill()
- } else if (skin.hair === 'flat') {
- // 平刘海盖头
- c.beginPath()
- c.arc(0, headY - 1.5, 8.6, Math.PI, 0)
- c.quadraticCurveTo(8, headY - 6, 5, headY - 5.5)
- c.lineTo(-5, headY - 5.5)
- c.quadraticCurveTo(-8, headY - 6, -8.6, headY - 1.5)
- c.closePath()
- c.fill()
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.2
- c.beginPath()
- c.arc(0, headY - 1.5, 8.6, Math.PI * 1.05, -Math.PI * 0.05)
- c.stroke()
- } else if (skin.hair === 'long') {
- // 披肩长发:顶盖 + 两侧垂发
- c.beginPath()
- c.arc(0, headY - 2, 8.8, Math.PI, 0)
- c.closePath()
- c.fill()
- c.beginPath()
- c.moveTo(-8.6, headY - 2)
- c.quadraticCurveTo(-10.5, headY + 8, -8, headY + 15)
- c.lineTo(-4.5, headY + 13)
- c.quadraticCurveTo(-7, headY + 5, -6.4, headY - 1)
- c.closePath()
- c.fill()
- c.beginPath()
- c.moveTo(8.6, headY - 2)
- c.quadraticCurveTo(10.5, headY + 8, 8, headY + 15)
- c.lineTo(4.5, headY + 13)
- c.quadraticCurveTo(7, headY + 5, 6.4, headY - 1)
- c.closePath()
- c.fill()
- } else if (skin.hair === 'curly') {
- // 蓬卷:一圈圆团
- ;[[-6, headY - 6, 4.4], [0, headY - 9, 5], [6, headY - 6, 4.4], [-3, headY - 8.5, 4], [3, headY - 8.5, 4]].forEach(([hx, hy, r]) => {
- c.beginPath()
- c.arc(hx, hy, r, 0, 7)
- c.fill()
+ torsoShape(c, f, top, 1.5, 0.98, 0.9)
+ gradFill(c, 'rb' + pal.vest, top, 1.5, pal.vestLo, pal.vest, shade(pal.vest, 0.34))
+ c.strokeStyle = OUT
+ c.lineWidth = 1.4
+ c.beginPath(); c.moveTo(-f.shW * 0.6, top + 4); c.lineTo(f.shW * 0.6, top + 4); c.stroke()
+ c.beginPath(); c.moveTo(-f.shW * 0.55, -4); c.lineTo(f.shW * 0.55, -4); c.stroke()
+ const glow = 0.65 + Math.sin((Date.now() % 1e5) / 260) * 0.25
+ c.fillStyle = `rgba(127,224,255,${glow})`
+ c.beginPath(); c.arc(0, -8.5, 2.4, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1.2; c.stroke()
+ c.fillStyle = shade(pal.vest, -0.35)
+ ;[[-f.shW * 0.42, top + 2], [f.shW * 0.42, top + 2], [-f.shW * 0.42, 0], [f.shW * 0.42, 0]].forEach(([x, y]) => {
+ c.beginPath(); c.arc(x, y, 1, 0, TAU); c.fill()
})
- } else if (skin.hair === 'crown') {
- // 短发 + 小金冠
+ return
+ }
+ if (o === 'dress') {
c.beginPath()
- c.arc(0, headY - 2.5, 8.5, Math.PI * 1.05, -Math.PI * 0.05)
- c.quadraticCurveTo(0, headY - 7, -8.2, headY - 3.6)
+ torsoShape(c, f, top, -6.5, 0.86, 0.68)
+ gradFill(c, 'db' + pal.vest, top, -6.5, pal.vestLo, pal.vest, pal.vestHi)
+ // 裙摆
+ c.beginPath()
+ c.moveTo(-f.shW * 0.66, -7)
+ c.quadraticCurveTo(-f.shW * 1.5, 4, -f.shW * 1.42, 12.5)
+ c.quadraticCurveTo(0, 15, f.shW * 1.42, 12.5)
+ c.quadraticCurveTo(f.shW * 1.5, 4, f.shW * 0.66, -7)
c.closePath()
- c.fill()
+ gradFill(c, 'ds' + pal.vest, -7, 13, shade(pal.vest, -0.34), pal.vest, pal.vestHi)
+ // 胸口衬布 + 腰带
c.beginPath()
- c.moveTo(-5.5, headY - 9)
- c.lineTo(-5.5, headY - 14)
- c.lineTo(-2.8, headY - 10.5)
- c.lineTo(0, headY - 15.5)
- c.lineTo(2.8, headY - 10.5)
- c.lineTo(5.5, headY - 14)
- c.lineTo(5.5, headY - 9)
- c.closePath()
- fs(c, '#e8c14d', '#8e6a14', 1.4)
- } else if (skin.hair === 'pigtails') {
- c.beginPath()
- c.arc(0, headY - 2, 8.6, Math.PI, 0)
+ c.moveTo(-f.shW * 0.4, top + 1)
+ c.lineTo(f.shW * 0.4, top + 1)
+ c.lineTo(f.shW * 0.3, -6.5)
+ c.lineTo(-f.shW * 0.3, -6.5)
c.closePath()
+ c.fillStyle = pal.shirt
c.fill()
- ;[-1, 1].forEach((s) => {
- c.beginPath()
- c.moveTo(s * 6.5, headY - 1)
- c.quadraticCurveTo(s * 12, headY + 4, s * 9, headY + 12)
- c.quadraticCurveTo(s * 7, headY + 6, s * 5, headY + 1)
- c.closePath()
- c.fill()
- c.fillStyle = '#c45a3a'
- c.beginPath()
- c.arc(s * 7.2, headY + 1, 1.8, 0, 7)
- c.fill()
- c.fillStyle = skin.hairColor
- })
- } else if (skin.hair === 'braids') {
- c.beginPath()
- c.arc(0, headY - 2.2, 8.5, Math.PI, 0)
- c.closePath()
- c.fill()
- ;[-1, 1].forEach((s) => {
- c.beginPath()
- c.moveTo(s * 6, headY)
- c.quadraticCurveTo(s * 9, headY + 6, s * 6.5, headY + 13)
- c.lineTo(s * 4, headY + 12)
- c.quadraticCurveTo(s * 6, headY + 5, s * 4.5, headY)
- c.closePath()
- c.fill()
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1
- c.beginPath()
- c.moveTo(s * 5.2, headY + 2)
- c.lineTo(s * 6.4, headY + 6)
- c.lineTo(s * 5, headY + 10)
- c.stroke()
- })
- } else if (skin.hair === 'toque') {
- c.beginPath()
- c.arc(0, headY - 1, 8.2, Math.PI * 1.05, -Math.PI * 0.05)
- c.closePath()
- c.fill()
- c.fillStyle = '#8a2c28'
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.5
- c.beginPath()
- c.ellipse(0, headY - 6, 9.4, 4.2, 0, Math.PI, 0)
- c.fill()
- c.stroke()
- c.beginPath()
- c.rect(-7.2, headY - 14, 14.4, 8)
- c.fill()
- c.stroke()
- c.fillStyle = '#f4eee4'
- c.fillRect(-7.2, headY - 8.2, 14.4, 2.2)
- c.beginPath()
- c.arc(0, headY - 15.5, 2.4, 0, 7)
- c.fill()
- c.stroke()
- } else if (skin.hair === 'bun') {
- c.beginPath()
- c.arc(0, headY - 2, 8.5, Math.PI, 0)
- c.closePath()
- c.fill()
- c.beginPath()
- c.arc(0, headY - 11.2, 4.1, 0, 7)
- c.fill()
- c.strokeStyle = '#1d1409'
+ c.strokeStyle = OUT
c.lineWidth = 1.2
c.stroke()
- } else if (skin.hair === 'chef') {
+ c.strokeStyle = shade(pal.vest, -0.4)
+ c.lineWidth = 2.2
+ c.beginPath(); c.moveTo(-f.shW * 0.68, -6.8); c.lineTo(f.shW * 0.68, -6.8); c.stroke()
+ return
+ }
+ if (o === 'robe') {
c.beginPath()
- c.arc(0, headY - 1.5, 8.2, Math.PI, 0)
+ torsoShape(c, f, top, 13.5, 0.92, 1.3)
+ gradFill(c, 'ro' + pal.vest, top, 13.5, pal.vestLo, pal.vest, pal.vestHi)
+ // 中缝开襟
+ c.beginPath()
+ c.moveTo(-1.7, top + 0.5)
+ c.lineTo(1.7, top + 0.5)
+ c.lineTo(2.8, 13)
+ c.lineTo(-2.8, 13)
c.closePath()
+ c.fillStyle = pal.shirt
c.fill()
- c.fillStyle = '#f4eee4'
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.4
- c.beginPath()
- c.ellipse(0, headY - 7.4, 8.6, 2.8, 0, 0, 7)
- c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.2
c.stroke()
+ // 草绳腰带
+ c.strokeStyle = '#c9a86a'
+ c.lineWidth = 2
+ c.beginPath(); c.moveTo(-f.shW * 0.8, -5.5); c.quadraticCurveTo(0, -4.2, f.shW * 0.8, -5.5); c.stroke()
+ c.fillStyle = '#a8854e'
+ c.beginPath(); c.arc(0, -4.6, 1.6, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ return
+ }
+ if (o === 'coat') {
c.beginPath()
- c.ellipse(0, headY - 15.2, 6.4, 7.2, 0, 0, 7)
- c.fill()
- c.stroke()
- } else if (skin.hair === 'horns') {
+ torsoShape(c, f, top, 9, 1, 0.94)
+ gradFill(c, 'co' + pal.vest, top, 9, pal.vestLo, pal.vest, pal.vestHi)
+ // 门襟与翻领
+ c.strokeStyle = pal.shirtLo
+ c.lineWidth = 1.3
+ c.beginPath(); c.moveTo(0.4, top + 1.5); c.lineTo(1.2, 8.5); c.stroke()
c.beginPath()
- c.arc(0, headY - 2, 7.6, Math.PI, 0)
+ c.moveTo(-1, top + 0.5); c.lineTo(-f.shW * 0.55, top + 4.5); c.lineTo(-1, top + 6); c.closePath()
+ fs(c, pal.shirt, OUT, 1.2)
+ c.beginPath()
+ c.moveTo(1, top + 0.5); c.lineTo(f.shW * 0.55, top + 4.5); c.lineTo(1, top + 6); c.closePath()
+ fs(c, pal.shirt, OUT, 1.2)
+ c.fillStyle = pal.vestLo
+ ;[[0.9, top + 8], [1, top + 12], [1.1, 4]].forEach(([x, y]) => {
+ c.beginPath(); c.arc(x, y, 0.9, 0, TAU); c.fill()
+ })
+ return
+ }
+ if (o === 'armor') {
+ // 内衬 + 胸甲
+ c.beginPath()
+ torsoShape(c, f, top, 2, 0.9, 0.8)
+ gradFill(c, 'ar0' + pal.shirt, top, 2, pal.shirtLo, pal.shirt, pal.shirtHi)
+ c.beginPath()
+ torsoShape(c, f, top + 0.5, 0.5, 1.04, 0.88)
+ gradFill(c, 'ar1' + pal.vest, top, 1, shade(pal.vest, -0.34), pal.vest, shade(pal.vest, 0.4))
+ // 中脊与铆钉
+ c.strokeStyle = shade(pal.vest, 0.42)
+ c.lineWidth = 1.3
+ c.beginPath(); c.moveTo(0, top + 2); c.lineTo(0, -1); c.stroke()
+ c.fillStyle = shade(pal.vest, -0.4)
+ ;[[-f.shW * 0.5, top + 2.5], [f.shW * 0.5, top + 2.5], [-f.shW * 0.44, -1.5], [f.shW * 0.44, -1.5]].forEach(([x, y]) => {
+ c.beginPath(); c.arc(x, y, 1.1, 0, TAU); c.fill()
+ })
+ // 腰带
+ c.strokeStyle = '#241a10'
+ c.lineWidth = 2.6
+ c.beginPath(); c.moveTo(-f.shW * 0.86, -1.8); c.lineTo(f.shW * 0.86, -1.8); c.stroke()
+ c.fillStyle = '#c9a03c'
+ c.beginPath(); c.roundRect(-2, -3, 4, 2.6, 0.8); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ return
+ }
+ if (o === 'suit') {
+ c.beginPath()
+ torsoShape(c, f, top, 2, 0.94, 0.84)
+ gradFill(c, 'su' + pal.vest, top, 2, pal.vestLo, pal.vest, pal.vestHi)
+ // 衬衫 V 领 + 领带
+ c.beginPath()
+ c.moveTo(-2.4, top + 0.5)
+ c.lineTo(2.4, top + 0.5)
+ c.lineTo(0, -5.5)
c.closePath()
- c.fill()
- c.fillStyle = '#e8d8c0'
- ;[-1, 1].forEach((s) => {
- c.beginPath()
- c.moveTo(s * 5.2, headY - 6)
- c.quadraticCurveTo(s * 12, headY - 16, s * 3.6, headY - 18)
- c.quadraticCurveTo(s * 8.2, headY - 10, s * 4.4, headY - 5)
- c.closePath()
- c.fill()
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.3
- c.stroke()
- })
- } else if (skin.hair === 'fuzzy') {
- ;[[-7, headY - 4, 5], [0, headY - 8.2, 6], [7, headY - 4, 5], [-3.8, headY - 8, 4.6], [3.8, headY - 8, 4.6]].forEach(([hx, hy, r]) => {
- c.beginPath()
- c.arc(hx, hy, r, 0, 7)
- c.fill()
- })
- } else if (skin.hair === 'leafy') {
- ;[[-6.2, headY - 8, 4.4, -0.5], [0, headY - 11.2, 5.2, 0], [6.2, headY - 8, 4.4, 0.5], [-3, headY - 6, 3.6, -0.2], [3, headY - 6, 3.6, 0.2]].forEach(([hx, hy, r, rot]) => {
- c.beginPath()
- c.ellipse(hx, hy, r, r * 0.52, rot, 0, 7)
- c.fill()
- })
- } else if (skin.hair === 'fins') {
- ;[-1, 1].forEach((s) => {
- c.beginPath()
- c.moveTo(s * 7, headY - 2)
- c.quadraticCurveTo(s * 13.5, headY - 7, s * 8.2, headY + 6)
- c.quadraticCurveTo(s * 6, headY + 1, s * 6.4, headY)
- c.closePath()
- c.fill()
- })
- } else if (skin.hair === 'scout') {
+ fs(c, pal.shirt, OUT, 1.2)
c.beginPath()
- c.arc(0, headY - 2, 8.3, Math.PI, 0)
+ c.moveTo(-1.1, top + 1)
+ c.lineTo(1.1, top + 1)
+ c.lineTo(0.5, -3)
+ c.lineTo(-0.5, -3)
c.closePath()
- c.fill()
- c.fillStyle = '#c45a28'
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.4
+ fs(c, '#8e2c3a', OUT, 1)
+ c.fillStyle = pal.vestLo
+ c.beginPath(); c.arc(0.8, 0, 0.8, 0, TAU); c.fill()
+ return
+ }
+ if (o === 'plaid') {
c.beginPath()
- c.ellipse(0, headY - 6.4, 10.2, 3.1, 0, Math.PI, 0)
- c.fill()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ gradFill(c, 'pl' + pal.shirt, top, 1.5, pal.shirtLo, pal.shirt, pal.shirtHi)
+ c.save()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ c.clip()
+ c.strokeStyle = rgba(skin.vestColor || '#a33d3d', 0.75)
+ c.lineWidth = 1.15
+ for (let x = -f.shW; x <= f.shW; x += 2.6) {
+ c.beginPath(); c.moveTo(x, top); c.lineTo(x + 0.7, 2); c.stroke()
+ }
+ for (let y = top + 1.5; y <= 1.5; y += 2.9) {
+ c.beginPath(); c.moveTo(-f.shW, y); c.lineTo(f.shW, y); c.stroke()
+ }
+ c.restore()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ c.strokeStyle = OUT
+ c.lineWidth = 1.7
c.stroke()
+ return
+ }
+ if (o === 'stripes') {
c.beginPath()
- c.rect(-6.6, headY - 13.2, 13.2, 7.2)
- c.fill()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ gradFill(c, 'st' + pal.shirt, top, 1.5, pal.shirtLo, pal.shirt, pal.shirtHi)
+ c.save()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ c.clip()
+ c.strokeStyle = skin.vestColor || '#c43a3a'
+ c.lineWidth = 2.1
+ for (let y = top + 1.8; y <= 1.5; y += 3) {
+ c.beginPath(); c.moveTo(-f.shW, y); c.lineTo(f.shW, y); c.stroke()
+ }
+ c.restore()
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ c.strokeStyle = OUT
+ c.lineWidth = 1.7
c.stroke()
- c.fillStyle = '#e8c14d'
+ return
+ }
+ if (o === 'apron') {
c.beginPath()
- c.moveTo(0, headY - 11.4)
- c.lineTo(2.1, headY - 8.2)
- c.lineTo(-2.1, headY - 8.2)
+ torsoShape(c, f, top, 1.5, 0.92, 0.82)
+ gradFill(c, 'ap0' + pal.shirt, top, 1.5, pal.shirtLo, pal.shirt, pal.shirtHi)
+ c.beginPath()
+ c.moveTo(-f.shW * 0.62, -7.5)
+ c.lineTo(f.shW * 0.62, -7.5)
+ c.quadraticCurveTo(f.shW * 0.85, -2, f.shW * 0.8, 5)
+ c.lineTo(-f.shW * 0.8, 5)
+ c.quadraticCurveTo(-f.shW * 0.85, -2, -f.shW * 0.62, -7.5)
c.closePath()
+ gradFill(c, 'ap1' + pal.vest, -7.5, 5, pal.vestLo, pal.vest, pal.vestHi)
+ // 挂脖带 + 口袋
+ c.strokeStyle = pal.vestLo
+ c.lineWidth = 1.6
+ c.beginPath(); c.moveTo(-f.shW * 0.4, -7.5); c.lineTo(-f.shW * 0.28, top + 1); c.stroke()
+ c.beginPath(); c.moveTo(f.shW * 0.4, -7.5); c.lineTo(f.shW * 0.28, top + 1); c.stroke()
+ c.strokeStyle = shade(pal.vest, -0.4)
+ c.lineWidth = 1.2
+ c.beginPath(); c.roundRect(-f.shW * 0.42, 0.5, f.shW * 0.84, 3.4, 1.4); c.stroke()
+ return
+ }
+ if (o === 'tank') {
+ // 裸臂胸膛 + 背心
+ c.beginPath()
+ torsoShape(c, f, top, 1.5, 1, 0.86)
+ gradFill(c, 'tk0' + pal.skin, top, 1.5, pal.skinLo, pal.skin, pal.skinHi)
+ // 胸肌线
+ c.strokeStyle = rgba('#000000', 0.14)
+ c.lineWidth = 1.2
+ c.beginPath(); c.arc(f.shW * 0.3, top + 6, 2.4, 0.4, 2.4); c.stroke()
+ c.beginPath(); c.arc(-f.shW * 0.3, top + 6, 2.4, 0.7, 2.7); c.stroke()
+ c.beginPath()
+ torsoShape(c, f, top + 4, 1.5, 0.94, 0.84)
+ gradFill(c, 'tk1' + pal.shirt, top + 4, 1.5, pal.shirtLo, pal.shirt, pal.shirtHi)
+ return
+ }
+ // 默认 = vest:衬衣 + 两片马甲
+ c.beginPath()
+ torsoShape(c, f, top, 1.5, 0.92, 0.8)
+ gradFill(c, 've0' + pal.shirt, top, 1.5, pal.shirtLo, pal.shirt, pal.shirtHi)
+ ;[-1, 1].forEach((s) => {
+ c.beginPath()
+ c.moveTo(s * f.shW * 0.95, top + 0.5)
+ c.lineTo(s * f.shW * 0.3, top + 0.8)
+ c.lineTo(s * f.shW * 0.46, 1.5)
+ c.lineTo(s * f.shW * 0.95, 1.5)
+ c.closePath()
+ gradFill(c, 've1' + pal.vest + s, top, 2, pal.vestLo, pal.vest, pal.vestHi)
+ })
+ // 领口
+ c.strokeStyle = shade(pal.shirt, -0.3)
+ c.lineWidth = 1.2
+ c.beginPath(); c.moveTo(-2.2, top + 0.6); c.lineTo(0, top + 2.6); c.lineTo(2.2, top + 0.6); c.stroke()
+}
+
+// =====================================================================
+// 四肢
+// =====================================================================
+function drawArm(c, skin, pal, u, fo, dims, back) {
+ const sl = sleeveOf(skin)
+ const k = back ? -0.24 : 0
+ const upCol = shade(sl.upper, k)
+ const foCol = shade(sl.fore, k)
+ taper(c, u.x, u.y, u.ex, u.ey, dims.armW, dims.armW * 0.82, upCol, OUT, 1.25)
+ taper(c, fo.x, fo.y, fo.ex, fo.ey, dims.armW * 0.82, dims.armW * 0.62, foCol, OUT, 1.2)
+ // 手
+ c.fillStyle = shade(sl.hand, k)
+ c.beginPath()
+ c.arc(fo.ex, fo.ey, dims.armW * 0.62, 0, TAU)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.15
+ c.stroke()
+}
+function drawLeg(c, skin, u, lo, dims, back) {
+ const lg = legOf(skin)
+ const k = back ? -0.24 : 0
+ taper(c, u.x, u.y, u.ex, u.ey, dims.legW, dims.legW * 0.8, shade(lg.col, k), OUT, 1.3)
+ taper(c, lo.x, lo.y, lo.ex, lo.ey, dims.legW * 0.8, dims.legW * 0.62, shade(lg.col, k), OUT, 1.25)
+ // 鞋
+ c.save()
+ c.translate(lo.ex, lo.ey)
+ c.rotate(lo.ang - Math.PI / 2)
+ c.fillStyle = shade(lg.foot, k)
+ c.beginPath()
+ c.ellipse(1, 0.3, dims.legW * 0.72, dims.legW * 0.42, 0, 0, TAU)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.15
+ c.stroke()
+ c.restore()
+}
+
+// =====================================================================
+// 头部:颅骨 + 五官 + 发型(背/前两层)+ 专属饰品
+// =====================================================================
+function drawSkull(c, skin, pal, f, tq) {
+ const r = f.headR
+ if (skin.figure === 'robot') {
+ blob(c, boxPts(-r * 0.86, -r * 0.92, r * 1.72, r * 1.84, r * 0.34, 2), { fill: linGrad(c, 'rh' + pal.skin, -r, -r, r, r, [[0, shade(pal.skin, 0.2)], [1, shade(pal.skin, -0.16)]]), stroke: OUT, lw: 1.7, amp: 0.35, seed: pal.seed, tq })
+ // 天线
+ c.strokeStyle = OUT
+ c.lineWidth = 1.6
+ c.beginPath(); c.moveTo(-r * 0.3, -r * 0.92); c.quadraticCurveTo(-r * 0.42, -r * 1.5, -r * 0.2, -r * 1.62); c.stroke()
+ const blink = (Date.now() % 1600) < 220
+ c.fillStyle = blink ? '#ffb43c' : '#7fe0ff'
+ c.beginPath(); c.arc(-r * 0.2, -r * 1.66, 1.5, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ return
+ }
+ blob(c, ellipsePts(0, 0, r, r * 1.06, 14), {
+ fill: linGrad(c, 'hd' + pal.skin, -r * 0.6, -r, r * 0.7, r, [[0, pal.skinHi], [0.55, pal.skin], [1, pal.skinLo]]),
+ stroke: OUT, lw: 1.7, amp: 0.4, seed: pal.seed, tq,
+ })
+ // 耳
+ c.beginPath()
+ c.ellipse(-r * 0.78, 0.6, r * 0.16, r * 0.24, -0.3, 0, TAU)
+ fs(c, pal.skin, OUT, 1.1)
+ // 鼻尖(3/4 侧脸的小突起)
+ c.beginPath()
+ c.arc(r * 0.94, r * 0.12, r * 0.13, -1.2, 1.2)
+ c.strokeStyle = OUT
+ c.lineWidth = 1.1
+ c.stroke()
+ // 腮红
+ c.fillStyle = 'rgba(214,106,88,0.22)'
+ c.beginPath()
+ c.ellipse(r * 0.1, r * 0.42, r * 0.2, r * 0.13, 0, 0, TAU)
+ c.fill()
+}
+
+function drawFace(c, skin, pal, f, time) {
+ const r = f.headR
+ const face = skin.face || 'default'
+ if (face === 'robot') {
+ c.fillStyle = '#1a2028'
+ c.beginPath(); c.roundRect(-r * 0.55, -r * 0.34, r * 1.5, r * 0.5, 1.5); c.fill()
+ c.fillStyle = '#7fe0ff'
+ const blink = ((time + pal.seed) % 3.4) > 3.25
+ if (!blink) {
+ c.beginPath(); c.arc(-r * 0.08, -r * 0.1, r * 0.14, 0, TAU); c.fill()
+ c.beginPath(); c.arc(r * 0.42, -r * 0.1, r * 0.14, 0, TAU); c.fill()
+ }
+ c.strokeStyle = OUT
+ c.lineWidth = 1
+ c.beginPath(); c.roundRect(-r * 0.55, -r * 0.34, r * 1.5, r * 0.5, 1.5); c.stroke()
+ // 嘴格栅
+ c.strokeStyle = '#1a2028'
+ c.lineWidth = 1.2
+ c.beginPath(); c.moveTo(-r * 0.2, r * 0.42); c.lineTo(r * 0.5, r * 0.42); c.stroke()
+ return
+ }
+ // 眨眼(每只角色错相)
+ const bt = (time * 0.5 + pal.seed * 0.731) % 1
+ const blink = bt < 0.06 ? Math.sin((bt / 0.06) * Math.PI) : 0
+ const eye = (ex) => {
+ if (blink > 0.5) {
+ c.strokeStyle = OUT
+ c.lineWidth = 1.2
+ c.beginPath(); c.moveTo(ex - 1.4, 0.2); c.lineTo(ex + 1.4, 0.2); c.stroke()
+ return
+ }
+ c.fillStyle = '#f8f6ee'
+ c.beginPath(); c.ellipse(ex, -0.6, 1.7, 2.1, 0, 0, TAU); c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 0.9
+ c.stroke()
+ c.fillStyle = '#241a10'
+ c.beginPath(); c.arc(ex + 0.5, -0.4, 1.05, 0, TAU); c.fill()
+ c.fillStyle = '#fff'
+ c.beginPath(); c.arc(ex + 0.85, -0.75, 0.38, 0, TAU); c.fill()
+ }
+ // 眉
+ c.strokeStyle = pal.hairLo
+ c.lineWidth = 1.1
+ c.beginPath(); c.moveTo(1.4, -3.4); c.lineTo(3.9, -3.8); c.stroke()
+ c.beginPath(); c.moveTo(5.2, -3.8); c.lineTo(7.7, -3.3); c.stroke()
+ eye(2.6)
+ eye(6.4)
+ // 嘴
+ c.strokeStyle = OUT
+ c.lineWidth = 1.15
+ if (face === 'mime') {
+ c.fillStyle = '#d8524e'
+ c.beginPath(); c.arc(2.6, -0.6, 1.5, 0, TAU); c.fill()
+ c.beginPath(); c.arc(6.4, -0.6, 1.5, 0, TAU); c.fill()
+ c.strokeStyle = '#241a10'
+ c.beginPath(); c.arc(4.6, 3.2, 1.9, 0.15, Math.PI - 0.3); c.stroke()
+ return
+ }
+ c.beginPath()
+ c.arc(4.6, 2.9, 2, 0.25, Math.PI - 0.45)
+ c.stroke()
+ if (face === 'beard') {
+ blob(c, [
+ [-r * 0.5, r * 0.26], [0, r * 0.34], [r * 0.62, r * 0.22],
+ [r * 0.58, r * 0.72], [r * 0.1, r * 1.06], [-r * 0.4, r * 0.9],
+ ], { fill: linGrad(c, 'bd' + pal.hair, 0, r * 0.2, 0, r * 1.1, [[0, pal.hair], [1, pal.hairLo]]), stroke: OUT, lw: 1.1, amp: 0.3, seed: pal.seed + 5, tq: qTime(time, 6) })
+ // 嘴仍露出
+ c.strokeStyle = '#0e0a06'
+ c.lineWidth = 1
+ c.beginPath(); c.arc(4.6, 3, 1.5, 0.4, Math.PI - 0.6); c.stroke()
+ } else if (face === 'mustache') {
+ c.fillStyle = pal.hair
+ c.beginPath()
+ c.ellipse(2.6, 2.7, 2.6, 1, -0.28, 0, TAU)
+ c.ellipse(6.8, 2.6, 2.4, 1, 0.24, 0, TAU)
c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 0.9
+ c.beginPath(); c.ellipse(2.6, 2.7, 2.6, 1, -0.28, 0, TAU); c.stroke()
+ } else if (face === 'glasses') {
+ c.strokeStyle = '#241a10'
+ c.lineWidth = 1.3
+ c.beginPath(); c.arc(2.6, -0.6, 2.5, 0, TAU); c.stroke()
+ c.beginPath(); c.arc(6.4, -0.6, 2.5, 0, TAU); c.stroke()
+ c.beginPath(); c.moveTo(5.1, -0.6); c.lineTo(4, -0.6); c.stroke()
+ c.beginPath(); c.moveTo(0.2, -0.8); c.lineTo(-r * 0.72, -1.4); c.stroke()
+ c.fillStyle = 'rgba(200,230,240,0.3)'
+ c.beginPath(); c.arc(2.6, -0.6, 2.5, 0, TAU); c.fill()
+ c.beginPath(); c.arc(6.4, -0.6, 2.5, 0, TAU); c.fill()
}
}
-// 手持工具(在前臂末端坐标系内绘制)
-function drawToolLegacy(c, toolCode) {
- if (!toolCode) return
+// 发型分两层:back 画在颅骨后,front 画在五官之上
+function drawHair(c, skin, pal, f, layer, time) {
+ const r = f.headR
+ const hair = skin.hair
+ if (!hair || hair === 'none') return
+ const seed = pal.seed
+ const tq = qTime(time, 7)
+ const H = pal.hair
+ const HLo = pal.hairLo
+ const HHi = pal.hairHi
+ const cap = () => {
+ // 头盖基础(多数发型的公共底)
+ blob(c, ellipsePts(0, -r * 0.24, r * 0.98, r * 0.82, 12, -0.06), { fill: H, stroke: OUT, lw: 1.4, amp: 0.35, seed, tq })
+ c.strokeStyle = HHi
+ c.lineWidth = 1
+ c.beginPath(); c.arc(-r * 0.1, -r * 0.3, r * 0.62, Math.PI * 1.15, Math.PI * 1.75); c.stroke()
+ }
+ if (hair === 'spiky') {
+ if (layer === 'front') {
+ cap()
+ blob(c, [
+ [-r * 0.95, -r * 0.2], [-r * 1.05, -r * 1.15], [-r * 0.5, -r * 0.68],
+ [-r * 0.32, -r * 1.5], [r * 0.1, -r * 0.78], [r * 0.42, -r * 1.34],
+ [r * 0.66, -r * 0.6], [r * 0.95, -r * 0.16],
+ ], { fill: H, stroke: OUT, lw: 1.3, amp: 0.3, seed: seed + 1, tq })
+ c.strokeStyle = HHi
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(-r * 0.5, -r * 0.75); c.lineTo(-r * 0.36, -r * 1.2); c.stroke()
+ c.beginPath(); c.moveTo(r * 0.2, -r * 0.8); c.lineTo(r * 0.36, -r * 1.15); c.stroke()
+ }
+ } else if (hair === 'flat') {
+ if (layer === 'front') {
+ blob(c, ellipsePts(0, -r * 0.3, r * 1.0, r * 0.78, 12, -0.04), { fill: H, stroke: OUT, lw: 1.4, amp: 0.3, seed, tq })
+ // 齐刘海
+ c.beginPath()
+ c.moveTo(-r * 0.9, -r * 0.28)
+ c.quadraticCurveTo(-r * 0.4, -r * 0.06, 0, -r * 0.24)
+ c.quadraticCurveTo(r * 0.5, -r * 0.05, r * 0.92, -r * 0.3)
+ c.quadraticCurveTo(r * 0.6, -r * 0.62, 0, -r * 0.58)
+ c.quadraticCurveTo(-r * 0.6, -r * 0.6, -r * 0.9, -r * 0.28)
+ c.closePath()
+ fs(c, HLo, OUT, 1)
+ }
+ } else if (hair === 'long') {
+ if (layer === 'back') {
+ // 披肩长发在脑后
+ blob(c, [
+ [-r * 0.9, -r * 0.3], [-r * 1.2, r * 0.5], [-r * 0.95, r * 1.45],
+ [-r * 0.4, r * 1.6], [-r * 0.5, r * 0.7], [-r * 0.66, r * 0.1],
+ [0, r * 0.2], [r * 0.6, -r * 0.2], [r * 0.9, -r * 0.5],
+ ], { fill: linGrad(c, 'lh' + H, 0, -r, 0, r * 1.6, [[0, H], [1, HLo]]), stroke: OUT, lw: 1.3, amp: 0.45, seed, tq })
+ } else {
+ cap()
+ c.strokeStyle = HHi
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(-r * 0.8, r * 0.2); c.quadraticCurveTo(-r * 0.9, r * 0.8, -r * 0.7, r * 1.3); c.stroke()
+ }
+ } else if (hair === 'curly') {
+ if (layer === 'front') {
+ const balls = [[-r * 0.62, -r * 0.62, r * 0.42], [0, -r * 0.86, r * 0.48], [r * 0.6, -r * 0.58, r * 0.4], [-r * 0.3, -r * 0.8, r * 0.38], [r * 0.32, -r * 0.78, r * 0.38]]
+ balls.forEach(([bx, by, br], i) => {
+ blob(c, ellipsePts(bx, by, br, br, 9), { fill: i % 2 ? H : HLo, stroke: OUT, lw: 1.2, amp: 0.4, seed: seed + i, tq })
+ })
+ c.fillStyle = HHi
+ c.beginPath(); c.arc(-r * 0.7, -r * 0.74, r * 0.12, 0, TAU); c.fill()
+ c.beginPath(); c.arc(-r * 0.05, -r * 0.98, r * 0.13, 0, TAU); c.fill()
+ }
+ } else if (hair === 'crown') {
+ if (layer === 'front') {
+ cap()
+ // 金冠
+ c.beginPath()
+ c.moveTo(-r * 0.52, -r * 0.84)
+ c.lineTo(-r * 0.52, -r * 1.32)
+ c.lineTo(-r * 0.26, -r * 1.0)
+ c.lineTo(0, -r * 1.46)
+ c.lineTo(r * 0.26, -r * 1.0)
+ c.lineTo(r * 0.52, -r * 1.32)
+ c.lineTo(r * 0.52, -r * 0.84)
+ c.closePath()
+ fs(c, linGrad(c, 'cr', -r * 0.5, -r * 1.4, r * 0.5, -r * 0.8, [[0, '#f4dc8a'], [1, '#c9971e']]), '#8e6a14', 1.3)
+ c.fillStyle = '#d8524e'
+ c.beginPath(); c.arc(0, -r * 1.06, r * 0.1, 0, TAU); c.fill()
+ }
+ } else if (hair === 'pigtails') {
+ if (layer === 'back') {
+ ;[-1, 1].forEach((s) => {
+ c.save()
+ c.translate(s * r * 0.72, -r * 0.1)
+ c.rotate(s * (0.5 + Math.sin(time * 2.6 + seed) * 0.06))
+ blob(c, ellipsePts(0, r * 0.55, r * 0.3, r * 0.72, 9), { fill: H, stroke: OUT, lw: 1.2, amp: 0.35, seed: seed + s, tq })
+ c.fillStyle = '#c45a3a'
+ c.beginPath(); c.arc(0, -r * 0.12, r * 0.16, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ c.restore()
+ })
+ } else {
+ cap()
+ }
+ } else if (hair === 'braids') {
+ if (layer === 'back') {
+ ;[-1, 1].forEach((s) => {
+ c.save()
+ c.translate(s * r * 0.62, r * 0.1)
+ blob(c, ellipsePts(0, r * 0.6, r * 0.24, r * 0.85, 8), { fill: H, stroke: OUT, lw: 1.2, amp: 0.3, seed: seed + s + 2, tq })
+ c.strokeStyle = HLo
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(0, r * 0.2); c.lineTo(s * r * 0.1, r * 0.6); c.lineTo(0, r * 1.05); c.stroke()
+ c.restore()
+ })
+ } else {
+ cap()
+ }
+ } else if (hair === 'toque') {
+ if (layer === 'front') {
+ // 毛线帽
+ blob(c, ellipsePts(0, -r * 0.42, r * 0.92, r * 0.7, 12), { fill: '#8a2c28', stroke: OUT, lw: 1.4, amp: 0.3, seed, tq })
+ c.fillStyle = '#a83c34'
+ c.beginPath(); c.roundRect(-r * 0.94, -r * 0.5, r * 1.88, r * 0.34, 2); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1.2; c.stroke()
+ c.fillStyle = '#f4eee4'
+ c.beginPath(); c.arc(0, -r * 1.28, r * 0.22, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1.1; c.stroke()
+ }
+ } else if (hair === 'bun') {
+ if (layer === 'front') {
+ cap()
+ c.beginPath()
+ c.arc(0, -r * 1.18, r * 0.4, 0, TAU)
+ fs(c, linGrad(c, 'bn' + H, -r * 0.4, -r * 1.5, r * 0.3, -r * 0.9, [[0, HHi], [1, HLo]]), OUT, 1.2)
+ // 发髻纹
+ c.strokeStyle = HLo
+ c.lineWidth = 0.9
+ c.beginPath(); c.arc(0, -r * 1.18, r * 0.24, 0.6, 2.6); c.stroke()
+ }
+ } else if (hair === 'chef') {
+ if (layer === 'front') {
+ cap()
+ // 厨师帽
+ blob(c, ellipsePts(0, -r * 1.3, r * 0.72, r * 0.66, 11), { fill: '#f7f4ec', stroke: OUT, lw: 1.4, amp: 0.4, seed, tq })
+ c.fillStyle = '#efece2'
+ c.beginPath(); c.roundRect(-r * 0.88, -r * 0.86, r * 1.76, r * 0.4, 2); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1.2; c.stroke()
+ c.strokeStyle = '#d8d4c6'
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(-r * 0.3, -r * 1.7); c.quadraticCurveTo(-r * 0.2, -r * 1.4, -r * 0.3, -r * 1.05); c.stroke()
+ }
+ } else if (hair === 'horns') {
+ if (layer === 'front') {
+ blob(c, ellipsePts(0, -r * 0.3, r * 0.86, r * 0.72, 11), { fill: H, stroke: OUT, lw: 1.4, amp: 0.3, seed, tq })
+ ;[-1, 1].forEach((s) => {
+ blob(c, [
+ [s * r * 0.5, -r * 0.55], [s * r * 1.18, -r * 1.5], [s * r * 0.34, -r * 1.66], [s * r * 0.4, -r * 0.85],
+ ], { fill: '#e8d8c0', stroke: OUT, lw: 1.25, amp: 0.25, seed: seed + s, tq })
+ })
+ }
+ } else if (hair === 'fuzzy') {
+ if (layer === 'front') {
+ const balls = [[-r * 0.66, -r * 0.4, r * 0.46], [0, -r * 0.78, r * 0.54], [r * 0.64, -r * 0.38, r * 0.44], [-r * 0.34, -r * 0.76, r * 0.42], [r * 0.34, -r * 0.74, r * 0.42]]
+ balls.forEach(([bx, by, br], i) => {
+ blob(c, ellipsePts(bx, by, br, br, 9), { fill: i % 2 ? H : HLo, stroke: OUT, lw: 1.2, amp: 0.35, seed: seed + i, tq })
+ })
+ // 小蜘蛛眼斑
+ c.fillStyle = '#1d1409'
+ ;[[-r * 0.42, -r * 0.3], [-r * 0.14, -r * 0.4], [r * 0.14, -r * 0.4], [r * 0.42, -r * 0.3]].forEach(([ex, ey]) => {
+ c.beginPath(); c.arc(ex, ey, 1.1, 0, TAU); c.fill()
+ })
+ }
+ } else if (hair === 'leafy') {
+ if (layer === 'front') {
+ ;[[-r * 0.58, -r * 0.72, -0.5], [0, -r * 1.02, 0], [r * 0.58, -r * 0.72, 0.5], [-r * 0.28, -r * 0.55, -0.2], [r * 0.28, -r * 0.55, 0.2]].forEach(([bx, by, rot], i) => {
+ c.save()
+ c.translate(bx, by)
+ c.rotate(rot)
+ blob(c, ellipsePts(0, 0, r * 0.44, r * 0.22, 8), { fill: i % 2 ? pal.hair : shade(pal.hair, 0.2), stroke: OUT, lw: 1.1, amp: 0.25, seed: seed + i, tq })
+ c.strokeStyle = shade(pal.hair, -0.3)
+ c.lineWidth = 0.9
+ c.beginPath(); c.moveTo(-r * 0.36, 0); c.lineTo(r * 0.36, 0); c.stroke()
+ c.restore()
+ })
+ }
+ } else if (hair === 'fins') {
+ if (layer === 'back') {
+ ;[-1, 1].forEach((s) => {
+ blob(c, [
+ [s * r * 0.66, -r * 0.2], [s * r * 1.3, -r * 0.66], [s * r * 0.78, r * 0.55], [s * r * 0.6, r * 0.05],
+ ], { fill: H, stroke: OUT, lw: 1.2, amp: 0.3, seed: seed + s, tq })
+ })
+ } else {
+ blob(c, ellipsePts(0, -r * 0.26, r * 0.9, r * 0.74, 11), { fill: H, stroke: OUT, lw: 1.3, amp: 0.3, seed, tq })
+ }
+ } else if (hair === 'scout') {
+ if (layer === 'front') {
+ cap()
+ // 童子军帽
+ c.fillStyle = '#c45a28'
+ c.beginPath(); c.ellipse(0, -r * 0.56, r * 1.06, r * 0.3, 0, Math.PI, 0); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1.3; c.stroke()
+ c.beginPath(); c.roundRect(-r * 0.68, -r * 1.24, r * 1.36, r * 0.7, 2); c.fill(); c.stroke()
+ c.fillStyle = '#e8c14d'
+ c.beginPath(); c.moveTo(0, -r * 1.1); c.lineTo(r * 0.2, -r * 0.78); c.lineTo(-r * 0.2, -r * 0.78); c.closePath(); c.fill()
+ c.strokeStyle = '#8e6a14'; c.lineWidth = 1; c.stroke()
+ }
+ }
+}
+
+// 专属皮肤头饰(沿用原 codes 分支,补描边与体积)
+function drawAccessory(c, skin, pal, f, time) {
+ const code = skin.code
+ const r = f.headR
+ const headY = 0
c.save()
- c.translate(0, 11)
- c.rotate(-0.5)
- if (toolCode === 'shovel') {
- c.strokeStyle = '#7a5b33'
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 4); c.lineTo(0, -14); c.stroke()
- c.beginPath()
- c.moveTo(-4, -14)
- c.quadraticCurveTo(0, -24, 4, -14)
- c.closePath()
- fs(c, '#9aa0a8', '#1d1409', 1.4)
- } else if (toolCode === 'tentaclespike') {
- c.strokeStyle = '#6b4a7e'
- c.lineWidth = 3.4
- c.beginPath(); c.moveTo(0, 6); c.quadraticCurveTo(2, -8, 0, -20); c.stroke()
- c.fillStyle = '#e8e2d4'
- ;[[0, -8], [1, -14]].forEach(([x, y]) => {
- c.beginPath(); c.moveTo(x, y); c.lineTo(x + 4, y - 1); c.lineTo(x + 1, y - 4); c.closePath(); c.fill()
+ c.lineJoin = 'round'
+ c.lineCap = 'round'
+ if (code === 'flame' || code === 'lava') {
+ c.strokeStyle = OUT; c.lineWidth = 1.6
+ c.beginPath(); c.arc(-2.6, -3.4, 3.2, 0, TAU); c.stroke()
+ c.beginPath(); c.arc(3.4, -3.4, 3.2, 0, TAU); c.stroke()
+ c.fillStyle = code === 'lava' ? '#ff9a3c' : '#f0923c'
+ c.beginPath(); c.arc(-2.6, -3.4, 2.6, 0, TAU); c.fill()
+ c.beginPath(); c.arc(3.4, -3.4, 2.6, 0, TAU); c.fill()
+ c.fillStyle = 'rgba(255,255,255,0.4)'
+ c.beginPath(); c.arc(-3.2, -4.2, 0.9, 0, TAU); c.fill()
+ c.beginPath(); c.arc(2.8, -4.2, 0.9, 0, TAU); c.fill()
+ c.strokeStyle = OUT
+ c.beginPath(); c.moveTo(-5.8, -4); c.lineTo(-0.4, -4.8); c.moveTo(6, -4); c.lineTo(0.6, -4.8); c.stroke()
+ } else if (code === 'ocean' || code === 'navy') {
+ c.fillStyle = code === 'ocean' ? '#f4f8fa' : '#2c3e68'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
+ c.beginPath(); c.ellipse(0, -r * 0.78, r * 1.02, r * 0.34, 0, Math.PI, 0); c.fill(); c.stroke()
+ c.fillStyle = code === 'ocean' ? '#2f6d8e' : '#c9a03c'
+ c.fillRect(-r, -r * 0.84, r * 2, 2)
+ c.strokeStyle = OUT
+ c.lineWidth = 1
+ c.strokeRect(-r, -r * 0.84, r * 2, 2)
+ c.fillStyle = '#1d1409'
+ c.beginPath(); c.arc(0, -r * 1.14, 2.2, 0, TAU); c.fill()
+ } else if (code === 'forest' || code === 'moss' || code === 'swamp') {
+ const col = code === 'swamp' ? '#4a5c2e' : code === 'moss' ? '#6a7a3d' : '#3f6b35'
+ c.fillStyle = col
+ c.strokeStyle = OUT; c.lineWidth = 1.1
+ ;[[-r * 0.66, -r * 0.64], [-r * 0.26, -r * 0.94], [r * 0.2, -r * 0.98], [r * 0.64, -r * 0.62]].forEach(([hx, hy], i) => {
+ c.save(); c.translate(hx, hy); c.rotate(i * 0.5 - 0.6)
+ c.beginPath(); c.ellipse(0, 0, 3.4, 1.7, 0, 0, TAU); c.fill(); c.stroke()
+ c.restore()
})
- } else if (toolCode === 'torch') {
- c.strokeStyle = '#7a5b33'
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 4); c.lineTo(0, -12); c.stroke()
+ } else if (code === 'honey' || code === 'gold') {
+ c.fillStyle = code === 'honey' ? '#f0c040' : '#e0b83a'
+ c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
+ c.beginPath(); c.arc(r * 0.62, -r * 0.44, 2.5, 0, TAU); c.fill(); c.stroke()
+ c.fillStyle = '#fff'
+ c.beginPath(); c.arc(r * 0.62, -r * 0.52, 0.9, 0, TAU); c.fill()
+ } else if (code === 'dusk' || code === 'plum') {
+ c.fillStyle = code === 'dusk' ? '#3a2a52' : '#8a4a7e'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
+ c.beginPath(); c.roundRect(-r * 0.58, -r * 1.14, r * 1.16, r * 0.38, 1); c.fill(); c.stroke()
+ c.beginPath(); c.roundRect(-r * 0.4, -r * 1.5, r * 0.8, r * 0.42, 1); c.fill(); c.stroke()
+ c.fillStyle = '#e8c14d'
+ c.fillRect(-r * 0.58, -r * 0.92, r * 1.16, 1.5)
+ } else if (code === 'mint' || code === 'sky') {
+ c.strokeStyle = code === 'mint' ? '#3f5548' : '#4a668c'; c.lineWidth = 1.5
+ c.beginPath(); c.arc(2.6, -0.6, 2.7, 0, TAU); c.stroke()
+ c.beginPath(); c.arc(6.4, -0.6, 2.7, 0, TAU); c.stroke()
+ c.beginPath(); c.moveTo(5.3, -0.6); c.lineTo(3.7, -0.6); c.stroke()
+ if (code === 'sky') {
+ c.fillStyle = 'rgba(200,230,250,0.35)'
+ c.beginPath(); c.arc(2.6, -0.6, 2.7, 0, TAU); c.fill()
+ c.beginPath(); c.arc(6.4, -0.6, 2.7, 0, TAU); c.fill()
+ }
+ } else if (code === 'ash' || code === 'rust') {
+ c.fillStyle = code === 'ash' ? '#8a8a94' : '#a05a2a'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
c.beginPath()
- c.moveTo(0, -20)
- c.quadraticCurveTo(5, -13, 0, -10)
- c.quadraticCurveTo(-5, -13, 0, -20)
- fs(c, '#f0923c', '#b5541e', 1.2)
- } else if (toolCode === 'axe') {
- c.strokeStyle = '#7a5b33'
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 4); c.lineTo(0, -13); c.stroke()
+ c.moveTo(-r * 0.82, -r * 0.55)
+ c.quadraticCurveTo(0, -r * 1.32, r * 0.82, -r * 0.55)
+ c.quadraticCurveTo(0, -r * 0.82, -r * 0.82, -r * 0.55)
+ c.closePath(); c.fill(); c.stroke()
c.beginPath()
- c.moveTo(0, -13); c.lineTo(8, -10); c.lineTo(7, -4); c.lineTo(0, -7)
- c.closePath()
- fs(c, '#9aa0a8', '#1d1409', 1.4)
- } else if (toolCode === 'pick') {
- c.strokeStyle = '#7a5b33'
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 4); c.lineTo(0, -13); c.stroke()
+ c.moveTo(r * 0.78, -r * 0.55)
+ c.quadraticCurveTo(r * 1.2, 0, r * 0.9, r * 0.35)
+ c.lineTo(r * 0.68, r * 0.2)
+ c.closePath(); c.fill(); c.stroke()
+ } else if (code === 'rose') {
+ c.fillStyle = '#d86a8a'
+ c.strokeStyle = '#7e3348'; c.lineWidth = 1.1
+ const cx = r * 0.64
+ const cy = -r * 0.46
+ ;[[0, 2], [1.7, 1.2], [1.7, -1.2], [0, -2], [-1.7, -1.2], [-1.7, 1.2]].forEach(([px, py], i) => {
+ c.beginPath(); c.arc(cx + px * 0.8, cy + py * 0.8, 1.9 - i * 0.12, 0, TAU); c.fill(); c.stroke()
+ })
+ c.fillStyle = 'rgba(255,255,255,0.35)'
+ c.beginPath(); c.arc(cx - 0.7, cy - 0.9, 0.8, 0, TAU); c.fill()
+ } else if (code === 'sand') {
+ c.fillStyle = '#c9a86a'
+ c.strokeStyle = OUT; c.lineWidth = 1.3
c.beginPath()
- c.moveTo(-7, -12)
- c.quadraticCurveTo(0, -19, 7, -12)
- c.quadraticCurveTo(0, -15, -7, -12)
- fs(c, '#9aa0a8', '#1d1409', 1.4)
- } else if (toolCode === 'spear') {
- c.strokeStyle = '#7a5b33'
- c.lineWidth = 2.8
- c.beginPath(); c.moveTo(0, 8); c.lineTo(0, -16); c.stroke()
+ c.moveTo(-r * 0.8, -r * 0.66)
+ c.quadraticCurveTo(0, -r * 1.4, r * 0.8, -r * 0.66)
+ c.quadraticCurveTo(0, -r * 0.8, -r * 0.8, -r * 0.66)
+ c.closePath(); c.fill(); c.stroke()
+ c.fillStyle = '#a8854e'
+ c.fillRect(-r * 0.8, -r * 0.9, r * 1.6, 2.2)
+ c.strokeStyle = OUT; c.lineWidth = 1; c.strokeRect(-r * 0.8, -r * 0.9, r * 1.6, 2.2)
+ } else if (code === 'blizzard' || code === 'glacier') {
+ c.fillStyle = code === 'blizzard' ? '#dfe6f0' : '#b8d8e8'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
c.beginPath()
- c.moveTo(-2.6, -15); c.lineTo(0, -23); c.lineTo(2.6, -15)
- c.closePath()
- fs(c, '#b0b4bc', '#1d1409', 1.4)
+ c.moveTo(-r * 0.86, -r * 0.36)
+ c.quadraticCurveTo(-r * 0.92, -r * 1.3, 0, -r * 1.3)
+ c.quadraticCurveTo(r * 0.92, -r * 1.3, r * 0.86, -r * 0.36)
+ c.quadraticCurveTo(0, -r * 0.66, -r * 0.86, -r * 0.36)
+ c.closePath(); c.fill(); c.stroke()
+ c.strokeStyle = 'rgba(255,255,255,0.7)'
+ c.lineWidth = 1.6
+ c.beginPath(); c.moveTo(-r * 0.6, -r * 0.9); c.quadraticCurveTo(0, -r * 1.06, r * 0.6, -r * 0.9); c.stroke()
+ } else if (code === 'crow') {
+ c.fillStyle = '#2a2534'
+ c.strokeStyle = OUT; c.lineWidth = 1.2
+ c.save()
+ c.translate(r * 0.66, -r * 0.62)
+ c.rotate(0.35 + Math.sin(time * 2.2) * 0.05)
+ c.beginPath()
+ c.moveTo(0, 0); c.quadraticCurveTo(r * 0.75, -r * 0.9, r * 0.5, r * 0.4)
+ c.quadraticCurveTo(r * 0.2, r * 0.15, 0, 0.2)
+ c.closePath(); c.fill(); c.stroke()
+ c.restore()
+ } else if (code === 'sunset') {
+ c.fillStyle = '#e8823c'
+ c.strokeStyle = OUT; c.lineWidth = 1.3
+ c.beginPath(); c.ellipse(0, -r * 0.76, r * 0.94, r * 0.3, 0, Math.PI, 0); c.fill(); c.stroke()
+ c.fillStyle = '#f8d8a8'
+ c.beginPath()
+ c.moveTo(r * 0.58, -r * 0.84)
+ c.quadraticCurveTo(r * 1.2, -r * 1.5, r * 1.05, -r * 0.4)
+ c.quadraticCurveTo(r * 0.8, -r * 0.66, r * 0.58, -r * 0.56)
+ c.closePath(); c.fill(); c.stroke()
+ } else if (code === 'thorn') {
+ c.strokeStyle = '#54682c'; c.lineWidth = 1.4
+ c.beginPath(); c.moveTo(-r * 0.68, -r * 0.56); c.quadraticCurveTo(0, -r * 1.12, r * 0.68, -r * 0.56); c.stroke()
+ ;[[-r * 0.4, -r * 0.76], [0, -r * 0.94], [r * 0.4, -r * 0.76]].forEach(([hx, hy]) => {
+ c.beginPath(); c.moveTo(hx, hy); c.lineTo(hx * 1.15, hy - 4); c.stroke()
+ })
+ } else if (code === 'shadow' || code === 'void') {
+ c.fillStyle = code === 'shadow' ? '#1e1828' : '#141020'
+ c.strokeStyle = OUT; c.lineWidth = 1.1
+ c.beginPath()
+ c.moveTo(-r * 0.6, -r * 0.56)
+ c.quadraticCurveTo(0, -r * 0.14, r * 0.6, -r * 0.56)
+ c.quadraticCurveTo(r * 0.3, r * 0.24, 0, r * 0.3)
+ c.quadraticCurveTo(-r * 0.3, r * 0.24, -r * 0.6, -r * 0.56)
+ c.closePath(); c.fill(); c.stroke()
+ c.fillStyle = code === 'shadow' ? '#8e5cf0' : '#5a4a9e'
+ c.beginPath(); c.arc(4.6, -0.5, 1.5, 0, TAU); c.fill()
+ c.fillStyle = 'rgba(255,255,255,0.5)'
+ c.beginPath(); c.arc(4.6, -0.5, 0.6, 0, TAU); c.fill()
+ } else if (code === 'blood') {
+ c.strokeStyle = '#8e1c24'; c.lineWidth = 1.5
+ c.beginPath(); c.moveTo(-r * 0.6, -r * 0.18); c.lineTo(-r * 0.3, r * 0.2); c.stroke()
+ c.beginPath(); c.moveTo(-r * 0.26, -r * 0.18); c.lineTo(-r * 0.56, r * 0.2); c.stroke()
+ c.beginPath(); c.moveTo(r * 0.62, 0); c.lineTo(r * 0.9, r * 0.34); c.stroke()
+ } else if (code === 'willow') {
+ c.fillStyle = '#c45a3a'
+ c.strokeStyle = OUT; c.lineWidth = 1.1
+ c.save()
+ c.translate(r * 0.7, r * 0.16)
+ c.rotate(0.2)
+ c.beginPath(); c.roundRect(-1, -2, 2, 5, 1); c.fill(); c.stroke()
+ blob(c, [[0, -2], [2.6, -5], [0, -3.6], [-2.4, -5]], { fill: '#f0923c', stroke: '#b5541e', lw: 1, amp: 0.2, seed: 3, tq: qTime(time, 6) })
+ c.restore()
+ } else if (code === 'wendy') {
+ c.fillStyle = '#c96a8a'
+ c.strokeStyle = OUT; c.lineWidth = 1.1
+ c.beginPath(); c.arc(-r * 0.6, -r * 0.4, 2.3, 0, TAU); c.fill(); c.stroke()
+ c.fillStyle = '#6a7a3d'
+ c.beginPath(); c.ellipse(-r * 0.6, -r * 0.14, 1.5, 0.8, 0.4, 0, TAU); c.fill()
+ } else if (code === 'maxwell') {
+ // 高顶礼帽(替代旧条纹帽)
+ c.fillStyle = '#141018'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
+ c.beginPath(); c.ellipse(0, -r * 0.66, r * 0.98, r * 0.24, 0, 0, TAU); c.fill(); c.stroke()
+ c.beginPath(); c.roundRect(-r * 0.56, -r * 2.1, r * 1.12, r * 1.5, 2); c.fill(); c.stroke()
+ c.fillStyle = '#c43a3a'
+ c.fillRect(-r * 0.56, -r * 0.92, r * 1.12, 2)
+ c.strokeStyle = OUT; c.lineWidth = 0.9; c.strokeRect(-r * 0.56, -r * 0.92, r * 1.12, 2)
+ } else if (code === 'wigfrid') {
+ // 维京头盔 + 角
+ c.fillStyle = '#c9b080'
+ c.strokeStyle = OUT; c.lineWidth = 1.4
+ c.beginPath(); c.ellipse(0, -r * 0.62, r * 0.96, r * 0.36, 0, Math.PI, 0); c.fill(); c.stroke()
+ c.fillStyle = '#e8d8c0'
+ ;[-1, 1].forEach((s) => {
+ blob(c, [
+ [s * r * 0.7, -r * 0.7], [s * r * 1.4, -r * 1.7], [s * r * 0.5, -r * 1.5], [s * r * 0.55, -r * 0.66],
+ ], { fill: '#e8d8c0', stroke: OUT, lw: 1.3, amp: 0.25, seed: pal.seed + s, tq: qTime(time, 6) })
+ })
+ c.strokeStyle = '#8a6a38'
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(-r * 0.7, -r * 0.64); c.lineTo(r * 0.7, -r * 0.64); c.stroke()
+ } else if (code === 'wanda') {
+ c.fillStyle = '#e8c14d'
+ c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
+ c.beginPath(); c.arc(r * 0.78, -r * 0.7, 3, 0, TAU); c.fill(); c.stroke()
+ c.strokeStyle = OUT; c.lineWidth = 1
+ c.beginPath(); c.moveTo(r * 0.78, -r * 0.7); c.lineTo(r * 0.78, -r * 0.98); c.moveTo(r * 0.78, -r * 0.7); c.lineTo(r * 1.02, -r * 0.6); c.stroke()
+ } else if (code === 'wortox') {
+ c.fillStyle = '#f0e060'
+ c.beginPath(); c.arc(2.6, -0.9, 1.8, 0, TAU); c.fill()
+ c.beginPath(); c.arc(6.4, -0.9, 1.8, 0, TAU); c.fill()
+ c.fillStyle = '#1d1409'
+ c.beginPath(); c.arc(2.6, -0.9, 0.7, 0, TAU); c.fill()
+ c.beginPath(); c.arc(6.4, -0.9, 0.7, 0, TAU); c.fill()
+ } else if (code === 'wurt') {
+ c.fillStyle = '#3a6a48'
+ c.beginPath(); c.ellipse(4.6, 4.4, 3.2, 1.5, 0, 0, TAU); c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1
+ c.stroke()
+ } else if (code === 'king') {
+ c.fillStyle = '#e8c14d'
+ c.strokeStyle = '#8e6a14'; c.lineWidth = 1
+ c.beginPath(); c.arc(0, shY(f) + 1, 1.6, 0, TAU); c.fill(); c.stroke()
}
c.restore()
}
-// 增强手持物:更大、轮廓更清楚、材质区分更明显(斧/镐/铲/矛/触手/火把)
+// =====================================================================
+// 手持工具(手部坐标系:+x 沿前臂延伸方向)+ 挥击拖尾
+// =====================================================================
function resolveToolKind(toolCode) {
if (!toolCode) return ''
if (toolCode === 'pickaxe' || toolCode === 'indpickaxe' || toolCode === 'pick') return 'pick'
@@ -387,667 +1155,487 @@ function resolveToolKind(toolCode) {
if (toolCode === 'husbshears') return 'shears'
return toolCode
}
+const TOOL_REST = { axe: -3.55, pick: -3.55, shovel: -3.5, hoe: -3.55, shears: -3.4, torch: -3.2, spear: -2.95, tentaclespike: -3.55 }
+const TOOL_LEN = { axe: 23, pick: 21, shovel: 25, hoe: 23, shears: 13, torch: 20, spear: 34, tentaclespike: 27 }
-function drawToolEnhanced(c, toolCode, toolAng = 0) {
- if (!toolCode) return
- const kind = resolveToolKind(toolCode)
+function drawToolShape(c, kind, time) {
c.save()
- c.translate(0, 9)
- c.rotate(toolAng)
- c.lineJoin = 'round'
- c.lineCap = 'round'
+ c.scale(1.1, 1.1) // 整体放大一点,手握存在感更强
const wood = '#8a5a2b'
- const dark = '#1d1409'
- const iron = '#9aa0a8'
- if (kind === 'shovel') {
- // 木柄 + 宽铲头
- c.strokeStyle = wood
- c.lineWidth = 3.2
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -16); c.stroke()
- c.beginPath()
- c.moveTo(-6, -16)
- c.quadraticCurveTo(0, -28, 6, -16)
- c.lineTo(4, -12)
- c.quadraticCurveTo(0, -15, -4, -12)
- c.closePath()
- fs(c, iron, dark, 1.4)
- c.fillStyle = 'rgba(255,255,255,0.35)'
- c.beginPath(); c.moveTo(-2, -16); c.quadraticCurveTo(0, -22, 2, -16); c.closePath(); c.fill()
- } else if (kind === 'hoe') {
- c.strokeStyle = wood
- c.lineWidth = 3.2
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -18); c.stroke()
- c.beginPath()
- c.moveTo(0, -16); c.lineTo(12, -12); c.lineTo(12, -6); c.lineTo(2, -10)
- c.closePath()
- fs(c, iron, dark, 1.4)
- } else if (kind === 'shears') {
- c.strokeStyle = '#6f767d'
- c.lineWidth = 2.4
- c.beginPath(); c.moveTo(-3, 4); c.lineTo(4, -18); c.stroke()
- c.beginPath(); c.moveTo(3, 4); c.lineTo(-4, -18); c.stroke()
- c.fillStyle = '#4a4f55'
- c.beginPath(); c.arc(0, -6, 2.2, 0, 7); c.fill()
- } else if (kind === 'tentaclespike') {
- c.strokeStyle = '#6b4a7e'
- c.lineWidth = 3.6
- c.beginPath(); c.moveTo(0, 8); c.quadraticCurveTo(3, -10, 0, -24); c.stroke()
- c.fillStyle = '#e8e2d4'
- ;[[0, -10], [1, -16], [-1, -21]].forEach(([x, y]) => {
- c.beginPath(); c.moveTo(x, y); c.lineTo(x + 5, y - 2); c.lineTo(x + 1, y - 6); c.closePath(); c.fill()
- })
- } else if (kind === 'torch') {
- c.strokeStyle = wood
- c.lineWidth = 3.2
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
- c.strokeStyle = '#c9a06a'
- c.lineWidth = 1.4
- c.beginPath(); c.moveTo(-2, -8); c.lineTo(2, -6); c.moveTo(-2, -4); c.lineTo(2, -2); c.stroke()
- c.beginPath()
- c.moveTo(0, -22)
- c.quadraticCurveTo(7, -15, 0, -10)
- c.quadraticCurveTo(-7, -15, 0, -22)
- fs(c, '#f0923c', '#b5541e', 1.2)
- c.beginPath()
- c.moveTo(0, -19)
- c.quadraticCurveTo(4, -15, 0, -12)
- c.quadraticCurveTo(-4, -15, 0, -19)
- fs(c, '#ffd76a', '#e8a03c', 1)
- } else if (kind === 'axe') {
- c.strokeStyle = wood
- c.lineWidth = 3.2
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -15); c.stroke()
- c.strokeStyle = '#c9a06a'
- c.lineWidth = 1.2
- c.beginPath(); c.moveTo(-2, -11); c.lineTo(2, -9); c.moveTo(-2, -7); c.lineTo(2, -5); c.stroke()
- c.beginPath()
- c.moveTo(0, -15); c.lineTo(11, -11); c.lineTo(10, -3); c.lineTo(4, -6); c.lineTo(0, -8)
- c.closePath()
- fs(c, '#b8bec6', dark, 1.4)
- c.fillStyle = 'rgba(255,255,255,0.4)'
- c.beginPath(); c.moveTo(1, -14); c.lineTo(9, -10.4); c.lineTo(8, -5.5); c.lineTo(3, -8); c.closePath(); c.fill()
- } else if (kind === 'pick') {
- c.strokeStyle = wood
- c.lineWidth = 3.2
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
- c.beginPath()
- c.moveTo(-9, -13)
- c.quadraticCurveTo(0, -22, 9, -13)
- c.quadraticCurveTo(5, -15, 0, -15)
- c.quadraticCurveTo(-5, -15, -9, -13)
- c.closePath()
- fs(c, '#5c6470', dark, 1.4)
- c.fillStyle = 'rgba(255,255,255,0.28)'
- c.beginPath(); c.moveTo(-6, -14); c.quadraticCurveTo(0, -20, 6, -14); c.quadraticCurveTo(0, -17, -6, -14); c.closePath(); c.fill()
- } else if (kind === 'spear') {
- c.strokeStyle = wood
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 8); c.lineTo(0, -24); c.stroke()
- c.strokeStyle = '#c9a06a'
- c.lineWidth = 1.4
- c.beginPath(); c.moveTo(-2.4, -13); c.lineTo(2.4, -11); c.moveTo(-2.4, -9); c.lineTo(2.4, -7); c.stroke()
- c.beginPath()
- c.moveTo(-3.4, -23); c.lineTo(0, -32); c.lineTo(3.4, -23)
- c.closePath()
- fs(c, '#c3c8d0', dark, 1.4)
- c.fillStyle = 'rgba(255,255,255,0.45)'
- c.beginPath(); c.moveTo(0, -30); c.lineTo(1.6, -24); c.lineTo(-1.6, -24); c.closePath(); c.fill()
- } else {
- // 未知手持:通用木柄,避免挥空
- c.strokeStyle = wood
- c.lineWidth = 3
- c.beginPath(); c.moveTo(0, 6); c.lineTo(0, -14); c.stroke()
- c.fillStyle = iron
- c.strokeStyle = dark
- c.lineWidth = 1.4
- c.beginPath(); c.roundRect(-4, -20, 8, 8, 2); c.fill(); c.stroke()
- }
- c.restore()
-}
-
-function figScale(skin) {
- if (skin.figure === 'bulky') return { body: 1.28, head: 1.08, leg: 5.6, slim: 0 }
- if (skin.figure === 'slim') return { body: 0.86, head: 0.94, leg: 3.6, slim: 1 }
- if (skin.figure === 'robot') return { body: 1.06, head: 1, leg: 4.2, slim: 0 }
- if (skin.figure === 'plant') return { body: 0.92, head: 1.04, leg: 3.4, slim: 1 }
- return { body: 1, head: 1, leg: 4.4, slim: 0 }
-}
-
-function drawLegs(c, skin, walkSwing) {
- const f = figScale(skin)
- const metal = skin.figure === 'robot'
- c.strokeStyle = metal ? '#5a6270' : '#2b2b33'
- c.lineWidth = f.leg
- c.beginPath()
- c.moveTo(-3 * f.body, 2)
- c.lineTo(-3 * f.body + walkSwing * 4.5, 15)
- c.moveTo(3 * f.body, 2)
- c.lineTo(3 * f.body - walkSwing * 4.5, 15)
- c.stroke()
- c.fillStyle = metal ? '#3a4048' : '#1d1409'
- c.beginPath()
- c.ellipse(-3 * f.body + walkSwing * 4.5, 16, metal ? 3.8 : 3.4, 2, 0, 0, 7)
- c.ellipse(3 * f.body - walkSwing * 4.5, 16, metal ? 3.8 : 3.4, 2, 0, 0, 7)
- c.fill()
-}
-
-function drawOutfit(c, skin, breathe) {
- const f = figScale(skin)
- const b = breathe
- const w = 6 * f.body
- const outfit = skin.outfit || 'vest'
- if (outfit === 'dress') {
- c.beginPath()
- c.moveTo(-w * 0.85, -14 + b)
- c.lineTo(w * 0.85, -14 + b)
- c.lineTo(w * 1.55, 8)
- c.lineTo(-w * 1.55, 8)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.7)
- c.beginPath()
- c.moveTo(-w * 0.7, -14 + b)
- c.lineTo(w * 0.7, -14 + b)
- c.lineTo(w * 0.75, -4)
- c.lineTo(-w * 0.75, -4)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.4)
- return
- }
- if (outfit === 'robe') {
- c.beginPath()
- c.moveTo(-w * 0.9, -15 + b)
- c.lineTo(w * 0.9, -15 + b)
- c.lineTo(w * 1.7, 9)
- c.quadraticCurveTo(0, 12, -w * 1.7, 9)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.7)
- c.beginPath()
- c.moveTo(-2, -14 + b)
- c.lineTo(2, -14 + b)
- c.lineTo(3, 8)
- c.lineTo(-3, 8)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.2)
- return
- }
- if (outfit === 'coat') {
- c.beginPath()
- c.moveTo(-w * 1.15, -14 + b)
- c.lineTo(w * 1.15, -14 + b)
- c.lineTo(w * 1.35, 8)
- c.lineTo(-w * 1.35, 8)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.7)
- c.beginPath()
- c.moveTo(-2.2, -13 + b)
- c.lineTo(2.2, -13 + b)
- c.lineTo(2.6, 7)
- c.lineTo(-2.6, 7)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.3)
- return
- }
- if (outfit === 'armor') {
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.15, 5)
- c.lineTo(-w * 1.15, 5)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.7)
- c.beginPath()
- c.moveTo(-w * 1.35, -13 + b)
- c.lineTo(-w * 0.15, -12 + b)
- c.lineTo(-w * 0.25, 2)
- c.lineTo(-w * 1.4, 3)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.6)
- c.beginPath()
- c.moveTo(w * 1.35, -13 + b)
- c.lineTo(w * 0.15, -12 + b)
- c.lineTo(w * 0.25, 2)
- c.lineTo(w * 1.4, 3)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.6)
- return
- }
- if (outfit === 'suit') {
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.05, 5)
- c.lineTo(-w * 1.05, 5)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.7)
- c.beginPath()
- c.moveTo(-1.6, -13 + b)
- c.lineTo(1.6, -13 + b)
- c.lineTo(2, 5)
- c.lineTo(-2, 5)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.2)
- c.strokeStyle = '#e8c14d'
- c.lineWidth = 1.2
- c.beginPath()
- c.moveTo(0, -10 + b)
- c.lineTo(0, 4)
- c.stroke()
- return
- }
- if (outfit === 'plaid') {
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.05, 4)
- c.lineTo(-w * 1.05, 4)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.7)
- c.strokeStyle = skin.vestColor
- c.lineWidth = 1.1
- for (let i = -5; i <= 5; i += 2.6) {
- c.beginPath(); c.moveTo(i, -13 + b); c.lineTo(i + 0.6, 4); c.stroke()
- }
- for (let y = -12; y <= 3; y += 3) {
- c.beginPath(); c.moveTo(-w, y + b); c.lineTo(w, y + b); c.stroke()
- }
- return
- }
- if (outfit === 'stripes') {
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.05, 4)
- c.lineTo(-w * 1.05, 4)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.7)
- c.strokeStyle = skin.vestColor
- c.lineWidth = 2
- for (let y = -12; y <= 3; y += 3.2) {
- c.beginPath(); c.moveTo(-w + 0.5, y + b); c.lineTo(w - 0.5, y + b); c.stroke()
- }
- return
- }
- if (outfit === 'apron') {
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.05, 4)
- c.lineTo(-w * 1.05, 4)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.6)
- c.beginPath()
- c.moveTo(-w * 0.85, -8 + b)
- c.lineTo(w * 0.85, -8 + b)
- c.lineTo(w * 0.95, 6)
- c.lineTo(-w * 0.95, 6)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.4)
- return
- }
- if (outfit === 'tank') {
- c.beginPath()
- c.moveTo(-w * 1.15, -12 + b)
- c.lineTo(w * 1.15, -12 + b)
- c.lineTo(w * 1.25, 5)
- c.lineTo(-w * 1.25, 5)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.7)
- c.strokeStyle = 'rgba(29,20,9,0.25)'
- c.lineWidth = 1.2
- c.beginPath(); c.arc(-3.2 * f.body, -6 + b, 2.4, 0, 7); c.stroke()
- c.beginPath(); c.arc(3.2 * f.body, -6 + b, 2.4, 0, 7); c.stroke()
- return
- }
- c.beginPath()
- c.moveTo(-w, -14 + b)
- c.lineTo(w, -14 + b)
- c.lineTo(w * 1.12, 4)
- c.lineTo(-w * 1.12, 4)
- c.closePath()
- fs(c, skin.shirtColor, '#1d1409', 1.8)
- c.beginPath()
- c.moveTo(-w * 1.08, -14 + b)
- c.lineTo(-2, -13 + b)
- c.lineTo(-2.6, 4)
- c.lineTo(-w * 1.12, 4)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.6)
- c.beginPath()
- c.moveTo(w * 1.08, -14 + b)
- c.lineTo(2, -13 + b)
- c.lineTo(2.6, 4)
- c.lineTo(w * 1.12, 4)
- c.closePath()
- fs(c, skin.vestColor, '#1d1409', 1.6)
-}
-
-function drawHead(c, skin, headY) {
- const f = figScale(skin)
- if (skin.figure === 'robot') {
- c.beginPath()
- c.rect(-8.2, headY - 8.4, 16.4, 16.2)
- fs(c, skin.skinTone, '#1d1409', 1.8)
- c.fillStyle = skin.vestColor
- c.fillRect(-8.2, headY - 2, 16.4, 2)
- return
- }
- if (skin.figure === 'plant') {
- c.beginPath()
- c.ellipse(0, headY, 7.4 * f.head, 9.2 * f.head, 0, 0, 7)
- fs(c, skin.skinTone, '#1d1409', 1.8)
- return
- }
- c.beginPath()
- c.arc(0, headY, 8.4 * f.head, 0, 7)
- fs(c, skin.skinTone, '#1d1409', 1.8)
-}
-
-function drawFace(c, skin, headY) {
- const face = skin.face || 'default'
- if (face === 'robot') {
- c.fillStyle = '#7fe0ff'
- c.beginPath(); c.arc(-3.2, headY - 1.4, 2.1, 0, 7); c.fill()
- c.beginPath(); c.arc(3.2, headY - 1.4, 2.1, 0, 7); c.fill()
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.2
- c.stroke()
- c.fillStyle = '#1d1409'
- c.fillRect(-3.6, headY + 3.2, 7.2, 1.6)
- c.strokeStyle = skin.hairColor
- c.lineWidth = 1.6
- c.beginPath(); c.moveTo(-2, headY - 8.6); c.lineTo(-2, headY - 15); c.stroke()
- c.beginPath(); c.moveTo(2, headY - 8.6); c.lineTo(2, headY - 13.5); c.stroke()
- c.fillStyle = '#7fe0ff'
- c.beginPath(); c.arc(-2, headY - 15.4, 1.5, 0, 7); c.fill()
- return
- }
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.8
- c.beginPath()
- c.moveTo(2.4, headY - 2)
- c.lineTo(2.4, headY + 0.6)
- c.moveTo(6.4, headY - 2)
- c.lineTo(6.4, headY + 0.6)
- c.stroke()
- c.lineWidth = 1.4
- c.beginPath()
- c.arc(4.6, headY + 3.4, 2, 0.3, Math.PI - 0.5)
- c.stroke()
- if (face === 'beard') {
- c.fillStyle = skin.hairColor
- c.beginPath()
- c.moveTo(-5.5, headY + 3)
- c.quadraticCurveTo(0, headY + 12, 5.5, headY + 3)
- c.quadraticCurveTo(0, headY + 6.5, -5.5, headY + 3)
- c.closePath()
- c.fill()
- } else if (face === 'mustache') {
- c.fillStyle = skin.hairColor
- c.beginPath()
- c.ellipse(-2.2, headY + 3.6, 3.2, 1.3, -0.25, 0, 7)
- c.ellipse(2.2, headY + 3.6, 3.2, 1.3, 0.25, 0, 7)
- c.fill()
- } else if (face === 'mime') {
- c.fillStyle = '#d8524e'
- c.beginPath(); c.arc(-3.6, headY + 1.6, 1.6, 0, 7); c.fill()
- c.beginPath(); c.arc(3.6, headY + 1.6, 1.6, 0, 7); c.fill()
- } else if (face === 'glasses') {
- c.strokeStyle = '#1d1409'
- c.lineWidth = 1.4
- c.beginPath(); c.arc(2.6, headY - 0.6, 2.6, 0, 7); c.stroke()
- c.beginPath(); c.arc(6.2, headY - 0.6, 2.6, 0, 7); c.stroke()
- c.beginPath(); c.moveTo(5.2, headY - 0.6); c.lineTo(3.6, headY - 0.6); c.stroke()
- }
-}
-
-// 皮肤差异化头饰/面饰:同一发型也靠头饰/面纹区分
-function drawSkinAccessory(c, skin, headY) {
- const code = skin.code || ''
- c.save()
+ const woodLo = '#6b4420'
+ const woodHi = '#a8763e'
c.lineJoin = 'round'
c.lineCap = 'round'
- if (code === 'flame' || code === 'lava') {
- // 护目镜
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.6
- c.beginPath(); c.arc(-3, headY - 3.4, 3.4, 0, 7); c.stroke()
- c.beginPath(); c.arc(3, headY - 3.4, 3.4, 0, 7); c.stroke()
- c.fillStyle = code === 'lava' ? '#ff9a3c' : '#f0923c'
- c.fill()
- c.strokeStyle = '#1d1409'
- c.beginPath(); c.moveTo(-6.2, headY - 4); c.lineTo(-0.8, headY - 4.6); c.moveTo(6.2, headY - 4); c.lineTo(0.8, headY - 4.6); c.stroke()
- } else if (code === 'ocean' || code === 'navy') {
- // 水手/军官帽
- c.fillStyle = code === 'ocean' ? '#f4f8fa' : '#2c3e68'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.ellipse(0, headY - 8, 10, 3.6, 0, Math.PI, 0); c.fill(); c.stroke()
- c.fillStyle = code === 'ocean' ? '#2f6d8e' : '#c9a03c'
- c.fillRect(-10, headY - 8.8, 20, 2)
- c.fillStyle = '#1d1409'
- c.beginPath(); c.arc(0, headY - 12, 2.4, 0, 7); c.fill()
- } else if (code === 'forest' || code === 'moss' || code === 'swamp') {
- // 叶片/芦苇头环
- c.fillStyle = code === 'swamp' ? '#4a5c2e' : code === 'moss' ? '#6a7a3d' : '#3f6b35'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.2
- ;[[-7, headY - 7], [-3, headY - 10], [2, headY - 10.5], [7, headY - 7]].forEach(([hx, hy]) => {
- c.beginPath(); c.ellipse(hx, hy, 3.6, 1.8, hx * 0.35, 0, 7); c.fill(); c.stroke()
- })
- } else if (code === 'honey' || code === 'gold') {
- // 蜜糖/金币发夹
- c.fillStyle = code === 'honey' ? '#f0c040' : '#e0b83a'
- c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
- c.beginPath(); c.arc(6.5, headY - 5, 2.6, 0, 7); c.fill(); c.stroke()
- c.fillStyle = '#fff'
- c.beginPath(); c.arc(6.5, headY - 5.6, 0.9, 0, 7); c.fill()
- } else if (code === 'dusk' || code === 'plum') {
- // 魔术师小礼帽 / 贵妇小帽
- c.fillStyle = code === 'dusk' ? '#3a2a52' : '#8a4a7e'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.rect(-6, headY - 12, 12, 4); c.fill(); c.stroke()
- c.beginPath(); c.rect(-4, headY - 16, 8, 4.5); c.fill(); c.stroke()
- c.fillStyle = '#e8c14d'; c.fillRect(-6, headY - 10, 12, 1.6)
- } else if (code === 'mint' || code === 'sky') {
- // 圆框眼镜 / 飞行风镜
- c.strokeStyle = code === 'mint' ? '#3f5548' : '#4a668c'; c.lineWidth = 1.5
- c.beginPath(); c.arc(-3.2, headY - 2.6, 2.8, 0, 7); c.stroke()
- c.beginPath(); c.arc(3.2, headY - 2.6, 2.8, 0, 7); c.stroke()
- c.beginPath(); c.moveTo(-0.6, headY - 2.6); c.lineTo(0.6, headY - 2.6); c.stroke()
- } else if (code === 'ash' || code === 'rust') {
- // 头巾
- c.fillStyle = code === 'ash' ? '#8a8a94' : '#a05a2a'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.moveTo(-8.6, headY - 6); c.quadraticCurveTo(0, headY - 14, 8.6, headY - 6); c.quadraticCurveTo(0, headY - 9, -8.6, headY - 6); c.closePath(); c.fill(); c.stroke()
- c.beginPath(); c.moveTo(8, headY - 6); c.lineTo(11, headY + 2); c.lineTo(8.6, headY + 3); c.closePath(); c.fill(); c.stroke()
- } else if (code === 'rose') {
- // 蔷薇发饰
- c.fillStyle = '#d86a8a'
- c.strokeStyle = '#7e3348'; c.lineWidth = 1.1
- ;[[0, 2.2], [1.8, 1.4], [1.8, -1.4], [0, -2.2], [-1.8, -1.4], [-1.8, 1.4]].forEach(([px, py]) => {
- c.beginPath(); c.arc(6.8 + px, headY - 5 + py, 2.1, 0, 7); c.fill(); c.stroke()
- })
- } else if (code === 'sand') {
- // 旅人头巾
- c.fillStyle = '#c9a86a'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.3
- c.beginPath(); c.moveTo(-8.4, headY - 7); c.quadraticCurveTo(0, headY - 15, 8.4, headY - 7); c.quadraticCurveTo(0, headY - 8.5, -8.4, headY - 7); c.closePath(); c.fill(); c.stroke()
- c.fillStyle = '#a8854e'
- c.fillRect(-8.4, headY - 9.6, 16.8, 2.4)
- } else if (code === 'blizzard' || code === 'glacier') {
- // 毛绒兜帽
- c.fillStyle = code === 'blizzard' ? '#dfe6f0' : '#b8d8e8'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.moveTo(-8.6, headY - 4); c.quadraticCurveTo(-9, headY - 14, 0, headY - 14); c.quadraticCurveTo(9, headY - 14, 8.6, headY - 4); c.quadraticCurveTo(0, headY - 7, -8.6, headY - 4); c.closePath(); c.fill(); c.stroke()
- } else if (code === 'crow') {
- // 鸦羽
- c.fillStyle = '#2a2534'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.2
- c.beginPath(); c.moveTo(7, headY - 7); c.quadraticCurveTo(14, headY - 16, 12, headY - 2); c.quadraticCurveTo(9, headY - 5, 7, headY - 3); c.closePath(); c.fill(); c.stroke()
- } else if (code === 'sunset') {
- // 吟游者羽帽
- c.fillStyle = '#e8823c'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.3
- c.beginPath(); c.ellipse(0, headY - 8, 9, 3.2, 0, Math.PI, 0); c.fill(); c.stroke()
- c.fillStyle = '#f8d8a8'
- c.beginPath(); c.moveTo(6, headY - 9); c.quadraticCurveTo(12, headY - 16, 11, headY - 4); c.quadraticCurveTo(8, headY - 7, 6, headY - 6); c.closePath(); c.fill(); c.stroke()
- } else if (code === 'thorn') {
- // 荆棘头冠
- c.strokeStyle = '#54682c'; c.lineWidth = 1.4
- c.beginPath(); c.moveTo(-7, headY - 6); c.quadraticCurveTo(0, headY - 12, 7, headY - 6); c.stroke()
- ;[[-4, headY - 8], [0, headY - 10], [4, headY - 8]].forEach(([hx, hy]) => {
- c.beginPath(); c.moveTo(hx, hy); c.lineTo(hx, hy - 4); c.stroke()
- })
- } else if (code === 'shadow' || code === 'void') {
- // 半脸面具
- c.fillStyle = code === 'shadow' ? '#1e1828' : '#141020'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
- c.beginPath(); c.moveTo(-6, headY - 6); c.quadraticCurveTo(0, headY - 2, 6, headY - 6); c.quadraticCurveTo(3, headY + 2, 0, headY + 3); c.quadraticCurveTo(-3, headY + 2, -6, headY - 6); c.closePath(); c.fill(); c.stroke()
- c.fillStyle = code === 'shadow' ? '#8e5cf0' : '#5a4a9e'
- c.beginPath(); c.arc(0, headY - 1.5, 1.6, 0, 7); c.fill()
- } else if (code === 'blood') {
- // 血月战纹
- c.strokeStyle = '#8e1c24'; c.lineWidth = 1.5
- c.beginPath(); c.moveTo(-6.5, headY - 2); c.lineTo(-3.5, headY + 2); c.stroke()
- c.beginPath(); c.moveTo(-3, headY - 2); c.lineTo(-6, headY + 2); c.stroke()
- } else if (code === 'willow') {
- c.fillStyle = '#c45a3a'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
- c.beginPath(); c.rect(6.2, headY + 1, 2.2, 5); c.fill(); c.stroke()
+ const handle = (len, w = 3.2) => {
+ c.strokeStyle = OUT
+ c.lineWidth = w + 1.4
+ c.beginPath(); c.moveTo(-len * 0.3, 0); c.lineTo(len * 0.72, 0); c.stroke()
+ c.strokeStyle = linGrad(c, 'hw' + len, -len * 0.3, 0, len * 0.72, 0, [[0, woodLo], [0.4, wood], [1, woodHi]])
+ c.lineWidth = w
+ c.beginPath(); c.moveTo(-len * 0.3, 0); c.lineTo(len * 0.72, 0); c.stroke()
+ // 握把绑绳
+ c.strokeStyle = '#4a3018'
+ c.lineWidth = 1.1
+ c.beginPath(); c.moveTo(-len * 0.12, -w * 0.5); c.lineTo(-len * 0.06, w * 0.5); c.stroke()
+ c.beginPath(); c.moveTo(0, -w * 0.5); c.lineTo(len * 0.06, w * 0.5); c.stroke()
+ }
+ const metalHead = (pts, col = '#b8bec6', hi = 'rgba(255,255,255,0.42)') => {
c.beginPath()
- c.moveTo(7.3, headY + 1)
- c.quadraticCurveTo(10, headY - 3, 7.3, headY - 1)
- c.quadraticCurveTo(5, headY - 3, 7.3, headY + 1)
- fs(c, '#f0923c', '#b5541e', 1)
- } else if (code === 'wendy') {
- c.fillStyle = '#c96a8a'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.1
- c.beginPath(); c.arc(-6.4, headY - 4, 2.4, 0, 7); c.fill(); c.stroke()
- c.fillStyle = '#6a7a3d'
- c.beginPath(); c.ellipse(-6.4, headY - 1.2, 1.6, 0.8, 0.4, 0, 7); c.fill()
- } else if (code === 'king') {
- // 金冠已在发型中体现;这里补红披肩扣
- c.fillStyle = '#e8c14d'
- c.strokeStyle = '#8e6a14'; c.lineWidth = 1
- c.beginPath(); c.arc(0, -14, 1.6, 0, 7); c.fill(); c.stroke()
- } else if (code === 'maxwell') {
- c.fillStyle = '#141018'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.rect(-8.2, headY - 10, 16.4, 3.2); c.fill(); c.stroke()
- c.beginPath(); c.rect(-5.6, headY - 18.4, 11.2, 8.6); c.fill(); c.stroke()
- c.fillStyle = '#c43a3a'; c.fillRect(-8.2, headY - 8.4, 16.4, 1.4)
- } else if (code === 'wigfrid') {
- c.fillStyle = '#c9b080'
- c.strokeStyle = '#1d1409'; c.lineWidth = 1.4
- c.beginPath(); c.ellipse(0, headY - 7, 9.6, 3.5, 0, Math.PI, 0); c.fill(); c.stroke()
- c.fillStyle = '#e8d8c0'
- ;[-1, 1].forEach((s) => {
+ pts.forEach(([px, py], i) => (i ? c.lineTo(px, py) : c.moveTo(px, py)))
+ c.closePath()
+ fs(c, col, OUT, 1.4)
+ if (pts.length > 3) {
c.beginPath()
- c.moveTo(s * 7, headY - 8)
- c.quadraticCurveTo(s * 14, headY - 18, s * 5, headY - 16)
- c.quadraticCurveTo(s * 9, headY - 10, s * 6, headY - 7)
+ c.moveTo(pts[0][0], pts[0][1])
+ for (let i = 1; i < Math.ceil(pts.length / 2); i++) c.lineTo(pts[i][0], pts[i][1])
c.closePath()
- c.fill(); c.stroke()
+ c.fillStyle = hi
+ c.fill()
+ }
+ }
+ if (kind === 'axe') {
+ handle(23)
+ metalHead([[12, -1], [13.5, 6.5], [8.5, 9], [11, 1.2]], '#b8bec6')
+ metalHead([[12, -1], [16.5, 4], [15, 10.5], [13.5, 6.5]], '#9aa4ae')
+ c.fillStyle = '#8a5a2b'
+ c.beginPath(); c.arc(11.4, 0.4, 1.7, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ } else if (kind === 'pick') {
+ handle(21)
+ metalHead([[11, -2.5], [17, -6], [19, -1.5], [15, 1], [11, 2.5]], '#5c6470', 'rgba(255,255,255,0.3)')
+ metalHead([[11, -2.5], [5, -6.5], [3, -2], [7, 0.6], [11, 2.5]], '#4c545e', 'rgba(255,255,255,0.16)')
+ } else if (kind === 'shovel') {
+ handle(25)
+ metalHead([[13, -3.4], [17.5, 0], [16, 6], [10, 6.5], [11.5, 0]], '#9aa4ae')
+ c.strokeStyle = shade('#9aa4ae', -0.35)
+ c.lineWidth = 1
+ c.beginPath(); c.moveTo(13.5, 0.5); c.lineTo(15, 5.5); c.stroke()
+ } else if (kind === 'hoe') {
+ handle(23)
+ metalHead([[12, -3], [19, -1.5], [19.5, 3], [12.5, 2]], '#9aa4ae')
+ } else if (kind === 'shears') {
+ c.strokeStyle = OUT
+ c.lineWidth = 3.4
+ c.beginPath(); c.moveTo(-3, 3.5); c.lineTo(9, -4.5); c.stroke()
+ c.strokeStyle = '#7a828a'
+ c.lineWidth = 2.2
+ c.beginPath(); c.moveTo(-3, 3.5); c.lineTo(9, -4.5); c.stroke()
+ c.strokeStyle = OUT
+ c.lineWidth = 3
+ c.beginPath(); c.moveTo(-3, 6); c.lineTo(7, -7.5); c.stroke()
+ c.strokeStyle = '#5c6470'
+ c.lineWidth = 1.8
+ c.beginPath(); c.moveTo(-3, 6); c.lineTo(7, -7.5); c.stroke()
+ c.fillStyle = '#4a4f55'
+ c.beginPath(); c.arc(0, 0, 2, 0, TAU); c.fill()
+ c.strokeStyle = OUT; c.lineWidth = 1; c.stroke()
+ } else if (kind === 'torch') {
+ handle(20, 3)
+ // 火焰(随时间摇曳)
+ const fl = Math.sin(time * 11) * 1.6
+ c.beginPath()
+ c.moveTo(11, -8 + fl * 0.3)
+ c.quadraticCurveTo(16.5, -3 + fl, 11.5, 0.5)
+ c.quadraticCurveTo(7, -3 + fl, 11, -8 + fl * 0.3)
+ c.closePath()
+ fs(c, '#f0923c', '#b5541e', 1.1)
+ c.beginPath()
+ c.moveTo(11.2, -5.6 + fl * 0.2)
+ c.quadraticCurveTo(13.6, -3.4 + fl * 0.7, 11.4, -1.4)
+ c.quadraticCurveTo(9.4, -3.4 + fl * 0.7, 11.2, -5.6 + fl * 0.2)
+ c.closePath()
+ fs(c, '#ffd76a', null)
+ } else if (kind === 'spear') {
+ handle(34, 2.9)
+ metalHead([[17.5, -2.6], [17.5, 2.6], [25.5, 0]], '#c3c8d0', 'rgba(255,255,255,0.5)')
+ c.strokeStyle = '#c9a06a'
+ c.lineWidth = 1.3
+ c.beginPath(); c.moveTo(12, -1.6); c.lineTo(15, 1.4); c.moveTo(14, -1.6); c.lineTo(17, 1.4); c.stroke()
+ } else if (kind === 'tentaclespike') {
+ c.strokeStyle = OUT
+ c.lineWidth = 4.6
+ c.beginPath(); c.moveTo(-7, 1); c.quadraticCurveTo(8, 2, 18, -2); c.stroke()
+ c.strokeStyle = linGrad(c, 'tp', -7, 0, 18, 0, [[0, '#4c3a58'], [1, '#6b4a7e']])
+ c.lineWidth = 3.4
+ c.beginPath(); c.moveTo(-7, 1); c.quadraticCurveTo(8, 2, 18, -2); c.stroke()
+ c.fillStyle = '#e8e2d4'
+ ;[[4, 1.2], [9, 0.4], [14, -0.8]].forEach(([sx, sy], i) => {
+ c.beginPath()
+ c.moveTo(sx, sy)
+ c.lineTo(sx + 2, sy + 4.4 - i)
+ c.lineTo(sx + 4.4 - i * 0.6, sy + 0.6)
+ c.closePath()
+ c.fill()
})
- } else if (code === 'webber') {
- c.fillStyle = '#1d1409'
- ;[[-4.8, headY - 3.4], [-1.6, headY - 4.6], [1.6, headY - 4.6], [4.8, headY - 3.4]].forEach(([ex, ey]) => {
- c.beginPath(); c.arc(ex, ey, 1.15, 0, 7); c.fill()
- })
- } else if (code === 'wanda') {
- c.fillStyle = '#e8c14d'
- c.strokeStyle = '#8e6a14'; c.lineWidth = 1.2
- c.beginPath(); c.arc(8.2, -8, 3.3, 0, 7); c.fill(); c.stroke()
- c.strokeStyle = '#1d1409'; c.lineWidth = 1
- c.beginPath(); c.moveTo(8.2, -8); c.lineTo(8.2, -9.8); c.moveTo(8.2, -8); c.lineTo(9.6, -7.2); c.stroke()
- } else if (code === 'wortox') {
- c.fillStyle = '#f0e060'
- c.beginPath(); c.arc(-3.2, headY - 1.6, 1.85, 0, 7); c.fill()
- c.beginPath(); c.arc(3.2, headY - 1.6, 1.85, 0, 7); c.fill()
- c.fillStyle = '#1d1409'
- c.beginPath(); c.arc(-3.2, headY - 1.6, 0.7, 0, 7); c.fill()
- c.beginPath(); c.arc(3.2, headY - 1.6, 0.7, 0, 7); c.fill()
- } else if (code === 'wurt') {
- c.fillStyle = '#3a6a48'
- c.beginPath(); c.ellipse(0, headY + 4.4, 3.4, 1.6, 0, 0, 7); c.fill()
+ } else if (kind) {
+ // 未知手持:通用木柄锤
+ handle(16)
+ metalHead([[9, -4], [15, -4], [15, 4], [9, 4]], '#9aa4ae')
}
c.restore()
}
+function toolWorldAng(pose, handAng) {
+ return handAng + (pose.tool ?? -3.55)
+}
-// 通用人物绘制:以脚底 (0,0) 为原点,头顶约 -32px
+// 挥击拖尾:以「基础姿势 + 幽灵帧动作采样」合成历史手位,画渐隐扇形(跨度过大不画,避免糊成一面旗)
+// 注意:basePose 必须是未混合动作的姿势,否则动作偏移会被 addPose 重复叠加
+function drawToolTrail(c, skel, kind, basePose, add, act, tNorm) {
+ if (tNorm <= 0.04 || tNorm >= 0.98) return
+ const len = TOOL_LEN[kind] || 16
+ const d1 = 0.07
+ const d2 = 0.15
+ const pts = []
+ ;[tNorm - d2, tNorm - d1, tNorm].forEach((tg) => {
+ const ap = sampleClip(ACT_CLIPS[act] || ACT_CLIPS.swing, Math.max(0, Math.min(1, tg)))
+ const gp = addPose(addPose(basePose, ap), add || {})
+ if (gp.tool == null) gp.tool = TOOL_REST[kind] ?? -3.55
+ const P = computePose(skel, gp)
+ const hand = P['armF.f']
+ const wa = toolWorldAng(gp, hand.ang)
+ pts.push({
+ hx: hand.ex, hy: hand.ey,
+ tx: hand.ex + Math.cos(wa) * len, ty: hand.ey + Math.sin(wa) * len,
+ })
+ })
+ const span = Math.hypot(pts[2].tx - pts[0].tx, pts[2].ty - pts[0].ty)
+ if (span < 7 || span > 120) return
+ for (let i = 0; i < 2; i++) {
+ const a = pts[i]
+ const b = pts[i + 1]
+ c.beginPath()
+ c.moveTo(a.hx, a.hy)
+ c.lineTo(a.tx, a.ty)
+ c.lineTo(b.tx, b.ty)
+ c.lineTo(b.hx, b.hy)
+ c.closePath()
+ c.fillStyle = `rgba(255,246,220,${i === 0 ? 0.05 : 0.12})`
+ c.fill()
+ }
+}
+
+// =====================================================================
+// 幽灵形态
+// =====================================================================
+function drawGhost(c, time) {
+ const wob = Math.sin(time * 2.4) * 1.6
+ c.globalAlpha = 0.55
+ c.beginPath()
+ c.moveTo(0, -30)
+ c.bezierCurveTo(12, -28, 13, -10, 10 + wob, 2)
+ c.quadraticCurveTo(6, 8, 2, 3)
+ c.quadraticCurveTo(0, 8, -2, 3)
+ c.quadraticCurveTo(-6, 8, -10 - wob, 2)
+ c.bezierCurveTo(-13, -10, -12, -28, 0, -30)
+ fs(c, '#e9ecf4', 'rgba(120,130,160,0.8)', 1.6)
+ c.fillStyle = '#2a2a3a'
+ c.beginPath()
+ c.arc(-3.4, -20, 1.6, 0, TAU)
+ c.arc(3.4, -20, 1.6, 0, TAU)
+ c.fill()
+ c.globalAlpha = 1
+}
+
+// =====================================================================
+// 主入口:drawPlayerFig(ctx, skin, pose)(签名与旧版一致)
// pose = { dir, walking, walkT, swing, breathe, toolCode, ghost, time, act, fall }
-// act: 动作分化(chop 横劈 / mine 上凿下砸 / dig 下铲 / attack 突刺 / eat 抬手进食),swing 1→0 驱动
-// fall: 死亡倒地进度 0→1(倒完后由调用方切换为幽灵形态)
+// =====================================================================
export function drawPlayerFig(c, skin, pose = {}) {
const { dir = 1, walking = false, walkT = 0, swing = 0, breathe = 0, toolCode = '', ghost = false, time = 0, act = '', fall = 0 } = pose
c.save()
- c.scale(dir, 1)
c.lineJoin = 'round'
c.lineCap = 'round'
- if (!ghost && fall > 0) {
- // 死亡倒地:绕脚底向后倒 + 渐隐
- c.rotate(fall * 1.4)
- c.globalAlpha = 1 - fall * 0.35
- }
if (ghost) {
- // 幽灵形态:半透明白影摇曳 + 黑点眼(复刻原版幽灵)
- const wob = Math.sin(time * 2.4) * 1.6
- c.globalAlpha = 0.55
- c.beginPath()
- c.moveTo(0, -30)
- c.bezierCurveTo(12, -28, 13, -10, 10 + wob, 2)
- c.quadraticCurveTo(6, 8, 2, 3)
- c.quadraticCurveTo(0, 8, -2, 3)
- c.quadraticCurveTo(-6, 8, -10 - wob, 2)
- c.bezierCurveTo(-13, -10, -12, -28, 0, -30)
- fs(c, '#e9ecf4', 'rgba(120,130,160,0.8)', 1.6)
- c.fillStyle = '#2a2a3a'
- c.beginPath()
- c.arc(-3.4, -20, 1.6, 0, 7)
- c.arc(3.4, -20, 1.6, 0, 7)
- c.fill()
- c.globalAlpha = 1
+ drawGhost(c, time)
c.restore()
return
}
- const walkSwing = walking ? Math.sin(walkT * 11) : 0
- const f = figScale(skin)
- drawLegs(c, skin, walkSwing)
- drawOutfit(c, skin, breathe)
- c.strokeStyle = skin.figure === 'robot' ? '#5a6270' : skin.shirtColor
- c.lineWidth = skin.figure === 'bulky' ? 4.6 : 3.6
- c.beginPath()
- c.moveTo(-5 * f.body, -11 + breathe)
- c.lineTo(-7 * f.body - walkSwing * 3, -1 + Math.abs(walkSwing))
- c.stroke()
- const headY = (skin.figure === 'bulky' ? -24 : -22) + breathe
- drawHead(c, skin, headY)
- if (skin.hair && skin.hair !== 'none') drawHair(c, skin, headY)
- drawFace(c, skin, headY)
- drawSkinAccessory(c, skin, headY)
- let swingAng = -0.35 + walkSwing * 0.32
- let thrust = 0
- if (swing > 0) {
- const t = 1 - swing
- if (act === 'mine' || act === 'dig') swingAng = -2.7 + t * 3.2
- else if (act === 'attack') { swingAng = -1.05; thrust = Math.sin(t * Math.PI) * 8 }
- else if (act === 'eat') swingAng = -0.4 - Math.sin(t * Math.PI) * 1.95
- else swingAng = -2.1 + t * 2.7
+ c.scale(dir, 1)
+ if (fall > 0) {
+ c.rotate(fall * 1.4)
+ c.globalAlpha = 1 - fall * 0.35
}
- const toolAng = swing > 0 ? 0 : act === 'eat' ? 0.9 : act === 'attack' ? 0 : act === 'chop' ? -0.25 : (act === 'mine' || act === 'dig') ? -0.5 : -0.15
+ const f = figOf(skin)
+ const skel = skelFor(skin)
+ const pal = palOf(skin)
+ const tq = qTime(time, 9)
+ const kind = resolveToolKind(toolCode)
+
+ // ---- 姿势合成 ----
+ const basePose = walking ? clipPose(CLIP_WALK, walkT) : clipPose(CLIP_IDLE, time + pal.seed * 0.37)
+ let p = basePose
+ let actPose = null
+ let tNorm = 0
+ if (swing > 0) {
+ tNorm = 1 - swing
+ actPose = sampleClip(ACT_CLIPS[act] || ACT_CLIPS.swing, tNorm)
+ p = blendPose(basePose, actPose, Math.min(1, tNorm * 7))
+ }
+ if (p.tool == null) p.tool = TOOL_REST[kind] ?? -3.55
+ // 呼吸 + 倒地瘫软(加性层)
+ const add = {
+ 'root.y': breathe * 0.85,
+ spine: breathe * 0.012,
+ head: breathe * 0.008,
+ 'armF.u': breathe * 0.02,
+ 'armA.u': breathe * 0.02,
+ }
+ if (fall > 0) {
+ add.spine = (add.spine || 0) + fall * 0.35
+ add['armF.u'] = (add['armF.u'] || 0) + fall * 0.9
+ add['armA.u'] = (add['armA.u'] || 0) + fall * 0.9
+ add['legF.u'] = (add['legF.u'] || 0) + fall * 0.35
+ add['legA.u'] = (add['legA.u'] || 0) + fall * 0.3
+ }
+ p = addPose(p, add)
+ const P = computePose(skel, p)
+
+ // ---- 影子 ----
+ if (fall <= 0) {
+ c.fillStyle = 'rgba(20,16,8,0.28)'
+ c.beginPath()
+ c.ellipse(0, 0.8, 12.5, 3.9, 0, 0, TAU)
+ c.fill()
+ }
+
+ // ---- 肢体(后 → 前)----
+ drawArm(c, skin, pal, P['armA.u'], P['armA.f'], f, true)
+ drawLeg(c, skin, P['legA.u'], P['legA.f'], f, true)
+ drawLeg(c, skin, P['legF.u'], P['legF.f'], f, false)
+ // 颈
+ boneStroke(c, P.neck.x, P.neck.y, P.neck.ex, P.neck.ey, f.armW * 0.9, shade(pal.skin, -0.12))
+
+ // ---- 躯干 ----
c.save()
- c.translate(5 * f.body + thrust, -11 + breathe)
- c.rotate(swingAng)
- c.strokeStyle = skin.figure === 'robot' ? '#5a6270' : skin.shirtColor
- c.lineWidth = skin.figure === 'bulky' ? 4.6 : 3.6
- c.beginPath()
- c.moveTo(0, 0)
- c.lineTo(0, 11)
- c.stroke()
- drawToolEnhanced(c, toolCode, toolAng)
+ c.translate(P.hips.x, P.hips.y)
+ c.rotate(P.spine.ang + Math.PI / 2)
+ drawTorso(c, skin, pal, f, tq)
c.restore()
+
+ // ---- 头 ----
+ c.save()
+ c.translate(P.head.x, P.head.y)
+ c.rotate(P.head.ang + Math.PI / 2)
+ drawHair(c, skin, pal, f, 'back', time)
+ drawSkull(c, skin, pal, f, tq)
+ drawFace(c, skin, pal, f, time)
+ drawHair(c, skin, pal, f, 'front', time)
+ drawAccessory(c, skin, pal, f, time)
+ c.restore()
+
+ // ---- 工具拖尾 + 前臂 ----
+ if (swing > 0 && (act === 'chop' || act === 'mine' || act === 'swing')) {
+ drawToolTrail(c, skel, kind, basePose, add, act, tNorm)
+ }
+ drawArm(c, skin, pal, P['armF.u'], P['armF.f'], f, false)
+ if (toolCode && fall <= 0) {
+ const hand = P['armF.f']
+ c.save()
+ c.translate(hand.ex, hand.ey)
+ c.rotate(toolWorldAng(p, hand.ang))
+ drawToolShape(c, kind, time)
+ c.restore()
+ }
c.restore()
}
-// 离屏渲染 72px 立绘 dataURL(更衣室/房间座位预览用,带缓存)
+// =====================================================================
+// 营地守卫(camp.js 调用):皮甲战士持长矛,巡逻/突刺两套动作
+// o = { dir, moving, walkT, cd, bob, time };突刺由 m.cd(0.35→0)驱动
+// =====================================================================
+const CLIP_GUARD_THRUST = { dur: 1, loop: false, tracks: {
+ 'root.x': [[0, -1.5], [0.3, 6, 'out'], [0.6, 4.5], [1, 0]],
+ 'armF.u': [[0, 0.2], [0.3, -0.75, 'out'], [0.6, -0.6], [1, 0]],
+ 'armF.f': [[0, 0.3], [0.3, -0.05, 'out'], [1, 0.1]],
+ spine: [[0, 0], [0.3, 0.16, 'out'], [1, 0]],
+ tool: [[0, -2.95], [0.3, -0.6, 'out'], [1, -2.9, 'inOut']],
+} }
+export function drawGuard(c, o = {}) {
+ const { dir = 1, moving = false, walkT = 0, cd = 0, bob = 0, time = 0 } = o
+ const f = FIGS.human
+ const skel = skelFor({ figure: 'human' })
+ const tq = qTime(time, 8)
+ c.save()
+ c.lineJoin = 'round'
+ c.lineCap = 'round'
+ c.scale(dir, 1)
+ let p = moving ? clipPose(CLIP_WALK, walkT) : clipPose(CLIP_IDLE, time + 0.7)
+ if (cd > 0) p = blendPose(p, sampleClip(CLIP_GUARD_THRUST, 1 - cd / 0.35), 1)
+ const P = computePose(skel, p)
+ const leather = '#8a5f34'
+ const leatherLo = '#6b4a2a'
+ const leatherHi = '#a97b47'
+ const metal = '#9aa4ae'
+ const metalHi = '#c3ccd6'
+ // 影子
+ c.fillStyle = 'rgba(20,16,8,0.28)'
+ c.beginPath()
+ c.ellipse(0, 0.8, 12.5, 3.9, 0, 0, TAU)
+ c.fill()
+ // 披风(背后,随动作飘)
+ const sway = Math.sin(time * 2.6) * 2 + (moving ? 3 : 0) + (cd > 0 ? -4 : 0)
+ const shTopY = f.hipY - f.spine - f.chest + 2
+ blob(c, [
+ [-f.shW * 0.4, shTopY],
+ [-f.shW * 1.5, f.hipY + 4],
+ [-f.shW * 1.7 + sway, -5],
+ [-f.shW * 0.9 + sway * 0.5, 0],
+ ], { fill: linGrad(c, 'gc', -8, shTopY, -2, 0, [[0, '#a83a30'], [1, '#6e2018']]), stroke: OUT, lw: 1.4, amp: 0.4, seed: 9, tq })
+ // 后侧肢体(暗)
+ drawGuardLimb(c, P['legA.u'], P['legA.f'], f, -0.26)
+ drawGuardArm(c, P['armA.u'], P['armA.f'], f, -0.26)
+ drawGuardLimb(c, P['legF.u'], P['legF.f'], f, 0)
+ // 颈
+ boneStroke(c, P.neck.x, P.neck.y, P.neck.ex, P.neck.ey, f.armW * 0.9, '#d8a878')
+ // 躯干:皮甲
+ c.save()
+ c.translate(P.hips.x, P.hips.y)
+ c.rotate(P.spine.ang + Math.PI / 2)
+ const top = shY(f)
+ c.beginPath()
+ torsoShape(c, f, top, 2, 1.02, 0.9)
+ gradFill(c, 'gd0', top, 2, leatherLo, leather, leatherHi)
+ // 护胸横带 + 铆钉
+ c.strokeStyle = leatherLo
+ c.lineWidth = 2.2
+ ;[top + 4, top + 8.5, -4].forEach((y) => {
+ c.beginPath()
+ c.moveTo(-f.shW * 0.82, y)
+ c.lineTo(f.shW * 0.82, y)
+ c.stroke()
+ })
+ c.fillStyle = '#c9a03c'
+ ;[[-f.shW * 0.6, top + 6.2], [f.shW * 0.6, top + 6.2], [0, -4]].forEach(([x, y]) => {
+ c.beginPath()
+ c.arc(x, y, 1.1, 0, TAU)
+ c.fill()
+ })
+ // 腰带 + 垂布
+ c.strokeStyle = '#3a2a18'
+ c.lineWidth = 3
+ c.beginPath()
+ c.moveTo(-f.shW * 0.9, -1.5)
+ c.lineTo(f.shW * 0.9, -1.5)
+ c.stroke()
+ c.fillStyle = '#8a2c28'
+ c.beginPath()
+ c.roundRect(-2.4, -1, 4.8, 6, 1)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.1
+ c.stroke()
+ c.restore()
+ // 肩甲(双肩圆盘)
+ ;[['armA.u', -0.2], ['armF.u', 0]].forEach(([nm, k]) => {
+ const u = P[nm]
+ c.fillStyle = shade(metal, k)
+ c.beginPath()
+ c.arc(u.x + (u.ex - u.x) * 0.12, u.y + (u.ey - u.y) * 0.12, 4.4, 0, TAU)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.4
+ c.stroke()
+ c.fillStyle = metalHi
+ c.beginPath()
+ c.arc(u.x + (u.ex - u.x) * 0.12 - 1.2, u.y + (u.ey - u.y) * 0.12 - 1.4, 1.5, 0, TAU)
+ c.fill()
+ })
+ // 头 + 盔 + 红缨
+ c.save()
+ c.translate(P.head.x, P.head.y)
+ c.rotate(P.head.ang + Math.PI / 2)
+ const r = f.headR
+ blob(c, ellipsePts(0, 0, r, r * 1.06, 12), {
+ fill: linGrad(c, 'ghd', -r * 0.6, -r, r * 0.7, r, [[0, '#f0d4ac'], [1, '#c99a6a']]),
+ stroke: OUT, lw: 1.7, amp: 0.35, seed: 8, tq,
+ })
+ // 严肃眉眼
+ c.strokeStyle = OUT
+ c.lineWidth = 1.3
+ c.beginPath()
+ c.moveTo(1.6, -3); c.lineTo(4, -3.6)
+ c.moveTo(5.4, -3.6); c.lineTo(7.8, -3)
+ c.stroke()
+ c.fillStyle = '#241a10'
+ c.beginPath()
+ c.arc(2.8, -1, 1.2, 0, TAU)
+ c.arc(6.6, -1, 1.2, 0, TAU)
+ c.fill()
+ c.beginPath()
+ c.moveTo(3.4, 3.6)
+ c.lineTo(6.2, 3.2)
+ c.stroke()
+ // 金属盔(半圆 + 帽檐 + 护鼻 + 铆钉)
+ blob(c, ellipsePts(0, -r * 0.3, r * 1.04, r * 0.82, 11), {
+ fill: linGrad(c, 'ghm', -r, -r * 1.2, r * 0.8, 0, [[0, metalHi], [0.55, metal], [1, '#6e7883']]),
+ stroke: OUT, lw: 1.6, amp: 0.3, seed: 10, tq,
+ })
+ c.fillStyle = metal
+ c.beginPath()
+ c.roundRect(-r * 1.06, -r * 0.4, r * 2.12, 2.6, 1.2)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.2
+ c.stroke()
+ c.fillStyle = metalHi
+ c.beginPath()
+ c.roundRect(1.5, -r * 0.4, 2, r * 0.72, 1)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1
+ c.stroke()
+ c.fillStyle = '#7e8894'
+ ;[[-r * 0.6, -r * 0.62], [r * 0.44, -r * 0.62]].forEach(([x, y]) => {
+ c.beginPath()
+ c.arc(x, y, 1, 0, TAU)
+ c.fill()
+ })
+ // 红缨(飘动)
+ const pl = Math.sin(time * 3.4) * 2 + (moving ? 2.5 : 0)
+ blob(c, [
+ [0, -r * 1.05], [2.5 + pl, -r * 1.7], [5 + pl * 1.4, -r * 2.1],
+ [2 + pl, -r * 1.75], [0.5, -r * 1.3],
+ ], { fill: linGrad(c, 'gp', 0, -r * 2.2, 0, -r, [[0, '#e05248'], [1, '#8e1c24']]), stroke: OUT, lw: 1.2, amp: 0.3, seed: 11, tq })
+ c.restore()
+ // 前臂 + 长矛
+ drawGuardArm(c, P['armF.u'], P['armF.f'], f, 0)
+ const hand = P['armF.f']
+ c.save()
+ c.translate(hand.ex, hand.ey)
+ c.rotate(hand.ang + (p.tool ?? -2.95))
+ drawToolShape(c, 'spear', time)
+ c.restore()
+ c.restore()
+}
+// 守卫肢体:皮靴 + 护胫
+function drawGuardLimb(c, u, lo, f, k) {
+ const col = shade('#4a4038', k)
+ taper(c, u.x, u.y, u.ex, u.ey, f.legW, f.legW * 0.8, col, OUT, 1.3)
+ taper(c, lo.x, lo.y, lo.ex, lo.ey, f.legW * 0.8, f.legW * 0.62, col, OUT, 1.25)
+ c.fillStyle = shade('#5b3a1e', k)
+ c.beginPath()
+ c.ellipse(lo.ex + 1, lo.ey + 0.4, f.legW * 0.78, f.legW * 0.46, 0, 0, TAU)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.2
+ c.stroke()
+}
+function drawGuardArm(c, u, fo, f, k) {
+ taper(c, u.x, u.y, u.ex, u.ey, f.armW + 0.6, f.armW * 0.85, shade('#8a5f34', k), OUT, 1.3)
+ taper(c, fo.x, fo.y, fo.ex, fo.ey, f.armW * 0.85, f.armW * 0.66, shade('#6b4a2a', k), OUT, 1.25)
+ c.fillStyle = shade('#e8c9a0', k)
+ c.beginPath()
+ c.arc(fo.ex, fo.ey, f.armW * 0.62, 0, TAU)
+ c.fill()
+ c.strokeStyle = OUT
+ c.lineWidth = 1.15
+ c.stroke()
+}
+
+// =====================================================================
+// 离屏渲染立绘 dataURL(更衣室/房间座位预览用,带缓存)
+// =====================================================================
const previewCache = {}
export function renderSkinPreview(code, size = 72) {
const key = code + '@' + size
@@ -1057,22 +1645,22 @@ export function renderSkinPreview(code, size = 72) {
cv.width = size
cv.height = size
const c = cv.getContext('2d')
- // 每个皮肤给不同底色光晕,卡片预览不再长得一样
- c.save()
- c.fillStyle = skin.vestColor || '#a33d3d'
- c.globalAlpha = 0.18
- c.beginPath()
- c.arc(size / 2, size / 2, size * 0.48, 0, 7)
- c.fill()
- c.globalAlpha = 1
- c.strokeStyle = skin.hairColor || '#15100c'
- c.lineWidth = 1.5
- c.beginPath()
- c.arc(size / 2, size / 2, size * 0.46, 0, 7)
- c.stroke()
- c.restore()
- c.translate(size / 2, size * 0.86)
- const scale = size / 52
+ // 每个皮肤给不同底色光晕,卡片预览不再长得一样
+ c.save()
+ c.fillStyle = skin.vestColor || '#a33d3d'
+ c.globalAlpha = 0.18
+ c.beginPath()
+ c.arc(size / 2, size / 2, size * 0.48, 0, 7)
+ c.fill()
+ c.globalAlpha = 1
+ c.strokeStyle = skin.hairColor || '#15100c'
+ c.lineWidth = 1.5
+ c.beginPath()
+ c.arc(size / 2, size / 2, size * 0.46, 0, 7)
+ c.stroke()
+ c.restore()
+ c.translate(size / 2, size * 0.92)
+ const scale = size / 70
c.scale(scale, scale)
drawPlayerFig(c, skin, { dir: 1 })
const url = cv.toDataURL()
diff --git a/src/router/index.js b/src/router/index.js
index 5bd704d..2c4f1a5 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -10,6 +10,7 @@ const routes = [
{ path: '/rank', name: 'rank', component: () => import('../views/Rank.vue') },
{ path: '/profile', name: 'profile', component: () => import('../views/Profile.vue') },
{ path: '/play/:code', name: 'play', component: () => import('../views/GamePlay.vue') },
+ { path: '/detail/:code', name: 'detail', component: () => import('../views/GameDetail.vue') },
{ path: '/battle', name: 'battle', component: () => import('../views/Battle.vue') },
{ path: '/battle/room', name: 'battleRoom', component: () => import('../views/BattleRoom.vue') },
{ path: '/chat', name: 'chat', component: () => import('../views/Chat.vue') },
diff --git a/src/views/GameDetail.vue b/src/views/GameDetail.vue
new file mode 100644
index 0000000..0c82a41
--- /dev/null
+++ b/src/views/GameDetail.vue
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+
+
+
+
+ 本周免费
+
+
+
{{ displayName }}
+
{{ info.tagline }}
+
+ {{ t }}
+
+
{{ game.description }}
+
+ 分类:{{ game.category }}
+ 积分规则:{{ game.score_per_point }} 分/点
+ 我的最高分:{{ detail.best_score }}
+ 已游玩 {{ game.play_count }} 次
+
+
+
+
+
+ {{ game.price }} 积分
+
+
+
+
+
+
+
+
游戏特性
+
+
+
{{ f.icon }}
+
{{ f.title }}
+
{{ f.desc }}
+
+
+
+
+
+ {{ sec.title }}
+
+
+
{{ b.icon }}{{ b.name }}
+
{{ b.desc }}
+
+
+
+
+
+ {{ String(i + 1).padStart(2, '0') }}{{ n }}
+
+
+
+
+
+
+ 内置 Mod({{ mods.length }} 款)
+
+
+
+ {{ m.name }}
+ {{ modTag(m.tag) }}
+
+
{{ m.desc }}
+
得分 ×{{ m.factor }}
+
+
+
+
+
+
+
+
操作说明
+
+
+ {{ c.keys }}
+ {{ c.desc }}
+
+
+
+
+
{{ code === 'mario' ? '通关技巧' : '生存技巧' }}
+
+
+
+
+
+
+
diff --git a/src/views/GamePlay.vue b/src/views/GamePlay.vue
index eae9b71..fe4fc14 100644
--- a/src/views/GamePlay.vue
+++ b/src/views/GamePlay.vue
@@ -11,9 +11,12 @@ import { useStarveCoopStore } from '../stores/starveCoop'
import { gameRegistry } from '../games'
import { renderSkinPreview, filterSkins } from '../games/starveSkins'
import { modMeta } from '../games/starve/mods/registry'
+import { MOD_MANUAL } from '../games/starve/mods/manual.js'
+import { sfxEnabled, setSfxEnabled, musicEnabled, setMusicEnabled } from '../games/starve/audio'
import LevelSelect from '../components/LevelSelect.vue'
import GameIcon from '../components/GameIcon.vue'
import PixelAvatar from '../components/PixelAvatar.vue'
+import ScreenModeToggle from '../components/ScreenModeToggle.vue'
const route = useRoute()
const router = useRouter()
@@ -41,6 +44,7 @@ const buyingProp = ref('') // 正在购买的道具码(防连点)
// ---- 关卡进度(带 levels 配置的游戏才启用) ----
const levelsMeta = meta?.levels || null // {total, names?}
+const inGameSelect = !!meta?.inGameSelect // 选关在游戏内完成(马里奥风格),平台只留开始按钮
const progress = ref({ level: 1, friends: [] }) // 我的进度 + 好友进度
// ---- 云存档(注册表标记 saveable 的游戏才启用,如饥荒;支持多存档) ----
@@ -92,6 +96,17 @@ function onHeadMouseMove(e) {
headShow.value = e.clientY <= 130
}
const touchMode = ref(localStorage.getItem('starve_touch_mode') || 'both')
+// 年糕求生音频开关(starve/audio 模块单例,游戏内与设置面板共享)
+const sfxOn = ref(sfxEnabled())
+const musicOn = ref(musicEnabled())
+function toggleSfx() {
+ setSfxEnabled(!sfxOn.value)
+ sfxOn.value = sfxEnabled()
+}
+function toggleMusic() {
+ setMusicEnabled(!musicOn.value)
+ musicOn.value = musicEnabled()
+}
function saveTouchMode(mode) {
touchMode.value = mode
localStorage.setItem('starve_touch_mode', mode)
@@ -158,6 +173,23 @@ const playHeadClasses = computed(() => ({
const modsAvail = meta?.mods ? modMeta() : []
const selMods = ref([])
const showModModal = ref(false)
+// ---- Mod 手册:全部 mod 的玩法/功能/注意事项(manual.js 静态文案)----
+const manualMode = ref(false)
+const manualOpenId = ref('')
+const manualTag = ref('全部')
+const MANUAL_TAGS = ['全部', '内容', '难度', '增益', '便利']
+// ---- 地图大小倍率:新开局/联机房主生效,续档沿用存档世界 ----
+const mapScale = ref(Number(localStorage.getItem('starve_mapscale') || 1) || 1)
+function setMapScale(s) {
+ mapScale.value = s
+ try { localStorage.setItem('starve_mapscale', String(s)) } catch {}
+}
+const manualList = computed(() => MOD_MANUAL
+ .filter((en) => manualTag.value === '全部' || en.tag === manualTag.value)
+ .map((en) => {
+ const meta = modsAvail.find((m) => m.id === en.id)
+ return { ...en, factor: meta?.factor ?? 1, desc: meta?.desc || '' }
+ }))
try {
selMods.value = JSON.parse(localStorage.getItem('mods_' + code) || '[]').filter((id) => modsAvail.some((m) => m.id === id))
} catch { selMods.value = [] }
@@ -238,9 +270,10 @@ async function load() {
phase.value = 'ready'
}
-// 开始本局:数字=起始关卡;{save}=从云存档继续;{saveName}=新开局命名
-function startGame(arg = 1) {
- const level = typeof arg === 'number' ? arg : 1
+// 开始本局:数字=起始关卡;{save}=从云存档继续;{saveName}=新开局命名;无参=游戏内选关(传进度下限)或第 1 关
+function startGame(arg) {
+ // 游戏内选关的游戏:把服务器进度作为解锁下限传给组件
+ const level = typeof arg === 'number' ? arg : (inGameSelect ? Math.max(1, Number(progress.value.level) || 1) : 1)
const save = typeof arg === 'object' && arg?.save ? arg.save : null
const saveName = typeof arg === 'object' && arg?.saveName ? String(arg.saveName).trim() : ''
liveScore.value = save?.score || 0
@@ -257,7 +290,7 @@ function startGame(arg = 1) {
const deadline = performance.now() + 3000
const tryStart = () => {
// 新开局带上勾选的 mod;续档时以存档内记录为准(组件侧处理)
- if (gameRef.value?.start) gameRef.value.start({ level, save, skin: mySkin.value, mods: save ? undefined : [...selMods.value] })
+ if (gameRef.value?.start) gameRef.value.start({ level, save, skin: mySkin.value, mods: save ? undefined : [...selMods.value], worldScale: save ? undefined : mapScale.value })
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
}
requestAnimationFrame(tryStart)
@@ -431,6 +464,17 @@ async function joinRoom() {
}
if (await ensureCoopConn()) coopStore.joinRoom(joinCode.value)
}
+// 房主点击开始:房间被服务端回收(WS 静默重连等)时给出提示并退回入口,而不是静默无效;
+// 正常路径仍走 coopStore.startGame() → 后端广播 status=playing → 双端 startCoopGame()
+function hostClickStart() {
+ if (!coopStore.inRoom || !coopStore.connected) {
+ toast('房间已失效,请重新创建或加入', 'info')
+ coopStore.leaveRoom()
+ coopStage.value = 'mode'
+ return
+ }
+ coopStore.startGame()
+}
function leaveRoom() {
coopStore.leaveRoom()
}
@@ -509,7 +553,7 @@ function startCoopGame() {
const deadline = performance.now() + 3000
const tryStart = () => {
// 房主决定本局 mod 集合,客机等待全量快照对齐
- if (gameRef.value?.start) gameRef.value.start({ coop: coopCtx, skin: mySkin.value, mods: coopStore.isHost ? [...selMods.value] : [] })
+ if (gameRef.value?.start) gameRef.value.start({ coop: coopCtx, skin: mySkin.value, mods: coopStore.isHost ? [...selMods.value] : [], worldScale: coopStore.isHost ? mapScale.value : undefined })
else if (performance.now() < deadline) requestAnimationFrame(tryStart)
}
requestAnimationFrame(tryStart)
@@ -744,6 +788,8 @@ onBeforeUnmount(() => {
本局 {{ liveScore }}
最高 {{ detail.best_score }}
💰 {{ userStore.user?.points ?? 0 }}
+
+
@@ -782,6 +828,7 @@ onBeforeUnmount(() => {
使用 ✨双倍积分卡(剩 {{ propCount('double_points') }} 张),本局积分翻倍
+
+