Honey Village: six model builds + hub frontend, Dockerized for Dokploy

This commit is contained in:
2026-07-15 10:50:12 -04:00
commit 67253bded5
523 changed files with 58879 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from "playwright";
import { preview } from "vite";
const server = await preview({ preview: { port: 5194 } });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
page.on("pageerror", e => errors.push(e.message));
await page.goto("http://localhost:5194/");
await page.waitForTimeout(2500);
await page.fill("#name-input", "");
await page.press("#name-input", "Enter");
await page.waitForTimeout(2000);
const name = await page.evaluate(() => window.__director.playerName);
const scene = await page.evaluate(() => window.__director.currentScene);
await page.screenshot({ path: process.env.SHOT_DIR + "/dist_house.png" });
console.log("name(默认):", name, "| scene:", scene, "| ERRORS:", errors.length);
errors.forEach(e => console.log(" -", e));
await browser.close();
server.httpServer.close();
process.exit(errors.length || name !== "小满" || scene !== "house" ? 1 : 0);
+74
View File
@@ -0,0 +1,74 @@
// 无头验收脚本:控制台零报错 + 标题→Day1 流程 + Debug 面板侵蚀矩阵 + 四结局
// 运行:node tools/e2e.mjs [--full]
import { chromium } from "playwright";
import { createServer } from "vite";
import { mkdirSync } from "fs";
const SHOT_DIR = process.env.SHOT_DIR || "/tmp/mtc-shots";
mkdirSync(SHOT_DIR, { recursive: true });
const server = await createServer({ server: { port: 5199 } });
await server.listen();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); });
page.on("pageerror", (err) => errors.push("PAGEERROR: " + err.message));
const shot = (name) => page.screenshot({ path: `${SHOT_DIR}/${name}.png` });
const sleep = (ms) => page.waitForTimeout(ms);
await page.goto("http://localhost:5199/");
await sleep(2500);
await shot("01_title");
// 输入名字并开始
await page.fill("#name-input", "测试员");
await page.press("#name-input", "Enter");
await sleep(2000);
await shot("02_day1_house");
// 走出小屋:向下走
await page.keyboard.down("s"); await sleep(1400); await page.keyboard.up("s");
await sleep(1200);
await shot("03_plaza");
// Debug 面板:侵蚀矩阵五档
await page.keyboard.press("`");
await sleep(300);
await shot("04_debug_open");
for (const h of [10, 40, 70, 90, 100]) {
await page.fill("#dbg-h", String(h));
await page.click("#dbg-h-set");
await sleep(700);
await shot(`05_stage_h${h}`);
}
// 传送遍历全部场景
for (const s of ["bakery", "store", "clinic", "lake", "field", "well", "fogwall", "white_corridor", "white_ward", "plaza", "house"]) {
await page.click(`[data-tp="${s}"]`);
await sleep(900);
await shot(`06_scene_${s}`);
}
// 四结局强制触发
for (const e of ["E1", "E4", "E2", "E3"]) {
await page.click(`[data-end="${e}"]`);
await sleep(e === "E3" ? 14000 : 6000);
await shot(`07_ending_${e}`);
// 回到游戏继续测试(reload 后需重进)
if (e !== "E3") {
await page.reload();
await sleep(2200);
await page.fill("#name-input", "测试员");
await page.press("#name-input", "Enter");
await sleep(1800);
await page.keyboard.press("`");
await sleep(300);
}
}
console.log("CONSOLE ERRORS:", errors.length);
errors.slice(0, 20).forEach(e => console.log(" -", e));
await browser.close();
await server.close();
process.exit(errors.length ? 1 : 0);
+62
View File
@@ -0,0 +1,62 @@
// 门往返专项:每条通道双向走一遍,确认到达后 1.2 秒内不被弹回(防出生点压住触发区)
import { chromium } from "playwright";
import { createServer } from "vite";
const server = await createServer({ server: { port: 5193 } });
await server.listen();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
page.on("pageerror", e => errors.push(e.message));
const sleep = ms => page.waitForTimeout(ms);
const tp = (x, y) => page.evaluate(([x, y]) => {
const sc = window.__game.scene.getScenes(true).find(s => s.player);
sc.player.setPosition(x, y); sc.player.body.reset(x, y);
}, [x, y]);
const cur = () => page.evaluate(() => window.__director.currentScene);
const failures = [];
// 沿指定方向持续走 ms 毫秒(模拟真实按键走进出口)
const walk = async (key, ms) => { await page.keyboard.down(key); await sleep(ms); await page.keyboard.up(key); };
await page.goto("http://localhost:5193/");
await sleep(2500);
await page.fill("#name-input", "门测");
await page.press("#name-input", "Enter");
await sleep(1800);
// [出发场景, 门口站位, 行走方向, 目标场景]
const LEGS = [
["house", [88, 110], "s", "plaza"], // 小屋 → 广场
["plaza", [72, 90], "w", "house"], // 广场 → 小屋
["house", [88, 110], "s", "plaza"],
["plaza", [152, 90], "w", "bakery"], // 广场 → 面包房
["bakery", [128, 140], "s", "plaza"],
["plaza", [344, 90], "w", "store"], // 广场 → 杂货店
["store", [128, 140], "s", "plaza"],
["plaza", [424, 90], "w", "clinic"], // 广场 → 诊所
["clinic", [96, 110], "s", "plaza"],
["plaza", [20, 148], "a", "lake"], // 广场 → 湖畔
["lake", [360, 120], "d", "plaza"], // 湖畔 → 广场
["plaza", [462, 148], "d", "field"], // 广场 → 花田
["field", [24, 104], "a", "plaza"], // 花田 → 广场
["plaza", [232, 24], "w", "well"], // 广场 → 古井
["well", [112, 136], "s", "plaza"], // 古井 → 广场
["plaza", [232, 296], "s", "fogwall"], // 广场 → 雾墙
["fogwall", [168, 30], "w", "plaza"], // 雾墙 → 广场(用户报告的死循环路径)
];
for (const [from, [x, y], dir, to] of LEGS) {
const at = await cur();
if (at !== from) { console.log(`FAIL 前置不在 ${from}(在 ${at}`); failures.push(from + "->" + to); continue; }
await tp(x, y); await sleep(250);
await walk(dir, 900);
let arrived = false;
for (let i = 0; i < 20; i++) { await sleep(150); if ((await cur()) === to) { arrived = true; break; } }
await sleep(1200); // 到达后必须站得住
const settled = (await cur()) === to;
console.log(`${arrived && settled ? "PASS" : "FAIL"} ${from}${to}${arrived && !settled ? "(被弹回!)" : ""}`);
if (!(arrived && settled)) failures.push(from + "->" + to);
}
console.log("CONSOLE ERRORS:", errors.length);
console.log("FAILURES:", failures.length, failures.join(" | "));
await browser.close(); await server.close();
process.exit(errors.length || failures.length ? 1 : 0);
+291
View File
@@ -0,0 +1,291 @@
// 七日全流程自动化验收:任务/线索/悲伤行为/黄昏/梦境/结局 + 控制台零报错
// 运行:node tools/e2e_flow.mjs
import { chromium } from "playwright";
import { createServer } from "vite";
import { mkdirSync } from "fs";
const SHOT_DIR = process.env.SHOT_DIR || "/tmp/mtc-flow";
mkdirSync(SHOT_DIR, { recursive: true });
const server = await createServer({ server: { port: 5198 } });
await server.listen();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
page.on("pageerror", (e) => errors.push("PAGEERROR: " + e.message));
const sleep = (ms) => page.waitForTimeout(ms);
// 无头环境下 press() 的 down/up 同帧到达会吞掉 JustDown,改为跨帧按键
const pressE = async () => { await page.keyboard.down("e"); await sleep(90); await page.keyboard.up("e"); };
const shot = (n) => page.screenshot({ path: `${SHOT_DIR}/${n}.png` });
const D = (expr) => page.evaluate(`(() => { const d = window.__director; return ${expr}; })()`);
const failures = [];
const check = async (label, expr) => {
const ok = await D(expr);
console.log(`${ok ? "PASS" : "FAIL"} ${label}`);
if (!ok) {
failures.push(label);
const diag = await page.evaluate(() => {
const d = window.__director;
const sc = window.__game.scene.getScenes(true).find((s) => s.player);
return `day=${d.day} scene=${d.currentScene} wipes=${d.wipes} clues=[${[...d.clues]}] cutscene=${d.cutscene} dialog=${window.__game.scene.getScene("ui").dialogOpen} player=${sc ? Math.round(sc.player.x) + "," + Math.round(sc.player.y) : "-"} trans=${sc ? sc.transitioning : "-"}`;
});
console.log(" diag:", diag);
}
};
// 把玩家瞬移到当前场景某像素点
const tp = (x, y) => page.evaluate(([x, y]) => {
const g = window.__game;
const sc = g.scene.getScenes(true).find((s) => s.player);
sc.player.setPosition(x, y);
sc.player.body.reset(x, y);
}, [x, y]);
const curScene = () => page.evaluate(() => window.__director.currentScene);
// 连按 E 直到对话结束
const talk = async (times = 14) => {
for (let i = 0; i < times; i++) {
await pressE();
await sleep(160);
const open = await page.evaluate(() => window.__game.scene.getScene("ui").dialogOpen);
if (!open) break;
}
};
const interactAt = async (x, y, presses = 14) => {
await tp(x, y); await sleep(350);
await pressE(); await sleep(300);
await talk(presses);
await sleep(200);
};
// 走进门(瞬移到门内并等场景切换)
const enterDoor = async (x, y, expectScene) => {
await tp(x, y);
for (let i = 0; i < 30; i++) { await sleep(200); if ((await curScene()) === expectScene) break; }
const ok = (await curScene()) === expectScene;
console.log(`${ok ? "PASS" : "FAIL"} 进入 ${expectScene}`);
if (!ok) {
failures.push("进入 " + expectScene);
const diag = await page.evaluate(() => {
const d = window.__director;
const sc = window.__game.scene.getScenes(true).find((s) => s.player);
return `scene=${d.currentScene} cutscene=${d.cutscene} dialog=${window.__game.scene.getScene("ui").dialogOpen} player=${sc ? Math.round(sc.player.x) + "," + Math.round(sc.player.y) : "-"} trans=${sc ? sc.transitioning : "-"}`;
});
console.log(" diag:", diag);
}
await sleep(400);
};
const sleepToNextDay = async () => {
await enterDoor(72, 68, "house"); // 回小屋(从广场小屋门)
await interactAt(30, 70, 6); // 床
await sleep(14500); // 梦境过场
await sleep(1200);
};
await page.goto("http://localhost:5198/");
await sleep(2500);
await page.fill("#name-input", "验收员");
await page.press("#name-input", "Enter");
await sleep(1800);
// ===== Day 1 =====
await check("D1 开局幸福 30", "d.day===1 && d.happiness===30 && d.awareness===0");
await interactAt(30, 40, 4); // 日历(D1 无线索)
await check("D1 日历尚无 C1", "!d.clues.has('C1')");
await enterDoor(88, 122, "plaza"); // 出小屋
await interactAt(188, 126); // 公告板
await check("D1 公告已读", "d.announced===true");
// T2 浇水 ×3
await interactAt(120, 198); await interactAt(120, 230); await interactAt(120, 262);
await check("T2 完成 +10", "d.questStatus['T2']==='done'");
// T1 取面包
await enterDoor(152, 70, "bakery");
await interactAt(72, 52);
await check("T1 拿到面包", "d.breadHeld===3");
await enterDoor(120, 154, "plaza");
await interactAt(264, 66); // 大宝
await enterDoor(344, 70, "store");
await interactAt(56, 78); // 咪咪
await enterDoor(120, 154, "plaza");
await enterDoor(6, 148, "lake");
await interactAt(292, 96); // 龟爷爷
await check("T1 完成", "d.questStatus['T1']==='done'");
await check("T3 完成(问好×3", "d.questStatus['T3']==='done'");
await check("D1 黄昏已触发", "d.dusk===true");
await shot("d1_dusk");
await enterDoor(378, 112, "plaza"); // 湖畔右缘回广场
await sleepToNextDay();
// ===== Day 2 =====
await check("进入 Day 2", "d.day===2");
await interactAt(30, 40, 6); // 日历 C1
await check("C1 获得(觉知+8", "d.clues.has('C1') && d.awareness>=8");
await enterDoor(88, 122, "plaza");
await interactAt(188, 126); // 公告
await interactAt(280, 46, 8); // 邮筒信件 C3
await check("C3 获得", "d.clues.has('C3')");
await interactAt(264, 66); // 大宝取信
await check("T4 拿到信", "d.lettersHeld===3");
await enterDoor(152, 70, "bakery");
await interactAt(72, 52); // 圆圆收信
await enterDoor(120, 154, "plaza");
await enterDoor(344, 70, "store");
await interactAt(56, 78); // 咪咪收信
// T5 清点货架
await interactAt(56, 46); await interactAt(104, 46); await interactAt(152, 46, 8);
await check("T5 完成", "d.questStatus['T5']==='done'");
await interactAt(198, 92, 8); // C5 货架
await check("C5 获得", "d.clues.has('C5')");
await enterDoor(120, 154, "plaza");
await enterDoor(424, 70, "clinic");
await interactAt(104, 46, 10); // 芙医生收第3封信 → C4
await check("T4 完成 & C4 获得", "d.questStatus['T4']==='done' && d.clues.has('C4')");
await enterDoor(88, 122, "plaza");
await sleepToNextDay();
// ===== Day 3 =====
await check("进入 Day 3", "d.day===3");
await enterDoor(88, 122, "plaza");
await interactAt(188, 126);
// T6 采莓果
await enterDoor(470, 148, "field");
for (const [x, y] of [[72, 82], [136, 98], [200, 82], [248, 130], [104, 162]]) await interactAt(x, y, 4);
await check("T6 完成", "d.questStatus['T6']==='done'");
await enterDoor(4, 100, "plaza");
// T7 野餐
await enterDoor(6, 148, "lake");
await interactAt(128, 118, 10);
await check("T7 完成", "d.questStatus['T7']==='done'");
await shot("d3_lake");
// 面包房看配方 C2
await enterDoor(378, 112, "plaza");
await enterDoor(152, 70, "bakery");
await interactAt(120, 78, 8);
await check("C2 获得", "d.clues.has('C2')");
await enterDoor(120, 154, "plaza");
await sleepToNextDay();
// ===== Day 4(完美的重复) =====
await check("进入 Day 4", "d.day===4");
await enterDoor(88, 122, "plaza");
await interactAt(188, 126);
await interactAt(120, 198); await interactAt(120, 230); await interactAt(120, 262);
await check("T9 完成", "d.questStatus['T9']==='done'");
await enterDoor(152, 70, "bakery");
await interactAt(72, 52);
await enterDoor(120, 154, "plaza");
await interactAt(264, 66);
await enterDoor(344, 70, "store");
await interactAt(56, 78);
await enterDoor(120, 154, "plaza");
await enterDoor(6, 148, "lake");
await interactAt(292, 96, 8); // 龟爷爷(送面包)
await interactAt(292, 96, 8); // 再谈一次(C9 第二段)
await check("T8 完成", "d.questStatus['T8']==='done'");
await check("T10 完成", "d.questStatus['T10']==='done'");
await check("C9 链已记 D2/D4", "d.guiChain.has(4)");
await enterDoor(378, 112, "plaza");
await sleepToNextDay();
// ===== Day 5(自由日 + 古井/雾墙开闸) =====
await check("进入 Day 5", "d.day===5");
await enterDoor(88, 122, "plaza");
await interactAt(188, 126); // 公告
// T11 接受→揉面
await enterDoor(152, 70, "bakery");
await tp(72, 52); await sleep(300);
await pressE(); await sleep(500); // 打开对话
await talk(4); // 读完问句 → 进入选项
await pressE(); await sleep(300); // 选第一项:帮忙
await talk(6);
await check("T11 已接受", "d.t11Accepted===true");
await interactAt(168, 102, 8); // 揉面台
await check("T11 完成", "d.questStatus['T11']==='done'");
await enterDoor(120, 154, "plaza");
// T12 擦公告板(第2次浮现 C6)
await tp(188, 126); await sleep(300);
await pressE(); await sleep(400);
await talk(3); await pressE(); await sleep(300); // 选:擦拭
await talk(4);
await interactAt(188, 126, 8); // 第2次 → C6
await check("C6 获得", "d.clues.has('C6')");
await interactAt(188, 126, 8); // 第3次 → 完成
await check("T12 完成", "d.questStatus['T12']==='done'");
// 古井 C7
await enterDoor(232, 4, "well");
await interactAt(104, 92, 10);
await sleep(5600); // 注视古井 5 秒
await check("C7 获得", "d.clues.has('C7')");
await check("注视古井 +3 觉知", "d.stared.has('well')");
// 诊所 C8
await enterDoor(104, 148, "plaza");
await enterDoor(424, 70, "clinic");
await interactAt(76, 74, 8);
await check("C8 获得", "d.clues.has('C8')");
await enterDoor(88, 122, "plaza");
await sleepToNextDay();
// ===== Day 6(加速 + T14 + 雨) =====
await check("进入 Day 6", "d.day===6");
await enterDoor(88, 122, "plaza");
await interactAt(188, 126);
// 龟爷爷 C9 第三段
await enterDoor(6, 148, "lake");
await interactAt(292, 96, 10);
await check("C9 获得(D6 第三段)", "d.clues.has('C9')");
await enterDoor(378, 112, "plaza");
// T13 挂彩带 ×4
for (const [x, y] of [[188, 190], [300, 190], [188, 270], [300, 270]]) {
await interactAt(x, y);
console.log(" ribbons:", await D("[...d.ribbons].join('|')"));
}
await check("T13 完成", "d.questStatus['T13']==='done'");
// T14 保持微笑 60 秒(把计时快进以免真等 60s)
await interactAt(240, 210, 4); // 启动
await sleep(1500);
await page.evaluate(() => {
const sc = window.__game.scene.getScenes(true).find((s) => s.player);
sc.t14Start -= 61000; // 快进
});
await sleep(1500);
await talk(6);
await check("T14 完成", "d.questStatus['T14']==='done'");
await check("D6 黄昏(脚本雨)", "d.dusk===true");
await sleep(1000); await shot("d6_rain");
// 雨中站立 10 秒 → 悲伤行为
const hpBefore = await D("d.happiness");
await page.evaluate(() => {
const sc = window.__game.scene.getScenes(true).find((s) => s.player);
sc.rainStillAt -= 11000;
});
await sleep(800);
const hpAfter = await D("d.happiness");
console.log(`${hpAfter <= hpBefore ? "PASS" : "FAIL"} 雨中站立触发悲伤行为(${hpBefore}${hpAfter}`);
if (hpAfter > hpBefore) failures.push("rain sadact");
await sleepToNextDay();
// ===== Day 7(分歧日:E3 苏醒线) =====
await check("进入 Day 7", "d.day===7");
await check("线索 ≥6", "d.clues.size>=6");
await D("d.setAwareness(80)"); // 觉知补足(自然流程中来自更多迟疑选项/发呆)
await enterDoor(88, 122, "plaza");
await sleep(1500);
await check("庆典已开始", "d.celebrationStarted===true");
await shot("d7_festival");
// 走向雾墙 → E3
await enterDoor(232, 312, "fogwall");
await interactAt(160, 82, 8);
await sleep(3500); // 白闪 → 白色走廊
await check("进入白色走廊(E3 模式)", "d.e3Mode===true && d.currentScene==='white_corridor'");
await tp(312, 60); await sleep(1500); // 走到走廊尽头
await check("进入病房", "d.currentScene==='white_ward'");
await interactAt(80, 72, 6); // 病历
await sleep(16000);
await shot("d7_e3_final");
console.log("CONSOLE ERRORS:", errors.length);
errors.slice(0, 20).forEach((e) => console.log(" -", e));
console.log("FAILURES:", failures.length);
await browser.close();
await server.close();
process.exit(errors.length || failures.length ? 1 : 0);
+138
View File
@@ -0,0 +1,138 @@
// 机制专项:悲伤行为 5 项 / 强制欢乐庆典与逃离 / S4 文本腐蚀 / E4·E1 条件判定
import { chromium } from "playwright";
import { createServer } from "vite";
import { mkdirSync } from "fs";
const SHOT_DIR = process.env.SHOT_DIR || "/tmp/mtc-mech";
mkdirSync(SHOT_DIR, { recursive: true });
const server = await createServer({ server: { port: 5195 } });
await server.listen();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
page.on("pageerror", e => errors.push("PAGEERROR: " + e.message));
const sleep = ms => page.waitForTimeout(ms);
const pressE = async () => { await page.keyboard.down("e"); await sleep(90); await page.keyboard.up("e"); };
const D = expr => page.evaluate(`(() => { const d = window.__director; return ${expr}; })()`);
const failures = [];
const check = async (label, expr) => {
const ok = await D(expr);
console.log(`${ok ? "PASS" : "FAIL"} ${label}`);
if (!ok) failures.push(label);
};
const tp = (x, y) => page.evaluate(([x, y]) => {
const sc = window.__game.scene.getScenes(true).find(s => s.player);
sc.player.setPosition(x, y); sc.player.body.reset(x, y);
}, [x, y]);
const chooseOption = async (idx) => {
for (let i = 0; i < 10; i++) {
const cm = await page.evaluate(() => window.__game.scene.getScene("ui").choiceMode);
if (cm) break;
await pressE(); await sleep(220);
}
await page.evaluate((i) => {
const ui = window.__game.scene.getScene("ui");
ui.choiceIdx = i; ui.renderChoiceSel();
}, idx);
await pressE(); await sleep(300);
};
const talk = async (n = 14) => {
for (let i = 0; i < n; i++) {
await pressE(); await sleep(160);
if (!(await page.evaluate(() => window.__game.scene.getScene("ui").dialogOpen))) break;
}
};
await page.goto("http://localhost:5195/");
await sleep(2500);
await page.fill("#name-input", "小满");
await page.press("#name-input", "Enter");
await sleep(1800);
// 出小屋到广场
await tp(88, 122); await sleep(1200);
// ① 否定选项(咪咪 D1 有三选项)
await page.evaluate(() => { const s = window.__game.scene.getScenes(true).find(x => x.player); s.scene.start("store"); window.__director.pendingSpawn = "entry"; });
await sleep(900);
let h0 = await D("d.happiness"), a0 = await D("d.awareness");
await tp(56, 78); await sleep(350);
await pressE(); await sleep(300);
await chooseOption(2); // 第三项:否定
await talk(4);
await check(`否定选项:幸福-5 觉知+3${h0}→)`, `d.happiness===${h0}-5 && d.awareness===${a0}+3`);
// ② 纠缠:同一 NPC 一天 ≥5 次
h0 = await D("d.happiness");
for (let i = 0; i < 5; i++) { await tp(56, 78); await sleep(250); await pressE(); await sleep(250); await talk(6); }
await check("纠缠:第5次对话触发悲伤行为", `d.happiness<=${h0} - 5 + 10`); // 对话有选项可能+2,宽松判定
const nag = await D("d.nagged.has('mimi')");
console.log(`${nag ? "PASS" : "FAIL"} 纠缠已记录`); if (!nag) failures.push("nag");
// ③ 发呆:10 秒不动且不在任务点
await page.evaluate(() => { window.__director.pendingSpawn = "center"; const s = window.__game.scene.getScenes(true).find(x => x.player); s.scene.start("plaza"); });
await sleep(900);
await tp(240, 130); // 空地
h0 = await D("d.happiness");
await page.evaluate(() => { const s = window.__game.scene.getScenes(true).find(x => x.player); s.lastMoveAt -= 11000; });
await sleep(600);
await check(`发呆:幸福-5${h0}→)`, `d.happiness===${h0}-5`);
// ④ 拒绝任务(D5 T11
await page.evaluate(() => { window.__director.startDay(5); });
await page.evaluate(() => { const s = window.__game.scene.getScenes(true).find(x => x.player); window.__director.pendingSpawn = "entry"; s.scene.start("bakery"); });
await sleep(900);
await tp(72, 52); await sleep(350);
await pressE(); await sleep(300);
await chooseOption(1); // 第二项:拒绝
await talk(4);
await check("拒绝任务:T11 refused 且被记录", "d.questStatus['T11']==='refused' && d.refusals===1");
// ⑤ 强制欢乐庆典(幸福<30 持续 2 分钟)+ 逃离计数
await D("d.setHappiness(10)");
await page.evaluate(() => { window.__director["lowSince"] = performance.now() - 121000; });
await sleep(800);
await check("欢乐庆典触发", "d.celebrationActive===true");
await page.screenshot({ path: SHOT_DIR + "/celebration.png" });
// 从场景边缘走离逃脱
await tp(120, 154); await sleep(1500);
await check("逃离欢乐庆典计数", "d.celebrationActive===false && d.escapes===1 && d.happiness===10");
// 不逃离 → 拉回 50
await page.evaluate(() => { window.__director["lowSince"] = performance.now() - 121000; });
await sleep(900);
await check("再次触发", "d.celebrationActive===true");
await sleep(8500);
await check("未逃离:幸福拉回 ~50", "d.happiness>=45 && d.celebrationActive===false && d.escapes===1");
// ⑥ S4 咪咪文本腐蚀(错字+句尾标点×3)
await D("d.setHappiness(100)");
await page.evaluate(() => { const s = window.__game.scene.getScenes(true).find(x => x.player); window.__director.pendingSpawn = "entry"; s.scene.start("store"); });
await sleep(900);
await D("d.startDay(3)"); // D3 咪咪台词密集含可腐蚀词
await D("d.setHappiness(100)");
await tp(56, 78); await sleep(350);
await pressE(); await sleep(1200);
await pressE(); await sleep(1600); // 等台词打完
const line = await page.evaluate(() => window.__game.scene.getScene("ui").lineText.text);
console.log(" 咪咪 S4 台词:", line);
const corrupted = /辛福|开欣|美妤|大伽|微效|名天|恬|央光|棚友|泳远/.test(line) || /[。!?]{3}/.test(line);
console.log(`${corrupted ? "PASS" : "FAIL"} S4 咪咪台词出现腐蚀`);
if (!corrupted) failures.push("S4 corruption");
await page.screenshot({ path: SHOT_DIR + "/mimi_s4.png" });
// ⑦ E1/E4 条件判定(decideEnding 逻辑)
await check("E1 判定", "d.decideEnding(false)==='E1' || d.awareness>29 ? true : d.decideEnding(false)==='E1'");
const e1 = await page.evaluate(() => { const d = window.__director; d.setAwareness(0); d.setHappiness(95); return d.decideEnding(false); });
console.log(`${e1 === "E1" ? "PASS" : "FAIL"} 觉知≤29 且幸福≥90 → E1(得 ${e1}`); if (e1 !== "E1") failures.push("E1");
const e2 = await page.evaluate(() => { const d = window.__director; d.setAwareness(50); return d.decideEnding(false); });
console.log(`${e2 === "E2" ? "PASS" : "FAIL"} 觉知 30-69 → E2(得 ${e2}`); if (e2 !== "E2") failures.push("E2");
const e4 = await page.evaluate(() => { const d = window.__director; d.e4ok = true; d.escapes = 3; return d.decideEnding(false); });
console.log(`${e4 === "E4" ? "PASS" : "FAIL"} 静默条件 → E4(得 ${e4}`); if (e4 !== "E4") failures.push("E4");
const e3 = await page.evaluate(() => { const d = window.__director; d.e4ok = false; d.setAwareness(80); for (let i=1;i<=6;i++) d.clues.add("C"+i); return d.decideEnding(true); });
console.log(`${e3 === "E3" ? "PASS" : "FAIL"} 觉知≥70 线索≥6 走向雾墙 → E3(得 ${e3}`); if (e3 !== "E3") failures.push("E3");
console.log("CONSOLE ERRORS:", errors.length);
errors.slice(0, 10).forEach(e => console.log(" -", e));
console.log("FAILURES:", failures.length, failures.join(" | "));
await browser.close(); await server.close();
process.exit(errors.length || failures.length ? 1 : 0);
+757
View File
@@ -0,0 +1,757 @@
-- 《崩坏童话:蜜糖村》全部像素素材生成脚本(Aseprite Lua
-- 运行:aseprite -b --script tools/gen_art.lua
-- 输出:public/assets/*.png + art/*.aseprite(源文件)
-- 全部素材为本脚本逐像素绘制的原创内容,无任何外部素材。
local pc = app.pixelColor
local function C(hex, a)
a = a or 255
local r = tonumber(hex:sub(1, 2), 16)
local g = tonumber(hex:sub(3, 4), 16)
local b = tonumber(hex:sub(5, 6), 16)
return pc.rgba(r, g, b, a)
end
-- ============ 糖果色板(村庄)+ 灰白色板(白色层) ============
local PAL = {
outline = C("5a4232"),
black = C("2a2420"),
white = C("ffffff"),
-- 草地/植物
grass = C("8fd05a"), grassD = C("79b94a"), grassL = C("a5e070"),
leaf = C("5da53f"), leafD = C("4a8a32"), trunk = C("8a5a34"), trunkD = C("6e4526"),
-- 路面/石
path = C("e8d5a8"), pathD = C("cbb488"), stone = C("c8c0a8"), stoneD = C("a8a088"),
-- 水
water = C("7ec8e8"), waterD = C("5fb0d8"), waterL = C("b8e6f8"),
-- 花
pink = C("ff9ecb"), red = C("e05858"), yellow = C("ffd94a"), berry = C("e8497a"),
-- 建筑
wall = C("fff1d0"), wallD = C("e8d8b0"), roof = C("f4924a"), roofD = C("d97733"),
wood = C("c98a4b"), woodD = C("a86a34"), woodL = C("e0aa6a"),
-- 室内
floor = C("e6c896"), floorD = C("d4b47e"), clinic = C("eaf2ec"), clinicD = C("d0e0d6"),
inwall = C("f6e8c8"), inwallD = C("e0cca0"),
-- 灰白层
wgray = C("e8e8ea"), wgrayD = C("cfcfd4"), wgrayDD = C("aeaeb6"), wdark = C("8a8a92"),
-- 其他
fog = C("e8ecf0"), fogD = C("cdd6de"),
metal = C("9aa4ac"), blue = C("5a88c8"), sky = C("8ecae6"),
}
-- ============ 绘制助手 ============
local function makeCtx(img, ox, oy)
local ctx = {}
function ctx.px(x, y, c) img:putPixel(ox + x, oy + y, c) end
function ctx.rect(x, y, w, h, c)
for j = y, y + h - 1 do for i = x, x + w - 1 do img:putPixel(ox + i, oy + j, c) end end
end
function ctx.hline(x, y, w, c) ctx.rect(x, y, w, 1, c) end
function ctx.vline(x, y, h, c) ctx.rect(x, y, 1, h, c) end
function ctx.dither(x, y, w, h, c, step)
step = step or 2
for j = y, y + h - 1 do for i = x, x + w - 1 do
if (i + j) % step == 0 then img:putPixel(ox + i, oy + j, c) end
end end
end
return ctx
end
local function saveSheet(img, name)
local spr = Sprite(img.width, img.height, ColorMode.RGB)
spr.cels[1].image = img
spr:saveAs("art/" .. name .. ".aseprite")
spr:saveCopyAs("public/assets/" .. name .. ".png")
spr:close()
end
-- ============ 瓦片集:10 列 × 8 行 = 80 瓦(16×16 ============
local T = {}
T[0] = function(c) -- 草地1
c.rect(0, 0, 16, 16, PAL.grass)
c.px(3, 4, PAL.grassL); c.px(11, 9, PAL.grassL); c.px(6, 13, PAL.grassD); c.px(13, 2, PAL.grassD)
end
T[1] = function(c) -- 草地2(草茎)
c.rect(0, 0, 16, 16, PAL.grass)
c.px(4, 5, PAL.grassD); c.px(4, 4, PAL.grassD); c.px(10, 10, PAL.grassD); c.px(10, 9, PAL.grassD)
c.px(13, 5, PAL.grassL); c.px(2, 12, PAL.grassL)
end
T[2] = function(c) -- 草地+白花
T[0](c); c.px(5, 6, PAL.white); c.px(4, 5, PAL.white); c.px(6, 5, PAL.white); c.px(5, 4, PAL.white); c.px(5, 5, PAL.yellow)
c.px(12, 12, PAL.white)
end
T[3] = function(c) -- 草地+粉花
T[1](c); c.px(11, 4, PAL.pink); c.px(10, 3, PAL.pink); c.px(12, 3, PAL.pink); c.px(11, 2, PAL.pink); c.px(11, 3, PAL.yellow)
c.px(3, 10, PAL.pink)
end
T[4] = function(c) -- 石板路
c.rect(0, 0, 16, 16, PAL.path)
c.hline(0, 7, 16, PAL.pathD); c.vline(7, 0, 8, PAL.pathD); c.vline(3, 8, 8, PAL.pathD); c.vline(12, 8, 8, PAL.pathD)
c.px(2, 3, PAL.pathD); c.px(10, 12, PAL.pathD)
end
T[5] = function(c) -- 石板路2
c.rect(0, 0, 16, 16, PAL.path)
c.hline(0, 3, 16, PAL.pathD); c.hline(0, 11, 16, PAL.pathD); c.vline(5, 4, 7, PAL.pathD); c.vline(11, 12, 4, PAL.pathD); c.vline(11, 0, 3, PAL.pathD)
end
T[6] = function(c) -- 路缘(草→路)
c.rect(0, 0, 16, 8, PAL.grass); c.rect(0, 8, 16, 8, PAL.path)
c.hline(0, 7, 16, PAL.grassD); c.hline(0, 8, 16, PAL.pathD)
c.px(3, 6, PAL.grassL); c.px(11, 12, PAL.pathD)
end
T[7] = function(c) -- 湖水1
c.rect(0, 0, 16, 16, PAL.water)
c.hline(2, 4, 4, PAL.waterL); c.hline(9, 10, 4, PAL.waterL); c.hline(5, 13, 3, PAL.waterD)
end
T[8] = function(c) -- 湖水2
c.rect(0, 0, 16, 16, PAL.water)
c.hline(8, 3, 4, PAL.waterD); c.hline(1, 9, 4, PAL.waterL); c.hline(11, 14, 3, PAL.waterL)
end
T[9] = function(c) -- 岸边(上草下水)
c.rect(0, 0, 16, 6, PAL.grass); c.rect(0, 6, 16, 10, PAL.water)
c.hline(0, 5, 16, PAL.grassD); c.hline(0, 6, 16, PAL.waterL)
end
T[10] = function(c) -- 花丛
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(1, 2, 14, 12, PAL.leaf)
c.px(1, 2, PAL.grass); c.px(14, 2, PAL.grass); c.px(1, 13, PAL.grass); c.px(14, 13, PAL.grass)
c.px(4, 5, PAL.pink); c.px(8, 4, PAL.yellow); c.px(12, 6, PAL.white); c.px(3, 9, PAL.yellow)
c.px(7, 8, PAL.pink); c.px(11, 10, PAL.pink); c.px(5, 12, PAL.white); c.px(10, 12, PAL.yellow)
end
T[11] = function(c) -- 莓果丛(有果)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 3, 12, 11, PAL.leaf); c.rect(3, 2, 10, 13, PAL.leaf)
c.dither(3, 3, 10, 10, PAL.leafD, 3)
c.px(5, 5, PAL.berry); c.px(10, 4, PAL.berry); c.px(7, 8, PAL.berry); c.px(11, 9, PAL.berry); c.px(4, 10, PAL.berry)
end
T[12] = function(c) -- 莓果丛(已采)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 3, 12, 11, PAL.leaf); c.rect(3, 2, 10, 13, PAL.leaf)
c.dither(3, 3, 10, 10, PAL.leafD, 3)
end
T[13] = function(c) -- 树·左上
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(4, 2, 12, 14, PAL.leaf)
c.px(4, 2, PAL.grass); c.px(5, 2, PAL.grass); c.px(4, 3, PAL.grass)
c.dither(5, 4, 11, 12, PAL.leafD, 4); c.px(8, 5, PAL.grassL); c.px(12, 8, PAL.grassL)
end
T[14] = function(c) -- 树·右上
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(0, 2, 12, 14, PAL.leaf)
c.px(11, 2, PAL.grass); c.px(10, 2, PAL.grass); c.px(11, 3, PAL.grass)
c.dither(0, 4, 11, 12, PAL.leafD, 4); c.px(4, 6, PAL.grassL)
end
T[15] = function(c) -- 树·左下
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(4, 0, 12, 6, PAL.leaf); c.dither(4, 0, 12, 6, PAL.leafD, 4)
c.px(4, 5, PAL.grass)
c.rect(10, 6, 6, 10, PAL.trunk); c.vline(10, 6, 10, PAL.trunkD)
end
T[16] = function(c) -- 树·右下
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(0, 0, 12, 6, PAL.leaf); c.dither(0, 0, 12, 6, PAL.leafD, 4)
c.px(11, 5, PAL.grass)
c.rect(0, 6, 6, 10, PAL.trunk); c.vline(5, 6, 10, PAL.trunkD)
end
T[17] = function(c) -- 小灌木
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(3, 5, 10, 9, PAL.leaf); c.rect(4, 4, 8, 11, PAL.leaf)
c.dither(4, 5, 8, 8, PAL.leafD, 3)
end
T[18] = function(c) -- 房屋外墙
c.rect(0, 0, 16, 16, PAL.wall)
c.hline(0, 15, 16, PAL.wallD); c.vline(0, 0, 16, PAL.wallD)
c.px(4, 4, PAL.wallD); c.px(11, 9, PAL.wallD)
end
T[19] = function(c) -- 外墙+窗
T[18](c)
c.rect(4, 4, 8, 8, PAL.woodD); c.rect(5, 5, 6, 6, PAL.sky)
c.vline(7, 5, 6, PAL.woodD); c.hline(5, 8, 6, PAL.woodD)
c.px(6, 6, PAL.waterL)
end
T[20] = function(c) -- 门
c.rect(0, 0, 16, 16, PAL.wall)
c.rect(3, 1, 10, 15, PAL.wood); c.rect(4, 2, 8, 14, PAL.woodL)
c.vline(4, 2, 14, PAL.wood); c.px(10, 9, PAL.woodD); c.hline(4, 2, 8, PAL.wood)
end
T[21] = function(c) -- 屋顶·左
c.rect(0, 0, 16, 16, PAL.roof)
c.hline(0, 0, 16, PAL.roofD); c.hline(0, 5, 16, PAL.roofD); c.hline(0, 10, 16, PAL.roofD); c.hline(0, 15, 16, PAL.roofD)
c.vline(0, 0, 16, PAL.roofD)
end
T[22] = function(c) -- 屋顶·右
c.rect(0, 0, 16, 16, PAL.roof)
c.hline(0, 0, 16, PAL.roofD); c.hline(0, 5, 16, PAL.roofD); c.hline(0, 10, 16, PAL.roofD); c.hline(0, 15, 16, PAL.roofD)
c.vline(15, 0, 16, PAL.roofD)
end
T[23] = function(c) -- 屋檐
c.rect(0, 0, 16, 8, PAL.roof); c.hline(0, 7, 16, PAL.roofD); c.hline(0, 6, 16, PAL.roofD)
c.rect(0, 8, 16, 8, PAL.wall); c.hline(0, 8, 16, PAL.wallD)
end
T[24] = function(c) -- 木地板
c.rect(0, 0, 16, 16, PAL.floor)
c.hline(0, 4, 16, PAL.floorD); c.hline(0, 9, 16, PAL.floorD); c.hline(0, 14, 16, PAL.floorD)
c.px(5, 2, PAL.floorD); c.px(12, 7, PAL.floorD); c.px(3, 12, PAL.floorD)
end
T[25] = function(c) -- 木地板2
c.rect(0, 0, 16, 16, PAL.floor)
c.hline(0, 2, 16, PAL.floorD); c.hline(0, 7, 16, PAL.floorD); c.hline(0, 12, 16, PAL.floorD)
c.px(9, 4, PAL.floorD); c.px(2, 9, PAL.floorD)
end
T[26] = function(c) -- 诊所地砖
c.rect(0, 0, 16, 16, PAL.clinic)
c.hline(0, 7, 16, PAL.clinicD); c.vline(7, 0, 16, PAL.clinicD); c.hline(0, 15, 16, PAL.clinicD); c.vline(15, 0, 16, PAL.clinicD)
end
T[27] = function(c) -- 室内墙
c.rect(0, 0, 16, 16, PAL.inwall)
c.dither(0, 0, 16, 16, PAL.inwallD, 8)
end
T[28] = function(c) -- 室内墙裙
c.rect(0, 0, 16, 10, PAL.inwall)
c.rect(0, 10, 16, 6, PAL.wood); c.hline(0, 10, 16, PAL.woodD)
end
T[29] = function(c) -- 地毯
c.rect(0, 0, 16, 16, PAL.pink)
c.rect(1, 1, 14, 14, C("ffc4de")); c.rect(3, 3, 10, 10, PAL.pink)
c.px(7, 7, PAL.white); c.px(8, 8, PAL.white)
end
T[30] = function(c) -- 床头(枕头)
c.rect(0, 0, 16, 16, PAL.wood)
c.rect(1, 1, 14, 15, C("f8f4e8"))
c.rect(3, 3, 10, 7, PAL.white); c.hline(3, 9, 10, C("d8d4c8"))
c.hline(1, 0, 14, PAL.woodD)
end
T[31] = function(c) -- 床尾(被子)
c.rect(0, 0, 16, 16, PAL.wood)
c.rect(1, 0, 14, 15, C("8ec8e0"))
c.hline(1, 3, 14, C("6fb0d0")); c.hline(1, 8, 14, C("6fb0d0")); c.hline(1, 13, 14, C("6fb0d0"))
c.hline(1, 15, 14, PAL.woodD)
end
T[32] = function(c) -- 柜台
c.rect(0, 0, 16, 16, PAL.wood)
c.rect(0, 0, 16, 6, PAL.woodL); c.hline(0, 6, 16, PAL.woodD)
c.vline(0, 0, 16, PAL.woodD); c.vline(15, 0, 16, PAL.woodD); c.hline(0, 15, 16, PAL.woodD)
end
T[33] = function(c) -- 烤炉
c.rect(0, 0, 16, 16, PAL.stoneD)
c.rect(1, 1, 14, 14, PAL.stone)
c.rect(3, 6, 10, 7, PAL.black)
c.rect(4, 8, 8, 4, C("f4924a")); c.px(6, 9, PAL.yellow); c.px(9, 10, PAL.yellow)
c.hline(3, 3, 10, PAL.stoneD)
end
T[34] = function(c) -- 货架(空)
c.rect(0, 0, 16, 16, PAL.wood)
c.rect(1, 1, 14, 14, PAL.woodL)
c.hline(1, 5, 14, PAL.woodD); c.hline(1, 10, 14, PAL.woodD); c.hline(1, 14, 14, PAL.woodD)
c.vline(0, 0, 16, PAL.woodD); c.vline(15, 0, 16, PAL.woodD)
end
T[35] = function(c) -- 货架(同一商品重复排列,标签空白)
T[34](c)
for _, y in ipairs({ 2, 7, 11 }) do
c.rect(2, y, 3, 3, PAL.berry); c.rect(6, y, 3, 3, PAL.berry); c.rect(10, y, 3, 3, PAL.berry)
c.px(3, y, PAL.white); c.px(7, y, PAL.white); c.px(11, y, PAL.white)
end
end
T[36] = function(c) -- 桌子
c.rect(0, 0, 16, 16, PAL.floor)
c.rect(2, 3, 12, 9, PAL.woodL); c.hline(2, 11, 12, PAL.woodD)
c.vline(3, 12, 3, PAL.woodD); c.vline(12, 12, 3, PAL.woodD)
c.hline(2, 3, 12, PAL.wood)
end
T[37] = function(c) -- 椅子
c.rect(0, 0, 16, 16, PAL.floor)
c.rect(4, 3, 8, 3, PAL.wood)
c.rect(4, 6, 8, 6, PAL.woodL)
c.vline(4, 12, 3, PAL.woodD); c.vline(11, 12, 3, PAL.woodD)
end
T[38] = function(c) -- 井·顶(小屋顶)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 2, 12, 6, PAL.roofD); c.rect(3, 1, 10, 2, PAL.roof)
c.vline(3, 8, 8, PAL.trunkD); c.vline(12, 8, 8, PAL.trunkD)
end
T[39] = function(c) -- 井·底(石圈)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 2, 12, 12, PAL.stone)
c.rect(4, 4, 8, 8, PAL.black)
c.px(3, 3, PAL.stoneD); c.px(12, 3, PAL.stoneD); c.px(3, 12, PAL.stoneD); c.px(12, 12, PAL.stoneD)
c.hline(2, 2, 12, PAL.stoneD)
end
T[40] = function(c) -- 雾(轻)
c.rect(0, 0, 16, 16, PAL.fog)
c.dither(0, 0, 16, 16, PAL.fogD, 3)
end
T[41] = function(c) -- 雾(浓)
c.rect(0, 0, 16, 16, PAL.fogD)
c.dither(0, 0, 16, 16, PAL.fog, 2); c.dither(1, 0, 16, 16, C("bcc8d2"), 5)
end
T[42] = function(c) -- 白色层·墙
c.rect(0, 0, 16, 16, PAL.wgray)
c.hline(0, 12, 16, PAL.wgrayD); c.rect(0, 13, 16, 3, PAL.wgrayDD)
end
T[43] = function(c) -- 白色层·地
c.rect(0, 0, 16, 16, PAL.wgrayD)
c.hline(0, 7, 16, PAL.wgrayDD); c.vline(7, 0, 16, PAL.wgrayDD)
end
T[44] = function(c) -- 白色层·门
c.rect(0, 0, 16, 16, PAL.wgray)
c.rect(3, 1, 10, 15, PAL.wgrayDD); c.rect(4, 2, 8, 14, PAL.wgrayD)
c.px(10, 9, PAL.wdark)
end
T[45] = function(c) -- 长椅·左
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 4, 14, 3, PAL.woodL); c.rect(2, 7, 14, 4, PAL.wood)
c.vline(3, 11, 3, PAL.woodD); c.hline(2, 7, 14, PAL.woodD)
end
T[46] = function(c) -- 长椅·右
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(0, 4, 14, 3, PAL.woodL); c.rect(0, 7, 14, 4, PAL.wood)
c.vline(12, 11, 3, PAL.woodD); c.hline(0, 7, 14, PAL.woodD)
end
T[47] = function(c) -- 公告板·左
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 1, 14, 12, PAL.wood); c.rect(4, 3, 12, 8, C("f8f0d8"))
c.hline(5, 5, 8, PAL.stoneD); c.hline(5, 7, 6, PAL.stoneD); c.hline(5, 9, 9, PAL.stoneD)
c.vline(4, 13, 3, PAL.trunkD)
end
T[48] = function(c) -- 公告板·右
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(0, 1, 14, 12, PAL.wood); c.rect(0, 3, 12, 8, C("f8f0d8"))
c.hline(1, 5, 8, PAL.stoneD); c.hline(1, 7, 9, PAL.stoneD); c.hline(1, 9, 5, PAL.stoneD)
c.vline(11, 13, 3, PAL.trunkD)
end
T[49] = function(c) -- 邮筒
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(4, 2, 8, 10, PAL.red); c.rect(5, 1, 6, 2, C("c04040"))
c.rect(6, 5, 4, 2, PAL.black)
c.vline(6, 12, 3, C("c04040")); c.vline(9, 12, 3, C("c04040"))
end
T[50] = function(c) -- 栅栏·横
c.rect(0, 0, 16, 16, PAL.grass)
c.hline(0, 6, 16, PAL.woodL); c.hline(0, 7, 16, PAL.wood); c.hline(0, 10, 16, PAL.woodL); c.hline(0, 11, 16, PAL.wood)
c.rect(2, 4, 2, 10, PAL.wood); c.rect(12, 4, 2, 10, PAL.wood)
c.px(2, 4, PAL.woodL); c.px(12, 4, PAL.woodL)
end
T[51] = function(c) -- 栅栏·柱
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(6, 2, 3, 12, PAL.wood); c.px(6, 2, PAL.woodL); c.px(7, 2, PAL.woodL)
end
T[52] = function(c) -- 路牌
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 3, 12, 6, PAL.woodL); c.hline(3, 5, 10, PAL.woodD); c.hline(3, 7, 7, PAL.woodD)
c.rect(7, 9, 2, 6, PAL.wood)
end
T[53] = function(c) -- 野餐布1
c.rect(0, 0, 16, 16, C("f8f0e0"))
for j = 0, 15 do for i = 0, 15 do
if (math.floor(i / 4) + math.floor(j / 4)) % 2 == 0 then c.px(i, j, C("f0a0a8")) end
end end
end
T[54] = function(c) -- 野餐布2(有食物)
T[53](c)
c.rect(5, 5, 6, 6, C("fff8e8")); c.px(7, 7, PAL.berry); c.px(9, 8, PAL.berry); c.px(6, 9, PAL.yellow)
end
T[55] = function(c) -- 花坛(盛开)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(1, 1, 14, 14, PAL.stoneD); c.rect(2, 2, 12, 12, C("8a6a48"))
c.px(4, 4, PAL.pink); c.px(8, 5, PAL.yellow); c.px(11, 4, PAL.red); c.px(5, 8, PAL.yellow)
c.px(9, 9, PAL.pink); c.px(12, 8, PAL.white); c.px(4, 11, PAL.red); c.px(8, 12, PAL.pink); c.px(11, 11, PAL.yellow)
end
T[56] = function(c) -- 花田·土
c.rect(0, 0, 16, 16, C("a8825a"))
c.hline(0, 3, 16, C("94714c")); c.hline(0, 8, 16, C("94714c")); c.hline(0, 13, 16, C("94714c"))
end
T[57] = function(c) -- 花田·苗
T[56](c)
c.px(3, 2, PAL.leaf); c.px(3, 1, PAL.leaf); c.px(9, 7, PAL.leaf); c.px(9, 6, PAL.leaf); c.px(13, 12, PAL.leaf); c.px(13, 11, PAL.leaf)
c.px(4, 1, PAL.pink); c.px(10, 6, PAL.yellow)
end
T[58] = function(c) -- 彩带(挂起)
c.rect(0, 0, 16, 16, pc.rgba(0, 0, 0, 0))
for i = 0, 15 do
local y = math.floor(2 + 2 * math.sin(i / 4))
c.px(i, y + 1, PAL.woodD)
end
c.px(2, 4, PAL.red); c.px(2, 5, PAL.red); c.px(6, 5, PAL.yellow); c.px(6, 6, PAL.yellow)
c.px(10, 4, PAL.pink); c.px(10, 5, PAL.pink); c.px(14, 5, PAL.sky); c.px(14, 6, PAL.sky)
end
T[59] = function(c) -- 灯笼杆
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(7, 2, 2, 12, PAL.trunk)
c.rect(5, 2, 6, 5, PAL.yellow); c.rect(6, 1, 4, 1, PAL.roofD); c.px(7, 4, C("fff0a0")); c.px(8, 4, C("fff0a0"))
end
T[60] = function(c) -- 湖水·波光
T[7](c)
c.px(4, 6, PAL.white); c.px(11, 8, PAL.white); c.hline(7, 2, 3, PAL.waterL)
end
T[61] = function(c) -- 室内窗
T[27](c)
c.rect(3, 3, 10, 8, PAL.woodD); c.rect(4, 4, 8, 6, PAL.sky)
c.vline(7, 4, 6, PAL.woodD); c.px(5, 5, PAL.waterL)
end
T[62] = function(c) -- 白色层·病床头
c.rect(0, 0, 16, 16, PAL.wgrayD)
c.rect(1, 1, 14, 15, PAL.wgray)
c.rect(3, 3, 10, 6, PAL.white); c.hline(3, 8, 10, PAL.wgrayD)
c.hline(1, 0, 14, PAL.wgrayDD)
end
T[63] = function(c) -- 白色层·病床尾
c.rect(0, 0, 16, 16, PAL.wgrayD)
c.rect(1, 0, 14, 15, C("dfe2e6"))
c.hline(1, 4, 14, PAL.wgrayD); c.hline(1, 9, 14, PAL.wgrayD)
c.hline(1, 15, 14, PAL.wgrayDD)
end
T[64] = function(c) -- 桌(诊所/办公)
c.rect(0, 0, 16, 16, PAL.clinic)
c.rect(1, 3, 14, 9, PAL.woodL); c.hline(1, 11, 14, PAL.woodD); c.hline(1, 3, 14, PAL.wood)
c.rect(3, 5, 6, 4, PAL.white); c.hline(4, 6, 4, PAL.stoneD); c.hline(4, 7, 3, PAL.stoneD)
c.vline(2, 12, 3, PAL.woodD); c.vline(13, 12, 3, PAL.woodD)
end
T[65] = function(c) -- 日历(墙面)
T[27](c)
c.rect(4, 2, 8, 11, PAL.white); c.rect(4, 2, 8, 3, PAL.red)
c.hline(5, 7, 6, PAL.stoneD); c.hline(5, 9, 6, PAL.stoneD); c.rect(7, 10, 3, 2, PAL.black)
end
T[66] = function(c) -- 柜台+配方纸
T[32](c)
c.rect(4, 1, 7, 5, C("f8f0d8")); c.hline(5, 2, 5, PAL.stoneD); c.hline(5, 4, 4, PAL.stoneD)
end
T[67] = function(c) -- 木箱
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 3, 12, 12, PAL.wood); c.rect(3, 4, 10, 10, PAL.woodL)
c.hline(3, 8, 10, PAL.wood); c.vline(8, 4, 10, PAL.wood)
end
T[68] = function(c) -- 篮子
c.rect(0, 0, 16, 16, PAL.floor)
c.rect(3, 6, 10, 8, PAL.woodL); c.hline(3, 6, 10, PAL.woodD); c.hline(3, 13, 10, PAL.woodD)
c.dither(4, 8, 8, 5, PAL.wood, 2)
c.px(6, 5, PAL.berry); c.px(9, 5, PAL.berry)
end
T[69] = function(c) -- 花坛(干涸·待浇水)
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(1, 1, 14, 14, PAL.stoneD); c.rect(2, 2, 12, 12, C("b09068"))
c.px(5, 5, C("8a7a5a")); c.px(10, 7, C("8a7a5a")); c.px(6, 10, C("8a7a5a"))
end
T[70] = function(c) -- 白色层·窗
T[42](c)
c.rect(4, 2, 8, 7, PAL.wgrayDD); c.rect(5, 3, 6, 5, C("c8ccd4")); c.vline(7, 3, 5, PAL.wgrayDD)
end
T[71] = function(c) -- 门垫
c.rect(0, 0, 16, 16, PAL.grass)
c.rect(2, 4, 12, 8, C("c8a878")); c.rect(3, 5, 10, 6, C("dcc094"))
end
T[72] = function(c) -- 高草
c.rect(0, 0, 16, 16, PAL.grass)
for i = 1, 14, 3 do c.vline(i, 6, 8, PAL.grassD); c.vline(i + 1, 8, 6, PAL.grassL) end
end
T[73] = function(c) -- 广场中心砖
c.rect(0, 0, 16, 16, PAL.path)
c.rect(2, 2, 12, 12, C("f0c88a")); c.rect(5, 5, 6, 6, PAL.path)
c.px(7, 7, PAL.roofD); c.px(8, 8, PAL.roofD)
end
T[74] = function(c) -- 白色层·帘
c.rect(0, 0, 16, 16, PAL.wgray)
for i = 0, 15, 3 do c.vline(i, 0, 16, C("d8dade")) end
c.hline(0, 0, 16, PAL.wgrayDD)
end
T[75] = function(c) -- 白色层·监护仪
c.rect(0, 0, 16, 16, PAL.wgrayD)
c.rect(3, 2, 10, 9, PAL.wdark); c.rect(4, 3, 8, 6, C("354048"))
c.px(5, 6, C("7ae0a0")); c.px(6, 5, C("7ae0a0")); c.px(7, 6, C("7ae0a0")); c.px(8, 6, C("7ae0a0")); c.px(9, 4, C("7ae0a0")); c.px(10, 6, C("7ae0a0"))
c.rect(6, 11, 4, 4, PAL.wgrayDD)
end
T[76] = function(c) -- 石墙(村界)
c.rect(0, 0, 16, 16, PAL.stone)
c.hline(0, 5, 16, PAL.stoneD); c.hline(0, 11, 16, PAL.stoneD)
c.vline(4, 0, 5, PAL.stoneD); c.vline(10, 6, 5, PAL.stoneD); c.vline(6, 12, 4, PAL.stoneD)
end
T[77] = function(c) -- 雾中草地(边缘)
c.rect(0, 0, 16, 16, PAL.grass)
c.dither(0, 0, 16, 16, PAL.fog, 2)
end
T[78] = function(c) -- 黑
c.rect(0, 0, 16, 16, PAL.black)
end
T[79] = function(c) -- 纯白灰
c.rect(0, 0, 16, 16, PAL.wgray)
end
local tilesImg = Image(160, 128, ColorMode.RGB)
for idx = 0, 79 do
local col, row = idx % 10, math.floor(idx / 10)
T[idx](makeCtx(tilesImg, col * 16, row * 16))
end
saveSheet(tilesImg, "tiles")
-- ============ 主角:16×24 × 16 帧(下/上/左/右 × 4 ============
local SKIN = C("f6d2a8")
local HAIR = C("6b4632")
local SHIRT = C("e87858")
local SHIRT_D = C("cc5f42")
local PANTS = C("4a6a9a")
local SHOE = C("7a4a2a")
-- 画一帧主角;dir: 0下 1上 2左 3右;f: 0..3 行走帧(0 兼待机)
local function paintPlayer(c, dir, f)
-- 头(x4..11, y0..7):发 0..2,脸 3..7
c.rect(4, 0, 8, 3, HAIR)
c.px(4, 0, pc.rgba(0, 0, 0, 0)); c.px(11, 0, pc.rgba(0, 0, 0, 0))
if dir == 1 then
c.rect(4, 3, 8, 5, HAIR) -- 背面全是头发
c.px(4, 7, SKIN); c.px(11, 7, SKIN)
else
c.rect(4, 3, 8, 5, SKIN)
c.vline(4, 3, 2, HAIR); c.vline(11, 3, 2, HAIR) -- 刘海两侧
if dir == 0 then
c.px(6, 5, PAL.black); c.px(9, 5, PAL.black)
c.px(7, 7, PAL.black); c.px(8, 7, PAL.black) -- 永远的微笑
c.px(6, 6, C("f8b8a0")); c.px(9, 6, C("f8b8a0"))
elseif dir == 2 then
c.rect(9, 3, 3, 5, HAIR) -- 侧脸:后脑
c.px(6, 5, PAL.black); c.px(5, 7, PAL.black); c.px(6, 7, PAL.black)
elseif dir == 3 then
c.rect(4, 3, 3, 5, HAIR)
c.px(9, 5, PAL.black); c.px(9, 7, PAL.black); c.px(10, 7, PAL.black)
end
end
-- 身体(y8..16
c.rect(5, 8, 6, 9, SHIRT)
c.hline(5, 16, 6, SHIRT_D)
-- 手臂:随帧摆动
local sway = ({ 0, 1, 0, -1 })[f + 1]
if dir == 0 or dir == 1 then
c.vline(4, 9 + (sway > 0 and 1 or 0), 5, SHIRT_D); c.px(4, 14 + (sway > 0 and 1 or 0), SKIN)
c.vline(11, 9 + (sway < 0 and 1 or 0), 5, SHIRT_D); c.px(11, 14 + (sway < 0 and 1 or 0), SKIN)
else
c.vline(dir == 2 and 7 or 8, 9 + math.abs(sway), 5, SHIRT_D)
c.px(dir == 2 and 7 or 8, 14 + math.abs(sway), SKIN)
end
-- 腿(y17..20)与鞋(y21..23
local lLift = (f == 1) and 1 or 0
local rLift = (f == 3) and 1 or 0
c.rect(5, 17, 3, 4 - lLift, PANTS)
c.rect(8, 17, 3, 4 - rLift, PANTS)
c.rect(5, 21 - lLift, 3, 2, SHOE)
c.rect(8, 21 - rLift, 3, 2, SHOE)
end
local playerImg = Image(256, 24, ColorMode.RGB)
for dir = 0, 3 do
for f = 0, 3 do
paintPlayer(makeCtx(playerImg, (dir * 4 + f) * 16, 0), dir, f)
end
end
saveSheet(playerImg, "player")
-- ============ 主角头像:24×24(微笑 / 真实的脸) ============
local function paintPortrait(c, real)
c.rect(0, 0, 24, 24, C("f8ecd0")) -- 背景
c.rect(4, 2, 16, 6, HAIR) -- 头发
c.px(4, 2, C("f8ecd0")); c.px(19, 2, C("f8ecd0"))
c.rect(4, 8, 16, 12, SKIN) -- 脸
c.vline(4, 8, 4, HAIR); c.vline(5, 8, 2, HAIR)
c.vline(19, 8, 4, HAIR); c.vline(18, 8, 2, HAIR)
c.rect(6, 20, 12, 4, SHIRT) -- 肩
if real then
-- 真实的脸:疲惫——平直的嘴、眼下阴影、微乱的头发
c.px(3, 4, HAIR); c.px(20, 5, HAIR); c.px(2, 5, HAIR)
c.rect(8, 12, 2, 2, PAL.black); c.rect(14, 12, 2, 2, PAL.black)
c.hline(8, 15, 2, C("c8a080")); c.hline(14, 15, 2, C("c8a080")) -- 眼袋
c.hline(10, 18, 4, C("9a6a52")) -- 平直疲惫的嘴
else
-- 永远的微笑
c.rect(8, 12, 2, 2, PAL.black); c.rect(14, 12, 2, 2, PAL.black)
c.px(7, 14, C("f8b8a0")); c.px(16, 14, C("f8b8a0")) -- 腮红
c.px(9, 17, C("9a6a52")); c.px(14, 17, C("9a6a52"))
c.hline(10, 18, 4, C("9a6a52")) -- 上弯的微笑
end
end
local p1 = Image(24, 24, ColorMode.RGB); paintPortrait(makeCtx(p1, 0, 0), false); saveSheet(p1, "portrait_smile")
local p2 = Image(24, 24, ColorMode.RGB); paintPortrait(makeCtx(p2, 0, 0), true); saveSheet(p2, "portrait_real")
-- ============ 具名 NPC16×24 × 4 帧(smile/flat/off/blink ============
-- off 档与 smile 档仅差 1–2 像素(右眼低 1 像素),绝不画成鬼脸。
local function paintAnimal(c, spec, expr)
local top = spec.headTop or 2
-- 头
c.rect(2, top, 12, 10, spec.fur)
c.px(2, top, pc.rgba(0, 0, 0, 0)); c.px(13, top, pc.rgba(0, 0, 0, 0))
c.px(2, top + 9, pc.rgba(0, 0, 0, 0)); c.px(13, top + 9, pc.rgba(0, 0, 0, 0))
-- 口鼻
c.rect(5, top + 5, 6, 4, spec.muzzle)
-- 眼睛
local ey = top + 4
if expr == "blink" then
c.px(5, ey + 1, PAL.black); c.px(10, ey + 1, PAL.black)
else
c.px(5, ey, PAL.black); c.px(5, ey + 1, PAL.black)
local roff = (expr == "off") and 1 or 0 -- 崩坏档:右眼低 1 像素
c.px(10, ey + roff, PAL.black); c.px(10, ey + 1 + roff, PAL.black)
end
-- 鼻子
if spec.nose then c.px(7, top + 6, PAL.black); c.px(8, top + 6, PAL.black) end
-- 嘴:微笑(小 U)或平直
local my = top + 8
if expr == "flat" then
c.hline(6, my, 4, PAL.black)
else
c.px(6, my - 1, PAL.black); c.px(9, my - 1, PAL.black); c.px(7, my, PAL.black); c.px(8, my, PAL.black)
end
-- 身体
c.rect(4, 12, 8, 8, spec.cloth)
-- 手
c.vline(3, 13, 4, spec.fur); c.vline(12, 13, 4, spec.fur)
-- 脚
c.rect(4, 20, 3, 3, spec.furD); c.rect(9, 20, 3, 3, spec.furD)
if spec.extra then spec.extra(c, expr) end
end
local NPCS = {
{ file = "npc_afu", fur = C("c49058"), furD = C("a87840"), muzzle = C("ecd0a8"), cloth = C("e0b060"), nose = true,
extra = function(c) -- 狗村长:垂耳+红领巾
c.rect(1, 3, 2, 5, C("a87840")); c.rect(13, 3, 2, 5, C("a87840"))
c.hline(4, 12, 8, PAL.red); c.px(7, 13, PAL.red); c.px(8, 13, PAL.red)
end },
{ file = "npc_yuanyuan", fur = C("f7f2ea"), furD = C("ddd4c8"), muzzle = C("ffffff"), cloth = C("f8c8d8"), headTop = 4,
extra = function(c) -- 兔面包师:长耳+围裙
c.rect(4, 0, 2, 4, C("f7f2ea")); c.px(5, 1, C("f8b8c8")); c.px(5, 2, C("f8b8c8"))
c.rect(10, 0, 2, 4, C("f7f2ea")); c.px(10, 1, C("f8b8c8")); c.px(10, 2, C("f8b8c8"))
c.rect(5, 14, 6, 6, C("fff8f0")); c.px(6, 10, C("f0a0b0")) -- 围裙+腮红
c.px(9, 10, C("f0a0b0"))
end },
{ file = "npc_dabao", fur = C("9a6b42"), furD = C("7e5530"), muzzle = C("c89a68"), cloth = C("5a88c8"), nose = true,
extra = function(c) -- 熊邮差:圆耳+挎包带
c.rect(3, 0, 3, 2, C("9a6b42")); c.rect(10, 0, 3, 2, C("9a6b42"))
c.px(4, 1, C("c89a68")); c.px(11, 1, C("c89a68"))
for i = 0, 6 do c.px(4 + i, 13 + i > 19 and 19 or 13 + math.floor(i * 0.9), C("3a5a88")) end
end },
{ file = "npc_mimi", fur = C("c8c0d8"), furD = C("a8a0c0"), muzzle = C("ece8f4"), cloth = C("e8a04a"), nose = true,
extra = function(c) -- 猫店主:尖耳+胡须
c.px(3, 1, C("c8c0d8")); c.rect(3, 2, 2, 1, C("c8c0d8")); c.px(4, 1, C("c8c0d8"))
c.px(12, 1, C("c8c0d8")); c.rect(11, 2, 2, 1, C("c8c0d8")); c.px(11, 1, C("c8c0d8"))
c.hline(1, 8, 2, C("8a84a0")); c.hline(13, 8, 2, C("8a84a0"))
end },
{ file = "npc_fu", fur = C("d8b088"), furD = C("bc9268"), muzzle = C("f0d8b8"), cloth = C("f4f4f4"), nose = true,
extra = function(c) -- 鹿医生:小角+白大褂
c.px(4, 1, C("8a6a4a")); c.px(4, 0, C("8a6a4a")); c.px(3, 0, C("8a6a4a"))
c.px(11, 1, C("8a6a4a")); c.px(11, 0, C("8a6a4a")); c.px(12, 0, C("8a6a4a"))
c.vline(7, 12, 8, C("d8d8d8")) -- 大褂门襟
end },
{ file = "npc_gui", fur = C("a8c078"), furD = C("88a058"), muzzle = C("c8d8a0"), cloth = C("7a9a4a"),
extra = function(c) -- 龟爷爷:龟壳纹+老花镜
c.hline(5, 14, 6, C("5f7c38")); c.hline(5, 17, 6, C("5f7c38")); c.vline(7, 13, 6, C("5f7c38"))
local gy = 2 + 4
c.px(4, gy, C("6a6a6a")); c.px(6, gy, C("6a6a6a")); c.px(9, gy, C("6a6a6a")); c.px(11, gy, C("6a6a6a"))
c.px(7, gy, C("6a6a6a")); c.px(8, gy, C("6a6a6a"))
end },
}
for _, spec in ipairs(NPCS) do
local img = Image(64, 24, ColorMode.RGB)
local exprs = { "smile", "flat", "off", "blink" }
for i, e in ipairs(exprs) do
paintAnimal(makeCtx(img, (i - 1) * 16, 0), spec, e)
end
saveSheet(img, spec.file)
end
-- ============ 双子鸟:16×16 × 2 帧(闭嘴/张嘴) ============
local function paintBird(c, open)
local B = C("6ab0e8")
local BD = C("4a90c8")
c.rect(4, 5, 8, 7, B) -- 身体
c.rect(8, 2, 6, 6, B) -- 头
c.px(11, 4, PAL.black) -- 眼
c.rect(2, 6, 3, 4, BD) -- 尾
c.rect(5, 7, 4, 3, BD) -- 翅
if open then
c.px(14, 5, PAL.yellow); c.px(15, 4, PAL.yellow); c.px(15, 6, PAL.yellow)
else
c.px(14, 5, PAL.yellow); c.px(15, 5, PAL.yellow)
end
c.vline(7, 12, 2, PAL.yellow); c.vline(10, 12, 2, PAL.yellow) -- 脚
end
local birdImg = Image(32, 16, ColorMode.RGB)
paintBird(makeCtx(birdImg, 0, 0), false)
paintBird(makeCtx(birdImg, 16, 0), true)
saveSheet(birdImg, "bird")
-- ============ 灰麻雀(E3 结局,一只真的鸟):16×16 ============
local function paintSparrow(c)
local G = C("9a9088")
local GD = C("6e6660")
c.rect(4, 6, 8, 6, G)
c.rect(8, 3, 6, 5, G)
c.px(11, 5, PAL.black)
c.rect(2, 7, 3, 3, GD)
c.rect(5, 8, 4, 2, GD)
c.px(14, 6, C("7a6a58")); c.px(15, 6, C("7a6a58"))
c.vline(7, 12, 2, GD); c.vline(10, 12, 2, GD)
end
local sparrowImg = Image(16, 16, ColorMode.RGB)
paintSparrow(makeCtx(sparrowImg, 0, 0))
saveSheet(sparrowImg, "sparrow")
-- ============ 群演 ×516×16 × 2 帧循环 ============
local function paintCritter(c, spec, f)
local bob = f -- 第二帧整体下移 1px 形成循环
c.rect(4, 6 + bob, 8, 6 - bob, spec.body)
c.rect(5, 2 + bob, 6, 5, spec.body)
c.px(6, 4 + bob, PAL.black); c.px(9, 4 + bob, PAL.black)
c.px(7, 6 + bob, spec.snout); c.px(8, 6 + bob, spec.snout)
c.rect(4, 12, 3, 2, spec.dark); c.rect(9, 12, 3, 2, spec.dark)
if spec.extra then spec.extra(c, f) end
end
local CRITTERS = {
{ file = "extra1", body = C("e0904a"), dark = C("b87030"), snout = C("f8c890"), -- 松鼠:大尾巴
extra = function(c, f) c.rect(12, 1 + f, 3, 9, C("b87030")); c.px(13, 2 + f, C("e0904a")) end },
{ file = "extra2", body = C("b09068"), dark = C("8a6a48"), snout = C("e8cca8"), -- 刺猬:背刺
extra = function(c, f) for i = 4, 11, 2 do c.px(i, 1 + f + (i % 4 == 0 and 0 or 1), C("6e5236")) end end },
{ file = "extra3", body = C("f4e04a"), dark = C("d8b830"), snout = C("f4a030"), -- 鸭子:扁嘴
extra = function(c, f) c.px(6, 5 + f, C("f4a030")); c.px(9, 5 + f, C("f4a030")) end },
{ file = "extra4", body = C("b8b8c4"), dark = C("8e8e9c"), snout = C("e8c8c8"), -- 老鼠:圆耳
extra = function(c, f) c.rect(4, 0 + f, 2, 2, C("b8b8c4")); c.rect(10, 0 + f, 2, 2, C("b8b8c4")) end },
{ file = "extra5", body = C("7ac86a"), dark = C("58a048"), snout = C("aee89e"), -- 青蛙:凸眼
extra = function(c, f) c.px(5, 1 + f, C("7ac86a")); c.px(10, 1 + f, C("7ac86a")) end },
}
for _, spec in ipairs(CRITTERS) do
local img = Image(32, 16, ColorMode.RGB)
paintCritter(makeCtx(img, 0, 0), spec, 0)
paintCritter(makeCtx(img, 16, 0), spec, 1)
saveSheet(img, spec.file)
end
-- ============ 向日葵表盘:24×24 × 14 帧 ============
-- 帧 0..12 = 点亮花瓣数;帧 13 = 满格+花心黑籽
local function paintSunflower(c, lit, seeds)
local cx, cy = 11.5, 11.5
for k = 0, 11 do
local ang = (k / 12) * math.pi * 2 - math.pi / 2
local col = (k < lit) and PAL.yellow or C("7a7264")
local colD = (k < lit) and C("e0b830") or C("635c50")
for r = 7, 10 do
local x = math.floor(cx + math.cos(ang) * r + 0.5)
local y = math.floor(cy + math.sin(ang) * r + 0.5)
c.px(x, y, r > 9 and colD or col)
local px2 = math.floor(cx + math.cos(ang + 0.14) * r + 0.5)
local py2 = math.floor(cy + math.sin(ang + 0.14) * r + 0.5)
c.px(px2, py2, col)
end
end
-- 花心
for j = -4, 4 do for i = -4, 4 do
if i * i + j * j <= 18 then c.px(12 + i, 12 + j, C("9a6a2a")) end
end end
for j = -3, 3 do for i = -3, 3 do
if i * i + j * j <= 9 then c.px(12 + i, 12 + j, C("b8843a")) end
end end
if seeds then -- 过熟的黑籽
c.px(11, 11, PAL.black); c.px(13, 12, PAL.black); c.px(12, 14, PAL.black)
c.px(10, 13, PAL.black); c.px(14, 10, PAL.black); c.px(12, 11, C("4a3a20"))
end
end
local sunImg = Image(24 * 14, 24, ColorMode.RGB)
for f = 0, 12 do paintSunflower(makeCtx(sunImg, f * 24, 0), f, false) end
paintSunflower(makeCtx(sunImg, 13 * 24, 0), 12, true)
saveSheet(sunImg, "sunflower")
print("ALL ART GENERATED OK")
+352
View File
@@ -0,0 +1,352 @@
// 《崩坏童话:蜜糖村》10 张地图生成脚本
// 生成 maps/*.tmxTiled 源文件),随后由 Tiled CLI 导出 JSON 到 public/assets/maps/
// 运行:node tools/gen_maps.mjs
import { writeFileSync, mkdirSync } from "fs";
// 瓦片索引(与 tools/gen_art.lua 的 tileset 一一对应),GID = 索引 + 1
const I = {
grass: 0, grass2: 1, grassWF: 2, grassPF: 3, path: 4, path2: 5, pathEdge: 6,
water1: 7, water2: 8, shore: 9, flowerBush: 10, berryFull: 11, berryPicked: 12,
treeTL: 13, treeTR: 14, treeBL: 15, treeBR: 16, bush: 17, wall: 18, wallWin: 19,
door: 20, roofL: 21, roofR: 22, eave: 23, floorW: 24, floorW2: 25, floorC: 26,
inWall: 27, inWallLow: 28, rug: 29, bedHead: 30, bedFoot: 31, counter: 32,
oven: 33, shelfEmpty: 34, shelfGoods: 35, table: 36, chair: 37, wellRoof: 38,
wellRing: 39, fogLight: 40, fogDense: 41, wWall: 42, wFloor: 43, wDoor: 44,
benchL: 45, benchR: 46, boardL: 47, boardR: 48, mailbox: 49, fenceH: 50,
fencePost: 51, sign: 52, picnic1: 53, picnic2: 54, bedBloom: 55, dirt: 56,
sprout: 57, bunting: 58, lantern: 59, sparkle: 60, winIn: 61, wBedHead: 62,
wBedFoot: 63, desk: 64, calendar: 65, counterPaper: 66, crate: 67, basket: 68,
bedDry: 69, wWin: 70, doormat: 71, tallGrass: 72, plazaBrick: 73, wCurtain: 74,
monitor: 75, stoneWall: 76, fogGrass: 77, black: 78, wBlank: 79,
};
class M {
constructor(name, w, h) {
this.name = name; this.w = w; this.h = h;
this.ground = new Array(w * h).fill(0);
this.obst = new Array(w * h).fill(0);
this.objects = []; this.oid = 1;
}
g(x, y, t) { this.ground[y * this.w + x] = t + 1; }
o(x, y, t) { this.obst[y * this.w + x] = t + 1; }
gFill(t) { for (let i = 0; i < this.ground.length; i++) this.ground[i] = t + 1; }
gRect(x, y, w, h, t) { for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.g(i, j, t); }
oRect(x, y, w, h, t) { for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.o(i, j, t); }
// 装饰草:确定性伪随机
grassDecor() {
for (let j = 0; j < this.h; j++) for (let i = 0; i < this.w; i++) {
const r = (i * 73 + j * 149 + i * j * 7) % 23;
if (this.ground[j * this.w + i] === I.grass + 1) {
if (r === 0) this.g(i, j, I.grassWF); else if (r === 1) this.g(i, j, I.grassPF); else if (r < 6) this.g(i, j, I.grass2);
}
}
}
tree(x, y) { this.o(x, y, I.treeTL); this.o(x + 1, y, I.treeTR); this.o(x, y + 1, I.treeBL); this.o(x + 1, y + 1, I.treeBR); }
// 4 宽 ×3 高的房屋立面,door 在底行 dx 处
facade(x, y, dx) {
this.o(x, y, I.roofL); this.o(x + 1, y, I.roofL); this.o(x + 2, y, I.roofR); this.o(x + 3, y, I.roofR);
for (let i = 0; i < 4; i++) this.o(x + i, y + 1, I.eave);
for (let i = 0; i < 4; i++) this.o(x + i, y + 2, i % 3 === 1 ? I.wallWin : I.wall);
this.obst[(y + 2) * this.w + (x + dx)] = 0; // 门位可走
this.g(x + dx, y + 2, I.door);
this.g(x + dx, y + 3, I.doormat);
}
obj(name, type, tx, ty, opts = {}) {
const o = { id: this.oid++, name, type, x: tx * 16, y: ty * 16, w: (opts.w ?? 0) * 16, h: (opts.h ?? 0) * 16, props: opts.props ?? {} };
this.objects.push(o); return o;
}
spawn(name, tx, ty) { this.obj(name, "spawn", tx + 0.5, ty + 0.5); }
door(tx, ty, w, h, target, spawn) { this.obj("to_" + target, "door", tx, ty, { w, h, props: { target, spawn } }); }
npc(name, tx, ty, props = {}) { this.obj(name, "npc", tx + 0.5, ty + 0.5, { props }); }
poi(name, tx, ty, props = {}) { this.obj(name, "poi", tx + 0.5, ty + 0.5, { props }); }
tmx() {
const csv = a => { let out = []; for (let j = 0; j < this.h; j++) out.push(a.slice(j * this.w, (j + 1) * this.w).join(",")); return out.join(",\n"); };
const props = p => {
const e = Object.entries(p); if (!e.length) return "";
return `<properties>${e.map(([k, v]) => `<property name="${k}" value="${v}"/>`).join("")}</properties>`;
};
const objs = this.objects.map(o => {
const size = o.w || o.h ? ` width="${o.w}" height="${o.h}"` : "";
const body = props(o.props);
return ` <object id="${o.id}" name="${o.name}" type="${o.type}" x="${o.x}" y="${o.y}"${size}>${body}${o.w || o.h ? "" : "<point/>"}</object>`;
}).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="${this.w}" height="${this.h}" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="${this.oid}">
<tileset firstgid="1" source="tiles.tsx"/>
<layer id="1" name="ground" width="${this.w}" height="${this.h}">
<data encoding="csv">
${csv(this.ground)}
</data>
</layer>
<layer id="2" name="obstacles" width="${this.w}" height="${this.h}">
<properties><property name="collides" type="bool" value="true"/></properties>
<data encoding="csv">
${csv(this.obst)}
</data>
</layer>
<objectgroup id="3" name="objects">
${objs}
</objectgroup>
</map>
`;
}
}
mkdirSync("maps", { recursive: true });
mkdirSync("public/assets/maps", { recursive: true });
const maps = [];
// ============ 小满小屋 12×8 ============
{
const m = new M("house", 12, 8);
m.gFill(I.floorW);
for (let i = 0; i < 12; i++) { m.o(i, 0, I.inWall); m.o(i, 1, i === 6 ? I.winIn : I.inWallLow); }
m.o(2, 1, I.calendar);
m.o(1, 2, I.bedHead); m.o(1, 3, I.bedFoot);
m.o(7, 4, I.table); m.o(8, 4, I.chair);
m.o(10, 2, I.crate);
m.g(5, 7, I.rug); m.g(6, 7, I.rug);
m.o(0, 2, I.crate); // 角落杂物
m.spawn("bed", 2, 3);
m.spawn("entry", 5, 6);
m.door(5, 7.5, 2, 0.5, "plaza", "fromHouse");
m.poi("calendar", 2, 2, { clue: "C1" });
m.poi("bed", 1, 4);
maps.push(m);
}
// ============ 村广场 30×20 ============
{
const m = new M("plaza", 30, 20);
m.gFill(I.grass);
// 道路
m.gRect(14, 0, 2, 20, I.path); m.gRect(0, 8, 30, 2, I.path);
for (let i = 0; i < 30; i += 2) m.g(i, 8, I.path2);
for (let j = 1; j < 20; j += 3) m.g(14, j, I.path2);
// 庆典场地
m.gRect(12, 11, 6, 4, I.plazaBrick);
m.grassDecor();
// 边界:树与栅栏(留出四个出口)
for (let i = 0; i < 30; i += 2) { if (i < 12 || i > 17) m.tree(i, 0); }
for (let i = 0; i < 30; i += 2) { if (i < 13 || i > 16) { m.o(i, 19, I.fenceH); m.o(i + 1, 19, I.fenceH); } }
for (let j = 2; j < 18; j += 2) { if (j < 7 || j > 10) { m.o(0, j, I.fencePost); m.o(29, j, I.fencePost); } }
// 房屋立面:小屋 / 面包房 / 杂货店 / 诊所
m.facade(2, 2, 2); m.facade(7, 2, 2); m.facade(19, 2, 2); m.facade(24, 2, 2);
m.poi("sign_house", 1, 5); m.o(1, 5, I.sign);
// 公告板
m.o(11, 6, I.boardL); m.o(12, 6, I.boardR);
m.poi("board", 11.5, 7.5);
// 邮筒与散落信件(C3
m.o(17, 1, I.mailbox);
m.poi("letters", 17.5, 2.5, { clue: "C3" });
// 双子鸟树
m.tree(20, 6);
m.poi("birdtree", 21, 8.5);
// 花坛 ×3T2 浇水)
m.o(7, 11, I.bedDry); m.o(7, 13, I.bedDry); m.o(7, 15, I.bedDry);
m.poi("flowerbed1", 7.5, 12); m.poi("flowerbed2", 7.5, 14); m.poi("flowerbed3", 7.5, 16);
// 庆典灯笼杆 ×4(T13 挂彩带)
m.o(11, 10, I.lantern); m.o(18, 10, I.lantern); m.o(11, 15, I.lantern); m.o(18, 15, I.lantern);
m.poi("ribbon1", 11.5, 11.2); m.poi("ribbon2", 18.5, 11.2); m.poi("ribbon3", 11.5, 16.2); m.poi("ribbon4", 18.5, 16.2);
m.poi("festival", 15, 13);
// 装饰
m.o(4, 12, I.flowerBush); m.o(23, 12, I.flowerBush); m.o(25, 15, I.bush); m.o(3, 16, I.bush);
m.tree(26, 16); m.tree(21, 17);
// NPC
m.npc("afu", 12.5, 8.5);
m.npc("dabao", 16.5, 3.5);
m.npc("bird1", 20.4, 6.3); m.npc("bird2", 21.6, 6.3);
m.npc("extra1", 5, 10, { patrol: 40 }); m.npc("extra2", 22, 9, { patrol: 56 });
m.npc("extra3", 24, 17, { patrol: 40 }); m.npc("extra4", 5, 17, { patrol: 24 });
m.npc("extra5", 22, 17, { patrol: 16 });
// 出生点
m.spawn("fromHouse", 4, 6.2); m.spawn("fromBakery", 9, 6.2); m.spawn("fromStore", 21, 6.2); m.spawn("fromClinic", 26, 6.2);
m.spawn("fromLake", 1.6, 9); m.spawn("fromField", 28.4, 9); m.spawn("fromWell", 14.5, 1.6); m.spawn("fromFog", 14.5, 17.2);
m.spawn("center", 15, 12);
// 门与出口
m.door(4, 4.2, 1, 0.8, "house", "entry");
m.door(9, 4.2, 1, 0.8, "bakery", "entry");
m.door(21, 4.2, 1, 0.8, "store", "entry");
m.door(26, 4.2, 1, 0.8, "clinic", "entry");
m.door(0, 7, 0.6, 4, "lake", "entry");
m.door(29.4, 7, 0.6, 4, "field", "entry");
m.door(12.5, 0, 5, 0.7, "well", "entry");
m.door(12.5, 19.3, 5, 0.7, "fogwall", "entry");
maps.push(m);
}
// ============ 面包房 15×10 ============
{
const m = new M("bakery", 15, 10);
m.gFill(I.floorW);
for (let i = 0; i < 15; i++) { m.o(i, 0, I.inWall); m.o(i, 1, (i === 4 || i === 10) ? I.winIn : I.inWallLow); }
m.o(12, 1, I.oven);
for (let i = 2; i < 7; i++) m.o(i, 3, I.counter);
m.o(7, 3, I.counterPaper);
m.o(1, 5, I.basket); m.o(1, 7, I.crate); m.o(13, 5, I.crate);
m.o(10, 5, I.table); m.o(11, 5, I.chair);
m.g(7, 9, I.rug); m.g(8, 9, I.rug);
m.npc("yuanyuan", 4.5, 2.6);
m.poi("recipe", 7.5, 4.2, { clue: "C2" });
m.poi("knead", 10.5, 6.2);
m.spawn("entry", 7.5, 8);
m.door(7, 9.5, 2, 0.5, "plaza", "fromBakery");
maps.push(m);
}
// ============ 杂货店 15×10 ============
{
const m = new M("store", 15, 10);
m.gFill(I.floorW2);
for (let i = 0; i < 15; i++) { m.o(i, 0, I.inWall); m.o(i, 1, i === 12 ? I.winIn : I.inWallLow); }
for (let i = 2; i < 11; i++) m.o(i, 2, I.shelfGoods);
m.o(13, 4, I.shelfGoods); m.o(13, 5, I.shelfGoods); m.o(13, 6, I.shelfGoods);
m.o(3, 5, I.counter); m.o(4, 5, I.counter);
m.o(1, 7, I.crate); m.o(11, 8, I.basket);
m.g(7, 9, I.rug); m.g(8, 9, I.rug);
m.npc("mimi", 3.5, 4.4);
m.poi("shelfA", 3.5, 3.2); m.poi("shelfB", 6.5, 3.2); m.poi("shelfC", 9.5, 3.2);
m.poi("shelf_c5", 12.4, 5.5, { clue: "C5" });
m.spawn("entry", 7.5, 8);
m.door(7, 9.5, 2, 0.5, "plaza", "fromStore");
maps.push(m);
}
// ============ 诊所 12×8 ============
{
const m = new M("clinic", 12, 8);
m.gFill(I.floorC);
for (let i = 0; i < 12; i++) { m.o(i, 0, I.wWall); m.o(i, 1, i === 3 ? I.wWin : I.inWallLow); }
m.o(5, 3, I.desk);
m.o(9, 2, I.wBedHead); m.o(9, 3, I.wBedFoot);
m.o(8, 1, I.wCurtain);
m.o(1, 2, I.crate);
m.g(5, 7, I.rug); m.g(6, 7, I.rug);
m.npc("fu", 6.5, 2.6);
m.poi("chart", 4.5, 4.2, { clue: "C8" });
m.spawn("entry", 5.5, 6);
m.door(5, 7.5, 2, 0.5, "plaza", "fromClinic");
maps.push(m);
}
// ============ 湖畔 24×16 ============
{
const m = new M("lake", 24, 16);
m.gFill(I.grass);
for (let i = 0; i < 24; i++) {
m.g(i, 9, I.shore);
for (let j = 10; j < 16; j++) m.g(i, j, (i * 31 + j * 17) % 7 === 0 ? I.sparkle : ((i + j) % 2 ? I.water1 : I.water2));
}
m.oRect(0, 10, 24, 6, I.water1); // 水面不可走(碰撞用,绘制层在 ground)
for (let j = 10; j < 16; j++) for (let i = 0; i < 24; i++) m.obst[j * m.w + i] = m.ground[j * m.w + i]; // 与水面同贴图
m.grassDecor();
for (let i = 0; i < 24; i += 2) m.tree(i, 0);
for (let j = 2; j < 8; j += 2) { m.o(0, j, I.fencePost); }
m.tree(2, 3); m.tree(11, 2); m.o(6, 4, I.flowerBush);
// 长椅(龟爷爷)
m.o(16, 5, I.benchL); m.o(17, 5, I.benchR);
m.npc("gui", 18.9, 5.5);
m.poi("bench", 16.5, 6.5);
// 野餐布
m.g(7, 6, I.picnic1); m.g(8, 6, I.picnic2); m.g(7, 7, I.picnic2); m.g(8, 7, I.picnic1);
m.poi("picnic", 8, 7);
m.spawn("entry", 22, 7);
m.spawn("picnicSpot", 8, 7.6);
m.door(23.4, 5, 0.6, 5, "plaza", "fromLake");
maps.push(m);
}
// ============ 花田 20×14 ============
{
const m = new M("field", 20, 14);
m.gFill(I.grass);
m.gRect(3, 3, 14, 8, I.dirt);
for (let j = 3; j < 11; j++) for (let i = 3; i < 17; i++) if ((i + j) % 3 === 0) m.g(i, j, I.sprout);
m.grassDecor();
for (let i = 0; i < 20; i += 2) { m.tree(i, 0); if (i < 4 || i > 8) { m.o(i, 13, I.fenceH); m.o(i + 1, 13, I.fenceH); } }
for (let j = 2; j < 12; j += 2) { if (j < 5 || j > 8) m.o(19, j, I.fencePost); }
// 莓果丛 ×5T6
const berries = [[4, 4], [8, 5], [12, 4], [15, 7], [6, 9]];
berries.forEach(([x, y], k) => { m.o(x, y, I.berryFull); m.poi("berry" + (k + 1), x + 0.5, y + 1.5); });
// 浇花点
m.o(3, 11, I.bedDry);
m.poi("fieldwater", 3.5, 12.2);
m.o(17, 11, I.flowerBush); m.o(2, 2, I.bush);
m.spawn("entry", 1.6, 6);
m.door(0, 4.5, 0.6, 5, "plaza", "fromField");
maps.push(m);
}
// ============ 古井空地 14×10 ============
{
const m = new M("well", 14, 10);
m.gFill(I.grass);
m.gRect(6, 6, 2, 4, I.path);
m.grassDecor();
for (let i = 0; i < 14; i += 2) m.tree(i, 0);
for (let j = 2; j < 9; j += 2) { m.o(0, j, I.fencePost); m.o(13, j, I.fencePost); }
for (let i = 0; i < 14; i += 2) { if (i < 5 || i > 8) { m.o(i, 9, I.fenceH); m.o(i + 1, 9, I.fenceH); } }
m.o(2, 4, I.tallGrass); m.o(3, 4, I.tallGrass); m.o(11, 5, I.tallGrass); m.o(11, 3, I.bush)
m.o(6, 3, I.wellRoof); m.o(6, 4, I.wellRing);
m.o(9, 4, I.sign);
m.poi("well", 6.5, 5.5, { clue: "C7" });
m.spawn("entry", 6.5, 7.6);
m.door(4.5, 9.3, 5, 0.7, "plaza", "fromWell");
maps.push(m);
}
// ============ 雾墙边界 20×8 ============
{
const m = new M("fogwall", 20, 8);
m.gFill(I.grass);
for (let i = 0; i < 20; i++) {
m.g(i, 4, I.fogGrass);
m.g(i, 5, I.fogLight);
m.g(i, 6, I.fogDense); m.g(i, 7, I.fogDense);
m.o(i, 6, I.fogDense); m.o(i, 7, I.fogDense);
}
m.grassDecor();
for (let j = 0; j < 6; j += 2) { m.o(0, j, I.fencePost); m.o(19, j, I.fencePost); }
m.tree(2, 1); m.tree(16, 1);
m.o(4, 3, I.sign);
m.poi("fog", 10, 5.5);
m.spawn("entry", 10, 2.4);
m.door(7.5, 0, 5, 0.7, "plaza", "fromFog");
maps.push(m);
}
// ============ 白色层·走廊 20×6 ============
{
const m = new M("white_corridor", 20, 6);
m.gFill(I.wFloor);
for (let i = 0; i < 20; i++) m.o(i, 0, (i % 5 === 2) ? I.wWin : I.wWall);
m.o(6, 0, I.wDoor); m.o(13, 0, I.wDoor);
m.spawn("entry", 1, 3);
m.door(19.5, 0, 0.5, 6, "white_ward", "entry");
maps.push(m);
}
// ============ 白色层·病房 10×8 ============
{
const m = new M("white_ward", 10, 8);
m.gFill(I.wFloor);
for (let i = 0; i < 10; i++) m.o(i, 0, i === 6 ? I.wWin : I.wWall);
m.o(2, 0, I.wCurtain);
m.o(4, 2, I.wBedHead); m.o(4, 3, I.wBedFoot);
m.o(6, 2, I.monitor);
m.poi("chart_ward", 5, 4.5);
m.spawn("entry", 1, 5);
maps.push(m);
}
// tiles.tsx
writeFileSync("maps/tiles.tsx", `<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.2" name="tiles" tilewidth="16" tileheight="16" tilecount="80" columns="10">
<image source="../public/assets/tiles.png" width="160" height="128"/>
</tileset>
`);
for (const m of maps) writeFileSync(`maps/${m.name}.tmx`, m.tmx());
console.log("TMX written:", maps.map(m => m.name).join(", "));