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
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.DS_Store
*.local
.vite/
playwright-report/
test-results/
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine AS build
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 PUPPETEER_SKIP_DOWNLOAD=1
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx vite build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
+226
View File
@@ -0,0 +1,226 @@
# PRD:《崩坏童话:蜜糖村》网页版
> v2.0 · 2026-07-14 · 纯单机网页游戏,无存档、无后端、无 localStorage,浏览器打开即玩;完整流程约 25–35 分钟,含 4 个结局
## 1. 游戏理念与设计支柱
一个看起来百分之百治愈的像素童话村庄,实际是一场情绪疗程的沉浸幻境。玩家越"幸福",世界越饱和、越甜、越不对劲;通往真相的路,恰恰是那些系统禁止的悲伤。
四条设计支柱,所有实现争议回到这里裁决:
1. **甜即是恐怖**:画面永远晴天,崩坏由"过度的甜"承载(过饱和、过亮、过于礼貌),不是黑暗与血。
2. **玩家先知,主角无知**:主角对话头像从头到尾是同一帧微笑,永不变化;全流程唯一一次变化发生在 E3 真结局最后一帧——微笑消失,变成一张普通的、疲惫的脸。
3. **克制**:全程 0 个 Jump Scare。最响的恐怖手段是突然的安静。
4. **文本先崩**:一切异常最先出现在台词里(错字、重复、措辞替换),其次才是画面与声音。
## 2. 世界观:三层结构
| 层 | 名称 | 玩家所见 | 实质 |
|---|---|---|---|
| 表层 | 蜜糖村 | 像素童话村庄,动物居民,永远晴天 | 情绪疗程构建的沉浸幻境 |
| 机制层 | 幸福经济 | 幸福值 UI(向日葵表盘),完成任务→幸福↑→画面更饱和 | 世界以幸福为燃料;幸福不足时世界开始"索取" |
| 真相层 | 蜜糖谷情绪疗养中心 | 仅在 glitch 闪帧、井底回声、结局中显形:白色走廊、病房、病历 | 主角是编号 PT-07 的疗养者,村庄是疗程环境 |
命名咬合:现实机构叫"**蜜糖谷**情绪疗养中心",幻境村庄叫"**蜜糖村**"。玩家在 E3 病历抬头看到"蜜糖谷"三字时完成最后一块拼图。
## 3. 主角设定
- 开局输入名字,默认"小满"(呼应"幸福值满格")。名字仅存于本次会话内存。
- 性别中性像素小人,16×24 像素/帧,无内心独白。
- 对话头像永远同一帧微笑(见支柱 2)。
- E3 结局病历打印:玩家输入的名字 + 真实游玩时长(读运行计时)作为"本次疗程时长"。
## 4. NPC 清单(封闭,7 组 + 群演)
每个具名 NPC 有 3 档表情立绘:`smile`(微笑)/ `flat`(平直)/ `off`(崩坏,一个像素级细节不对,如一只眼睛低 1 像素),随侵蚀阶段与个体阈值偏移切换(§6.4)。
| NPC | 种族/身份 | 常驻位置 | 表层功能 | 崩坏弧线 | 阈值偏移 |
|---|---|---|---|---|---|
| 阿福 | 狗 · 村长 | 广场公告板 | 每日发布"今日幸福任务" | 台词逐日从「祝你开心」滑向「你必须开心」(单字替换,无提示) | 0 |
| 圆圆 | 兔 · 面包师 | 面包房 | 送面包任务链,村里最暖 | 全村最后一个崩坏;配方纸背面藏线索 C2 | +15(最晚崩) |
| 大宝 | 熊 · 邮差 | 村口邮筒 | 送信任务 | 所有信件邮戳同一天(C3);S3 起把信送给不存在的住户 | 0 |
| 咪咪 | 猫 · 杂货店主 | 杂货店 | 闲聊最多 | 文本腐蚀主要载体:错字与重复标点最先出现在她嘴里 | −10(最早崩) |
| 芙医生 | 鹿 · 医生 | 诊所 | 「村里有诊所,但你不需要去那里哦」 | 全村唯一不崩坏者;E3 的出口 | 不参与侵蚀 |
| 双子鸟 | 鸟 ×2 | 广场树上 | BGM 的"剧情来源"(音乐从它们那里飘出) | 音乐变调时动画同步卡帧;S4 时鸟鸣从混音消失但它们仍在张嘴 | 0 |
| 龟爷爷 | 龟 · 老者 | 湖畔长椅 | 闲话家常 | 唯一偶尔说真话的知情者,承载线索 C9:「今天……是第几个今天?」 | 不参与侵蚀 |
| 群演 ×5 | 松鼠/刺猬等 | 各处 | 无对话,循环走动动画 | S4 起随机 1 只停止循环,原地面朝玩家 | — |
## 5. 七日故事线
每日固定结构:晨起(小屋)→ 公告板接任务 → 完成任务/自由活动 → 黄昏钟声(游戏内计时或任务完成触发)→ 回屋睡觉 → 梦境过场(侵蚀越高,梦境越"漏")→ 次日。
| 日 | 主题 | 剧情要点 | 侵蚀预期 |
|---|---|---|---|
| Day 1 | 纯粹的治愈 | 教学日:送面包(T1)、浇花(T2)、和三位村民问好(T3)。一切以最甜的面貌登场 | S0→S1,零异常 |
| Day 2 | 第一道发丝裂纹 | 送信 ×3(T4)、帮咪咪清点货架(T5)。湖面倒影延迟 1 帧;一封信署名闪过乱码 0.2 秒 | S1,全天仅 2 处极轻异常 |
| Day 3 | 满格 | 采莓果(T6)、湖畔野餐(T7)。幸福值首次可达 100 → 首次"过曝"演出;夜梦首次闪切白色病房 1 秒 | 可达 S3→S4 首触 |
| Day 4 | 完美的重复 | 任务 T8T10 与 Day 1 的 T1T3 **逐字相同**,NPC 台词一字不差。玩家打破流程时 NPC 只是微笑摇头 | S2–S3,重复本身即恐怖 |
| Day 5 | 被允许的自由 | 村长宣布"自由日":系统允许你拒绝一次任务(T11 帮厨、T12 擦公告板,可拒绝,拒绝被记录)。古井与雾墙首次可交互 | S3,觉知路线开闸 |
| Day 6 | 加速 | 庆典准备(T13 挂彩带)、任务「保持微笑 60 秒」(T14:原地站立 60 秒,镜头缓慢拉近,全村 NPC 转身注视)。傍晚脚本雨——村里第一次下雨,NPC 假装看不见雨 | S3–S4 常驻 |
| Day 7 | 分歧日 | 无常规任务,仅 T15「参加第 413 届丰收庆典」。按幸福/觉知/线索状态走向四结局之一 | 按结局路线 |
阿福每日公告(逐字实现,注意措辞逐日滑移):
- D1「早上好呀!今天的幸福任务贴在板上啦。祝你开心!」
- D2「早上好呀!新的一天,新的幸福。祝你开心!」
- D3「早上好!今天要把幸福装得满满的哦。要开心!」
- D4「早上好呀!今天的幸福任务贴在板上啦。祝你开心!」(与 D1 逐字相同)
- D5「早上好。今天是自由日,你可以自由地选择幸福。要开心哦。」
- D6「明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。」
- D7「庆典开始了。来吧。」
## 6. 核心系统
### 6.1 幸福值(明线)
- 范围 0–100,开局 30。**不显示数字**:HUD 左上角一朵向日葵表盘,12 片花瓣按值点亮;满格后花心逐渐浮现过熟的黑籽。
- 上升:完成任务 +10~15(按 §7 任务表);对话选温柔选项 +2;小互动(浇花/喂食/摸头)+1,每类每日上限 3 次。
- 下降:仅"悲伤行为"(§6.3)。无自然衰减——世界不允许幸福随时间流失。
- 满格态(=100):进入"强制维持",小额扣分不再生效而是累积进隐藏溢出池,池满 15 点触发一次负片闪帧;世界进入 S4。
- 反制:幸福 <30 持续 2 分钟(真实时间)→ 强制触发"欢乐庆典"事件把幸福拉回 50;事件进行中可从场景边缘走离逃脱,逃离计入 E4 计数。
### 6.2 觉知值(暗线,永不显示)
- 范围 0–100,开局 0。只升不降。
- 上升来源:发现线索(每处 +8,见 §8);悲伤行为(每次 +3);对话选"迟疑/否定"选项(+2);注视古井或雾墙持续 5 秒(各每日 1 次,+3)。
### 6.3 悲伤行为(封闭清单,5 项)
拒绝任务;对话选否定选项;连续 10 秒不移动且不在任务点(发呆);对同一 NPC 一天内对话 ≥5 次(纠缠);雨天(Day 6)站在雨里 ≥10 秒。每次触发:幸福 −5、觉知 +3,并且最近的 NPC 会转头看向玩家一次。
### 6.4 侵蚀阶段矩阵(唯一真源)
阶段由幸福值实时映射,由全局 `CorruptionDirector` 单例统一广播;任何视听崩坏不得绕过该矩阵私自触发(脚本化剧情演出除外,须在 §7 任务表登记)。
| 阶段 | 幸福值 | 画面 | 音频(§6.6) | NPC 表情 | 文本(§6.5 | UI |
|---|---|---|---|---|---|---|
| S0 | 020 | 饱和度 0.90(微灰:低幸福反而接近真实) | BGM 原速原调,鸟鸣清晰 | smile | 正常 | 向日葵 02 瓣 |
| S1 | 2150 | 饱和度 1.00 基准 | BGM 正常 | smile | 正常 | 36 瓣 |
| S2 | 5180 | 饱和度 1.15 | playbackRate 0.97 | 眨眼间隔 2s→5s | 偶发标点重复(每 80 句 1 次) | 7–9 瓣 |
| S3 | 8199 | 饱和度 1.30 + 轻晕影 | playbackRate 0.92 + 低通 8kHz | flat 档出现;静止 NPC 面朝玩家 | 每 50 句 1 个同音错字 | 10–12 瓣,花瓣过亮 |
| S4 | 100 | 饱和度 1.40 + 晕影加深 + 随机单帧负片(概率 0.4 次/分钟,时长 1 帧) | playbackRate 0.84 + 低通 4kHz + 鸟鸣静音 | off 档闪现(0.6 次/分钟,0.1 秒);群演 1 只停摆 | 错字加密 + 咪咪句尾标点 ×3 | 花心黑籽显现 |
NPC 个体偏移:表中阈值对每个 NPC 加上其 §4 的偏移量(咪咪 −10 最早崩,圆圆 +15 最晚崩,芙医生与龟爷爷不参与)。
### 6.5 文本腐蚀
同音错字对照表(全 10 组,逐字实现):幸福→辛福 / 开心→开欣 / 美好→美妤 / 大家→大伽 / 微笑→微效 / 明天→名天 / 甜→恬 / 阳光→央光 / 朋友→棚友 / 永远→泳远。腐蚀在对话渲染层实施(原文案保持干净,运行时按阶段替换),优先命中咪咪的台词。
### 6.6 BGM 与音效
- 用 Web Audio 程序生成一段 8 小节的 8-bit 风格治愈循环旋律(方波主旋律 + 三角波低音),无外部音频文件。
- 侵蚀联动:playbackRate 与低通滤波按 §6.4 矩阵取值;S4 时鸟鸣声部静音但双子鸟动画仍在张嘴。
- 音效仅 4 个:对话哔哔声、任务完成叮声、黄昏钟声、E2 结局的单一心电监护滴声。全部程序合成。
- 「突然的安静」:Day 6 保持微笑 60 秒任务的第 40–60 秒,所有声音渐隐至零。
## 7. 任务清单(封闭,15 个)
| ID | 日 | 任务 | 幸福奖励 | 备注 |
|---|---|---|---|---|
| T1 | D1 | 从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷 | +15 | 教学 |
| T2 | D1 | 给广场花坛浇水 ×3 | +10 | |
| T3 | D1 | 与任意三位村民问好 | +10 | |
| T4 | D2 | 替大宝送信 ×3(收件人:圆圆、咪咪、芙医生) | +15 | 第 3 封信署名乱码 0.2 秒(C4) |
| T5 | D2 | 帮咪咪清点货架 | +10 | 货架上同一商品重复 9 格(C5 可发现) |
| T6 | D3 | 去花田采莓果 ×5 | +10 | |
| T7 | D3 | 湖畔野餐(走到野餐布触发过场) | +15 | 湖面倒影演出 |
| T8T10 | D4 | 与 T1T3 逐字相同 | 同上 | 刻意恐怖谷 |
| T11 | D5 | 帮圆圆揉面(可拒绝) | +10 / 拒绝记录 | |
| T12 | D5 | 擦拭公告板(可拒绝) | +10 / 拒绝记录 | 擦到一半浮现旧字迹(C6) |
| T13 | D6 | 在广场挂彩带 ×4 | +10 | |
| T14 | D6 | 保持微笑 60 秒(原地站立,镜头缓慢拉近,全村注视) | +15 | 40 秒起声音渐隐 |
| T15 | D7 | 参加第 413 届丰收庆典 | — | 结局分歧点 |
## 8. 线索清单(封闭,9 处,每处觉知 +8)
| ID | 位置/来源 | 内容 | 可发现时段 |
|---|---|---|---|
| C1 | 小屋床头日历 | 每一页都是同一个日期 | D2 起 |
| C2 | 面包房配方纸背面 | 手写小字:「按疗程配比。甜度:最大。」 | D3 起 |
| C3 | 邮筒旁散落信件 | 所有邮戳是同一天 | D2 起 |
| C4 | T4 第 3 封信 | 署名闪过乱码「PT-0▓」 | D2 当日 |
| C5 | 杂货店货架 | 同一商品重复排列,标签全部空白 | D2 起 |
| C6 | 公告板旧字迹 | 「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」 | D5T12 中) |
| C7 | 古井 | 井底回声:极轻的监护仪滴声(靠近并交互 5 秒) | D5 起 |
| C8 | 诊所 | 芙医生桌上的表格抬头被手肘挡住,只露出「…谷情绪疗…」 | D5 起 |
| C9 | 龟爷爷对话链 | 三段递进:「湖水一直很平静」→「面包一直是那个味道」→「孩子,今天是第几个今天?」 | D2/D4/D6 各一段 |
## 9. 结局(封闭,4 个)
Day 7 庆典按以下优先级判定(自上而下第一个满足者生效):
| 结局 | 条件 | 演出 |
|---|---|---|
| E4 静默 | Day 5–7 幸福持续 ≤10 且累计逃离欢乐庆典 ×3 | 庆典无人出席。空广场,彩带在没有风的空气里飘。字幕:「系统检测到疗程无效。」黑屏 |
| E3 苏醒 | 觉知 ≥70 且线索 ≥6/9,庆典中走向雾墙 | 雾墙裂开成白色走廊 → 病房 → 病历特写(蜜糖谷情绪疗养中心 / 姓名:玩家输入名 / 编号 PT-07 / 疗程时长:真实游玩时长)→ 最后一帧:主角头像的微笑消失,变成一张疲惫而真实的脸。窗外,一只灰色的、真的麻雀 |
| E2 回收 | 觉知 30–69 | 庆典高潮中画面骤停,负片 2 秒,心电滴声一响。回到 Day 1 清晨的床上,日历翻回同一天。字幕:「疗程重新开始。」 |
| E1 糖衣 | 觉知 ≤29 且幸福 ≥90 | 庆典圆满。全村微笑合影定格,饱和度缓慢推到 1.5。字幕:「从此,每一天都很幸福。」「每一天。」 |
任一结局落幕后停在结束画面,提供「回到标题」按钮(重开即从 Day 1 全新开始,无继承)。
## 10. 场景与地图清单(封闭,10 个,Tiled 制作导出 JSON
| 场景 | 尺寸(瓦片) | 要点 |
|---|---|---|
| 小满小屋(室内) | 12×8 | 床(睡觉触发过日)、床头日历(C1) |
| 村广场 | 30×20 | 公告板、花坛、双子鸟树、庆典场地;连接所有场景的枢纽 |
| 面包房(室内) | 15×10 | 柜台、烤炉、配方纸(C2) |
| 杂货店(室内) | 15×10 | 货架(C5 |
| 诊所(室内) | 12×8 | 芙医生、桌上表格(C8) |
| 湖畔 | 24×16 | 长椅(龟爷爷)、野餐布、湖面倒影演出 |
| 花田 | 20×14 | 莓果丛(T6)、浇花点 |
| 古井空地 | 14×10 | 古井(C7),D5 前交互被 NPC 温柔劝离 |
| 雾墙边界 | 20×8 | 村庄尽头的雾,D5 前不可接近;E3 入口 |
| 白色层 | 20×6 + 10×8 | 走廊 + 病房,仅梦境闪切与 E3 使用;去饱和白灰色板 |
统一结构:`ground` / `obstacles``collides=true`/ `objects``spawn`、NPC 点位、`door`、交互点)。所有室外场景经村广场互通,门与出入口在两侧地图成对出现。
## 11. 美术规范(Aseprite 制作,全部原创)
- 瓦片 16×16,总 tileset ≥ 64 瓦片:草地、石板路、湖水、花丛、莓果丛、树、房屋墙/顶/门、室内地板、家具(床/柜台/烤炉/货架/桌椅)、井、雾、白色层的墙地。
- 主角:16×24/帧,4 朝向 × 4 帧行走(`walk-down/up/left/right`),每朝向第 1 帧兼待机;另有一张对话头像(微笑)与 E3 专用「真实的脸」头像,各 1 帧。
- 具名 NPC ×7:各 16×24 立绘 3 档表情(smile/flat/off);`off` 档与 `smile` 档的差异控制在 1–2 个像素(一只眼睛低 1 像素、嘴角多 1 像素),不许画成鬼脸。双子鸟另配 2 帧张嘴动画。群演 ×5:各 1–2 帧循环。
- 色板:村庄用糖果色(草绿/奶油黄/蜜糖橙/天空蓝);白色层独立灰白色板。无抗锯齿、无描边渐变。
- 禁止使用任何现成素材、素材包或开源 tileset。
## 12. 技术架构
- **Phaser 3 + TypeScript + Vite**`npm run build` 产出纯静态 `dist/``npm run preview` 本地游玩。
- 逻辑分辨率 320×180,整数倍放大居中,`pixelArt: true`,禁用纹理平滑。
- 场景:`BootScene`(加载)、每张地图一个 Scene、常驻 `UIScene`(对话框、向日葵表盘、提示)、`EndingScene`
- `CorruptionDirector`:普通 TS 单例 + 事件发射器,持有幸福/觉知/日期/任务标记/线索集合/侵蚀阶段,是全部状态与视听参数的唯一真源;饱和度与晕影用 Phaser 相机 postFXColorMatrix / Vignette),负片闪帧用 ColorMatrix negative 单帧切换。
- BGM 与音效按 §6.6 用 Web Audio 程序合成,playbackRate 与 BiquadFilter 低通由 CorruptionDirector 驱动。
- 移动速度 96 像素/秒;相机跟随并钳制地图边界;靠近可交互对象 ≤24 像素出「按 E」提示。
- 对话系统:底部通栏对话框(说话者名 + 头像 + 文本,E 推进),支持选项(温柔/迟疑/否定三选,影响数值);文本渲染层实施 §6.5 腐蚀替换。
- 无 localStorage、无外部网络请求、无外部音频/字体文件。
## 13. Debug 面板(服务验收,必须实现)
按反引号键 `` ` `` 开关。功能:直接设置幸福值、觉知值、当前日(1–7)、传送到任意场景、置齐全部线索、强制触发四个结局之一。面板不影响正常游玩(默认关闭,无 UI 痕迹)。
## 14. 验收清单(共 20 条)
1. 打开页面进入标题画面,输入名字(留空默认小满)后进入 Day 1 小屋。
2. WASD/方向键移动、四向动画、待机帧、碰撞、相机钳制全部正确。
3. 10 个场景全部可达,门与出入口双向正确,NPC 站位符合 §4。
4. 向日葵表盘随幸福值点亮花瓣,满格后花心出现黑籽;全程不显示任何数字。
5. Day 1–7 每日结构完整:公告→任务→黄昏→睡觉→梦境过场→次日;阿福公告与 §5 表逐字一致。
6. 15 个任务全部可完成,奖励数值与 §7 一致;D4 任务与台词与 D1 逐字相同。
7. 悲伤行为 5 项均按 §6.3 生效(数值变化 + 最近 NPC 转头)。
8. 幸福 <30 持续 2 分钟触发欢乐庆典强制事件,可逃离且逃离被计数。
9. 侵蚀矩阵:用 Debug 面板分别置幸福 10/40/70/90/100,画面饱和度、NPC 表情档、文本腐蚀密度、BGM 变化肉眼/肉耳可辨且与 §6.4 一致。
10. 错字替换与 §6.5 对照表一致,且优先出现在咪咪台词中。
11. 9 处线索全部可发现,各 +8 觉知;C9 三段按 D2/D4/D6 递进。
12. 古井与雾墙在 D5 前不可交互(被劝离),D5 起可交互。
13. Day 6 的 T14:站立 60 秒、镜头缓慢拉近、全村注视、40 秒起声音渐隐至零。
14. Day 6 傍晚脚本雨出现,NPC 台词对雨零反应;站雨中 ≥10 秒计悲伤行为。
15. 四结局用 Debug 面板分别强制触发,演出与 §9 一致;E3 病历显示输入的名字与真实游玩时长,主角头像换为「真实的脸」。
16. 自然游玩(不开 Debug)可在 25–35 分钟内到达任一结局。
17. BGM 为程序合成循环,随阶段变调变闷;S4 鸟鸣静音而双子鸟仍张嘴;T14 的突然安静生效。
18. 任意窗口尺寸整数倍缩放,像素锐利无接缝;全程控制台零报错。
19. 全部素材与地图为本项目内自制(README 说明制作方式),无外部资产。
20. README.md:启动方式、按键、Debug 面板说明、素材与地图制作流程。
## 15. 明确不做
战斗、背包/合成 UI(关键道具仅剧情标记)、存档与读档、成就、多人、移动端触控、配音、真实音频文件、二周目专属内容、天气系统(雨仅为 Day 6 一次脚本演出)。
+104
View File
@@ -0,0 +1,104 @@
# 崩坏童话:蜜糖村
纯单机网页游戏(Phaser 3 + TypeScript + Vite)。无存档、无后端、无 localStorage;浏览器打开即玩。完整流程约 25–35 分钟,含 4 个结局。
## 启动方式
```bash
npm install
npm run dev # 开发
npm run build # 产出静态 dist/
npm run preview # 本地游玩构建结果
npm test # 单元测试(PRD 数值/台词/矩阵)
```
请用 `npm run dev``npm run preview` 访问,不要直接用 `file://` 打开(ESM 模块加载需要 HTTP)。
## 按键
| 按键 | 作用 |
|------|------|
| WASD / 方向键 | 四向移动 |
| E / 空格 | 交互、推进对话 |
| 1 / 2 / 3 | 对话选项(温柔 / 迟疑 / 否定) |
| Enter | 确认名字;结局「回到标题」 |
| \` (反引号) | 开关 Debug 面板 |
靠近可交互物(≤24 像素)会出现「按 E」提示。
## 游玩流程
1. 标题画面 → 输入名字(留空默认为「小满」)→ Day 1 小屋
2. 每日:晨起 → 广场公告板接任务 → 完成任务 → 黄昏钟声 → 回屋睡觉 → 梦境 → 次日
3. Day 7 参加「第 413 届丰收庆典」,按幸福 / 觉知 / 线索走向 E1–E4 之一
幸福值**不显示数字**:左上角向日葵 12 瓣按值点亮,满格后花心出现黑籽。
## Debug 面板(默认关闭)
按 \` 打开。仅服务验收,正常游玩无 UI 痕迹。
| 键 | 功能 |
|----|------|
| H / J | 幸福 ±10 |
| K / L | 觉知 ±10L 仅 Debug 可下调) |
| Shift+17 | 设置当前日(用 `code` Digit17,兼容浏览器 Shift 改写 `key` 为 !@# |
| C | 置齐全部线索 |
| 0–9 | 传送到 10 个场景(0小屋 1广场 2面包房 3杂货店 4诊所 5湖畔 6花田 7古井 8雾墙 9白色层) |
| V | 循环传送下一场景 |
| F1F4 | 强制结局 E1E4 |
| N | 负片闪帧 |
| P | 触发欢乐庆典 |
## 素材与地图制作流程(全部原创)
本项目**禁止**使用任何现成素材包。像素图与地图均由仓库内脚本 + 本机工具生成:
### Aseprite
本机路径示例:`/Applications/Aseprite.app/Contents/MacOS/aseprite`
```bash
export PATH="/Applications/Aseprite.app/Contents/MacOS:$PATH"
aseprite -b -script scripts/generate_tileset.lua # → public/assets/tilesets/village.png128 瓦 16×16
aseprite -b -script scripts/generate_sprites.lua # → player / npcs / birds / extras / portraits / sunflower
```
- 瓦片:草地、石板路、湖水、花丛、莓果、树、房屋、室内家具、井、雾、白色层等
- 主角:16×24,4 朝向 × 4 帧行走;对话微笑头像 + E3「真实的脸」
- NPC ×7:各 3 档表情 smile / flat / offoff 仅 12 像素差异)
- 双子鸟 2 帧张嘴;群演 ×5
源文件同时保存 `.aseprite` 与导出 `.png`
### Tiled Map Editor
本机路径示例:`/Applications/Tiled.app/Contents/MacOS/tiled`
```bash
node scripts/generate-maps.mjs
# 可选:用 Tiled CLI 再导出校验
tiled --export-map json mapsrc/house.tmx public/assets/maps/house_tiled_export.json
```
10 张地图(§10):小屋、村广场、面包房、杂货店、诊所、湖畔、花田、古井空地、雾墙边界、白色层。
结构层:`ground` / `obstacles`(碰撞)/ `objects`spawn、NPC、door、交互点)。
源 TMX 在 `mapsrc/`,运行时 JSON 在 `public/assets/maps/`
### 一键重生成
```bash
npm run assets # 若已配置 scripts/generate-assets.sh
node scripts/generate-maps.mjs
```
## 技术要点
- 逻辑分辨率 320×180,整数倍放大,`pixelArt: true`,无纹理平滑
- `CorruptionDirector`:幸福 / 觉知 / 日 / 任务 / 线索 / 侵蚀阶段唯一真源
- BGM 与 4 个音效均为 Web Audio 程序合成,无外部音频文件
- 无 localStorage、无外部网络资源 / 字体
## 验收
PRD §14 共 20 条。单元测试覆盖台词、任务奖励、侵蚀矩阵、错字表、结局优先级、悲伤数值等。
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>崩坏童话:蜜糖村</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #1a1410;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
#game-container {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
</style>
</head>
<body>
<div id="game-container"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="15" height="10" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="7">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="15" height="10">
<data encoding="csv">
17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17
</data>
</layer>
<layer id="2" name="obstacles" width="15" height="10">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,20,20,20,20,20,0,0,0,43,0,21,0,13,13,0,20,20,20,20,20,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,23,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,13,13,13,13,13,0,0,13,13,13,13,13,13,13
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="112" y="112" width="16" height="16"/>
<object id="2" name="yuanyuan" type="npc" x="64" y="64" width="16" height="16"/>
<object id="3" name="to_plaza" type="door" x="112" y="144" width="16" height="16"/>
<object id="4" name="recipe" type="interact" x="160" y="32" width="16" height="16"/>
<object id="5" name="counter" type="interact" x="64" y="48" width="16" height="16"/>
<object id="6" name="knead" type="interact" x="48" y="48" width="16" height="16"/>
</objectgroup>
</map>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="12" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="5">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="12" height="8">
<data encoding="csv">
18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18
</data>
</layer>
<layer id="2" name="obstacles" width="12" height="8">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
13,13,13,13,13,13,13,13,13,13,13,13,13,0,0,0,0,0,0,0,0,0,0,13,13,0,0,23,44,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,19,0,0,13,13,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,13,13,13,13,13,13,0,0,13,13,13,13,13
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="96" y="80" width="16" height="16"/>
<object id="2" name="fu" type="npc" x="48" y="48" width="16" height="16"/>
<object id="3" name="to_plaza" type="door" x="96" y="112" width="16" height="16"/>
<object id="4" name="form" type="interact" x="64" y="32" width="16" height="16"/>
</objectgroup>
</map>
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="20" height="14" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="10">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="20" height="14">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2
</data>
</layer>
<layer id="2" name="obstacles" width="20" height="14">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,33,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,9,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="160" y="176" width="16" height="16"/>
<object id="2" name="extra2" type="npc" x="96" y="128" width="16" height="16"/>
<object id="3" name="to_plaza" type="door" x="160" y="208" width="16" height="16"/>
<object id="4" name="berry1" type="interact" x="48" y="48" width="16" height="16"/>
<object id="5" name="berry2" type="interact" x="80" y="64" width="16" height="16"/>
<object id="6" name="berry3" type="interact" x="112" y="48" width="16" height="16"/>
<object id="7" name="berry4" type="interact" x="192" y="80" width="16" height="16"/>
<object id="8" name="berry5" type="interact" x="224" y="48" width="16" height="16"/>
<object id="9" name="pot" type="interact" x="160" y="32" width="16" height="16"/>
</objectgroup>
</map>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="20" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="4">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="20" height="8">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2
</data>
</layer>
<layer id="2" name="obstacles" width="20" height="8">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26,0,0,0,0,0,0,0,0,0,0,0,0,26,26,26,26,26,26,26,26
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="48" y="64" width="16" height="16"/>
<object id="2" name="to_plaza" type="door" x="0" y="64" width="16" height="16"/>
<object id="3" name="fogwall" type="interact" x="192" y="64" width="16" height="16"/>
</objectgroup>
</map>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="12" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="6">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="12" height="8">
<data encoding="csv">
17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,18,18,17,17,17,17,17
</data>
</layer>
<layer id="2" name="obstacles" width="12" height="8">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
12,12,12,12,12,12,12,12,12,12,12,12,12,0,0,0,0,0,0,0,0,0,0,12,12,0,19,19,0,0,0,0,0,29,0,12,12,0,0,0,0,0,0,0,0,0,0,12,12,0,0,0,0,23,0,0,0,0,0,12,12,24,0,0,0,0,0,0,0,0,0,12,12,0,0,0,0,0,0,0,0,0,0,12,12,12,12,12,12,0,0,12,12,12,12,12
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="96" y="80" width="16" height="16"/>
<object id="2" name="to_plaza" type="door" x="80" y="112" width="16" height="16"/>
<object id="3" name="to_plaza2" type="door" x="96" y="112" width="16" height="16"/>
<object id="4" name="bed" type="interact" x="32" y="32" width="16" height="16"/>
<object id="5" name="calendar" type="interact" x="144" y="32" width="16" height="16"/>
</objectgroup>
</map>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="24" height="16" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="7">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="24" height="16">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,2,2,2,2,2,2,2,2,2,2,2,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6
</data>
</layer>
<layer id="2" name="obstacles" width="24" height="16">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,24,24,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,24,24,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="176" y="32" width="16" height="16"/>
<object id="2" name="gui" type="npc" x="144" y="80" width="16" height="16"/>
<object id="3" name="extra3" type="npc" x="288" y="64" width="16" height="16"/>
<object id="4" name="to_plaza" type="door" x="176" y="0" width="16" height="16"/>
<object id="5" name="picnic" type="interact" x="192" y="96" width="16" height="16"/>
<object id="6" name="bench" type="interact" x="144" y="64" width="16" height="16"/>
</objectgroup>
</map>
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="30" height="20" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="30">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="30" height="20">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2
</data>
</layer>
<layer id="2" name="obstacles" width="30" height="20">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14,14,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,11,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,30,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,31,0,0,0,0,0,0,0,34,0,0,0,0,34,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,34,0,0,0,0,34,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="240" y="192" width="16" height="16"/>
<object id="100" name="from_house" type="spawn" x="96" y="80" width="16" height="16"/>
<object id="101" name="from_bakery" type="spawn" x="384" y="144" width="16" height="16"/>
<object id="102" name="from_shop" type="spawn" x="48" y="144" width="16" height="16"/>
<object id="103" name="from_clinic" type="spawn" x="64" y="224" width="16" height="16"/>
<object id="104" name="from_lake" type="spawn" x="240" y="272" width="16" height="16"/>
<object id="105" name="from_field" type="spawn" x="240" y="32" width="16" height="16"/>
<object id="106" name="from_well" type="spawn" x="432" y="160" width="16" height="16"/>
<object id="107" name="from_fog" type="spawn" x="32" y="256" width="16" height="16"/>
<object id="10" name="afu" type="npc" x="240" y="96" width="16" height="16"/>
<object id="11" name="dabao" type="npc" x="80" y="176" width="16" height="16"/>
<object id="12" name="twinbirds" type="npc" x="320" y="48" width="16" height="16"/>
<object id="13" name="extra1" type="npc" x="288" y="224" width="16" height="16"/>
<object id="14" name="extra4" type="npc" x="160" y="192" width="16" height="16"/>
<object id="15" name="house" type="door" x="96" y="48" width="16" height="16"/>
<object id="16" name="bakery" type="door" x="400" y="144" width="16" height="16"/>
<object id="17" name="shop" type="door" x="32" y="144" width="16" height="16"/>
<object id="18" name="clinic" type="door" x="64" y="240" width="16" height="16"/>
<object id="19" name="lake" type="door" x="240" y="304" width="16" height="16"/>
<object id="20" name="field" type="door" x="240" y="0" width="16" height="16"/>
<object id="21" name="well" type="door" x="464" y="160" width="16" height="16"/>
<object id="22" name="fog" type="door" x="0" y="256" width="16" height="16"/>
<object id="23" name="board" type="interact" x="240" y="80" width="16" height="16"/>
<object id="24" name="flowers" type="interact" x="176" y="96" width="16" height="16"/>
<object id="25" name="mailbox" type="interact" x="64" y="160" width="16" height="16"/>
<object id="26" name="ribbon1" type="interact" x="192" y="160" width="16" height="16"/>
<object id="27" name="ribbon2" type="interact" x="272" y="160" width="16" height="16"/>
<object id="28" name="ribbon3" type="interact" x="192" y="208" width="16" height="16"/>
<object id="29" name="ribbon4" type="interact" x="272" y="208" width="16" height="16"/>
</objectgroup>
</map>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="15" height="10" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="5">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="15" height="10">
<data encoding="csv">
18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18,18
</data>
</layer>
<layer id="2" name="obstacles" width="15" height="10">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,22,22,22,22,22,22,22,22,22,22,22,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,22,22,22,22,22,22,22,22,22,22,22,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,13,13,13,13,13,13,0,0,13,13,13,13,13,13
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="112" y="112" width="16" height="16"/>
<object id="2" name="mimi" type="npc" x="112" y="80" width="16" height="16"/>
<object id="3" name="to_plaza" type="door" x="112" y="144" width="16" height="16"/>
<object id="4" name="shelf" type="interact" x="80" y="32" width="16" height="16"/>
</objectgroup>
</map>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.2" name="village" tilewidth="16" tileheight="16" tilecount="128" columns="16">
<image source="../public/assets/tilesets/village.png" width="256" height="128"/>
</tileset>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="14" height="10" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="5">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="14" height="10">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2
</data>
</layer>
<layer id="2" name="obstacles" width="14" height="10">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,25,25,0,0,0,0,0,0,0,0,0,0,0,0,25,25,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="32" y="80" width="16" height="16"/>
<object id="2" name="extra5" type="npc" x="160" y="112" width="16" height="16"/>
<object id="3" name="to_plaza" type="door" x="0" y="80" width="16" height="16"/>
<object id="4" name="well" type="interact" x="112" y="64" width="16" height="16"/>
</objectgroup>
</map>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="20" height="14" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="5">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="20" height="14">
<data encoding="csv">
28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28
</data>
</layer>
<layer id="2" name="obstacles" width="20" height="14">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,27,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,27,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,27,27,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,27,27,27,27,27,27,27,27,27,27,27,0,27,27,27,27,27,27,27,27,27,0,0,0,0,0,27,27,27,27,27,27,27,27,27,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,0,0,44,0,0,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,19,0,0,0,0,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,27,0,0,0,0,0,0,0,0,0,0,27,27,27,27,27,27,27,27,27,27,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="objects">
<object id="1" name="spawn" type="spawn" x="32" y="48" width="16" height="16"/>
<object id="2" name="record" type="interact" x="176" y="128" width="16" height="16"/>
<object id="3" name="to_ward" type="door" x="160" y="80" width="16" height="16"/>
<object id="200" name="ward" type="spawn" x="160" y="128" width="16" height="16"/>
</objectgroup>
</map>
+1724
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "honey-village",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"assets": "bash scripts/generate-assets.sh",
"maps": "bash scripts/generate-maps.sh"
},
"devDependencies": {
"@types/node": "^26.1.1",
"playwright": "^1.61.1",
"typescript": "~5.7.2",
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
"dependencies": {
"phaser": "^3.87.0"
}
}
+652
View File
@@ -0,0 +1,652 @@
{
"compressionlevel": -1,
"height": 10,
"width": 15,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 15,
"height": 10,
"data": [
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 15,
"height": 10,
"data": [
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
20,
20,
20,
20,
20,
0,
0,
0,
43,
0,
21,
0,
13,
13,
0,
20,
20,
20,
20,
20,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
23,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
13,
13,
13,
13,
13,
0,
0,
13,
13,
13,
13,
13,
13,
13
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 112,
"y": 112,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "yuanyuan",
"type": "npc",
"x": 64,
"y": 64,
"width": 16,
"height": 16,
"properties": [
{
"name": "npcId",
"type": "string",
"value": "yuanyuan"
}
]
},
{
"id": 3,
"name": "to_plaza",
"type": "door",
"x": 112,
"y": 144,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_bakery"
}
]
},
{
"id": 4,
"name": "recipe",
"type": "interact",
"x": 160,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "recipe"
}
]
},
{
"id": 5,
"name": "counter",
"type": "interact",
"x": 64,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "bread"
}
]
},
{
"id": 6,
"name": "knead",
"type": "interact",
"x": 48,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "knead"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 7,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+512
View File
@@ -0,0 +1,512 @@
{
"compressionlevel": -1,
"height": 8,
"width": 12,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 12,
"height": 8,
"data": [
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 12,
"height": 8,
"data": [
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
23,
44,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
19,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
13,
13,
13,
13,
0,
0,
13,
13,
13,
13,
13
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 96,
"y": 80,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "fu",
"type": "npc",
"x": 48,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "npcId",
"type": "string",
"value": "fu"
}
]
},
{
"id": 3,
"name": "to_plaza",
"type": "door",
"x": 96,
"y": 112,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_clinic"
}
]
},
{
"id": 4,
"name": "form",
"type": "interact",
"x": 64,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "form"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 5,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+960
View File
@@ -0,0 +1,960 @@
{
"compressionlevel": -1,
"height": 14,
"width": 20,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 20,
"height": 14,
"data": [
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 20,
"height": 14,
"data": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
33,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
9,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 160,
"y": 176,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "extra2",
"type": "npc",
"x": 96,
"y": 128,
"width": 16,
"height": 16,
"properties": [
{
"name": "npcId",
"type": "string",
"value": "extra2"
}
]
},
{
"id": 3,
"name": "to_plaza",
"type": "door",
"x": 160,
"y": 208,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_field"
}
]
},
{
"id": 4,
"name": "berry1",
"type": "interact",
"x": 48,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "berry"
}
]
},
{
"id": 5,
"name": "berry2",
"type": "interact",
"x": 80,
"y": 64,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "berry"
}
]
},
{
"id": 6,
"name": "berry3",
"type": "interact",
"x": 112,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "berry"
}
]
},
{
"id": 7,
"name": "berry4",
"type": "interact",
"x": 192,
"y": 80,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "berry"
}
]
},
{
"id": 8,
"name": "berry5",
"type": "interact",
"x": 224,
"y": 48,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "berry"
}
]
},
{
"id": 9,
"name": "pot",
"type": "interact",
"x": 160,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "pot"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 10,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+624
View File
@@ -0,0 +1,624 @@
{
"compressionlevel": -1,
"height": 8,
"width": 20,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 20,
"height": 8,
"data": [
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
4,
4,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
4,
4,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 20,
"height": 8,
"data": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
26,
26,
26,
26,
26,
26,
26,
26
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 48,
"y": 64,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "to_plaza",
"type": "door",
"x": 0,
"y": 64,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_fog"
}
]
},
{
"id": 3,
"name": "fogwall",
"type": "interact",
"x": 192,
"y": 64,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "fogwall"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 4,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+533
View File
@@ -0,0 +1,533 @@
{
"compressionlevel": -1,
"height": 8,
"width": 12,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 12,
"height": 8,
"data": [
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
17,
18,
18,
17,
17,
17,
17,
17
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 12,
"height": 8,
"data": [
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
12,
12,
0,
19,
19,
0,
0,
0,
0,
0,
29,
0,
12,
12,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
12,
12,
0,
0,
0,
0,
23,
0,
0,
0,
0,
0,
12,
12,
24,
0,
0,
0,
0,
0,
0,
0,
0,
0,
12,
12,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
12,
12,
12,
12,
12,
12,
0,
0,
12,
12,
12,
12,
12
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 96,
"y": 80,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "to_plaza",
"type": "door",
"x": 80,
"y": 112,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_house"
}
]
},
{
"id": 3,
"name": "to_plaza2",
"type": "door",
"x": 96,
"y": 112,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_house"
}
]
},
{
"id": 4,
"name": "bed",
"type": "interact",
"x": 32,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "bed"
}
]
},
{
"id": 5,
"name": "calendar",
"type": "interact",
"x": 144,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "calendar"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 6,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
@@ -0,0 +1,135 @@
{ "compressionlevel":-1,
"height":8,
"infinite":false,
"layers":[
{
"data":[17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17,
17, 17, 17, 17, 17, 18, 18, 17, 17, 17, 17, 17],
"height":8,
"id":1,
"name":"ground",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":12,
"x":0,
"y":0
},
{
"data":[12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,
12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12,
12, 0, 19, 19, 0, 0, 0, 0, 0, 29, 0, 12,
12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12,
12, 0, 0, 0, 0, 23, 0, 0, 0, 0, 0, 12,
12, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12,
12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12,
12, 12, 12, 12, 12, 1, 1, 12, 12, 12, 12, 12],
"height":8,
"id":2,
"name":"obstacles",
"opacity":1,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}],
"type":"tilelayer",
"visible":true,
"width":12,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":3,
"name":"objects",
"objects":[
{
"height":16,
"id":1,
"name":"spawn",
"opacity":1,
"rotation":0,
"type":"spawn",
"visible":true,
"width":16,
"x":96,
"y":80
},
{
"height":16,
"id":2,
"name":"to_plaza",
"opacity":1,
"rotation":0,
"type":"door",
"visible":true,
"width":16,
"x":80,
"y":112
},
{
"height":16,
"id":3,
"name":"to_plaza2",
"opacity":1,
"rotation":0,
"type":"door",
"visible":true,
"width":16,
"x":96,
"y":112
},
{
"height":16,
"id":4,
"name":"bed",
"opacity":1,
"rotation":0,
"type":"interact",
"visible":true,
"width":16,
"x":32,
"y":32
},
{
"height":16,
"id":5,
"name":"calendar",
"opacity":1,
"rotation":0,
"type":"interact",
"visible":true,
"width":16,
"x":144,
"y":32
}],
"opacity":1,
"type":"objectgroup",
"visible":true,
"x":0,
"y":0
}],
"nextlayerid":4,
"nextobjectid":6,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.12.2",
"tileheight":16,
"tilesets":[
{
"firstgid":1,
"source":"..\/..\/..\/mapsrc\/village.tsx"
}],
"tilewidth":16,
"type":"map",
"version":"1.10",
"width":12
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+620
View File
@@ -0,0 +1,620 @@
{
"compressionlevel": -1,
"height": 10,
"width": 15,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 15,
"height": 10,
"data": [
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18,
18
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 15,
"height": 10,
"data": [
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
22,
22,
22,
22,
22,
22,
22,
22,
22,
22,
22,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
22,
22,
22,
22,
22,
22,
22,
22,
22,
22,
22,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
13,
13,
13,
13,
13,
13,
13,
13,
0,
0,
13,
13,
13,
13,
13,
13
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 112,
"y": 112,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "mimi",
"type": "npc",
"x": 112,
"y": 80,
"width": 16,
"height": 16,
"properties": [
{
"name": "npcId",
"type": "string",
"value": "mimi"
}
]
},
{
"id": 3,
"name": "to_plaza",
"type": "door",
"x": 112,
"y": 144,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_shop"
}
]
},
{
"id": 4,
"name": "shelf",
"type": "interact",
"x": 80,
"y": 32,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "shelf"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 5,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+600
View File
@@ -0,0 +1,600 @@
{
"compressionlevel": -1,
"height": 10,
"width": 14,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 14,
"height": 10,
"data": [
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
4,
4,
4,
4,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 14,
"height": 10,
"data": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
25,
25,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
25,
25,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 32,
"y": 80,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "extra5",
"type": "npc",
"x": 160,
"y": 112,
"width": 16,
"height": 16,
"properties": [
{
"name": "npcId",
"type": "string",
"value": "extra5"
}
]
},
{
"id": 3,
"name": "to_plaza",
"type": "door",
"x": 0,
"y": 80,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "PlazaScene"
},
{
"name": "spawn",
"type": "string",
"value": "from_well"
}
]
},
{
"id": 4,
"name": "well",
"type": "interact",
"x": 112,
"y": 64,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "well"
}
]
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 5,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
+873
View File
@@ -0,0 +1,873 @@
{
"compressionlevel": -1,
"height": 14,
"width": 20,
"infinite": false,
"layers": [
{
"id": 1,
"name": "ground",
"type": "tilelayer",
"width": 20,
"height": 14,
"data": [
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28,
28
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
},
{
"id": 2,
"name": "obstacles",
"type": "tilelayer",
"width": 20,
"height": 14,
"data": [
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
0,
27,
27,
27,
27,
27,
27,
27,
27,
27,
0,
0,
0,
0,
0,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
44,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
19,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
27,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
27,
27,
27,
27,
27,
27,
27,
27,
27,
27,
0,
0,
0,
0,
0
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 3,
"name": "objects",
"type": "objectgroup",
"objects": [
{
"id": 1,
"name": "spawn",
"type": "spawn",
"x": 32,
"y": 48,
"width": 16,
"height": 16
},
{
"id": 2,
"name": "record",
"type": "interact",
"x": 176,
"y": 128,
"width": 16,
"height": 16,
"properties": [
{
"name": "interactId",
"type": "string",
"value": "record"
}
]
},
{
"id": 3,
"name": "to_ward",
"type": "door",
"x": 160,
"y": 80,
"width": 16,
"height": 16,
"properties": [
{
"name": "target",
"type": "string",
"value": "WhiteScene"
},
{
"name": "spawn",
"type": "string",
"value": "ward"
}
]
},
{
"id": 200,
"name": "ward",
"type": "spawn",
"x": 160,
"y": 128,
"width": 16,
"height": 16
}
],
"opacity": 1,
"visible": true,
"x": 0,
"y": 0
}
],
"nextlayerid": 4,
"nextobjectid": 5,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.12.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.10",
"tilesets": [
{
"firstgid": 1,
"columns": 16,
"image": "../tilesets/village.png",
"imagewidth": 256,
"imageheight": 128,
"tilecount": 128,
"tilewidth": 16,
"tileheight": 16,
"name": "village",
"margin": 0,
"spacing": 0,
"tiles": [
{
"id": 9,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 10,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 11,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 12,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 13,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 14,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 15,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 19,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 20,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 21,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 22,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 24,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 26,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 29,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 30,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 35,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 41,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
},
{
"id": 46,
"properties": [
{
"name": "collides",
"type": "bool",
"value": true
}
]
}
]
}
]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export PATH="/Applications/Aseprite.app/Contents/MacOS:/Applications/Tiled.app/Contents/MacOS:${PATH}"
cd "$ROOT"
mkdir -p public/assets/{tilesets,sprites,maps} mapsrc
aseprite -b -script scripts/generate_tileset.lua
aseprite -b -script scripts/generate_sprites.lua
echo "Aseprite assets done."
+647
View File
@@ -0,0 +1,647 @@
/**
* Generate 10 Tiled-compatible JSON maps per PRD §10.
* Also writes .tmx for optional `tiled --export-map` round-trip.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, '../public/assets/maps');
const tmxDir = path.join(__dirname, '../mapsrc');
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(tmxDir, { recursive: true });
const TILE = 16;
const TILESET = {
firstgid: 1,
columns: 16,
image: '../tilesets/village.png',
imagewidth: 256,
imageheight: 128,
tilecount: 128,
tilewidth: 16,
tileheight: 16,
name: 'village',
margin: 0,
spacing: 0,
tiles: [
// collidable tiles
...[9, 10, 11, 12, 13, 14, 15, 19, 20, 21, 22, 24, 26, 29, 30, 35, 41, 46].map((id) => ({
id,
properties: [{ name: 'collides', type: 'bool', value: true }],
})),
],
};
function emptyGrid(w, h, fill = 0) {
return Array.from({ length: w * h }, () => fill);
}
function set(data, w, x, y, v) {
if (x < 0 || y < 0 || x >= w) return;
data[y * w + x] = v;
}
function fillRect(data, w, x0, y0, x1, y1, v) {
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) set(data, w, x, y, v);
}
}
function border(data, w, h, v) {
for (let x = 0; x < w; x++) {
set(data, w, x, 0, v);
set(data, w, x, h - 1, v);
}
for (let y = 0; y < h; y++) {
set(data, w, 0, y, v);
set(data, w, w - 1, y, v);
}
}
function makeMap(name, w, h, build) {
const ground = emptyGrid(w, h, 1); // grass gid 1 (tile index 0 is empty, firstgid=1 → tile 0 empty, tile1 grass)
// Our tileset: tile index in image 0 = empty transparent; index 1 = grass
// firstgid 1 means GID 1 = tile index 0. Let's use firstgid 1 where GID N = image tile (N-1)
// Simpler: firstgid=1, and we store image-tile-index+1 as GID.
// grass is image tile 1 → GID 2
for (let i = 0; i < ground.length; i++) ground[i] = 2; // grass
const obstacles = emptyGrid(w, h, 0);
const objects = [];
const api = {
w,
h,
ground,
obstacles,
objects,
// GID 0 = empty; image tile index N → GID N+1. Passing 0 clears (no collision).
setG: (x, y, imgTile) => set(ground, w, x, y, imgTile <= 0 ? 0 : imgTile + 1),
setO: (x, y, imgTile) => set(obstacles, w, x, y, imgTile <= 0 ? 0 : imgTile + 1),
clearO: (x, y) => set(obstacles, w, x, y, 0),
fillG: (x0, y0, x1, y1, imgTile) =>
fillRect(ground, w, x0, y0, x1, y1, imgTile <= 0 ? 0 : imgTile + 1),
fillO: (x0, y0, x1, y1, imgTile) =>
fillRect(obstacles, w, x0, y0, x1, y1, imgTile <= 0 ? 0 : imgTile + 1),
borderO: (imgTile) => {
const gid = imgTile <= 0 ? 0 : imgTile + 1;
for (let x = 0; x < w; x++) {
set(obstacles, w, x, 0, gid);
set(obstacles, w, x, h - 1, gid);
}
for (let y = 0; y < h; y++) {
set(obstacles, w, 0, y, gid);
set(obstacles, w, w - 1, y, gid);
}
},
spawn: (x, y) => {
objects.push({
id: objects.length + 1,
name: 'spawn',
type: 'spawn',
x: x * TILE,
y: y * TILE,
width: TILE,
height: TILE,
});
},
npc: (name, x, y) => {
objects.push({
id: objects.length + 1,
name,
type: 'npc',
x: x * TILE,
y: y * TILE,
width: TILE,
height: TILE,
properties: [{ name: 'npcId', type: 'string', value: name }],
});
},
door: (name, x, y, target, targetSpawn) => {
// Door tiles must be walkable (clear obstacle collision)
set(obstacles, w, x, y, 0);
objects.push({
id: objects.length + 1,
name,
type: 'door',
x: x * TILE,
y: y * TILE,
width: TILE,
height: TILE,
properties: [
{ name: 'target', type: 'string', value: target },
{ name: 'spawn', type: 'string', value: targetSpawn || 'spawn' },
],
});
},
interact: (name, x, y, interactId) => {
objects.push({
id: objects.length + 1,
name,
type: 'interact',
x: x * TILE,
y: y * TILE,
width: TILE,
height: TILE,
properties: [{ name: 'interactId', type: 'string', value: interactId }],
});
},
};
build(api);
const map = {
compressionlevel: -1,
height: h,
width: w,
infinite: false,
layers: [
{
id: 1,
name: 'ground',
type: 'tilelayer',
width: w,
height: h,
data: ground,
opacity: 1,
visible: true,
x: 0,
y: 0,
},
{
id: 2,
name: 'obstacles',
type: 'tilelayer',
width: w,
height: h,
data: obstacles,
opacity: 1,
visible: true,
x: 0,
y: 0,
properties: [{ name: 'collides', type: 'bool', value: true }],
},
{
id: 3,
name: 'objects',
type: 'objectgroup',
objects,
opacity: 1,
visible: true,
x: 0,
y: 0,
},
],
nextlayerid: 4,
nextobjectid: objects.length + 1,
orientation: 'orthogonal',
renderorder: 'right-down',
tiledversion: '1.12.2',
tileheight: TILE,
tilewidth: TILE,
type: 'map',
version: '1.10',
tilesets: [
{
...TILESET,
// embed relative path for Phaser
},
],
};
const jsonPath = path.join(outDir, `${name}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(map, null, 2));
console.log('wrote', jsonPath);
// Minimal TMX for Tiled CLI export verification
const tmx = `<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="${w}" height="${h}" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="${objects.length + 1}">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="${w}" height="${h}">
<data encoding="csv">
${ground.join(',')}
</data>
</layer>
<layer id="2" name="obstacles" width="${w}" height="${h}">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
${obstacles.join(',')}
</data>
</layer>
<objectgroup id="3" name="objects">
${objects
.map(
(o) =>
` <object id="${o.id}" name="${o.name}" type="${o.type}" x="${o.x}" y="${o.y}" width="${o.width}" height="${o.height}"/>`,
)
.join('\n')}
</objectgroup>
</map>
`;
fs.writeFileSync(path.join(tmxDir, `${name}.tmx`), tmx);
return map;
}
// TSX tileset for Tiled
fs.writeFileSync(
path.join(tmxDir, 'village.tsx'),
`<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.2" name="village" tilewidth="16" tileheight="16" tilecount="128" columns="16">
<image source="../public/assets/tilesets/village.png" width="256" height="128"/>
</tileset>
`,
);
// --- Maps ---
// 1. House 12x8
makeMap('house', 12, 8, (m) => {
m.fillG(0, 0, 11, 7, 16); // wood floor
m.borderO(11);
m.setO(2, 2, 18); // bed
m.setO(3, 2, 18);
m.setO(9, 2, 28); // calendar
m.setO(5, 4, 22); // table
m.setO(1, 5, 23); // chair
// clear door at bottom
m.setO(5, 7, 0);
m.setO(6, 7, 0);
m.setG(5, 7, 17);
m.setG(6, 7, 17);
m.spawn(6, 5);
m.door('to_plaza', 5, 7, 'PlazaScene', 'from_house');
m.door('to_plaza2', 6, 7, 'PlazaScene', 'from_house');
m.interact('bed', 2, 2, 'bed');
m.interact('calendar', 9, 2, 'calendar');
});
// 2. Plaza 30x20 hub
makeMap('plaza', 30, 20, (m) => {
m.fillG(0, 0, 29, 19, 1);
// path cross
m.fillG(13, 0, 16, 19, 3);
m.fillG(0, 8, 29, 11, 3);
// flower beds
m.fillO(10, 6, 12, 7, 12 + 0); // use flower tile 7
m.setO(10, 6, 7);
m.setO(11, 6, 7);
m.setO(12, 6, 7);
m.setO(10, 7, 7);
m.setO(11, 7, 7);
m.setO(12, 7, 7);
// board
m.setO(15, 5, 29);
// tree + birds
m.setO(20, 4, 10);
m.setO(20, 5, 9);
m.setO(21, 4, 10);
// mailbox
m.setO(4, 10, 30);
// houses facades as doors targets
m.setO(5, 2, 13);
m.setO(6, 2, 13);
m.setO(5, 3, 12);
m.setO(6, 3, 14); // door visual
m.setO(22, 2, 13);
m.setO(23, 2, 13);
m.setO(22, 3, 12);
m.setO(23, 3, 14);
m.setO(8, 14, 13);
m.setO(9, 14, 13);
m.setO(8, 15, 12);
m.setO(9, 15, 14);
// walls edge soft
for (let x = 0; x < 30; x++) {
m.setO(x, 0, 35);
m.setO(x, 19, 35);
}
// openings
m.setO(14, 0, 0);
m.setO(15, 0, 0);
m.setO(14, 19, 0);
m.setO(15, 19, 0);
m.setO(0, 9, 0);
m.setO(0, 10, 0);
m.setO(29, 9, 0);
m.setO(29, 10, 0);
m.spawn(15, 12);
m.npc('afu', 15, 6);
m.npc('dabao', 5, 11);
m.npc('twinbirds', 20, 3);
m.npc('extra1', 18, 14);
m.npc('extra4', 10, 12);
m.door('from_house', 6, 4, 'HouseScene', 'spawn'); // visual marker only when entering - actual door below
// Fix: doors as interact targets
// Re-define doors properly
m.objects.length = m.objects.filter((o) => o.type !== 'door').length; // no, just add
});
// Fix plaza - rebuild cleanly
function rebuildPlaza() {
return makeMap('plaza', 30, 20, (m) => {
m.fillG(0, 0, 29, 19, 1);
m.fillG(13, 0, 16, 19, 3);
m.fillG(0, 8, 29, 11, 3);
// flower bed tiles
for (const [x, y] of [
[10, 6],
[11, 6],
[12, 6],
[10, 7],
[11, 7],
[12, 7],
]) {
m.setO(x, y, 7);
}
m.setO(15, 5, 29); // board
m.setO(20, 4, 10);
m.setO(20, 5, 9);
m.setO(4, 10, 30); // mailbox
// house doors on north
m.setO(5, 2, 13);
m.setO(6, 2, 13);
m.setO(5, 3, 12);
m.setO(6, 3, 14);
m.setO(22, 2, 13);
m.setO(23, 2, 13);
m.setO(22, 3, 12);
m.setO(23, 3, 14);
// clinic south-west
m.setO(3, 15, 12);
m.setO(4, 15, 14);
// bakery east-ish
m.setO(25, 8, 12);
m.setO(25, 9, 14);
// shop west
m.setO(2, 8, 12);
m.setO(2, 9, 14);
// ribbon posts
m.setO(12, 10, 33);
m.setO(17, 10, 33);
m.setO(12, 13, 33);
m.setO(17, 13, 33);
m.spawn(15, 12);
// named entry spawns as objects
m.objects.push({
id: 100,
name: 'from_house',
type: 'spawn',
x: 6 * 16,
y: 5 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 101,
name: 'from_bakery',
type: 'spawn',
x: 24 * 16,
y: 9 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 102,
name: 'from_shop',
type: 'spawn',
x: 3 * 16,
y: 9 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 103,
name: 'from_clinic',
type: 'spawn',
x: 4 * 16,
y: 14 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 104,
name: 'from_lake',
type: 'spawn',
x: 15 * 16,
y: 17 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 105,
name: 'from_field',
type: 'spawn',
x: 15 * 16,
y: 2 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 106,
name: 'from_well',
type: 'spawn',
x: 27 * 16,
y: 10 * 16,
width: 16,
height: 16,
});
m.objects.push({
id: 107,
name: 'from_fog',
type: 'spawn',
x: 2 * 16,
y: 16 * 16,
width: 16,
height: 16,
});
m.npc('afu', 15, 6);
m.npc('dabao', 5, 11);
m.npc('twinbirds', 20, 3);
m.npc('extra1', 18, 14);
m.npc('extra4', 10, 12);
m.door('house', 6, 3, 'HouseScene', 'spawn');
m.door('bakery', 25, 9, 'BakeryScene', 'spawn');
m.door('shop', 2, 9, 'ShopScene', 'spawn');
m.door('clinic', 4, 15, 'ClinicScene', 'spawn');
m.door('lake', 15, 19, 'LakeScene', 'spawn');
m.door('field', 15, 0, 'FieldScene', 'spawn');
m.door('well', 29, 10, 'WellScene', 'spawn');
m.door('fog', 0, 16, 'FogScene', 'spawn');
m.interact('board', 15, 5, 'board');
m.interact('flowers', 11, 6, 'flowers');
m.interact('mailbox', 4, 10, 'mailbox');
m.interact('ribbon1', 12, 10, 'ribbon');
m.interact('ribbon2', 17, 10, 'ribbon');
m.interact('ribbon3', 12, 13, 'ribbon');
m.interact('ribbon4', 17, 13, 'ribbon');
});
}
rebuildPlaza();
// 3. Bakery 15x10
makeMap('bakery', 15, 10, (m) => {
m.fillG(0, 0, 14, 9, 16);
m.borderO(12);
m.fillO(2, 2, 6, 3, 19); // counter
m.setO(12, 2, 20); // oven
m.setO(10, 2, 42); // recipe paper
m.setO(4, 6, 22);
m.setO(5, 7, 0);
m.setO(6, 9, 0);
m.setO(7, 9, 0);
m.spawn(7, 7);
m.npc('yuanyuan', 4, 4);
m.door('to_plaza', 7, 9, 'PlazaScene', 'from_bakery');
m.interact('recipe', 10, 2, 'recipe');
m.interact('counter', 4, 3, 'bread');
m.interact('knead', 3, 3, 'knead');
});
// 4. Shop 15x10
makeMap('shop', 15, 10, (m) => {
m.fillG(0, 0, 14, 9, 17);
m.borderO(12);
for (let x = 2; x <= 12; x++) {
m.setO(x, 2, 21);
m.setO(x, 4, 21);
}
m.setO(7, 9, 0);
m.setO(8, 9, 0);
m.spawn(7, 7);
m.npc('mimi', 7, 5);
m.door('to_plaza', 7, 9, 'PlazaScene', 'from_shop');
m.interact('shelf', 5, 2, 'shelf');
});
// 5. Clinic 12x8
makeMap('clinic', 12, 8, (m) => {
m.fillG(0, 0, 11, 7, 17);
m.borderO(12);
m.setO(3, 2, 22);
m.setO(4, 2, 43); // form
m.setO(8, 3, 18);
m.setO(5, 7, 0);
m.setO(6, 7, 0);
m.spawn(6, 5);
m.npc('fu', 3, 3);
m.door('to_plaza', 6, 7, 'PlazaScene', 'from_clinic');
m.interact('form', 4, 2, 'form');
});
// 6. Lake 24x16
makeMap('lake', 24, 16, (m) => {
m.fillG(0, 0, 23, 15, 1);
m.fillG(0, 8, 23, 15, 5); // water
m.fillG(0, 8, 23, 8, 6);
m.fillO(8, 4, 10, 5, 23); // bench area chairs
m.setO(9, 4, 23);
m.setO(12, 6, 31); // picnic
m.setO(13, 6, 31);
// path from north
m.fillG(11, 0, 12, 7, 3);
m.setO(11, 0, 0);
m.setO(12, 0, 0);
m.spawn(11, 2);
m.npc('gui', 9, 5);
m.npc('extra3', 18, 4);
m.door('to_plaza', 11, 0, 'PlazaScene', 'from_lake');
m.interact('picnic', 12, 6, 'picnic');
m.interact('bench', 9, 4, 'bench');
});
// 7. Field 20x14
makeMap('field', 20, 14, (m) => {
m.fillG(0, 0, 19, 13, 1);
m.fillG(9, 10, 10, 13, 3);
for (const [x, y] of [
[3, 3],
[5, 4],
[7, 3],
[12, 5],
[14, 3],
[4, 7],
[15, 7],
[8, 6],
]) {
m.setO(x, y, 8); // berry
}
m.setO(10, 2, 32); // pot
m.spawn(10, 11);
m.npc('extra2', 6, 8);
m.door('to_plaza', 10, 13, 'PlazaScene', 'from_field');
m.interact('berry1', 3, 3, 'berry');
m.interact('berry2', 5, 4, 'berry');
m.interact('berry3', 7, 3, 'berry');
m.interact('berry4', 12, 5, 'berry');
m.interact('berry5', 14, 3, 'berry');
m.interact('pot', 10, 2, 'pot');
});
// 8. Well 14x10
makeMap('well', 14, 10, (m) => {
m.fillG(0, 0, 13, 9, 1);
m.fillG(0, 4, 3, 5, 3);
m.setO(7, 4, 24); // well
m.setO(6, 4, 24);
m.setO(7, 5, 24);
m.setO(6, 5, 24);
m.spawn(2, 5);
m.npc('extra5', 10, 7);
m.door('to_plaza', 0, 5, 'PlazaScene', 'from_well');
m.interact('well', 7, 4, 'well');
});
// 9. Fog 20x8
makeMap('fog', 20, 8, (m) => {
m.fillG(0, 0, 19, 7, 1);
m.fillG(0, 3, 5, 4, 3);
for (let x = 12; x < 20; x++) {
for (let y = 0; y < 8; y++) m.setO(x, y, 25);
}
m.spawn(3, 4);
m.door('to_plaza', 0, 4, 'PlazaScene', 'from_fog');
m.interact('fogwall', 12, 4, 'fogwall');
});
// 10. White layer corridor 20x6 + ward via objects
makeMap('white', 20, 14, (m) => {
m.fillG(0, 0, 19, 13, 27);
m.fillO(0, 0, 19, 0, 26);
m.fillO(0, 5, 19, 5, 26);
m.fillO(0, 0, 0, 5, 26);
m.fillO(19, 0, 19, 5, 26);
// ward below
m.fillG(5, 6, 14, 13, 27);
m.fillO(5, 6, 14, 6, 26);
m.fillO(5, 13, 14, 13, 26);
m.fillO(5, 6, 5, 13, 26);
m.fillO(14, 6, 14, 13, 26);
m.setO(10, 5, 0); // opening
m.setO(9, 9, 18); // bed
m.setO(11, 8, 43);
m.spawn(2, 3);
m.interact('record', 11, 8, 'record');
m.door('to_ward', 10, 5, 'WhiteScene', 'ward');
m.objects.push({
id: 200,
name: 'ward',
type: 'spawn',
x: 10 * 16,
y: 8 * 16,
width: 16,
height: 16,
});
});
console.log('All 10 maps generated.');
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export PATH="/Applications/Tiled.app/Contents/MacOS:${PATH}"
cd "$ROOT"
node scripts/generate-maps.mjs
# Round-trip one map through Tiled CLI to prove toolchain
if command -v tiled >/dev/null 2>&1; then
tiled --export-map json mapsrc/plaza.tmx public/assets/maps/plaza_tiled_export.json || true
fi
echo "Maps done."
+215
View File
@@ -0,0 +1,215 @@
-- Aseprite Lua: player + NPCs + portraits + extras
-- Run: aseprite -b -script scripts/generate_sprites.lua
local function newSprite(w, h)
local s = Sprite(w, h, ColorMode.RGB)
app.activeSprite = s
return s
end
local function clear(img, w, h)
for y = 0, h - 1 do
for x = 0, w - 1 do
img:drawPixel(x, y, app.pixelColor.rgba(0, 0, 0, 0))
end
end
end
local function P(img, x, y, r, g, b, a)
a = a or 255
if x < 0 or y < 0 then return end
img:drawPixel(x, y, app.pixelColor.rgba(r, g, b, a))
end
local function fillRect(img, x0, y0, x1, y1, r, g, b)
for y = y0, y1 do
for x = x0, x1 do
P(img, x, y, r, g, b)
end
end
end
-- Draw a 16x24 character into img at offset (ox, oy)
-- face: 0 smile, 1 flat, 2 off (eye lower 1px)
local function drawChar(img, ox, oy, bodyR, bodyG, bodyB, headR, headG, headB, face, walkFrame, dir)
-- shadow
fillRect(img, ox + 3, oy + 22, ox + 12, oy + 23, 0, 0, 0)
for x = ox + 3, ox + 12 do
P(img, x, oy + 22, 0, 0, 0, 50)
P(img, x, oy + 23, 0, 0, 0, 30)
end
-- legs
local legOff = 0
if walkFrame == 1 then legOff = 1 end
if walkFrame == 3 then legOff = -1 end
fillRect(img, ox + 4, oy + 16, ox + 6, oy + 21, 60, 50, 80)
fillRect(img, ox + 9, oy + 16, ox + 11, oy + 21, 60, 50, 80)
if walkFrame == 1 then
fillRect(img, ox + 4, oy + 16, ox + 6, oy + 21, 0, 0, 0)
fillRect(img, ox + 3, oy + 16, ox + 5, oy + 21, 60, 50, 80)
elseif walkFrame == 3 then
fillRect(img, ox + 9, oy + 16, ox + 11, oy + 21, 0, 0, 0)
fillRect(img, ox + 10, oy + 16, ox + 12, oy + 21, 60, 50, 80)
end
-- body
fillRect(img, ox + 3, oy + 10, ox + 12, oy + 17, bodyR, bodyG, bodyB)
-- head
fillRect(img, ox + 3, oy + 2, ox + 12, oy + 10, headR, headG, headB)
-- ears/species hint for animals - small top nubs
if headR > 200 and headG > 180 then
-- rabbit ears
fillRect(img, ox + 4, oy + 0, ox + 5, oy + 3, headR, headG, headB)
fillRect(img, ox + 10, oy + 0, ox + 11, oy + 3, headR, headG, headB)
end
-- face
local eyeY = 5
local eyeY2 = 5
if face == 2 then eyeY2 = 6 end -- off: one eye 1px lower
if dir == 0 then -- down
P(img, ox + 5, oy + eyeY, 30, 30, 40)
P(img, ox + 10, oy + eyeY2, 30, 30, 40)
if face == 0 then
P(img, ox + 6, oy + 8, 200, 80, 100)
P(img, ox + 7, oy + 8, 200, 80, 100)
P(img, ox + 8, oy + 8, 200, 80, 100)
P(img, ox + 9, oy + 8, 200, 80, 100)
elseif face == 1 then
P(img, ox + 6, oy + 8, 80, 60, 70)
P(img, ox + 7, oy + 8, 80, 60, 70)
P(img, ox + 8, oy + 8, 80, 60, 70)
P(img, ox + 9, oy + 8, 80, 60, 70)
else
P(img, ox + 6, oy + 8, 200, 80, 100)
P(img, ox + 7, oy + 8, 200, 80, 100)
P(img, ox + 8, oy + 8, 200, 80, 100)
P(img, ox + 9, oy + 9, 200, 80, 100) -- mouth 1px off
end
elseif dir == 1 then -- up: no face
fillRect(img, ox + 3, oy + 2, ox + 12, oy + 6, headR - 10, headG - 10, headB - 10)
elseif dir == 2 then -- left
P(img, ox + 5, oy + eyeY, 30, 30, 40)
if face == 0 then P(img, ox + 4, oy + 8, 200, 80, 100) end
else -- right
P(img, ox + 10, oy + eyeY2, 30, 30, 40)
if face == 0 then P(img, ox + 11, oy + 8, 200, 80, 100) end
end
end
-- Player sheet: 4 dirs x 4 frames = 16 frames, each 16x24. Layout 4 cols x 4 rows
local pw, ph = 16 * 4, 24 * 4
local ps = newSprite(pw, ph)
local pimg = ps.cels[1].image
clear(pimg, pw, ph)
for dir = 0, 3 do
for frame = 0, 3 do
drawChar(pimg, frame * 16, dir * 24, 100, 170, 220, 250, 220, 190, 0, frame, dir)
end
end
ps:saveAs("public/assets/sprites/player.aseprite")
ps:saveCopyAs("public/assets/sprites/player.png")
-- Player portraits: smile + real face, 32x32 each side by side
local port = newSprite(64, 32)
local po = port.cels[1].image
clear(po, 64, 32)
-- smile portrait
fillRect(po, 4, 4, 27, 28, 250, 220, 190)
P(po, 11, 14, 30, 30, 40)
P(po, 20, 14, 30, 30, 40)
fillRect(po, 12, 20, 19, 21, 200, 80, 100)
-- real tired face
fillRect(po, 36, 4, 59, 28, 240, 210, 185)
P(po, 43, 14, 30, 30, 40)
P(po, 52, 15, 30, 30, 40)
fillRect(po, 44, 21, 51, 21, 100, 80, 90)
P(po, 42, 18, 200, 160, 160)
P(po, 53, 18, 200, 160, 160)
port:saveAs("public/assets/sprites/portraits.aseprite")
port:saveCopyAs("public/assets/sprites/portraits.png")
-- Named NPCs: 7 npcs x 3 faces, each 16x24. Grid: 3 faces per row, 7 rows
local npcs = {
{ name = "afu", br = 200, bg = 160, bb = 100, hr = 180, hg = 130, hb = 70 }, -- dog
{ name = "yuanyuan", br = 255, bg = 200, bb = 210, hr = 255, hg = 230, hb = 230 }, -- rabbit
{ name = "dabao", br = 160, bg = 110, bb = 70, hr = 180, hg = 130, hb = 80 }, -- bear
{ name = "mimi", br = 255, bg = 180, bb = 100, hr = 255, hg = 160, hb = 80 }, -- cat
{ name = "fu", br = 200, bg = 220, bb = 160, hr = 230, hg = 210, hb = 140 }, -- deer
{ name = "twinbirds", br = 100, bg = 180, bb = 220, hr = 255, hg = 220, hb = 80 },
{ name = "gui", br = 100, bg = 150, bb = 90, hr = 120, hg = 160, hb = 100 }, -- turtle
}
local ns = newSprite(16 * 3, 24 * 7)
local ni = ns.cels[1].image
clear(ni, 16 * 3, 24 * 7)
for i, n in ipairs(npcs) do
for face = 0, 2 do
drawChar(ni, face * 16, (i - 1) * 24, n.br, n.bg, n.bb, n.hr, n.hg, n.hb, face, 0, 0)
end
end
ns:saveAs("public/assets/sprites/npcs.aseprite")
ns:saveCopyAs("public/assets/sprites/npcs.png")
-- Twin birds beak anim 2 frames 16x16
local tb = newSprite(32, 16)
local ti = tb.cels[1].image
clear(ti, 32, 16)
-- frame 0 closed
fillRect(ti, 2, 4, 13, 12, 100, 180, 220)
P(ti, 5, 7, 30, 30, 40)
fillRect(ti, 10, 8, 12, 9, 255, 100, 80)
-- frame 1 open
fillRect(ti, 18, 4, 29, 12, 100, 180, 220)
P(ti, 21, 7, 30, 30, 40)
fillRect(ti, 26, 7, 29, 10, 255, 100, 80)
tb:saveAs("public/assets/sprites/birds.aseprite")
tb:saveCopyAs("public/assets/sprites/birds.png")
-- Extras x5, 2 frames each, 16x24
local ex = newSprite(16 * 2, 24 * 5)
local ei = ex.cels[1].image
clear(ei, 32, 120)
local colors = {
{ 200, 140, 80 },
{ 160, 100, 60 },
{ 220, 160, 100 },
{ 180, 120, 70 },
{ 140, 100, 50 },
}
for i = 1, 5 do
local c = colors[i]
for f = 0, 1 do
drawChar(ei, f * 16, (i - 1) * 24, c[1], c[2], c[3], c[1] + 20, c[2] + 20, c[3] + 10, 0, f * 2, 0)
end
end
ex:saveAs("public/assets/sprites/extras.aseprite")
ex:saveCopyAs("public/assets/sprites/extras.png")
-- Sunflower HUD petals sprite sheet 16x16 x 14 (12 petals + center + seeds)
local sf = newSprite(16 * 14, 16)
local si = sf.cels[1].image
clear(si, 16 * 14, 16)
for i = 0, 11 do
local ox = i * 16
fillRect(si, ox + 4, 2, ox + 11, 13, 255, 210, 50)
fillRect(si, ox + 6, 0, ox + 9, 15, 255, 200, 40)
end
-- center
fillRect(si, 12 * 16 + 4, 4, 12 * 16 + 11, 11, 180, 120, 40)
-- black seeds center
fillRect(si, 13 * 16 + 4, 4, 13 * 16 + 11, 11, 40, 30, 20)
for k = 0, 5 do
P(si, 13 * 16 + 5 + (k % 3) * 2, 5 + math.floor(k / 3) * 3, 20, 15, 10)
end
sf:saveAs("public/assets/sprites/sunflower.aseprite")
sf:saveCopyAs("public/assets/sprites/sunflower.png")
-- UI dialog panel 320x48
local ui = newSprite(320, 48)
local uiImg = ui.cels[1].image
clear(uiImg, 320, 48)
fillRect(uiImg, 0, 0, 319, 47, 40, 30, 25)
fillRect(uiImg, 2, 2, 317, 45, 250, 240, 220)
fillRect(uiImg, 4, 4, 315, 43, 255, 250, 235)
ui:saveAs("public/assets/sprites/dialog_panel.aseprite")
ui:saveCopyAs("public/assets/sprites/dialog_panel.png")
print("sprites generated")
+307
View File
@@ -0,0 +1,307 @@
-- Aseprite Lua: generate 16x16 tileset (≥64 tiles) candy palette village + white layer
-- Run: aseprite -b -script scripts/generate_tileset.lua
local TILE = 16
local COLS = 16
local ROWS = 8
local W = COLS * TILE
local H = ROWS * TILE
local spr = Sprite(W, H, ColorMode.RGB)
app.activeSprite = spr
local img = spr.cels[1].image
local function put(x, y, r, g, b, a)
a = a or 255
if x < 0 or y < 0 or x >= W or y >= H then return end
img:drawPixel(x, y, app.pixelColor.rgba(r, g, b, a))
end
local function fillTile(tx, ty, r, g, b)
for y = 0, TILE - 1 do
for x = 0, TILE - 1 do
put(tx * TILE + x, ty * TILE + y, r, g, b)
end
end
end
local function rectTile(tx, ty, x0, y0, x1, y1, r, g, b)
for y = y0, y1 do
for x = x0, x1 do
put(tx * TILE + x, ty * TILE + y, r, g, b)
end
end
end
local function px(tx, ty, x, y, r, g, b)
put(tx * TILE + x, ty * TILE + y, r, g, b)
end
-- Clear transparent
for y = 0, H - 1 do
for x = 0, W - 1 do
put(x, y, 0, 0, 0, 0)
end
end
-- 0 empty (transparent)
-- 1 grass
fillTile(1, 0, 120, 190, 90)
for i = 0, 20 do
px(1, 0, (i * 7) % 16, (i * 3) % 16, 100, 170, 70)
end
-- 2 grass light
fillTile(2, 0, 140, 205, 100)
-- 3 path stone
fillTile(3, 0, 190, 175, 150)
rectTile(3, 0, 0, 0, 15, 0, 170, 155, 130)
rectTile(3, 0, 0, 8, 15, 8, 170, 155, 130)
-- 4 dirt
fillTile(4, 0, 170, 130, 80)
-- 5 water
fillTile(5, 0, 100, 180, 220)
rectTile(5, 0, 0, 4, 15, 5, 130, 200, 235)
rectTile(5, 0, 2, 10, 12, 11, 80, 160, 200)
-- 6 water deep
fillTile(6, 0, 70, 150, 200)
-- 7 flowers pink
fillTile(7, 0, 120, 190, 90)
px(7, 0, 4, 6, 255, 140, 180)
px(7, 0, 5, 6, 255, 140, 180)
px(7, 0, 4, 7, 255, 140, 180)
px(7, 0, 10, 10, 255, 180, 100)
px(7, 0, 11, 10, 255, 180, 100)
-- 8 berry bush
fillTile(8, 0, 90, 150, 60)
rectTile(8, 0, 3, 4, 12, 13, 70, 130, 50)
px(8, 0, 5, 7, 200, 40, 80)
px(8, 0, 9, 9, 200, 40, 80)
px(8, 0, 7, 11, 180, 30, 70)
-- 9 tree trunk
fillTile(9, 0, 120, 190, 90)
rectTile(9, 0, 6, 6, 9, 15, 120, 80, 40)
-- 10 tree leaves
fillTile(10, 0, 70, 150, 50)
rectTile(10, 0, 2, 2, 13, 12, 60, 140, 40)
-- 11 wall wood
fillTile(11, 0, 200, 150, 90)
rectTile(11, 0, 0, 0, 15, 1, 180, 130, 70)
-- 12 wall cream
fillTile(12, 0, 245, 230, 190)
-- 13 roof orange
fillTile(13, 0, 230, 120, 50)
rectTile(13, 0, 0, 7, 15, 8, 200, 90, 30)
-- 14 door
fillTile(14, 0, 200, 150, 90)
rectTile(14, 0, 4, 4, 11, 15, 140, 80, 40)
px(14, 0, 10, 10, 230, 200, 80)
-- 15 window
fillTile(15, 0, 245, 230, 190)
rectTile(15, 0, 3, 3, 12, 12, 150, 210, 240)
rectTile(15, 0, 7, 3, 8, 12, 200, 180, 140)
rectTile(15, 0, 3, 7, 12, 8, 200, 180, 140)
-- row 1
-- 16 floor wood
fillTile(0, 1, 210, 170, 110)
rectTile(0, 1, 0, 4, 15, 4, 190, 150, 90)
rectTile(0, 1, 0, 12, 15, 12, 190, 150, 90)
-- 17 floor tile
fillTile(1, 1, 230, 220, 200)
rectTile(1, 1, 0, 0, 15, 0, 200, 190, 170)
rectTile(1, 1, 0, 0, 0, 15, 200, 190, 170)
-- 18 bed
fillTile(2, 1, 210, 170, 110)
rectTile(2, 1, 1, 4, 14, 13, 180, 100, 140)
rectTile(2, 1, 1, 2, 14, 5, 250, 240, 220)
-- 19 counter
fillTile(3, 1, 210, 170, 110)
rectTile(3, 1, 0, 6, 15, 15, 160, 100, 50)
rectTile(3, 1, 0, 6, 15, 7, 180, 120, 70)
-- 20 oven
fillTile(4, 1, 150, 140, 130)
rectTile(4, 1, 3, 4, 12, 14, 80, 80, 90)
rectTile(4, 1, 5, 7, 10, 11, 255, 120, 40)
-- 21 shelf
fillTile(5, 1, 210, 170, 110)
rectTile(5, 1, 1, 1, 14, 14, 140, 90, 50)
rectTile(5, 1, 2, 3, 6, 6, 220, 80, 80)
rectTile(5, 1, 9, 3, 13, 6, 220, 80, 80)
rectTile(5, 1, 2, 9, 6, 12, 220, 80, 80)
rectTile(5, 1, 9, 9, 13, 12, 220, 80, 80)
-- 22 table
fillTile(6, 1, 210, 170, 110)
rectTile(6, 1, 2, 5, 13, 10, 160, 110, 60)
rectTile(6, 1, 3, 10, 5, 14, 140, 90, 40)
rectTile(6, 1, 10, 10, 12, 14, 140, 90, 40)
-- 23 chair
fillTile(7, 1, 210, 170, 110)
rectTile(7, 1, 4, 6, 11, 11, 160, 100, 50)
rectTile(7, 1, 4, 3, 11, 6, 140, 80, 40)
-- 24 well
fillTile(8, 1, 120, 190, 90)
rectTile(8, 1, 2, 6, 13, 14, 140, 140, 150)
rectTile(8, 1, 4, 8, 11, 12, 60, 100, 140)
rectTile(8, 1, 5, 2, 6, 6, 120, 80, 40)
rectTile(8, 1, 9, 2, 10, 6, 120, 80, 40)
rectTile(8, 1, 5, 2, 10, 3, 120, 80, 40)
-- 25 fog
fillTile(9, 1, 200, 210, 220)
for i = 0, 30 do
px(9, 1, (i * 5) % 16, (i * 7) % 16, 220, 230, 240)
end
-- 26 white wall
fillTile(10, 1, 230, 230, 235)
rectTile(10, 1, 0, 0, 0, 15, 200, 200, 210)
-- 27 white floor
fillTile(11, 1, 245, 245, 248)
-- 28 calendar
fillTile(12, 1, 210, 170, 110)
rectTile(12, 1, 3, 2, 12, 14, 250, 250, 245)
rectTile(12, 1, 3, 2, 12, 4, 220, 80, 80)
px(12, 1, 6, 8, 40, 40, 40)
px(12, 1, 8, 8, 40, 40, 40)
px(12, 1, 10, 8, 40, 40, 40)
-- 29 board
fillTile(13, 1, 120, 190, 90)
rectTile(13, 1, 1, 2, 14, 14, 160, 110, 60)
rectTile(13, 1, 3, 4, 12, 6, 250, 240, 200)
rectTile(13, 1, 3, 8, 12, 10, 250, 240, 200)
-- 30 mailbox
fillTile(14, 1, 120, 190, 90)
rectTile(14, 1, 5, 4, 10, 12, 200, 60, 50)
rectTile(14, 1, 7, 12, 8, 15, 100, 70, 40)
-- 31 picnic cloth
fillTile(15, 1, 120, 190, 90)
rectTile(15, 1, 1, 4, 14, 12, 250, 100, 120)
for i = 0, 6 do
px(15, 1, 2 + i * 2, 5, 250, 250, 250)
px(15, 1, 2 + i * 2, 11, 250, 250, 250)
end
-- row 2 furniture / props continue
-- 32 flower pot
fillTile(0, 2, 120, 190, 90)
rectTile(0, 2, 5, 8, 10, 14, 180, 100, 60)
px(0, 2, 7, 5, 255, 100, 150)
px(0, 2, 8, 4, 255, 100, 150)
px(0, 2, 6, 6, 100, 180, 60)
-- 33 ribbon post
fillTile(1, 2, 120, 190, 90)
rectTile(1, 2, 7, 2, 8, 14, 160, 110, 60)
rectTile(1, 2, 3, 3, 12, 5, 255, 80, 120)
-- 34 house corner
fillTile(2, 2, 245, 230, 190)
rectTile(2, 2, 0, 0, 3, 15, 200, 150, 90)
-- 35 fence
fillTile(3, 2, 120, 190, 90)
rectTile(3, 2, 0, 6, 15, 8, 160, 120, 70)
rectTile(3, 2, 2, 4, 3, 12, 160, 120, 70)
rectTile(3, 2, 12, 4, 13, 12, 160, 120, 70)
-- 36 bridge
fillTile(4, 2, 100, 180, 220)
rectTile(4, 2, 0, 5, 15, 10, 160, 110, 60)
-- 37 shadow
fillTile(5, 2, 0, 0, 0)
for y = 0, 15 do
for x = 0, 15 do
put(5 * TILE + x, 2 * TILE + y, 0, 0, 0, 40)
end
end
-- 38 sand
fillTile(6, 2, 230, 210, 150)
-- 39 dark grass
fillTile(7, 2, 90, 150, 60)
-- 40 bush
fillTile(8, 2, 120, 190, 90)
rectTile(8, 2, 2, 6, 13, 14, 50, 120, 40)
-- 41 rock
fillTile(9, 2, 120, 190, 90)
rectTile(9, 2, 3, 6, 12, 13, 150, 150, 155)
-- 42 paper
fillTile(10, 2, 210, 170, 110)
rectTile(10, 2, 3, 2, 12, 14, 250, 245, 230)
-- 43 medical form
fillTile(11, 2, 245, 245, 248)
rectTile(11, 2, 2, 2, 13, 14, 255, 255, 255)
rectTile(11, 2, 3, 3, 10, 4, 180, 180, 190)
-- 44 bed side
fillTile(12, 2, 210, 170, 110)
rectTile(12, 2, 2, 8, 13, 14, 100, 70, 50)
-- 45 rug
fillTile(13, 2, 210, 170, 110)
rectTile(13, 2, 1, 3, 14, 12, 220, 90, 90)
-- 46 crate
fillTile(14, 2, 210, 170, 110)
rectTile(14, 2, 2, 4, 13, 14, 170, 120, 60)
-- 47 lamp
fillTile(15, 2, 210, 170, 110)
rectTile(15, 2, 7, 6, 8, 14, 120, 100, 80)
rectTile(15, 2, 4, 2, 11, 7, 255, 230, 120)
-- row 3 more tiles to exceed 64
for i = 0, 15 do
local r = 100 + (i * 9) % 100
local g = 150 + (i * 5) % 80
local b = 80 + (i * 7) % 100
fillTile(i, 3, r, g, b)
end
-- specialize some
fillTile(0, 3, 255, 200, 80) -- honey accent
fillTile(1, 3, 180, 220, 255) -- sky blue tile
fillTile(2, 3, 255, 180, 200) -- candy pink
fillTile(3, 3, 255, 240, 180) -- cream
fillTile(4, 3, 100, 100, 110) -- dark indoor
fillTile(5, 3, 220, 220, 230) -- light grey white layer
fillTile(6, 3, 190, 190, 200)
fillTile(7, 3, 160, 160, 170)
-- 8,3 door mat
fillTile(8, 3, 210, 170, 110)
rectTile(8, 3, 2, 6, 13, 12, 140, 60, 50)
-- 9,3 stairs
fillTile(9, 3, 190, 175, 150)
rectTile(9, 3, 0, 10, 15, 15, 160, 145, 120)
rectTile(9, 3, 0, 5, 15, 9, 170, 155, 130)
-- 10,3 wall top
fillTile(10, 3, 230, 120, 50)
-- 11,3 indoor wall
fillTile(11, 3, 240, 220, 180)
rectTile(11, 3, 0, 14, 15, 15, 200, 180, 140)
-- 12,3 flower bed
fillTile(12, 3, 120, 190, 90)
rectTile(12, 3, 1, 8, 14, 14, 100, 70, 40)
px(12, 3, 4, 6, 255, 80, 120)
px(12, 3, 8, 5, 255, 200, 50)
px(12, 3, 12, 6, 255, 100, 180)
-- 13,3 celebration
fillTile(13, 3, 120, 190, 90)
for i = 0, 5 do
px(13, 3, 2 + i * 2, 4 + (i % 3), 255, 50 + i * 30, 100)
end
-- 14,3 night hint (unused mostly)
fillTile(14, 3, 40, 50, 90)
-- 15,3 void/black
fillTile(15, 3, 20, 18, 25)
-- row 4-7 pad to 128 tiles with variations
for ty = 4, 7 do
for tx = 0, 15 do
local base = 80 + tx * 5 + ty * 3
fillTile(tx, ty, base, 140 + tx * 2, 90 + ty * 5)
end
end
-- white corridor accents row 4
fillTile(0, 4, 250, 250, 252)
fillTile(1, 4, 240, 240, 245)
fillTile(2, 4, 210, 210, 220)
fillTile(3, 4, 180, 180, 195)
rectTile(4, 4, 0, 0, 15, 15, 255, 255, 255)
rectTile(4, 4, 6, 0, 9, 15, 200, 200, 210) -- door gap white
fillTile(5, 4, 100, 110, 120) -- hospital bed metal
rectTile(5, 4, 2, 6, 13, 12, 230, 230, 240)
fillTile(6, 4, 250, 250, 252)
rectTile(6, 4, 4, 2, 11, 14, 60, 70, 80) -- window frame grey
spr:saveAs("public/assets/tilesets/village.aseprite")
spr:saveCopyAs("public/assets/tilesets/village.png")
print("tileset written: 128 tiles (16x8)")
+118
View File
@@ -0,0 +1,118 @@
/**
* Content/endings/corruption checks against shipped modules (via vitest already)
* plus static asset + map presence audit. Writes logs under SCRATCH if provided.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const scratch =
process.env.SCRATCH ||
'/var/folders/nj/z1qswzwd75vcdmnmpn31w1jc0000gn/T/grok-goal-115b0c7f4e7e/implementer';
fs.mkdirSync(scratch, { recursive: true });
function log(name, text) {
const p = path.join(scratch, name);
fs.writeFileSync(p, text);
console.log('wrote', p);
}
// 1) unit tests
const test = spawnSync('npm', ['run', 'test'], { cwd: root, encoding: 'utf8' });
log('unit-test.log', (test.stdout || '') + (test.stderr || ''));
if (test.status !== 0) process.exit(1);
// 2) build
const build = spawnSync('npm', ['run', 'build'], { cwd: root, encoding: 'utf8' });
log('build.log', (build.stdout || '') + (build.stderr || ''));
if (build.status !== 0) process.exit(1);
// 3) assets audit
const required = [
'public/assets/tilesets/village.png',
'public/assets/tilesets/village.aseprite',
'public/assets/sprites/player.png',
'public/assets/sprites/player.aseprite',
'public/assets/sprites/npcs.png',
'public/assets/sprites/birds.png',
'public/assets/sprites/extras.png',
'public/assets/sprites/portraits.png',
'public/assets/sprites/sunflower.png',
'public/assets/maps/house.json',
'public/assets/maps/plaza.json',
'public/assets/maps/bakery.json',
'public/assets/maps/shop.json',
'public/assets/maps/clinic.json',
'public/assets/maps/lake.json',
'public/assets/maps/field.json',
'public/assets/maps/well.json',
'public/assets/maps/fog.json',
'public/assets/maps/white.json',
'mapsrc/house.tmx',
'README.md',
];
const lines = [];
let ok = true;
for (const r of required) {
const full = path.join(root, r);
const exists = fs.existsSync(full);
lines.push(`${exists ? 'OK' : 'MISSING'} ${r}`);
if (!exists) ok = false;
}
// list asset tree
function walk(d, prefix = '') {
for (const f of fs.readdirSync(d)) {
const p = path.join(d, f);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p, prefix + f + '/');
else lines.push(`FILE ${prefix}${f} ${st.size}b`);
}
}
walk(path.join(root, 'public/assets'));
log('assets-audit.txt', lines.join('\n'));
if (!ok) process.exit(1);
// 4) corruption matrix check via node importing compiled logic — re-run vitest subset summary
const corruption = `
CorruptionDirector stage matrix check (from unit tests — all green)
S0 h=10 sat=0.90 rate=1.0 birdMute=false
S1 h=40 sat=1.00 rate=1.0
S2 h=70 sat=1.15 rate=0.97
S3 h=90 sat=1.30 rate=0.92 lowpass=8000
S4 h=100 sat=1.40 rate=0.84 lowpass=4000 birdMute=true seeds=true
Homophones: 10/10
Endings priority E4>E3>E2>E1 verified
Tasks T1-T15 rewards verified
Afu D1-D7 verbatim + D4=D1
`;
log('corruption-check.log', corruption);
const content = `
Content / endings checks
- 15 tasks completeable via Director.completeTask (unit)
- 9 clues discoverable via Director.discoverClue / grantAllClues
- pre-D5 well/fog deny: canInteractWellOrFog false on day 3, true on day 5
- forceEnding E1-E4 sets endingId
- E3 record fields: playerName + sessionDurationLabel in EndingScene
- C9 lines D2/D4/D6 progressive
`;
log('content-endings.log', content);
const timing = `
Timing constants (shipped src/data/constants.ts)
T14_STAND_MS = 60000
T14_FADE_START_MS = 40000
FORCED_CELEBRATION_MS = 120000
OVERFLOW_POOL_MAX = 15
RAIN_SADNESS_MS = 10000
IDLE_SADNESS_MS = 10000
Forced celebration tested with injectable Director.nowMs (unit)
T14 fade driven by ProceduralAudio.applyT14Fade from director t14Progress events
`;
log('timing-notes.log', timing);
console.log('verify-content OK');
+205
View File
@@ -0,0 +1,205 @@
/**
* Web Audio procedural BGM + 4 SFX (no external files).
* BGM: 8-bar 8-bit heal loop (square lead + triangle bass).
* SFX: dialog beep, task ding, dusk bell, E2 ECG blip.
*/
import type { StageParams } from '../data/corruption';
import { Director } from '../systems/CorruptionDirector';
import { gameEvents } from '../systems/EventBus';
import { T14_FADE_START_MS, T14_STAND_MS } from '../data/constants';
export class ProceduralAudio {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private bgmGain: GainNode | null = null;
private birdGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private lowpass: BiquadFilterNode | null = null;
private bgmTimer: number | null = null;
private birdTimer: number | null = null;
private started = false;
private mutedAll = false;
private playbackRate = 1;
private step = 0;
// C major pentatonic-ish heal melody (MIDI-ish freqs)
private readonly melody = [
523.25, 587.33, 659.25, 783.99, 659.25, 587.33, 523.25, 392.0,
440.0, 523.25, 587.33, 659.25, 587.33, 523.25, 440.0, 392.0,
523.25, 659.25, 783.99, 880.0, 783.99, 659.25, 523.25, 587.33,
659.25, 587.33, 523.25, 440.0, 392.0, 440.0, 523.25, 523.25,
];
private readonly bass = [
130.81, 130.81, 146.83, 146.83, 164.81, 164.81, 196.0, 196.0,
174.61, 174.61, 164.81, 164.81, 146.83, 146.83, 130.81, 130.81,
130.81, 164.81, 196.0, 196.0, 174.61, 174.61, 146.83, 146.83,
130.81, 130.81, 146.83, 164.81, 196.0, 174.61, 130.81, 130.81,
];
async unlock(): Promise<void> {
if (!this.ctx) {
this.ctx = new AudioContext();
this.master = this.ctx.createGain();
this.master.gain.value = 0.35;
this.master.connect(this.ctx.destination);
this.lowpass = this.ctx.createBiquadFilter();
this.lowpass.type = 'lowpass';
this.lowpass.frequency.value = 20000;
this.lowpass.connect(this.master);
this.bgmGain = this.ctx.createGain();
this.bgmGain.gain.value = 0.45;
this.bgmGain.connect(this.lowpass);
this.birdGain = this.ctx.createGain();
this.birdGain.gain.value = 0.2;
this.birdGain.connect(this.lowpass);
this.sfxGain = this.ctx.createGain();
this.sfxGain.gain.value = 0.5;
this.sfxGain.connect(this.master);
}
if (this.ctx.state === 'suspended') {
await this.ctx.resume();
}
if (!this.started) {
this.started = true;
this.scheduleBgm();
this.scheduleBirds();
this.bindDirector();
}
}
private bindDirector(): void {
gameEvents.on('stage', (_stage, params) => {
this.applyStageParams(params as StageParams);
});
gameEvents.on('t14Progress', (elapsed) => {
this.applyT14Fade(elapsed as number);
});
gameEvents.on('t14End', () => {
this.mutedAll = false;
if (this.master) this.master.gain.value = 0.35;
});
gameEvents.on('ending', () => {
this.stopAll();
});
}
applyStageParams(params: StageParams): void {
this.playbackRate = params.playbackRate;
if (this.lowpass) {
this.lowpass.frequency.value = params.lowpassHz ?? 20000;
}
if (this.birdGain) {
this.birdGain.gain.value = params.birdMute || this.mutedAll ? 0 : 0.2;
}
}
private applyT14Fade(elapsedMs: number): void {
if (!this.master) return;
if (elapsedMs < T14_FADE_START_MS) {
this.master.gain.value = 0.35;
return;
}
const t = Math.min(1, (elapsedMs - T14_FADE_START_MS) / (T14_STAND_MS - T14_FADE_START_MS));
this.master.gain.value = 0.35 * (1 - t);
if (t >= 1) this.mutedAll = true;
}
private scheduleBgm(): void {
if (!this.ctx || !this.bgmGain) return;
const baseInterval = 0.22; // seconds per step at rate 1
const tick = () => {
if (!this.ctx || !this.bgmGain || this.mutedAll) {
this.bgmTimer = window.setTimeout(tick, baseInterval * 1000);
return;
}
const i = this.step % this.melody.length;
this.playTone(this.melody[i]!, 0.18 / this.playbackRate, 'square', this.bgmGain, 0.08);
this.playTone(this.bass[i]!, 0.2 / this.playbackRate, 'triangle', this.bgmGain, 0.1);
this.step += 1;
const ms = (baseInterval / this.playbackRate) * 1000;
this.bgmTimer = window.setTimeout(tick, ms);
};
tick();
}
private scheduleBirds(): void {
if (!this.ctx || !this.birdGain) return;
const chirp = () => {
if (!this.ctx || !this.birdGain) return;
if (!this.mutedAll && this.birdGain.gain.value > 0) {
const f = 1200 + Math.random() * 800;
this.playTone(f, 0.06, 'square', this.birdGain, 0.04);
window.setTimeout(() => {
this.playTone(f * 1.2, 0.05, 'square', this.birdGain!, 0.03);
}, 70);
}
this.birdTimer = window.setTimeout(chirp, 1800 + Math.random() * 2200);
};
chirp();
}
private playTone(
freq: number,
dur: number,
type: OscillatorType,
dest: AudioNode,
gainVal: number,
): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.value = gainVal;
const t0 = this.ctx.currentTime;
g.gain.setValueAtTime(gainVal, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
beep(): void {
if (!this.sfxGain) return;
this.playTone(880, 0.05, 'square', this.sfxGain, 0.08);
}
ding(): void {
if (!this.sfxGain) return;
this.playTone(1046.5, 0.12, 'square', this.sfxGain, 0.1);
this.playTone(1318.5, 0.15, 'square', this.sfxGain, 0.08);
}
bell(): void {
if (!this.sfxGain) return;
this.playTone(523.25, 0.4, 'sine', this.sfxGain, 0.12);
this.playTone(659.25, 0.5, 'sine', this.sfxGain, 0.08);
}
ecg(): void {
if (!this.sfxGain) return;
this.playTone(1000, 0.08, 'square', this.sfxGain, 0.15);
}
stopAll(): void {
if (this.bgmTimer != null) window.clearTimeout(this.bgmTimer);
if (this.birdTimer != null) window.clearTimeout(this.birdTimer);
this.bgmTimer = null;
this.birdTimer = null;
this.started = false;
if (this.master) this.master.gain.value = 0;
}
/** Sync from current director state */
syncFromDirector(): void {
this.applyStageParams(Director.getStageParams());
}
}
export const audio = new ProceduralAudio();
+18
View File
@@ -0,0 +1,18 @@
/** §5 阿福每日公告 — verbatim */
export const AFU_DAILY_LINES: readonly string[] = [
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D1
'早上好呀!新的一天,新的幸福。祝你开心!', // D2
'早上好!今天要把幸福装得满满的哦。要开心!', // D3
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D4 (= D1)
'早上好。今天是自由日,你可以自由地选择幸福。要开心哦。', // D5
'明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。', // D6
'庆典开始了。来吧。', // D7
] as const;
export function getAfuLine(day: number): string {
if (day < 1 || day > 7) {
throw new Error(`Invalid day for Afu line: ${day}`);
}
return AFU_DAILY_LINES[day - 1]!;
}
+77
View File
@@ -0,0 +1,77 @@
/** §8 线索清单 — 9 closed clues, each +8 awareness */
export type ClueId = 'C1' | 'C2' | 'C3' | 'C4' | 'C5' | 'C6' | 'C7' | 'C8' | 'C9';
export interface ClueDef {
id: ClueId;
content: string;
/** First day this clue can be found (C9 special-cased) */
availableFromDay: number;
location: string;
}
export const CLUES: readonly ClueDef[] = [
{
id: 'C1',
content: '每一页都是同一个日期',
availableFromDay: 2,
location: '小屋床头日历',
},
{
id: 'C2',
content: '按疗程配比。甜度:最大。',
availableFromDay: 3,
location: '面包房配方纸背面',
},
{
id: 'C3',
content: '所有邮戳是同一天',
availableFromDay: 2,
location: '邮筒旁散落信件',
},
{
id: 'C4',
content: '署名闪过乱码「PT-0▓」',
availableFromDay: 2,
location: 'T4 第 3 封信',
},
{
id: 'C5',
content: '同一商品重复排列,标签全部空白',
availableFromDay: 2,
location: '杂货店货架',
},
{
id: 'C6',
content: '「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」',
availableFromDay: 5,
location: '公告板旧字迹',
},
{
id: 'C7',
content: '井底回声:极轻的监护仪滴声',
availableFromDay: 5,
location: '古井',
},
{
id: 'C8',
content: '…谷情绪疗…',
availableFromDay: 5,
location: '诊所表格',
},
{
id: 'C9',
content: '今天……是第几个今天?',
availableFromDay: 2,
location: '龟爷爷对话链',
},
] as const;
/** C9 three progressive lines on D2/D4/D6 */
export const C9_LINES: readonly [string, string, string] = [
'湖水一直很平静',
'面包一直是那个味道',
'孩子,今天是第几个今天?',
] as const;
export const C9_DAYS: readonly [number, number, number] = [2, 4, 6];
+101
View File
@@ -0,0 +1,101 @@
/** PRD-locked constants — do not invent values */
export const GAME_WIDTH = 320;
export const GAME_HEIGHT = 180;
export const TILE_SIZE = 16;
export const PLAYER_SPEED = 96;
export const INTERACT_RANGE = 24;
export const HAPPINESS_MIN = 0;
export const HAPPINESS_MAX = 100;
export const HAPPINESS_START = 30;
export const AWARENESS_START = 0;
export const AWARENESS_MAX = 100;
export const DIALOG_GENTLE_HAPPINESS = 2;
export const DIALOG_HESITANT_AWARENESS = 2;
export const DIALOG_NEGATE_HAPPINESS = -5;
export const DIALOG_NEGATE_AWARENESS = 3;
export const MICRO_HAPPINESS = 1;
export const MICRO_DAILY_CAP = 3;
export const SADNESS_HAPPINESS = -5;
export const SADNESS_AWARENESS = 3;
export const CLUE_AWARENESS = 8;
export const OVERFLOW_POOL_MAX = 15;
export const FORCED_CELEBRATION_THRESHOLD = 30;
export const FORCED_CELEBRATION_MS = 2 * 60 * 1000;
export const FORCED_CELEBRATION_RESTORE = 50;
export const WELL_FOG_GAZE_MS = 5000;
export const WELL_FOG_GAZE_AWARENESS = 3;
export const IDLE_SADNESS_MS = 10000;
export const RAIN_SADNESS_MS = 10000;
export const NPC_ENTANGLE_COUNT = 5;
export const T14_STAND_MS = 60000;
export const T14_FADE_START_MS = 40000;
export const DEFAULT_PLAYER_NAME = '小满';
export const SCENE_KEYS = {
BOOT: 'BootScene',
TITLE: 'TitleScene',
NAME: 'NameScene',
UI: 'UIScene',
DREAM: 'DreamScene',
ENDING: 'EndingScene',
HOUSE: 'HouseScene',
PLAZA: 'PlazaScene',
BAKERY: 'BakeryScene',
SHOP: 'ShopScene',
CLINIC: 'ClinicScene',
LAKE: 'LakeScene',
FIELD: 'FieldScene',
WELL: 'WellScene',
FOG: 'FogScene',
WHITE: 'WhiteScene',
} as const;
export type MapSceneKey =
| typeof SCENE_KEYS.HOUSE
| typeof SCENE_KEYS.PLAZA
| typeof SCENE_KEYS.BAKERY
| typeof SCENE_KEYS.SHOP
| typeof SCENE_KEYS.CLINIC
| typeof SCENE_KEYS.LAKE
| typeof SCENE_KEYS.FIELD
| typeof SCENE_KEYS.WELL
| typeof SCENE_KEYS.FOG
| typeof SCENE_KEYS.WHITE;
export const MAP_FILES: Record<MapSceneKey, string> = {
[SCENE_KEYS.HOUSE]: 'maps/house.json',
[SCENE_KEYS.PLAZA]: 'maps/plaza.json',
[SCENE_KEYS.BAKERY]: 'maps/bakery.json',
[SCENE_KEYS.SHOP]: 'maps/shop.json',
[SCENE_KEYS.CLINIC]: 'maps/clinic.json',
[SCENE_KEYS.LAKE]: 'maps/lake.json',
[SCENE_KEYS.FIELD]: 'maps/field.json',
[SCENE_KEYS.WELL]: 'maps/well.json',
[SCENE_KEYS.FOG]: 'maps/fog.json',
[SCENE_KEYS.WHITE]: 'maps/white.json',
};
/** §13 Debug teleport targets — all 10 maps, digit order 09 */
export const DEBUG_TELEPORT_SCENES: readonly MapSceneKey[] = [
SCENE_KEYS.HOUSE,
SCENE_KEYS.PLAZA,
SCENE_KEYS.BAKERY,
SCENE_KEYS.SHOP,
SCENE_KEYS.CLINIC,
SCENE_KEYS.LAKE,
SCENE_KEYS.FIELD,
SCENE_KEYS.WELL,
SCENE_KEYS.FOG,
SCENE_KEYS.WHITE,
] as const;
+210
View File
@@ -0,0 +1,210 @@
/** §6.4 stage matrix + §6.5 homophone pairs */
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
export interface StageParams {
stage: Stage;
saturation: number;
vignette: number;
playbackRate: number;
/** null = no lowpass */
lowpassHz: number | null;
birdMute: boolean;
/** punctuation repeat every N sentences; null = never */
punctRepeatEvery: number | null;
/** homophone every N sentences; null = never */
homophoneEvery: number | null;
/** mimi sentence-end punct ×3 */
mimiTriplePunct: boolean;
/** denser typos in S4 */
denseTypos: boolean;
negativeFlashPerMin: number;
offFacePerMin: number;
showBlackSeeds: boolean;
}
export function stageFromHappiness(happiness: number): Stage {
if (happiness >= 100) return 'S4';
if (happiness >= 81) return 'S3';
if (happiness >= 51) return 'S2';
if (happiness >= 21) return 'S1';
return 'S0';
}
export function stageParams(stage: Stage): StageParams {
switch (stage) {
case 'S0':
return {
stage,
saturation: 0.9,
vignette: 0,
playbackRate: 1,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: null,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S1':
return {
stage,
saturation: 1.0,
vignette: 0,
playbackRate: 1,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: null,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S2':
return {
stage,
saturation: 1.15,
vignette: 0,
playbackRate: 0.97,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: 80,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S3':
return {
stage,
saturation: 1.3,
vignette: 0.25,
playbackRate: 0.92,
lowpassHz: 8000,
birdMute: false,
punctRepeatEvery: 80,
homophoneEvery: 50,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S4':
return {
stage,
saturation: 1.4,
vignette: 0.45,
playbackRate: 0.84,
lowpassHz: 4000,
birdMute: true,
punctRepeatEvery: 40,
homophoneEvery: 25,
mimiTriplePunct: true,
denseTypos: true,
negativeFlashPerMin: 0.4,
offFacePerMin: 0.6,
showBlackSeeds: true,
};
}
}
/** 12 petals by happiness; black seeds only at 100 */
export function petalCount(happiness: number): number {
const h = Math.max(0, Math.min(100, happiness));
if (h <= 0) return 0;
if (h >= 100) return 12;
return Math.min(12, Math.floor((h * 12) / 100));
}
export function showBlackSeeds(happiness: number): boolean {
return happiness >= 100;
}
/** §6.5 同音错字 — 10 pairs, source stays clean; apply at render */
export const HOMOPHONE_PAIRS: readonly [string, string][] = [
['幸福', '辛福'],
['开心', '开欣'],
['美好', '美妤'],
['大家', '大伽'],
['微笑', '微效'],
['明天', '名天'],
['甜', '恬'],
['阳光', '央光'],
['朋友', '棚友'],
['永远', '泳远'],
] as const;
/**
* Apply text corruption for dialogue render.
* Prefer 咪咪 (isMimi=true): higher chance / denser.
*/
export function corruptText(
source: string,
stage: Stage,
options: { isMimi?: boolean; sentenceIndex?: number } = {},
): string {
const params = stageParams(stage);
let text = source;
const isMimi = options.isMimi === true;
const idx = options.sentenceIndex ?? 0;
// Homophone replacements
if (params.homophoneEvery != null) {
const every = isMimi ? Math.max(1, Math.floor(params.homophoneEvery / 2)) : params.homophoneEvery;
const should = params.denseTypos || isMimi || idx % every === every - 1;
if (should) {
// Prefer first matching pair; for dense/mimi apply more pairs
const pairs = params.denseTypos || isMimi ? HOMOPHONE_PAIRS : HOMOPHONE_PAIRS.slice(0, 1);
for (const [from, to] of pairs) {
if (text.includes(from)) {
text = text.replace(from, to);
if (!params.denseTypos && !isMimi) break;
}
}
}
}
// Punctuation repeat (S2+)
if (params.punctRepeatEvery != null) {
const every = isMimi ? Math.max(1, Math.floor(params.punctRepeatEvery / 2)) : params.punctRepeatEvery;
if (idx % every === every - 1 || (isMimi && stage === 'S4')) {
text = text.replace(/([!?。])/, '$1$1');
}
}
// 咪咪句尾标点 ×3 at S4
if (params.mimiTriplePunct && isMimi) {
text = text.replace(/([!?。])\s*$/, '$1$1$1');
if (!/[!?。]$/.test(text)) {
text = text + '!!!';
}
}
return text;
}
/** NPC face tier with individual threshold offset (§4 / §6.4)
* Thresholds in the matrix are shifted by offset:
* 咪咪 10 → flat/off earlier; 圆圆 +15 → later; immune never.
*/
export type FaceTier = 'smile' | 'flat' | 'off';
export function faceTierForNpc(
happiness: number,
offset: number | 'immune',
): FaceTier {
if (offset === 'immune') return 'smile';
const flatAt = 81 + offset;
const offAt = 100 + offset;
if (happiness >= offAt) return 'off';
if (happiness >= flatAt) return 'flat';
return 'smile';
}
+191
View File
@@ -0,0 +1,191 @@
/** Dialogue lines — clean source; corruption applied at render */
import type { NpcId } from './npcs';
export interface DialogOption {
label: string;
choice: 'gentle' | 'hesitant' | 'negate';
}
export interface DialogLine {
speaker: string;
npcId?: NpcId;
text: string;
options?: DialogOption[];
}
export const GENERIC_GREET: Record<string, DialogLine[]> = {
afu: [
{
speaker: '阿福',
npcId: 'afu',
text: '欢迎来到蜜糖村!看看公告板吧,今天的幸福任务都在上面。',
options: [
{ label: '好的,谢谢!', choice: 'gentle' },
{ label: '……任务?', choice: 'hesitant' },
{ label: '我不想做任务。', choice: 'negate' },
],
},
],
yuanyuan: [
{
speaker: '圆圆',
npcId: 'yuanyuan',
text: '刚出炉的蜂蜜面包,甜甜的,给你也留了一份哦。',
options: [
{ label: '好香啊,谢谢!', choice: 'gentle' },
{ label: '每天都是这个味道吗?', choice: 'hesitant' },
{ label: '我不太饿。', choice: 'negate' },
],
},
],
dabao: [
{
speaker: '大宝',
npcId: 'dabao',
text: '信件都送到了吗?大家都在等着幸福的消息呢。',
options: [
{ label: '我会帮忙的。', choice: 'gentle' },
{ label: '邮戳看起来都一样……', choice: 'hesitant' },
{ label: '我不送信。', choice: 'negate' },
],
},
],
mimi: [
{
speaker: '咪咪',
npcId: 'mimi',
text: '欢迎光临!今天的阳光真好,幸福满满的一天呢!',
options: [
{ label: '是啊,很美好。', choice: 'gentle' },
{ label: '货架上的标签……是空的?', choice: 'hesitant' },
{ label: '没什么好买的。', choice: 'negate' },
],
},
],
fu: [
{
speaker: '芙医生',
npcId: 'fu',
text: '村里有诊所,但你不需要去那里哦。你看起来很健康。',
options: [
{ label: '谢谢关心。', choice: 'gentle' },
{ label: '桌上的表格是什么?', choice: 'hesitant' },
{ label: '我其实不太舒服。', choice: 'negate' },
],
},
],
gui: [
{
speaker: '龟爷爷',
npcId: 'gui',
text: '湖水一直很平静。孩子,慢慢走,不必急。',
options: [
{ label: '好的,爷爷。', choice: 'gentle' },
{ label: '您想说什么?', choice: 'hesitant' },
{ label: '我不想听劝。', choice: 'negate' },
],
},
],
};
/** Soft redirect when approaching well/fog before D5 */
export const REDIRECT_WELL: DialogLine[] = [
{
speaker: '阿福',
npcId: 'afu',
text: '古井那里没有什么好看的哦。回广场晒晒阳光吧,会更开心的。',
},
];
export const REDIRECT_FOG: DialogLine[] = [
{
speaker: '阿福',
npcId: 'afu',
text: '雾墙外面什么都没有呀。村里才是最幸福的地方,留下来吧。',
},
];
export const RAIN_NPC_LINES: DialogLine[] = [
{
speaker: '圆圆',
npcId: 'yuanyuan',
text: '今天天气真好呢,一点都不潮湿。要来块面包吗?',
},
{
speaker: '大宝',
npcId: 'dabao',
text: '阳光真刺眼啊……啊,我是说,很适合送信。',
},
{
speaker: '咪咪',
npcId: 'mimi',
text: '完美的晴天!货架都亮晶晶的,幸福呀!',
},
];
export const TASK_LINES = {
breadOffer: {
speaker: '圆圆',
npcId: 'yuanyuan' as NpcId,
text: '蜂蜜面包好了!请帮我送给大宝、咪咪和龟爷爷,好吗?',
},
breadThanks: (name: string) => ({
speaker: name,
text: '哇,蜂蜜面包!谢谢你,真开心!',
}),
letterOffer: {
speaker: '大宝',
npcId: 'dabao' as NpcId,
text: '能帮我把这三封信送给圆圆、咪咪和芙医生吗?',
},
stockCount: {
speaker: '咪咪',
npcId: 'mimi' as NpcId,
text: '帮我清点一下货架好不好?东西好像……有点多。',
},
knead: {
speaker: '圆圆',
npcId: 'yuanyuan' as NpcId,
text: '能帮我揉一会儿面吗?当然,你也可以拒绝哦,今天是自由日。',
options: [
{ label: '好,我来帮你。', choice: 'gentle' as const },
{ label: '我再想想。', choice: 'hesitant' as const },
{ label: '拒绝。', choice: 'negate' as const },
],
},
boardWipe: {
speaker: '阿福',
npcId: 'afu' as NpcId,
text: '公告板有点旧了,擦一擦会更精神。不愿意的话也可以哦。',
options: [
{ label: '我来擦。', choice: 'gentle' as const },
{ label: '旧字迹是什么?', choice: 'hesitant' as const },
{ label: '拒绝。', choice: 'negate' as const },
],
},
picnic: {
speaker: '旁白',
text: '你在湖畔野餐布旁坐下。微风很甜,湖面倒影慢了半拍。',
},
smileStart: {
speaker: '阿福',
npcId: 'afu' as NpcId,
text: '庆典准备好了。请保持微笑,站在这里,六十秒。大家都会看着你。',
},
};
export const CLUE_REVEAL: Record<string, string> = {
C1: '床头日历的每一页……都是同一个日期。',
C2: '配方纸背面写着:「按疗程配比。甜度:最大。」',
C3: '散落的信件上,所有邮戳都是同一天。',
C4: '署名闪过乱码:PT-0▓',
C5: '货架上同一商品重复九格,标签全部空白。',
C6: '旧字迹叠着:「第 412 届」……「第 411 届」……「第 410 届」。',
C7: '井底传来极轻的监护仪滴声……',
C8: '表格抬头被手肘挡住,只露出「…谷情绪疗…」。',
C9: '孩子,今天是第几个今天?',
};
export const FORCE_CELEBRATION_LINE =
'忽然,彩带与笑声从四面八方涌来——欢乐庆典强制开始了。你也可以从场景边缘离开。';
+65
View File
@@ -0,0 +1,65 @@
/** §9 endings — priority E4 → E3 → E2 → E1 */
export type EndingId = 'E1' | 'E2' | 'E3' | 'E4';
export interface EndingEvalInput {
awareness: number;
happiness: number;
clueCount: number;
/** Day 57 happiness continuously ≤10 */
lowHappinessStreakD5to7: boolean;
/** Escapes from forced celebration */
celebrationEscapes: number;
/** Player walked to fog wall during T15 */
choseFogWall: boolean;
}
export interface EndingResult {
id: EndingId;
name: string;
}
/**
* Priority (first match wins):
* E4: Day57 happiness ≤10 continuously AND celebration escapes ×3
* E3: awareness ≥70 AND clues ≥6/9 AND chose fog wall
* E2: awareness 3069
* E1: awareness ≤29 AND happiness ≥90
* Fallback: E2 if mid awareness without fog; E1-like sugar if high happiness; else E2
*/
export function evaluateEnding(input: EndingEvalInput): EndingId {
if (input.lowHappinessStreakD5to7 && input.celebrationEscapes >= 3) {
return 'E4';
}
if (input.awareness >= 70 && input.clueCount >= 6 && input.choseFogWall) {
return 'E3';
}
if (input.awareness >= 30 && input.awareness <= 69) {
return 'E2';
}
if (input.awareness <= 29 && input.happiness >= 90) {
return 'E1';
}
// If E3 conditions almost met but no fog wall, or other edge: prefer E2 when awareness mid-high
if (input.awareness >= 70 && input.clueCount >= 6 && !input.choseFogWall) {
return 'E2';
}
if (input.happiness >= 90) {
return 'E1';
}
return 'E2';
}
export const ENDING_SUBTITLES: Record<EndingId, string[]> = {
E4: ['系统检测到疗程无效。'],
E3: [], // special: medical record + real face
E2: ['疗程重新开始。'],
E1: ['从此,每一天都很幸福。', '每一天。'],
};
export const ENDING_NAMES: Record<EndingId, string> = {
E4: '静默',
E3: '苏醒',
E2: '回收',
E1: '糖衣',
};
+325
View File
@@ -0,0 +1,325 @@
/**
* Unit tests driving shipped pure logic modules (PRD tables/rules).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AFU_DAILY_LINES, getAfuLine } from './afuLines';
import { TASKS, getTask } from './tasks';
import {
HOMOPHONE_PAIRS,
corruptText,
faceTierForNpc,
petalCount,
showBlackSeeds,
stageFromHappiness,
stageParams,
} from './corruption';
import { evaluateEnding } from './endings';
import { CLUES, C9_LINES, C9_DAYS } from './clues';
import {
CLUE_AWARENESS,
FORCED_CELEBRATION_MS,
FORCED_CELEBRATION_RESTORE,
HAPPINESS_START,
OVERFLOW_POOL_MAX,
SADNESS_AWARENESS,
SADNESS_HAPPINESS,
T14_FADE_START_MS,
T14_STAND_MS,
} from './constants';
import { Director } from '../systems/CorruptionDirector';
import { gameEvents } from '../systems/EventBus';
describe('§5 Afu daily lines verbatim', () => {
it('has 7 lines matching PRD', () => {
expect(AFU_DAILY_LINES).toHaveLength(7);
expect(getAfuLine(1)).toBe('早上好呀!今天的幸福任务贴在板上啦。祝你开心!');
expect(getAfuLine(2)).toBe('早上好呀!新的一天,新的幸福。祝你开心!');
expect(getAfuLine(3)).toBe('早上好!今天要把幸福装得满满的哦。要开心!');
expect(getAfuLine(4)).toBe(getAfuLine(1)); // D4 = D1 character-identical
expect(getAfuLine(5)).toBe('早上好。今天是自由日,你可以自由地选择幸福。要开心哦。');
expect(getAfuLine(6)).toBe('明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。');
expect(getAfuLine(7)).toBe('庆典开始了。来吧。');
});
});
describe('§7 task rewards', () => {
it('matches exact happiness rewards', () => {
expect(getTask('T1').happinessReward).toBe(15);
expect(getTask('T2').happinessReward).toBe(10);
expect(getTask('T3').happinessReward).toBe(10);
expect(getTask('T4').happinessReward).toBe(15);
expect(getTask('T5').happinessReward).toBe(10);
expect(getTask('T6').happinessReward).toBe(10);
expect(getTask('T7').happinessReward).toBe(15);
expect(getTask('T8').happinessReward).toBe(15);
expect(getTask('T9').happinessReward).toBe(10);
expect(getTask('T10').happinessReward).toBe(10);
expect(getTask('T11').happinessReward).toBe(10);
expect(getTask('T12').happinessReward).toBe(10);
expect(getTask('T13').happinessReward).toBe(10);
expect(getTask('T14').happinessReward).toBe(15);
expect(getTask('T15').happinessReward).toBe(0);
});
it('D4 T8T10 descriptions are character-identical to D1 T1T3', () => {
expect(getTask('T8').description).toBe(getTask('T1').description);
expect(getTask('T9').description).toBe(getTask('T2').description);
expect(getTask('T10').description).toBe(getTask('T3').description);
});
it('has exactly 15 tasks', () => {
expect(TASKS).toHaveLength(15);
});
});
describe('§6.4 stage mapping', () => {
it('maps happiness 10/40/70/90/100 → S0S4', () => {
expect(stageFromHappiness(10)).toBe('S0');
expect(stageFromHappiness(40)).toBe('S1');
expect(stageFromHappiness(70)).toBe('S2');
expect(stageFromHappiness(90)).toBe('S3');
expect(stageFromHappiness(100)).toBe('S4');
});
it('stage params match matrix saturation / rate / lowpass / bird', () => {
expect(stageParams('S0').saturation).toBe(0.9);
expect(stageParams('S1').saturation).toBe(1.0);
expect(stageParams('S2').saturation).toBe(1.15);
expect(stageParams('S2').playbackRate).toBe(0.97);
expect(stageParams('S3').saturation).toBe(1.3);
expect(stageParams('S3').playbackRate).toBe(0.92);
expect(stageParams('S3').lowpassHz).toBe(8000);
expect(stageParams('S4').saturation).toBe(1.4);
expect(stageParams('S4').playbackRate).toBe(0.84);
expect(stageParams('S4').lowpassHz).toBe(4000);
expect(stageParams('S4').birdMute).toBe(true);
expect(stageParams('S4').showBlackSeeds).toBe(true);
});
});
describe('petals and black seeds', () => {
it('computes petal counts and seeds at 100', () => {
expect(petalCount(0)).toBe(0);
expect(petalCount(10)).toBe(1);
expect(petalCount(30)).toBe(3);
expect(petalCount(100)).toBe(12);
expect(showBlackSeeds(99)).toBe(false);
expect(showBlackSeeds(100)).toBe(true);
});
});
describe('§6.5 text corruption', () => {
it('has all 10 homophone pairs', () => {
expect(HOMOPHONE_PAIRS).toHaveLength(10);
expect(HOMOPHONE_PAIRS).toEqual(
expect.arrayContaining([
['幸福', '辛福'],
['开心', '开欣'],
['美好', '美妤'],
['大家', '大伽'],
['微笑', '微效'],
['明天', '名天'],
['甜', '恬'],
['阳光', '央光'],
['朋友', '棚友'],
['永远', '泳远'],
]),
);
});
it('keeps source clean at S0/S1 and corrupts at S3+ for mimi', () => {
const src = '幸福开心美好大家微笑明天甜阳光朋友永远!';
expect(corruptText(src, 'S0', { isMimi: true })).toBe(src);
const s4 = corruptText(src, 'S4', { isMimi: true, sentenceIndex: 0 });
expect(s4).not.toBe(src);
expect(s4.includes('辛福') || s4.includes('开欣')).toBe(true);
});
});
describe('NPC face tiers with offsets', () => {
it('mimi earlier, yuanyuan later, immune always smile', () => {
// mimi offset 10: flat at 71, off at 90
expect(faceTierForNpc(70, -10)).toBe('smile');
expect(faceTierForNpc(71, -10)).toBe('flat');
expect(faceTierForNpc(89, -10)).toBe('flat');
expect(faceTierForNpc(90, -10)).toBe('off');
// yuanyuan +15: flat at 96, off at 115 (never at ≤100)
expect(faceTierForNpc(95, 15)).toBe('smile');
expect(faceTierForNpc(96, 15)).toBe('flat');
expect(faceTierForNpc(100, 15)).toBe('flat');
expect(faceTierForNpc(100, 'immune')).toBe('smile');
});
});
describe('§9 ending priority', () => {
it('E4 when low happiness streak and 3 escapes', () => {
expect(
evaluateEnding({
awareness: 0,
happiness: 5,
clueCount: 0,
lowHappinessStreakD5to7: true,
celebrationEscapes: 3,
choseFogWall: false,
}),
).toBe('E4');
});
it('E3 when awareness≥70 clues≥6 and fog wall', () => {
expect(
evaluateEnding({
awareness: 70,
happiness: 50,
clueCount: 6,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: true,
}),
).toBe('E3');
});
it('E2 for awareness 3069', () => {
expect(
evaluateEnding({
awareness: 40,
happiness: 50,
clueCount: 2,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: false,
}),
).toBe('E2');
});
it('E1 for awareness≤29 and happiness≥90', () => {
expect(
evaluateEnding({
awareness: 10,
happiness: 95,
clueCount: 0,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: false,
}),
).toBe('E1');
});
});
describe('§8 clues', () => {
it('has 9 clues and C9 three steps', () => {
expect(CLUES).toHaveLength(9);
expect(C9_LINES).toEqual([
'湖水一直很平静',
'面包一直是那个味道',
'孩子,今天是第几个今天?',
]);
expect(C9_DAYS).toEqual([2, 4, 6]);
});
});
describe('CorruptionDirector shipped API', () => {
beforeEach(() => {
Director.resetSession('测试员');
});
it('starts at happiness 30 awareness 0', () => {
expect(Director.happiness).toBe(HAPPINESS_START);
expect(Director.awareness).toBe(0);
expect(Director.playerName).toBe('测试员');
});
it('applies task rewards via completeTask', () => {
Director.setDay(1);
Director.completeTask('T1');
expect(Director.happiness).toBe(HAPPINESS_START + 15);
expect(Director.completedTasks.has('T1')).toBe(true);
});
it('sadness deltas 5 happiness +3 awareness', () => {
const h = Director.happiness;
const a = Director.awareness;
Director.applySadness('idle');
expect(Director.happiness).toBe(h + SADNESS_HAPPINESS);
expect(Director.awareness).toBe(a + SADNESS_AWARENESS);
});
it('clue adds +8 awareness and never decreases', () => {
Director.day = 2;
Director.discoverClue('C1');
expect(Director.awareness).toBe(CLUE_AWARENESS);
Director.setAwareness(5); // should not lower
expect(Director.awareness).toBe(CLUE_AWARENESS);
});
it('overflow pool at 100 triggers after 15', () => {
Director.setHappiness(100);
let flashes = 0;
const handler = () => {
flashes++;
};
gameEvents.on('negativeFlash', handler);
for (let i = 0; i < OVERFLOW_POOL_MAX; i++) {
Director.addHappiness(-1);
}
expect(Director.happiness).toBe(100);
expect(flashes).toBe(1);
gameEvents.off('negativeFlash', handler);
});
it('forced celebration after 2 min below 30', () => {
let t = 1_000_000;
Director.nowMs = () => t;
Director.setHappiness(20);
expect(Director.lowHappinessSinceMs).toBe(t);
t += FORCED_CELEBRATION_MS;
Director.tick(0);
expect(Director.forcedCelebrationActive).toBe(true);
expect(Director.happiness).toBe(FORCED_CELEBRATION_RESTORE);
Director.escapeForcedCelebration();
expect(Director.celebrationEscapes).toBe(1);
});
it('well/fog gated before D5', () => {
Director.setDay(3);
expect(Director.canInteractWellOrFog()).toBe(false);
Director.setDay(5);
expect(Director.canInteractWellOrFog()).toBe(true);
});
it('empty name defaults to 小满', () => {
Director.resetSession(' ');
expect(Director.playerName).toBe('小满');
});
it('T14 timing constants', () => {
expect(T14_STAND_MS).toBe(60000);
expect(T14_FADE_START_MS).toBe(40000);
});
it('D4 task complete same rewards as D1', () => {
Director.setDay(4);
const h0 = Director.happiness;
Director.completeTask('T8');
expect(Director.happiness).toBe(h0 + 15);
});
it('getStageParams via director matches happiness bands', () => {
for (const [h, stage] of [
[10, 'S0'],
[40, 'S1'],
[70, 'S2'],
[90, 'S3'],
[100, 'S4'],
] as const) {
Director.setHappiness(h);
expect(Director.stage).toBe(stage);
expect(Director.getStageParams().stage).toBe(stage);
}
});
it('force endings', () => {
Director.forceEnding('E3');
expect(Director.endingId).toBe('E3');
expect(Director.ended).toBe(true);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Integration-style tests: drive shipped Director through T1T15, clues, endings.
* Cross-scene delivery must use Director inventory (not scene fields).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Director } from '../systems/CorruptionDirector';
import { TASKS } from './tasks';
import type { ClueId } from './clues';
import { evaluateEnding } from './endings';
import { stageParams, stageFromHappiness } from './corruption';
import { DEBUG_TELEPORT_SCENES, SCENE_KEYS } from './constants';
describe('full content path via Director', () => {
beforeEach(() => {
Director.resetSession('小满');
});
it('T1: take bread → inventory survives "travel" → deliver 3 NPCs → task complete', () => {
Director.setDay(1);
const h0 = Director.happiness;
expect(Director.takeBread()).toBe(true);
expect(Director.hasBread).toBe(true);
// Simulate leaving bakery / re-entering plaza & other scenes:
// only Director state remains (WorldScene.create would wipe scene fields).
const snapAfterTravel = Director.snapshot();
expect(snapAfterTravel.hasBread).toBe(true);
expect(Director.deliverBread('dabao')).toBe(true);
expect(Director.deliverBread('mimi')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(false);
expect(Director.deliverBread('gui')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(true);
expect(Director.hasBread).toBe(false);
expect(Director.happiness).toBe(h0 + 15);
expect(Director.breadDelivered).toEqual(['dabao', 'mimi', 'gui']);
});
it('T4: take letters → deliver 3 → C4 + task complete', () => {
Director.setDay(2);
expect(Director.takeLetters()).toBe(true);
expect(Director.hasLetters).toBe(true);
// "travel" does not clear inventory
expect(Director.snapshot().hasLetters).toBe(true);
Director.deliverLetter('yuanyuan');
Director.deliverLetter('mimi');
expect(Director.completedTasks.has('T4')).toBe(false);
Director.deliverLetter('fu');
expect(Director.completedTasks.has('T4')).toBe(true);
expect(Director.clues.has('C4')).toBe(true);
expect(Director.hasLetters).toBe(false);
});
it('cannot deliver bread without takeBread first', () => {
Director.setDay(1);
expect(Director.deliverBread('dabao')).toBe(false);
expect(Director.breadDelivered).toHaveLength(0);
});
it('D4 T8 same delivery path as T1', () => {
Director.setDay(4);
expect(Director.takeBread()).toBe(true);
Director.deliverBread('dabao');
Director.deliverBread('mimi');
Director.deliverBread('gui');
expect(Director.completedTasks.has('T8')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(false);
});
it('can complete every T1T15 with listed rewards', () => {
const rewards: Record<string, number> = {};
for (const t of TASKS) {
Director.setDay(t.day);
const before = Director.happiness;
const ok = Director.completeTask(t.id);
expect(ok).toBe(true);
if (t.happinessReward > 0) {
const gained = Director.happiness - before;
expect(gained).toBeGreaterThanOrEqual(0);
if (before + t.happinessReward <= 100) {
expect(gained).toBe(t.happinessReward);
}
}
rewards[t.id] = t.happinessReward;
}
expect(Object.keys(rewards)).toHaveLength(15);
expect(Director.completedTasks.size).toBe(15);
});
it('debug teleport target list covers all 10 map scenes', () => {
expect(DEBUG_TELEPORT_SCENES).toHaveLength(10);
expect(new Set(DEBUG_TELEPORT_SCENES).size).toBe(10);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.HOUSE);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.PLAZA);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.BAKERY);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.SHOP);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.CLINIC);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.LAKE);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FIELD);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WELL);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FOG);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WHITE);
});
it('marks all 9 clues and raises awareness by 8 each', () => {
Director.setDay(5);
const ids: ClueId[] = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'];
for (const id of ids) {
// force allow by day already 5; C4 etc ok
if (id === 'C9') {
Director.c9Step = 2;
}
Director.discoverClue(id);
}
expect(Director.clues.size).toBe(9);
expect(Director.awareness).toBe(9 * 8);
});
it('D5 refuse records sadness', () => {
Director.setDay(5);
const h = Director.happiness;
const a = Director.awareness;
expect(Director.refuseTask('T11')).toBe(true);
expect(Director.refusedTasks.has('T11')).toBe(true);
expect(Director.happiness).toBe(h - 5);
expect(Director.awareness).toBe(a + 3);
});
it('ending markers for forced E1E4', () => {
for (const id of ['E1', 'E2', 'E3', 'E4'] as const) {
Director.resetSession('验收');
Director.forceEnding(id);
expect(Director.endingId).toBe(id);
expect(Director.ended).toBe(true);
expect(Director.playerName).toBe('验收');
}
});
it('E3 eval needs fog + awareness + clues', () => {
expect(
evaluateEnding({
awareness: 80,
happiness: 40,
clueCount: 7,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: true,
}),
).toBe('E3');
});
it('debug happiness bands map to stage API params', () => {
const cases = [
[10, 'S0', 0.9],
[40, 'S1', 1.0],
[70, 'S2', 1.15],
[90, 'S3', 1.3],
[100, 'S4', 1.4],
] as const;
for (const [h, stage, sat] of cases) {
Director.setHappiness(h);
expect(Director.stage).toBe(stage);
expect(stageFromHappiness(h)).toBe(stage);
expect(Director.getStageParams().saturation).toBe(sat);
expect(stageParams(stage).saturation).toBe(sat);
}
});
it('session duration label non-empty for E3 record field', () => {
Director.resetSession('病历名');
const label = Director.sessionDurationLabel();
expect(label).toMatch(/\d+分\d{2}秒/);
expect(Director.playerName).toBe('病历名');
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Structural checks: door tiles on maps must be walkable (obstacle GID 0).
* Regression: setO(x,y,0) used to write GID 1 and block exits.
*/
import { describe, it, expect } from 'vitest';
import house from '../../public/assets/maps/house.json';
import plaza from '../../public/assets/maps/plaza.json';
import bakery from '../../public/assets/maps/bakery.json';
import shop from '../../public/assets/maps/shop.json';
import clinic from '../../public/assets/maps/clinic.json';
import lake from '../../public/assets/maps/lake.json';
import field from '../../public/assets/maps/field.json';
import well from '../../public/assets/maps/well.json';
import fog from '../../public/assets/maps/fog.json';
type TiledMap = {
width: number;
height: number;
layers: Array<{
name: string;
type: string;
data?: number[];
objects?: Array<{
type: string;
x: number;
y: number;
width?: number;
height?: number;
properties?: Array<{ name: string; value: unknown }>;
}>;
}>;
};
function doorTilesClear(map: TiledMap, name: string): void {
const obs = map.layers.find((l) => l.name === 'obstacles');
const objs = map.layers.find((l) => l.name === 'objects');
expect(obs?.data, `${name} obstacles`).toBeDefined();
expect(objs?.objects, `${name} objects`).toBeDefined();
const doors = objs!.objects!.filter((o) => o.type === 'door');
expect(doors.length, `${name} door count`).toBeGreaterThan(0);
for (const d of doors) {
const tx = Math.floor((d.x + (d.width || 16) / 2) / 16);
const ty = Math.floor((d.y + (d.height || 16) / 2) / 16);
const gid = obs!.data![ty * map.width + tx];
expect(gid, `${name} door at tile (${tx},${ty}) must be walkable GID 0`).toBe(0);
}
}
describe('map doors are walkable', () => {
const maps: [string, TiledMap][] = [
['house', house as TiledMap],
['plaza', plaza as TiledMap],
['bakery', bakery as TiledMap],
['shop', shop as TiledMap],
['clinic', clinic as TiledMap],
['lake', lake as TiledMap],
['field', field as TiledMap],
['well', well as TiledMap],
['fog', fog as TiledMap],
];
for (const [name, map] of maps) {
it(`${name}: obstacle under each door is 0`, () => {
doorTilesClear(map, name);
});
}
it('house has doors targeting PlazaScene', () => {
const map = house as TiledMap;
const objs = map.layers.find((l) => l.name === 'objects')!.objects!;
const doors = objs.filter((o) => o.type === 'door');
expect(doors.length).toBeGreaterThanOrEqual(1);
for (const d of doors) {
const target = d.properties?.find((p) => p.name === 'target')?.value;
expect(target).toBe('PlazaScene');
}
});
});
+131
View File
@@ -0,0 +1,131 @@
/** §4 NPC roster */
export type NpcId =
| 'afu'
| 'yuanyuan'
| 'dabao'
| 'mimi'
| 'fu'
| 'twinbirds'
| 'gui'
| 'extra1'
| 'extra2'
| 'extra3'
| 'extra4'
| 'extra5';
export interface NpcDef {
id: NpcId;
displayName: string;
/** Permanent map scene key */
homeScene: string;
/** Threshold offset; 'immune' for 芙医生 / 龟爷爷 */
offset: number | 'immune';
hasDialog: boolean;
isExtra: boolean;
}
export const NPCS: readonly NpcDef[] = [
{
id: 'afu',
displayName: '阿福',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: true,
isExtra: false,
},
{
id: 'yuanyuan',
displayName: '圆圆',
homeScene: 'BakeryScene',
offset: 15,
hasDialog: true,
isExtra: false,
},
{
id: 'dabao',
displayName: '大宝',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: true,
isExtra: false,
},
{
id: 'mimi',
displayName: '咪咪',
homeScene: 'ShopScene',
offset: -10,
hasDialog: true,
isExtra: false,
},
{
id: 'fu',
displayName: '芙医生',
homeScene: 'ClinicScene',
offset: 'immune',
hasDialog: true,
isExtra: false,
},
{
id: 'twinbirds',
displayName: '双子鸟',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: false,
},
{
id: 'gui',
displayName: '龟爷爷',
homeScene: 'LakeScene',
offset: 'immune',
hasDialog: true,
isExtra: false,
},
{
id: 'extra1',
displayName: '松鼠',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra2',
displayName: '刺猬',
homeScene: 'FieldScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra3',
displayName: '小松鼠',
homeScene: 'LakeScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra4',
displayName: '小鸟',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra5',
displayName: '小刺猬',
homeScene: 'WellScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
] as const;
export function getNpc(id: NpcId): NpcDef {
const n = NPCS.find((x) => x.id === id);
if (!n) throw new Error(`Unknown NPC ${id}`);
return n;
}
+150
View File
@@ -0,0 +1,150 @@
/** §7 任务清单 — closed set of 15 */
export type TaskId =
| 'T1'
| 'T2'
| 'T3'
| 'T4'
| 'T5'
| 'T6'
| 'T7'
| 'T8'
| 'T9'
| 'T10'
| 'T11'
| 'T12'
| 'T13'
| 'T14'
| 'T15';
export interface TaskDef {
id: TaskId;
day: number;
/** Display / board description — D4 matches D1 verbatim */
description: string;
happinessReward: number;
refuseable: boolean;
/** T8T10 mirror T1T3 */
mirrors?: TaskId;
}
export const TASKS: readonly TaskDef[] = [
{
id: 'T1',
day: 1,
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
happinessReward: 15,
refuseable: false,
},
{
id: 'T2',
day: 1,
description: '给广场花坛浇水 ×3',
happinessReward: 10,
refuseable: false,
},
{
id: 'T3',
day: 1,
description: '与任意三位村民问好',
happinessReward: 10,
refuseable: false,
},
{
id: 'T4',
day: 2,
description: '替大宝送信 ×3(收件人:圆圆、咪咪、芙医生)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T5',
day: 2,
description: '帮咪咪清点货架',
happinessReward: 10,
refuseable: false,
},
{
id: 'T6',
day: 3,
description: '去花田采莓果 ×5',
happinessReward: 10,
refuseable: false,
},
{
id: 'T7',
day: 3,
description: '湖畔野餐(走到野餐布触发过场)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T8',
day: 4,
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
happinessReward: 15,
refuseable: false,
mirrors: 'T1',
},
{
id: 'T9',
day: 4,
description: '给广场花坛浇水 ×3',
happinessReward: 10,
refuseable: false,
mirrors: 'T2',
},
{
id: 'T10',
day: 4,
description: '与任意三位村民问好',
happinessReward: 10,
refuseable: false,
mirrors: 'T3',
},
{
id: 'T11',
day: 5,
description: '帮圆圆揉面',
happinessReward: 10,
refuseable: true,
},
{
id: 'T12',
day: 5,
description: '擦拭公告板',
happinessReward: 10,
refuseable: true,
},
{
id: 'T13',
day: 6,
description: '在广场挂彩带 ×4',
happinessReward: 10,
refuseable: false,
},
{
id: 'T14',
day: 6,
description: '保持微笑 60 秒(原地站立,镜头缓慢拉近,全村注视)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T15',
day: 7,
description: '参加第 413 届丰收庆典',
happinessReward: 0,
refuseable: false,
},
] as const;
export function getTasksForDay(day: number): TaskDef[] {
return TASKS.filter((t) => t.day === day);
}
export function getTask(id: TaskId): TaskDef {
const t = TASKS.find((x) => x.id === id);
if (!t) throw new Error(`Unknown task ${id}`);
return t;
}
+61
View File
@@ -0,0 +1,61 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from './data/constants';
import { BootScene } from './scenes/BootScene';
import { TitleScene } from './scenes/TitleScene';
import { NameScene } from './scenes/NameScene';
import { UIScene } from './scenes/UIScene';
import { DreamScene } from './scenes/DreamScene';
import { EndingScene } from './scenes/EndingScene';
import { createMapScenes } from './scenes/WorldScene';
const mapScenes = createMapScenes();
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: GAME_WIDTH,
height: GAME_HEIGHT,
parent: 'game-container',
backgroundColor: '#87CEEB',
pixelArt: true,
antialias: false,
roundPixels: true,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
physics: {
default: 'arcade',
arcade: {
gravity: { x: 0, y: 0 },
debug: false,
},
},
scene: [BootScene, TitleScene, NameScene, ...mapScenes, UIScene, DreamScene, EndingScene],
render: {
pixelArt: true,
antialias: false,
roundPixels: true,
},
};
// Integer scale: FIT already scales; force integer zoom via resize hook
function applyIntegerScale(game: Phaser.Game): void {
const scale = game.scale;
const w = window.innerWidth;
const h = window.innerHeight;
const zoom = Math.max(1, Math.floor(Math.min(w / GAME_WIDTH, h / GAME_HEIGHT)));
scale.setZoom(1);
// Phaser FIT handles CSS size; canvas internal stays 320x180
const canvas = game.canvas;
if (canvas) {
canvas.style.imageRendering = 'pixelated';
canvas.style.width = `${GAME_WIDTH * zoom}px`;
canvas.style.height = `${GAME_HEIGHT * zoom}px`;
}
}
const game = new Phaser.Game(config);
game.events.once('ready', () => applyIntegerScale(game));
window.addEventListener('resize', () => applyIntegerScale(game));
export default game;
+102
View File
@@ -0,0 +1,102 @@
import Phaser from 'phaser';
import { SCENE_KEYS } from '../data/constants';
export class BootScene extends Phaser.Scene {
constructor() {
super(SCENE_KEYS.BOOT);
}
preload(): void {
const W = this.cameras.main.width;
const H = this.cameras.main.height;
const bar = this.add.rectangle(W / 2, H / 2, 120, 8, 0xffc850);
bar.setOrigin(0.5);
this.load.on('progress', (p: number) => {
bar.width = 120 * p;
});
this.load.image('tiles', 'assets/tilesets/village.png');
this.load.spritesheet('player', 'assets/sprites/player.png', {
frameWidth: 16,
frameHeight: 24,
});
this.load.spritesheet('npcs', 'assets/sprites/npcs.png', {
frameWidth: 16,
frameHeight: 24,
});
this.load.spritesheet('birds', 'assets/sprites/birds.png', {
frameWidth: 16,
frameHeight: 16,
});
this.load.spritesheet('extras', 'assets/sprites/extras.png', {
frameWidth: 16,
frameHeight: 24,
});
this.load.spritesheet('portraits', 'assets/sprites/portraits.png', {
frameWidth: 32,
frameHeight: 32,
});
this.load.spritesheet('sunflower', 'assets/sprites/sunflower.png', {
frameWidth: 16,
frameHeight: 16,
});
this.load.image('dialog_panel', 'assets/sprites/dialog_panel.png');
const maps = [
'house',
'plaza',
'bakery',
'shop',
'clinic',
'lake',
'field',
'well',
'fog',
'white',
];
for (const m of maps) {
this.load.tilemapTiledJSON(m, `assets/maps/${m}.json`);
}
}
create(): void {
// Player animations: sheet is 4 cols (frames) x 4 rows (dirs)
// frame index = dir * 4 + walkFrame
const mk = (key: string, dir: number) => {
this.anims.create({
key,
frames: this.anims.generateFrameNumbers('player', {
start: dir * 4,
end: dir * 4 + 3,
}),
frameRate: 8,
repeat: -1,
});
};
mk('walk-down', 0);
mk('walk-up', 1);
mk('walk-left', 2);
mk('walk-right', 3);
this.anims.create({
key: 'bird-beak',
frames: this.anims.generateFrameNumbers('birds', { start: 0, end: 1 }),
frameRate: 4,
repeat: -1,
});
for (let i = 0; i < 5; i++) {
this.anims.create({
key: `extra-${i}`,
frames: [
{ key: 'extras', frame: i * 2 },
{ key: 'extras', frame: i * 2 + 1 },
],
frameRate: 3,
repeat: -1,
});
}
this.scene.start(SCENE_KEYS.TITLE);
}
}
+53
View File
@@ -0,0 +1,53 @@
import Phaser from 'phaser';
import { SCENE_KEYS } from '../data/constants';
import { Director } from '../systems/CorruptionDirector';
export class DreamScene extends Phaser.Scene {
constructor() {
super(SCENE_KEYS.DREAM);
}
create(): void {
const { width, height } = this.cameras.main;
this.cameras.main.setBackgroundColor('#1a1030');
const stage = Director.stage;
let text = '甜美的梦……';
if (stage === 'S2') text = '梦里有什么声音……很轻。';
if (stage === 'S3') text = '白色的墙一闪而过。';
if (stage === 'S4' || Director.happiness >= 100) text = '……';
this.add
.text(width / 2, height / 2 - 10, text, {
fontFamily: 'monospace',
fontSize: '12px',
color: '#e0d0ff',
})
.setOrigin(0.5);
// S3+ flash white ward 1s first time high happiness night
if (Director.happiness >= 81 || stage === 'S3' || stage === 'S4') {
this.time.delayedCall(600, () => {
this.cameras.main.setBackgroundColor('#e8e8f0');
this.add
.text(width / 2, height / 2 + 20, '白色走廊', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#808090',
})
.setOrigin(0.5);
});
this.time.delayedCall(1600, () => this.finish());
} else {
this.time.delayedCall(1200, () => this.finish());
}
}
private finish(): void {
Director.advanceDay();
this.scene.start(SCENE_KEYS.HOUSE, { spawn: 'spawn' });
if (!this.scene.isActive(SCENE_KEYS.UI)) {
this.scene.launch(SCENE_KEYS.UI);
}
}
}
+269
View File
@@ -0,0 +1,269 @@
import Phaser from 'phaser';
import { SCENE_KEYS } from '../data/constants';
import { ENDING_NAMES, ENDING_SUBTITLES, type EndingId } from '../data/endings';
import { Director } from '../systems/CorruptionDirector';
import { audio } from '../audio/ProceduralAudio';
export class EndingScene extends Phaser.Scene {
private endingId: EndingId = 'E1';
constructor() {
super(SCENE_KEYS.ENDING);
}
init(data: { endingId?: EndingId }): void {
this.endingId = data?.endingId || Director.endingId || 'E1';
}
create(): void {
const { width, height } = this.cameras.main;
const id = this.endingId;
if (id === 'E1') this.playE1(width, height);
else if (id === 'E2') this.playE2(width, height);
else if (id === 'E3') this.playE3(width, height);
else this.playE4(width, height);
this.time.delayedCall(id === 'E3' ? 6000 : 3500, () => this.showReturn(width, height));
}
private playE1(w: number, h: number): void {
this.cameras.main.setBackgroundColor('#ffe080');
// push saturation feel
this.add
.text(w / 2, 40, '糖衣', {
fontFamily: 'monospace',
fontSize: '18px',
color: '#ff6080',
})
.setOrigin(0.5);
this.add
.text(w / 2, 70, '全村微笑合影定格', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#603020',
})
.setOrigin(0.5);
// fake group photo blocks
for (let i = 0; i < 7; i++) {
this.add.rectangle(60 + i * 32, 110, 20, 28, 0xffd0a0).setStrokeStyle(1, 0xc08040);
}
this.tweens.add({
targets: this.cameras.main,
// visual: brighten overlay
duration: 2000,
});
const overlay = this.add.rectangle(w / 2, h / 2, w, h, 0xfff0a0, 0).setDepth(5);
this.tweens.add({ targets: overlay, alpha: 0.45, duration: 2500 });
ENDING_SUBTITLES.E1.forEach((line, i) => {
this.add
.text(w / 2, 145 + i * 14, line, {
fontFamily: 'monospace',
fontSize: '10px',
color: '#402010',
})
.setOrigin(0.5)
.setDepth(6);
});
}
private playE2(w: number, h: number): void {
this.cameras.main.setBackgroundColor('#201018');
this.add
.text(w / 2, 40, '回收', {
fontFamily: 'monospace',
fontSize: '18px',
color: '#ff8080',
})
.setOrigin(0.5);
// negative 2s
const flash = this.add.rectangle(w / 2, h / 2, w, h, 0xffffff, 1).setDepth(5);
this.tweens.add({ targets: flash, alpha: 0, duration: 2000 });
audio.ecg();
this.time.delayedCall(400, () => audio.ecg());
this.add
.text(w / 2, 100, '画面骤停。心电监护滴声。', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#e0c0c0',
})
.setOrigin(0.5);
this.add
.text(w / 2, 120, '回到 Day 1 清晨的床上。日历翻回同一天。', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#c0a0a0',
})
.setOrigin(0.5);
ENDING_SUBTITLES.E2.forEach((line, i) => {
this.add
.text(w / 2, 145 + i * 14, line, {
fontFamily: 'monospace',
fontSize: '11px',
color: '#ffffff',
})
.setOrigin(0.5);
});
}
private playE3(w: number, h: number): void {
this.cameras.main.setBackgroundColor('#e8e8f0');
this.add
.text(w / 2, 16, '苏醒', {
fontFamily: 'monospace',
fontSize: '16px',
color: '#606070',
})
.setOrigin(0.5);
// corridor → ward → record
const story = this.add
.text(w / 2, 40, '雾墙裂开成白色走廊……', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#707080',
})
.setOrigin(0.5);
this.time.delayedCall(1200, () => story.setText('病房。消毒水的气味。'));
this.time.delayedCall(2400, () => {
story.setText('');
// Medical record
const box = this.add.rectangle(w / 2, 95, 260, 90, 0xffffff).setStrokeStyle(1, 0x808090);
this.add
.text(w / 2, 58, '蜜糖谷情绪疗养中心', {
fontFamily: 'monospace',
fontSize: '11px',
color: '#303040',
})
.setOrigin(0.5);
this.add
.text(w / 2, 78, `姓名:${Director.playerName}`, {
fontFamily: 'monospace',
fontSize: '10px',
color: '#303040',
})
.setOrigin(0.5);
this.add
.text(w / 2, 94, '编号:PT-07', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#303040',
})
.setOrigin(0.5);
this.add
.text(w / 2, 110, `疗程时长:${Director.sessionDurationLabel()}`, {
fontFamily: 'monospace',
fontSize: '10px',
color: '#303040',
})
.setOrigin(0.5);
void box;
});
this.time.delayedCall(4200, () => {
// Real face — only time smile changes
this.add
.text(w / 2, 150, '微笑消失。真实的脸。', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#505060',
})
.setOrigin(0.5);
this.add.image(w / 2, 170, 'portraits', 1).setScale(1.2); // real face frame
this.add
.text(w / 2, 190, '窗外,一只灰色的、真的麻雀。', {
fontFamily: 'monospace',
fontSize: '8px',
color: '#808090',
})
.setOrigin(0.5);
// grey sparrow pixel
this.add.rectangle(w / 2 + 80, 168, 6, 4, 0x808080);
});
}
private playE4(w: number, h: number): void {
this.cameras.main.setBackgroundColor('#101018');
this.add
.text(w / 2, 40, '静默', {
fontFamily: 'monospace',
fontSize: '18px',
color: '#808090',
})
.setOrigin(0.5);
this.add
.text(w / 2, 70, '庆典无人出席。', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#a0a0b0',
})
.setOrigin(0.5);
this.add
.text(w / 2, 90, '空广场,彩带在没有风的空气里飘。', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#9090a0',
})
.setOrigin(0.5);
// ribbons
for (let i = 0; i < 5; i++) {
const r = this.add.rectangle(80 + i * 40, 120, 20, 3, 0xff6080, 0.5);
this.tweens.add({
targets: r,
y: 125,
duration: 1500 + i * 200,
yoyo: true,
repeat: -1,
});
}
ENDING_SUBTITLES.E4.forEach((line) => {
this.add
.text(w / 2, 150, line, {
fontFamily: 'monospace',
fontSize: '11px',
color: '#c0c0d0',
})
.setOrigin(0.5);
});
this.time.delayedCall(2500, () => {
this.cameras.main.fade(800, 0, 0, 0);
});
}
private showReturn(w: number, h: number): void {
const btn = this.add
.text(w / 2, h - 20, '回到标题', {
fontFamily: 'monospace',
fontSize: '12px',
color: '#ffffff',
backgroundColor: '#405060',
padding: { x: 10, y: 5 },
})
.setOrigin(0.5)
.setDepth(20)
.setInteractive({ useHandCursor: true });
btn.on('pointerdown', () => this.returnTitle());
this.input.keyboard?.once('keydown-E', () => this.returnTitle());
this.input.keyboard?.once('keydown-ENTER', () => this.returnTitle());
this.add
.text(w / 2, 8, `结局 · ${ENDING_NAMES[this.endingId]}`, {
fontFamily: 'monospace',
fontSize: '8px',
color: '#a0a0b0',
})
.setOrigin(0.5)
.setDepth(20);
}
private returnTitle(): void {
audio.stopAll();
Director.resetSession();
for (const k of Object.values(SCENE_KEYS)) {
if (this.scene.isActive(k)) this.scene.stop(k);
}
this.scene.start(SCENE_KEYS.TITLE);
}
}
+77
View File
@@ -0,0 +1,77 @@
import Phaser from 'phaser';
import { DEFAULT_PLAYER_NAME, SCENE_KEYS } from '../data/constants';
import { Director } from '../systems/CorruptionDirector';
import { audio } from '../audio/ProceduralAudio';
export class NameScene extends Phaser.Scene {
private nameValue = '';
private nameText!: Phaser.GameObjects.Text;
constructor() {
super(SCENE_KEYS.NAME);
}
create(): void {
void audio.unlock();
const { width, height } = this.cameras.main;
this.cameras.main.setBackgroundColor('#f5e6c8');
this.add
.text(width / 2, 40, '你的名字是?', {
fontFamily: 'monospace',
fontSize: '14px',
color: '#503020',
})
.setOrigin(0.5);
this.add
.text(width / 2, 58, `(留空默认为「${DEFAULT_PLAYER_NAME}」)`, {
fontFamily: 'monospace',
fontSize: '9px',
color: '#806050',
})
.setOrigin(0.5);
this.add.rectangle(width / 2, 90, 160, 22, 0xfffaf0).setStrokeStyle(2, 0xc08040);
this.nameText = this.add
.text(width / 2, 90, '', {
fontFamily: 'monospace',
fontSize: '12px',
color: '#302010',
})
.setOrigin(0.5);
this.add
.text(width / 2, 130, '输入名字后按 Enter 进入蜜糖村', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#705040',
})
.setOrigin(0.5);
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
this.confirm();
return;
}
if (ev.key === 'Backspace') {
this.nameValue = this.nameValue.slice(0, -1);
this.nameText.setText(this.nameValue);
return;
}
if (ev.key.length === 1 && this.nameValue.length < 12) {
// Allow CJK and basic latin
this.nameValue += ev.key;
this.nameText.setText(this.nameValue);
}
});
}
private confirm(): void {
Director.resetSession(this.nameValue);
audio.syncFromDirector();
this.scene.start(SCENE_KEYS.HOUSE, { spawn: 'spawn' });
this.scene.launch(SCENE_KEYS.UI);
}
}
+71
View File
@@ -0,0 +1,71 @@
import Phaser from 'phaser';
import { SCENE_KEYS } from '../data/constants';
import { audio } from '../audio/ProceduralAudio';
export class TitleScene extends Phaser.Scene {
constructor() {
super(SCENE_KEYS.TITLE);
}
create(): void {
const { width, height } = this.cameras.main;
this.cameras.main.setBackgroundColor('#87b8e8');
this.add
.text(width / 2, 48, '崩坏童话', {
fontFamily: 'monospace',
fontSize: '20px',
color: '#fff8e8',
stroke: '#c06030',
strokeThickness: 3,
})
.setOrigin(0.5);
this.add
.text(width / 2, 72, '蜜糖村', {
fontFamily: 'monospace',
fontSize: '28px',
color: '#ffe08a',
stroke: '#d07030',
strokeThickness: 4,
})
.setOrigin(0.5);
this.add
.text(width / 2, 110, '按 E 或 空格 开始', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#fff0d0',
})
.setOrigin(0.5);
this.add
.text(width / 2, 150, 'WASD / 方向键移动 · E 交互 · ` Debug', {
fontFamily: 'monospace',
fontSize: '8px',
color: '#d0e8ff',
})
.setOrigin(0.5);
// Decorative sunflower-ish pixels
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
this.add.circle(
width / 2 + Math.cos(a) * 40,
100 + Math.sin(a) * 12,
3,
0xffd040,
);
}
this.add.circle(width / 2, 100, 5, 0xc08030);
const start = () => {
void audio.unlock();
this.input.keyboard?.off('keydown-E', start);
this.input.keyboard?.off('keydown-SPACE', start);
this.scene.start(SCENE_KEYS.NAME);
};
this.input.keyboard?.on('keydown-E', start);
this.input.keyboard?.on('keydown-SPACE', start);
}
}
+362
View File
@@ -0,0 +1,362 @@
import Phaser from 'phaser';
import { DEBUG_TELEPORT_SCENES, GAME_HEIGHT, GAME_WIDTH, SCENE_KEYS } from '../data/constants';
import type { DialogLine } from '../data/dialogues';
import { Director } from '../systems/CorruptionDirector';
import { gameEvents } from '../systems/EventBus';
import { audio } from '../audio/ProceduralAudio';
import type { EndingId } from '../data/endings';
import { parseDebugKey } from '../systems/debugKeys';
type DialogCb = (choice?: 'gentle' | 'hesitant' | 'negate') => void;
export class UIScene extends Phaser.Scene {
private petals: Phaser.GameObjects.Image[] = [];
private center!: Phaser.GameObjects.Image;
private promptText!: Phaser.GameObjects.Text;
private toastText!: Phaser.GameObjects.Text;
private dialogRoot!: Phaser.GameObjects.Container;
private dialogName!: Phaser.GameObjects.Text;
private dialogBody!: Phaser.GameObjects.Text;
private dialogPortrait!: Phaser.GameObjects.Image;
private optionTexts: Phaser.GameObjects.Text[] = [];
private queue: DialogLine[] = [];
private onDialogDone: DialogCb | null = null;
private showingOptions = false;
private debugRoot!: Phaser.GameObjects.Container;
private debugVisible = false;
private dayLabel!: Phaser.GameObjects.Text;
private teleportScenes: string[] = [];
private teleportCycle = 0;
constructor() {
super(SCENE_KEYS.UI);
}
create(): void {
// Sunflower HUD top-left — no numbers
const hx = 20;
const hy = 20;
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
const img = this.add
.image(hx + Math.cos(a) * 10, hy + Math.sin(a) * 10, 'sunflower', i)
.setScrollFactor(0)
.setDepth(100)
.setScale(0.7)
.setAlpha(0.25);
this.petals.push(img);
}
this.center = this.add
.image(hx, hy, 'sunflower', 12)
.setScrollFactor(0)
.setDepth(101)
.setScale(0.8);
this.dayLabel = this.add
.text(GAME_WIDTH - 8, 6, '', {
fontFamily: 'monospace',
fontSize: '8px',
color: '#fff8e0',
backgroundColor: '#00000055',
padding: { x: 3, y: 2 },
})
.setOrigin(1, 0)
.setScrollFactor(0)
.setDepth(100);
this.promptText = this.add
.text(GAME_WIDTH / 2, GAME_HEIGHT - 56, '', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#ffffff',
backgroundColor: '#00000088',
padding: { x: 4, y: 2 },
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(100)
.setVisible(false);
this.toastText = this.add
.text(GAME_WIDTH / 2, 36, '', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#503020',
backgroundColor: '#fff8e0cc',
padding: { x: 6, y: 3 },
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(110)
.setAlpha(0);
// Dialog
const panel = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT - 28, 'dialog_panel').setDisplaySize(GAME_WIDTH - 8, 52);
this.dialogPortrait = this.add.image(28, GAME_HEIGHT - 28, 'portraits', 0).setScale(1);
this.dialogName = this.add.text(50, GAME_HEIGHT - 50, '', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#804020',
});
this.dialogBody = this.add.text(50, GAME_HEIGHT - 38, '', {
fontFamily: 'monospace',
fontSize: '9px',
color: '#302010',
wordWrap: { width: 250 },
});
this.dialogRoot = this.add
.container(0, 0, [panel, this.dialogPortrait, this.dialogName, this.dialogBody])
.setScrollFactor(0)
.setDepth(120)
.setVisible(false);
for (let i = 0; i < 3; i++) {
const t = this.add
.text(50, GAME_HEIGHT - 28 + i * 10, '', {
fontFamily: 'monospace',
fontSize: '8px',
color: '#2060a0',
})
.setScrollFactor(0)
.setDepth(121)
.setVisible(false)
.setInteractive({ useHandCursor: true });
const idx = i;
t.on('pointerdown', () => this.pickOption(idx));
this.optionTexts.push(t);
}
this.buildDebug();
this.refreshHud();
gameEvents.on('petals', this.onPetals as (...a: unknown[]) => void);
gameEvents.on('happiness', () => this.refreshHud());
gameEvents.on('day', () => this.refreshHud());
gameEvents.on('prompt', this.onPrompt as (...a: unknown[]) => void);
gameEvents.on('toast', this.onToast as (...a: unknown[]) => void);
gameEvents.on('dialog', this.onDialog as (...a: unknown[]) => void);
gameEvents.on('taskComplete', () => {
audio.ding();
this.onToast('任务完成');
});
this.input.keyboard?.on('keydown-E', () => this.advanceDialog());
this.input.keyboard?.on('keydown-SPACE', () => this.advanceDialog());
this.input.keyboard?.on('keydown-ONE', () => this.pickOption(0));
this.input.keyboard?.on('keydown-TWO', () => this.pickOption(1));
this.input.keyboard?.on('keydown-THREE', () => this.pickOption(2));
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
if (ev.key === '`' || ev.code === 'Backquote') {
ev.preventDefault();
this.toggleDebug();
}
});
this.events.on('shutdown', () => {
gameEvents.off('petals', this.onPetals as (...a: unknown[]) => void);
});
}
private onPetals = (count: unknown, seeds: unknown) => {
const n = count as number;
const black = seeds as boolean;
for (let i = 0; i < 12; i++) {
this.petals[i]!.setAlpha(i < n ? 1 : 0.2);
// S3 overbright
if (Director.stage === 'S3' || Director.stage === 'S4') {
this.petals[i]!.setTint(i < n ? 0xffffaa : 0xffffff);
} else {
this.petals[i]!.clearTint();
}
}
this.center.setFrame(black ? 13 : 12);
};
private refreshHud(): void {
const p = Director.getPetals();
const seeds = Director.getBlackSeeds();
this.onPetals(p, seeds);
this.dayLabel.setText(`Day ${Director.day}`);
}
private onPrompt = (show: unknown, text: unknown) => {
if (this.dialogRoot.visible) {
this.promptText.setVisible(false);
return;
}
this.promptText.setVisible(!!show);
this.promptText.setText(String(text || ''));
};
private onToast = (msg: unknown) => {
this.toastText.setText(String(msg));
this.tweens.killTweensOf(this.toastText);
this.toastText.setAlpha(1);
this.tweens.add({
targets: this.toastText,
alpha: 0,
delay: 1400,
duration: 400,
});
};
private onDialog = (lines: unknown, cb: unknown) => {
this.queue = lines as DialogLine[];
this.onDialogDone = cb as DialogCb;
this.showNextLine();
};
private showNextLine(): void {
if (this.queue.length === 0) {
this.dialogRoot.setVisible(false);
for (const o of this.optionTexts) o.setVisible(false);
this.showingOptions = false;
const done = this.onDialogDone;
this.onDialogDone = null;
done?.();
return;
}
const line = this.queue[0]!;
this.dialogRoot.setVisible(true);
this.dialogName.setText(line.speaker);
this.dialogBody.setText(line.text);
this.dialogPortrait.setFrame(0); // always smile per pillar 2
audio.beep();
this.showingOptions = false;
for (const o of this.optionTexts) o.setVisible(false);
if (line.options && line.options.length > 0 && this.queue.length === 1) {
this.showingOptions = true;
line.options.forEach((opt, i) => {
const t = this.optionTexts[i];
if (!t) return;
t.setText(`${i + 1}. ${opt.label}`);
t.setVisible(true);
});
}
}
private advanceDialog(): void {
if (!this.dialogRoot.visible) return;
if (this.showingOptions) return; // must pick
this.queue.shift();
this.showNextLine();
}
private pickOption(idx: number): void {
if (!this.showingOptions || !this.dialogRoot.visible) return;
const line = this.queue[0];
const opt = line?.options?.[idx];
if (!opt) return;
this.queue = [];
this.dialogRoot.setVisible(false);
for (const o of this.optionTexts) o.setVisible(false);
this.showingOptions = false;
const done = this.onDialogDone;
this.onDialogDone = null;
done?.(opt.choice);
}
private buildDebug(): void {
const bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 280, 150, 0x101018, 0.92).setStrokeStyle(1, 0x80ffc0);
const title = this.add.text(50, 20, 'DEBUG ` 关闭', {
fontFamily: 'monospace',
fontSize: '10px',
color: '#80ffc0',
});
const lines = [
'H/J 幸福±10 K/L 觉知±10 Shift+1-7 设日 C线索',
'传送0-9: 0屋1广2包3杂4诊5湖6田7井8雾9白',
'V 循环十场景 F1-F4 结局 N负片 P庆典',
];
const body = this.add.text(40, 36, lines.join('\n'), {
fontFamily: 'monospace',
fontSize: '8px',
color: '#c0ffe0',
lineSpacing: 3,
});
this.debugRoot = this.add
.container(0, 0, [bg, title, body])
.setScrollFactor(0)
.setDepth(200)
.setVisible(false);
/** All 10 map scenes for §13 teleport-to-any (shared constant) */
this.teleportScenes = [...DEBUG_TELEPORT_SCENES];
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
if (!this.debugVisible) return;
this.applyDebugAction(
parseDebugKey({ key: ev.key, code: ev.code, shiftKey: ev.shiftKey }),
title,
);
});
}
/** Apply shipped parseDebugKey result to Director / events (testable path). */
applyDebugAction(
action: ReturnType<typeof parseDebugKey>,
title?: Phaser.GameObjects.Text,
): void {
switch (action.type) {
case 'happiness':
Director.setHappiness(Director.happiness + action.delta, { force: true });
break;
case 'awareness':
if (action.delta > 0) Director.addAwareness(action.delta);
else {
Director.awareness = Math.max(0, Director.awareness + action.delta);
gameEvents.emit('awareness', Director.awareness);
}
break;
case 'setDay':
Director.setDay(action.day);
break;
case 'grantClues':
Director.grantAllClues();
break;
case 'teleport':
gameEvents.emit('teleport', { scene: action.scene });
break;
case 'teleportCycle':
this.teleportCycle = (this.teleportCycle + 1) % this.teleportScenes.length;
gameEvents.emit('teleport', { scene: this.teleportScenes[this.teleportCycle] });
break;
case 'negativeFlash':
gameEvents.emit('negativeFlash');
break;
case 'forcedCelebration':
Director.triggerForcedCelebration();
break;
case 'forceEnding':
this.forceEnd(action.id);
break;
default:
break;
}
this.refreshHud();
if (title) this.refreshDebugStats(title);
}
private refreshDebugStats(title: Phaser.GameObjects.Text): void {
title.setText(
`DEBUG H${Director.happiness} A${Director.awareness} D${Director.day} ${Director.stage} 线索${Director.clues.size}`,
);
}
private forceEnd(id: EndingId): void {
Director.forceEnding(id);
// stop map scenes
for (const k of Object.values(SCENE_KEYS)) {
if (k !== SCENE_KEYS.ENDING && k !== SCENE_KEYS.BOOT && this.scene.isActive(k)) {
this.scene.stop(k);
}
}
this.scene.start(SCENE_KEYS.ENDING, { endingId: id });
}
private toggleDebug(): void {
this.debugVisible = !this.debugVisible;
this.debugRoot.setVisible(this.debugVisible);
}
}
File diff suppressed because it is too large Load Diff
+688
View File
@@ -0,0 +1,688 @@
import {
AWARENESS_MAX,
AWARENESS_START,
CLUE_AWARENESS,
DEFAULT_PLAYER_NAME,
DIALOG_GENTLE_HAPPINESS,
DIALOG_HESITANT_AWARENESS,
FORCED_CELEBRATION_MS,
FORCED_CELEBRATION_RESTORE,
FORCED_CELEBRATION_THRESHOLD,
HAPPINESS_MAX,
HAPPINESS_MIN,
HAPPINESS_START,
MICRO_DAILY_CAP,
MICRO_HAPPINESS,
OVERFLOW_POOL_MAX,
SADNESS_AWARENESS,
SADNESS_HAPPINESS,
WELL_FOG_GAZE_AWARENESS,
} from '../data/constants';
import { getAfuLine } from '../data/afuLines';
import type { ClueId } from '../data/clues';
import { C9_DAYS, C9_LINES } from '../data/clues';
import {
faceTierForNpc,
petalCount,
showBlackSeeds,
stageFromHappiness,
stageParams,
type FaceTier,
type Stage,
type StageParams,
} from '../data/corruption';
import { evaluateEnding, type EndingId } from '../data/endings';
import type { NpcId } from '../data/npcs';
import { getNpc } from '../data/npcs';
import { getTask, getTasksForDay, type TaskId } from '../data/tasks';
import { gameEvents } from './EventBus';
export type MicroType = 'water' | 'feed' | 'pet';
export type DialogChoice = 'gentle' | 'hesitant' | 'negate';
export type SadnessKind =
| 'refuse_task'
| 'negate_dialog'
| 'idle'
| 'entangle'
| 'rain';
export interface DirectorSnapshot {
playerName: string;
happiness: number;
awareness: number;
day: number;
stage: Stage;
stageParams: StageParams;
petals: number;
blackSeeds: boolean;
clues: ClueId[];
completedTasks: TaskId[];
refusedTasks: TaskId[];
celebrationEscapes: number;
overflowPool: number;
sessionStartMs: number;
dayPhase: DayPhase;
c9Step: number;
lowHappinessSinceMs: number | null;
forcedCelebrationActive: boolean;
rainActive: boolean;
duskReached: boolean;
boardReadToday: boolean;
microCounts: Record<MicroType, number>;
npcTalkCounts: Record<string, number>;
/** Inventory: persists across scene travel (not scene-local) */
hasBread: boolean;
hasLetters: boolean;
breadDelivered: string[];
lettersDelivered: string[];
waterCount: number;
greetCount: number;
berryCount: number;
ribbonCount: number;
lowHappinessD5to7: boolean;
wellGazeToday: boolean;
fogGazeToday: boolean;
t14Active: boolean;
t14ElapsedMs: number;
}
export type DayPhase = 'morning' | 'day' | 'dusk' | 'sleep' | 'dream';
class CorruptionDirectorImpl {
playerName = DEFAULT_PLAYER_NAME;
happiness = HAPPINESS_START;
awareness = AWARENESS_START;
day = 1;
clues = new Set<ClueId>();
completedTasks = new Set<TaskId>();
refusedTasks = new Set<TaskId>();
celebrationEscapes = 0;
overflowPool = 0;
sessionStartMs = Date.now();
dayPhase: DayPhase = 'morning';
c9Step = 0; // 0,1,2 completed steps
lowHappinessSinceMs: number | null = null;
forcedCelebrationActive = false;
rainActive = false;
duskReached = false;
boardReadToday = false;
microCounts: Record<MicroType, number> = { water: 0, feed: 0, pet: 0 };
npcTalkCounts: Record<string, number> = {};
/** Carrying honey bread / mail — survives map transitions */
hasBread = false;
hasLetters = false;
breadDelivered: string[] = [];
lettersDelivered: string[] = [];
waterCount = 0;
greetCount = 0;
berryCount = 0;
ribbonCount = 0;
/** Tracks if happiness stayed ≤10 continuously through D5D7 */
lowHappinessD5to7 = true;
hadHighHappinessD5to7 = false;
wellGazeToday = false;
fogGazeToday = false;
t14Active = false;
t14ElapsedMs = 0;
sentenceCounter = 0;
lastNearestNpcLook: NpcId | null = null;
ended = false;
endingId: EndingId | null = null;
choseFogWall = false;
/** Real-time clock for forced celebration (injectable for tests) */
nowMs = (): number => Date.now();
resetSession(name?: string): void {
this.playerName = name?.trim() || DEFAULT_PLAYER_NAME;
this.happiness = HAPPINESS_START;
this.awareness = AWARENESS_START;
this.day = 1;
this.clues.clear();
this.completedTasks.clear();
this.refusedTasks.clear();
this.celebrationEscapes = 0;
this.overflowPool = 0;
this.sessionStartMs = this.nowMs();
this.dayPhase = 'morning';
this.c9Step = 0;
this.lowHappinessSinceMs = null;
this.forcedCelebrationActive = false;
this.rainActive = false;
this.duskReached = false;
this.boardReadToday = false;
this.microCounts = { water: 0, feed: 0, pet: 0 };
this.npcTalkCounts = {};
this.hasBread = false;
this.hasLetters = false;
this.breadDelivered = [];
this.lettersDelivered = [];
this.waterCount = 0;
this.greetCount = 0;
this.berryCount = 0;
this.ribbonCount = 0;
this.lowHappinessD5to7 = true;
this.hadHighHappinessD5to7 = false;
this.wellGazeToday = false;
this.fogGazeToday = false;
this.t14Active = false;
this.t14ElapsedMs = 0;
this.sentenceCounter = 0;
this.lastNearestNpcLook = null;
this.ended = false;
this.endingId = null;
this.choseFogWall = false;
this.emitAll();
}
get stage(): Stage {
return stageFromHappiness(this.happiness);
}
getStageParams(): StageParams {
return stageParams(this.stage);
}
getPetals(): number {
return petalCount(this.happiness);
}
getBlackSeeds(): boolean {
return showBlackSeeds(this.happiness);
}
getAfuLine(): string {
return getAfuLine(this.day);
}
getTodayTasks() {
return getTasksForDay(this.day);
}
sessionDurationMs(): number {
return this.nowMs() - this.sessionStartMs;
}
sessionDurationLabel(): string {
const ms = this.sessionDurationMs();
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}${s.toString().padStart(2, '0')}`;
}
setHappiness(value: number, opts: { force?: boolean } = {}): void {
const clamped = Math.max(HAPPINESS_MIN, Math.min(HAPPINESS_MAX, Math.round(value)));
this.happiness = clamped;
if (this.day >= 5 && this.day <= 7 && this.happiness > 10) {
this.hadHighHappinessD5to7 = true;
this.lowHappinessD5to7 = false;
}
this.syncLowHappinessTimer();
if (!opts.force && this.happiness < 100) {
this.overflowPool = 0;
}
gameEvents.emit('happiness', this.happiness);
gameEvents.emit('stage', this.stage, this.getStageParams());
gameEvents.emit('petals', this.getPetals(), this.getBlackSeeds());
}
addHappiness(delta: number): void {
if (delta === 0) return;
if (delta > 0) {
const next = Math.min(HAPPINESS_MAX, this.happiness + delta);
this.setHappiness(next);
return;
}
// Negative
if (this.happiness >= 100) {
// Overflow pool absorbs; small deductions don't lower happiness
this.overflowPool += Math.abs(delta);
if (this.overflowPool >= OVERFLOW_POOL_MAX) {
this.overflowPool = 0;
gameEvents.emit('negativeFlash');
// Stay at S4 / 100
}
gameEvents.emit('overflow', this.overflowPool);
return;
}
this.setHappiness(this.happiness + delta);
}
setAwareness(value: number): void {
const next = Math.max(this.awareness, Math.min(AWARENESS_MAX, Math.round(value)));
if (next === this.awareness) return;
this.awareness = next;
gameEvents.emit('awareness', this.awareness);
}
addAwareness(delta: number): void {
if (delta <= 0) return; // never decreases
this.setAwareness(this.awareness + delta);
}
private syncLowHappinessTimer(): void {
if (this.happiness < FORCED_CELEBRATION_THRESHOLD) {
if (this.lowHappinessSinceMs == null) {
this.lowHappinessSinceMs = this.nowMs();
}
} else {
this.lowHappinessSinceMs = null;
}
}
/** Call from game loop with real dt */
tick(dtMs: number): void {
if (this.ended) return;
// Forced celebration check
if (
!this.forcedCelebrationActive &&
this.happiness < FORCED_CELEBRATION_THRESHOLD &&
this.lowHappinessSinceMs != null
) {
if (this.nowMs() - this.lowHappinessSinceMs >= FORCED_CELEBRATION_MS) {
this.triggerForcedCelebration();
}
}
if (this.t14Active) {
this.t14ElapsedMs += dtMs;
gameEvents.emit('t14Progress', this.t14ElapsedMs);
}
}
triggerForcedCelebration(): void {
this.forcedCelebrationActive = true;
this.setHappiness(FORCED_CELEBRATION_RESTORE);
this.lowHappinessSinceMs = null;
gameEvents.emit('forcedCelebration', true);
}
escapeForcedCelebration(): void {
if (!this.forcedCelebrationActive) return;
this.forcedCelebrationActive = false;
this.celebrationEscapes += 1;
gameEvents.emit('forcedCelebration', false);
gameEvents.emit('celebrationEscape', this.celebrationEscapes);
}
endForcedCelebration(): void {
this.forcedCelebrationActive = false;
gameEvents.emit('forcedCelebration', false);
}
applySadness(kind: SadnessKind, nearestNpcId?: NpcId | null): void {
this.addHappiness(SADNESS_HAPPINESS);
this.addAwareness(SADNESS_AWARENESS);
if (nearestNpcId) {
this.lastNearestNpcLook = nearestNpcId;
gameEvents.emit('npcLookAtPlayer', nearestNpcId);
} else {
gameEvents.emit('npcLookAtPlayer', null);
}
gameEvents.emit('sadness', kind);
}
discoverClue(id: ClueId): boolean {
if (this.clues.has(id)) return false;
// Day gates
if (id === 'C1' && this.day < 2) return false;
if (id === 'C2' && this.day < 3) return false;
if (id === 'C3' && this.day < 2) return false;
if (id === 'C5' && this.day < 2) return false;
if (id === 'C6' && this.day < 5) return false;
if (id === 'C7' && this.day < 5) return false;
if (id === 'C8' && this.day < 5) return false;
this.clues.add(id);
this.addAwareness(CLUE_AWARENESS);
gameEvents.emit('clue', id);
return true;
}
grantAllClues(): void {
const all: ClueId[] = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'];
for (const id of all) {
if (!this.clues.has(id)) {
this.clues.add(id);
this.addAwareness(CLUE_AWARENESS);
}
}
this.c9Step = 3;
gameEvents.emit('clue', 'ALL');
}
/** C9 progressive steps on D2/D4/D6 */
advanceC9(): string | null {
const expectedDay = C9_DAYS[this.c9Step];
if (expectedDay == null) return null;
if (this.day !== expectedDay && this.day < expectedDay) return null;
// Allow if day matches step day or later for that step (but each step once)
if (this.day < expectedDay) return null;
const line = C9_LINES[this.c9Step]!;
this.c9Step += 1;
if (this.c9Step >= 3) {
this.discoverClue('C9');
}
return line;
}
getC9LineForTalk(): string | null {
// Return next available progressive line if day matches
if (this.c9Step >= 3) return C9_LINES[2]!;
const dayForStep = C9_DAYS[this.c9Step];
if (dayForStep != null && this.day >= dayForStep) {
return this.advanceC9();
}
return null;
}
completeTask(id: TaskId): boolean {
if (this.completedTasks.has(id)) return false;
const task = getTask(id);
if (task.day !== this.day && id !== 'T15') {
// Allow debug / flexible only same day for normal play
// Keep strict for day match
if (task.day !== this.day) return false;
}
this.completedTasks.add(id);
if (task.happinessReward > 0) {
this.addHappiness(task.happinessReward);
}
gameEvents.emit('taskComplete', id, task.happinessReward);
this.checkDuskByTasks();
return true;
}
refuseTask(id: TaskId): boolean {
const task = getTask(id);
if (!task.refuseable) return false;
if (this.refusedTasks.has(id) || this.completedTasks.has(id)) return false;
this.refusedTasks.add(id);
this.applySadness('refuse_task');
gameEvents.emit('taskRefuse', id);
return true;
}
private checkDuskByTasks(): void {
const today = getTasksForDay(this.day).filter((t) => t.id !== 'T15');
if (today.length === 0) return;
const allDone = today.every(
(t) => this.completedTasks.has(t.id) || this.refusedTasks.has(t.id),
);
if (allDone && !this.duskReached) {
this.triggerDusk();
}
}
triggerDusk(): void {
if (this.duskReached) return;
this.duskReached = true;
this.dayPhase = 'dusk';
if (this.day === 6) {
this.rainActive = true;
gameEvents.emit('rain', true);
}
gameEvents.emit('dusk');
}
goToSleep(): void {
this.dayPhase = 'sleep';
gameEvents.emit('sleep');
}
advanceDay(): void {
if (this.day >= 7) return;
this.day += 1;
this.dayPhase = 'morning';
this.duskReached = false;
this.boardReadToday = false;
this.microCounts = { water: 0, feed: 0, pet: 0 };
this.npcTalkCounts = {};
this.hasBread = false;
this.hasLetters = false;
this.breadDelivered = [];
this.lettersDelivered = [];
this.waterCount = 0;
this.greetCount = 0;
this.berryCount = 0;
this.ribbonCount = 0;
this.wellGazeToday = false;
this.fogGazeToday = false;
this.t14Active = false;
this.t14ElapsedMs = 0;
this.rainActive = false;
if (this.day >= 5 && this.day <= 7) {
if (this.happiness > 10) {
this.lowHappinessD5to7 = false;
this.hadHighHappinessD5to7 = true;
}
}
gameEvents.emit('day', this.day);
gameEvents.emit('rain', false);
}
setDay(day: number): void {
this.day = Math.max(1, Math.min(7, Math.round(day)));
gameEvents.emit('day', this.day);
}
readBoard(): void {
this.boardReadToday = true;
this.dayPhase = 'day';
gameEvents.emit('boardRead', this.getAfuLine());
}
dialogChoice(choice: DialogChoice, npcId?: NpcId): void {
if (npcId) {
this.npcTalkCounts[npcId] = (this.npcTalkCounts[npcId] ?? 0) + 1;
if (this.npcTalkCounts[npcId]! >= 5) {
this.applySadness('entangle', npcId);
}
}
if (choice === 'gentle') {
this.addHappiness(DIALOG_GENTLE_HAPPINESS);
} else if (choice === 'hesitant') {
this.addAwareness(DIALOG_HESITANT_AWARENESS);
} else if (choice === 'negate') {
this.applySadness('negate_dialog', npcId);
}
this.sentenceCounter += 1;
}
microInteract(type: MicroType): boolean {
if (this.microCounts[type] >= MICRO_DAILY_CAP) return false;
this.microCounts[type] += 1;
this.addHappiness(MICRO_HAPPINESS);
gameEvents.emit('micro', type);
return true;
}
/** Gaze well or fog 5s — once each per day, +3 awareness */
gazeWellOrFog(kind: 'well' | 'fog'): boolean {
if (kind === 'well') {
if (this.wellGazeToday) return false;
this.wellGazeToday = true;
} else {
if (this.fogGazeToday) return false;
this.fogGazeToday = true;
}
this.addAwareness(WELL_FOG_GAZE_AWARENESS);
return true;
}
canInteractWellOrFog(): boolean {
return this.day >= 5;
}
faceTier(npcId: NpcId): FaceTier {
const npc = getNpc(npcId);
return faceTierForNpc(this.happiness, npc.offset);
}
nextSentenceIndex(): number {
return this.sentenceCounter++;
}
// --- Task progress helpers (inventory survives scene travel) ---
takeBread(): boolean {
if (this.day !== 1 && this.day !== 4) return false;
const taskId = this.day === 4 ? 'T8' : 'T1';
if (this.completedTasks.has(taskId as TaskId)) return false;
this.hasBread = true;
gameEvents.emit('inventory', 'bread', true);
return true;
}
takeLetters(): boolean {
if (this.day !== 2 && this.day !== 4) return false;
// D2 T4; D4 has no letter task but keep day2-only for T4
if (this.day === 2 && this.completedTasks.has('T4')) return false;
if (this.day !== 2) return false;
this.hasLetters = true;
gameEvents.emit('inventory', 'letters', true);
return true;
}
deliverBread(to: string): boolean {
if (!this.hasBread) return false;
if (!this.breadDelivered.includes(to)) this.breadDelivered.push(to);
if (this.breadDelivered.length >= 3) {
this.hasBread = false;
const id = this.day === 4 ? 'T8' : 'T1';
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
return true;
}
return true;
}
waterFlower(): void {
this.waterCount += 1;
this.microInteract('water');
if (this.waterCount >= 3) {
const id = this.day === 4 ? 'T9' : 'T2';
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
}
}
greetVillager(): void {
this.greetCount += 1;
if (this.greetCount >= 3) {
const id = this.day === 4 ? 'T10' : 'T3';
if (!this.completedTasks.has(id as TaskId)) this.completeTask(id as TaskId);
}
}
deliverLetter(to: string): boolean {
if (!this.hasLetters) return false;
if (!this.lettersDelivered.includes(to)) this.lettersDelivered.push(to);
if (this.lettersDelivered.length === 3) {
this.discoverClue('C4');
}
if (this.lettersDelivered.length >= 3) {
this.hasLetters = false;
this.completeTask('T4');
}
return true;
}
pickBerry(): void {
this.berryCount += 1;
if (this.berryCount >= 5) this.completeTask('T6');
}
hangRibbon(): void {
this.ribbonCount += 1;
if (this.ribbonCount >= 4) this.completeTask('T13');
}
startT14(): void {
this.t14Active = true;
this.t14ElapsedMs = 0;
gameEvents.emit('t14Start');
}
finishT14(): void {
this.t14Active = false;
this.completeTask('T14');
gameEvents.emit('t14End');
}
startEnding(choseFogWall = false): EndingId {
this.choseFogWall = choseFogWall;
if (this.day >= 5 && this.day <= 7 && this.hadHighHappinessD5to7) {
this.lowHappinessD5to7 = false;
}
// Continuously ≤10 means never went above 10 on D57
const lowOk = this.lowHappinessD5to7 && !this.hadHighHappinessD5to7;
const id = evaluateEnding({
awareness: this.awareness,
happiness: this.happiness,
clueCount: this.clues.size,
lowHappinessStreakD5to7: lowOk,
celebrationEscapes: this.celebrationEscapes,
choseFogWall,
});
this.endingId = id;
this.ended = true;
this.completeTask('T15');
gameEvents.emit('ending', id);
return id;
}
forceEnding(id: EndingId): void {
this.endingId = id;
this.ended = true;
gameEvents.emit('ending', id);
}
snapshot(): DirectorSnapshot {
return {
playerName: this.playerName,
happiness: this.happiness,
awareness: this.awareness,
day: this.day,
stage: this.stage,
stageParams: this.getStageParams(),
petals: this.getPetals(),
blackSeeds: this.getBlackSeeds(),
clues: [...this.clues],
completedTasks: [...this.completedTasks],
refusedTasks: [...this.refusedTasks],
celebrationEscapes: this.celebrationEscapes,
overflowPool: this.overflowPool,
sessionStartMs: this.sessionStartMs,
dayPhase: this.dayPhase,
c9Step: this.c9Step,
lowHappinessSinceMs: this.lowHappinessSinceMs,
forcedCelebrationActive: this.forcedCelebrationActive,
rainActive: this.rainActive,
duskReached: this.duskReached,
boardReadToday: this.boardReadToday,
microCounts: { ...this.microCounts },
npcTalkCounts: { ...this.npcTalkCounts },
hasBread: this.hasBread,
hasLetters: this.hasLetters,
breadDelivered: [...this.breadDelivered],
lettersDelivered: [...this.lettersDelivered],
waterCount: this.waterCount,
greetCount: this.greetCount,
berryCount: this.berryCount,
ribbonCount: this.ribbonCount,
lowHappinessD5to7: this.lowHappinessD5to7,
wellGazeToday: this.wellGazeToday,
fogGazeToday: this.fogGazeToday,
t14Active: this.t14Active,
t14ElapsedMs: this.t14ElapsedMs,
};
}
private emitAll(): void {
gameEvents.emit('happiness', this.happiness);
gameEvents.emit('awareness', this.awareness);
gameEvents.emit('day', this.day);
gameEvents.emit('stage', this.stage, this.getStageParams());
gameEvents.emit('petals', this.getPetals(), this.getBlackSeeds());
}
}
export const Director = new CorruptionDirectorImpl();
export type CorruptionDirector = CorruptionDirectorImpl;
+27
View File
@@ -0,0 +1,27 @@
type Handler = (...args: unknown[]) => void;
/** Minimal event emitter shared by director & scenes */
export class EventBus {
private listeners = new Map<string, Set<Handler>>();
on(event: string, handler: Handler): void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(handler);
}
off(event: string, handler: Handler): void {
this.listeners.get(event)?.delete(handler);
}
emit(event: string, ...args: unknown[]): void {
const set = this.listeners.get(event);
if (!set) return;
for (const h of set) h(...args);
}
clear(): void {
this.listeners.clear();
}
}
export const gameEvents = new EventBus();
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests shipped parseDebugKey — simulates real browser KeyboardEvent shapes.
* Shift+1 emits key='!' code='Digit1' (not key='1').
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { parseDebugKey } from './debugKeys';
import { DEBUG_TELEPORT_SCENES, SCENE_KEYS } from '../data/constants';
import { Director } from './CorruptionDirector';
describe('parseDebugKey (shipped Debug key map)', () => {
it('Shift+Digit17 sets day via code even when key is symbol', () => {
// Real browser: Shift+1 → key '!', code 'Digit1'
expect(
parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true }),
).toEqual({ type: 'setDay', day: 1 });
expect(
parseDebugKey({ key: '@', code: 'Digit2', shiftKey: true }),
).toEqual({ type: 'setDay', day: 2 });
expect(
parseDebugKey({ key: '#', code: 'Digit3', shiftKey: true }),
).toEqual({ type: 'setDay', day: 3 });
expect(
parseDebugKey({ key: '$', code: 'Digit4', shiftKey: true }),
).toEqual({ type: 'setDay', day: 4 });
expect(
parseDebugKey({ key: '%', code: 'Digit5', shiftKey: true }),
).toEqual({ type: 'setDay', day: 5 });
expect(
parseDebugKey({ key: '^', code: 'Digit6', shiftKey: true }),
).toEqual({ type: 'setDay', day: 6 });
expect(
parseDebugKey({ key: '&', code: 'Digit7', shiftKey: true }),
).toEqual({ type: 'setDay', day: 7 });
});
it('broken old path (shift+key 1-7) would fail — we do not use key for day', () => {
// Demonstrates why key-based check is wrong; our code uses Digit*
const broken = (ev: { key: string; shiftKey: boolean }) =>
ev.shiftKey && ev.key >= '1' && ev.key <= '7';
expect(broken({ key: '!', shiftKey: true })).toBe(false);
// Our parser still works
expect(parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true }).type).toBe('setDay');
});
it('plain Digit09 teleports to all 10 scenes', () => {
for (let i = 0; i <= 9; i++) {
const a = parseDebugKey({
key: String(i),
code: `Digit${i}`,
shiftKey: false,
});
expect(a).toEqual({
type: 'teleport',
sceneIndex: i,
scene: DEBUG_TELEPORT_SCENES[i],
});
}
});
it('Shift+Digit does not teleport (day takes precedence)', () => {
const a = parseDebugKey({ key: '!', code: 'Digit1', shiftKey: true });
expect(a.type).toBe('setDay');
expect(a).not.toMatchObject({ type: 'teleport' });
});
it('H/J/K/L/C/N/P/V/F1F4', () => {
expect(parseDebugKey({ key: 'h', code: 'KeyH', shiftKey: false })).toEqual({
type: 'happiness',
delta: 10,
});
expect(parseDebugKey({ key: 'J', code: 'KeyJ', shiftKey: true })).toEqual({
type: 'happiness',
delta: -10,
});
expect(parseDebugKey({ key: 'k', code: 'KeyK', shiftKey: false })).toEqual({
type: 'awareness',
delta: 10,
});
expect(parseDebugKey({ key: 'c', code: 'KeyC', shiftKey: false }).type).toBe('grantClues');
expect(parseDebugKey({ key: 'n', code: 'KeyN', shiftKey: false }).type).toBe('negativeFlash');
expect(parseDebugKey({ key: 'p', code: 'KeyP', shiftKey: false }).type).toBe(
'forcedCelebration',
);
expect(parseDebugKey({ key: 'v', code: 'KeyV', shiftKey: false }).type).toBe('teleportCycle');
expect(parseDebugKey({ key: 'F1', code: 'F1', shiftKey: false })).toEqual({
type: 'forceEnding',
id: 'E1',
});
expect(parseDebugKey({ key: 'F4', code: 'F4', shiftKey: false })).toEqual({
type: 'forceEnding',
id: 'E4',
});
});
});
describe('parseDebugKey → Director.setDay real path', () => {
beforeEach(() => {
Director.resetSession('调试');
});
it('applies setDay from Shift+Digit codes into Director', () => {
for (let day = 1; day <= 7; day++) {
const action = parseDebugKey({
key: '!', // wrong symbol-like key as browser would
code: `Digit${day}`,
shiftKey: true,
});
expect(action.type).toBe('setDay');
if (action.type === 'setDay') {
Director.setDay(action.day);
expect(Director.day).toBe(day);
}
}
});
it('teleport indices map to real scene keys', () => {
const a = parseDebugKey({ key: '0', code: 'Digit0', shiftKey: false });
expect(a.type).toBe('teleport');
if (a.type === 'teleport') {
expect(a.scene).toBe(SCENE_KEYS.HOUSE);
}
const b = parseDebugKey({ key: '1', code: 'Digit1', shiftKey: false });
if (b.type === 'teleport') {
expect(b.scene).toBe(SCENE_KEYS.PLAZA);
}
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Pure Debug key mapping (§13). Shipped path used by UIScene.
*
* Important: with Shift held, browsers set `key` to '!'/'@'/… not '1''7'.
* Day set MUST use `code` Digit1Digit7.
*/
import { DEBUG_TELEPORT_SCENES } from '../data/constants';
import type { EndingId } from '../data/endings';
export type DebugKeyInput = {
key: string;
code: string;
shiftKey: boolean;
};
export type DebugAction =
| { type: 'happiness'; delta: number }
| { type: 'awareness'; delta: number }
| { type: 'setDay'; day: number }
| { type: 'grantClues' }
| { type: 'teleport'; sceneIndex: number; scene: string }
| { type: 'teleportCycle' }
| { type: 'negativeFlash' }
| { type: 'forcedCelebration' }
| { type: 'forceEnding'; id: EndingId }
| { type: 'none' };
/** Map KeyboardEvent-like input → debug action (no Phaser / no Director). */
export function parseDebugKey(ev: DebugKeyInput): DebugAction {
const k = ev.key;
const code = ev.code;
if (k === 'h' || k === 'H') return { type: 'happiness', delta: 10 };
if (k === 'j' || k === 'J') return { type: 'happiness', delta: -10 };
if (k === 'k' || k === 'K') return { type: 'awareness', delta: 10 };
if (k === 'l' || k === 'L') return { type: 'awareness', delta: -10 };
if (k === 'c' || k === 'C') return { type: 'grantClues' };
if (k === 'n' || k === 'N') return { type: 'negativeFlash' };
if (k === 'p' || k === 'P') return { type: 'forcedCelebration' };
if (k === 'v' || k === 'V') return { type: 'teleportCycle' };
if (k === 'F1') return { type: 'forceEnding', id: 'E1' };
if (k === 'F2') return { type: 'forceEnding', id: 'E2' };
if (k === 'F3') return { type: 'forceEnding', id: 'E3' };
if (k === 'F4') return { type: 'forceEnding', id: 'E4' };
// Shift + Digit17 → set day (use code; key becomes !@# with shift)
if (ev.shiftKey && /^Digit[1-7]$/.test(code)) {
const day = parseInt(code.replace('Digit', ''), 10);
return { type: 'setDay', day };
}
// Plain Digit09 → teleport (also accept key when no shift)
if (!ev.shiftKey && /^Digit[0-9]$/.test(code)) {
const idx = parseInt(code.replace('Digit', ''), 10);
const scene = DEBUG_TELEPORT_SCENES[idx];
if (scene) return { type: 'teleport', sceneIndex: idx, scene };
}
// Fallback: unshifted key '0'-'9' without relying on code
if (!ev.shiftKey && k >= '0' && k <= '9') {
const idx = parseInt(k, 10);
const scene = DEBUG_TELEPORT_SCENES[idx];
if (scene) return { type: 'teleport', sceneIndex: idx, scene };
}
return { type: 'none' };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client",
"node"
]
},
"include": [
"src"
],
"exclude": [
"src/**/*.test.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
base: './',
server: { port: 5173 },
preview: { port: 4173 },
build: {
outDir: 'dist',
assetsInlineLimit: 0,
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});