Honey Village: six model builds + hub frontend, Dockerized for Dokploy
@@ -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
|
||||
@@ -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 | 完美的重复 | 任务 T8–T10 与 Day 1 的 T1–T3 **逐字相同**,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 | 0–20 | 饱和度 0.90(微灰:低幸福反而接近真实) | BGM 原速原调,鸟鸣清晰 | smile | 正常 | 向日葵 0–2 瓣 |
|
||||
| S1 | 21–50 | 饱和度 1.00 基准 | BGM 正常 | smile | 正常 | 3–6 瓣 |
|
||||
| S2 | 51–80 | 饱和度 1.15 | playbackRate 0.97 | 眨眼间隔 2s→5s | 偶发标点重复(每 80 句 1 次) | 7–9 瓣 |
|
||||
| S3 | 81–99 | 饱和度 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 | 湖面倒影演出 |
|
||||
| T8–T10 | D4 | 与 T1–T3 逐字相同 | 同上 | 刻意恐怖谷 |
|
||||
| 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 届」 | D5(T12 中) |
|
||||
| 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 相机 postFX(ColorMatrix / 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 一次脚本演出)。
|
||||
@@ -0,0 +1,126 @@
|
||||
# 崩坏童话:蜜糖村(网页版)
|
||||
|
||||
> 纯单机网页游戏 · Phaser 3 + TypeScript + Vite · 无后端、无存档、无 localStorage,浏览器打开即玩。
|
||||
|
||||
一个看起来百分之百治愈的像素童话村庄,实际是一场情绪疗程的沉浸幻境。玩家越"幸福",世界越饱和、越甜、越不对劲;通往真相的路,恰恰是那些系统禁止的悲伤。
|
||||
|
||||
## 启动方式
|
||||
|
||||
需要 Node.js 18+。
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev # 开发模式(http://localhost:5173)
|
||||
npm run build # 产出纯静态 dist/
|
||||
npm run preview # 本地预览构建产物(http://localhost:4173)
|
||||
```
|
||||
|
||||
`npm run build` 产出纯静态 `dist/`,可直接用任意静态服务器托管;`npm run preview` 本地游玩。
|
||||
|
||||
## 按键操作
|
||||
|
||||
| 按键 | 功能 |
|
||||
|---|---|
|
||||
| `W` `A` `S` `D` / 方向键 | 移动(四向动画 + 待机帧) |
|
||||
| `E` / `空格` / `回车` | 交互 / 推进对话 / 选择 |
|
||||
| `1` `2` `3` | 对话选项(温柔 / 迟疑 / 否定) |
|
||||
| `` ` ``(反引号) | 开关 Debug 面板 |
|
||||
|
||||
## Debug 面板(服务验收)
|
||||
|
||||
运行中按反引号键 `` ` `` 开关。默认关闭,关闭时无任何 UI 痕迹,不影响正常游玩。
|
||||
|
||||
功能:
|
||||
- 直接设置幸福值(10 / 40 / 70 / 90 / 100,分别对应 S0/S1/S2/S3/S4)
|
||||
- 切换觉知值(0 / 50 / 100)
|
||||
- 切换当前日(1–7 循环)
|
||||
- 置齐全部线索(C1–C9)
|
||||
- 传送到任意场景(小屋/广场/面包房/杂货店/诊所/湖畔/花田/古井/雾墙)
|
||||
- 强制触发四个结局之一(E1 糖衣 / E2 回收 / E3 苏醒 / E4 静默)
|
||||
|
||||
## 游玩说明
|
||||
|
||||
- 开局输入名字(留空默认「小满」)。
|
||||
- 七日流程:晨起 → 公告板接任务 → 完成任务/自由活动 → 黄昏钟声 → 回屋睡觉 → 梦境过场 → 次日。
|
||||
- 完整流程约 25–35 分钟,含 4 个结局。
|
||||
- 幸福值以左上角向日葵表盘表示(12 片花瓣),全程不显示数字。满格后花心浮现黑籽。
|
||||
|
||||
## 技术架构(PRD §12)
|
||||
|
||||
- **Phaser 3 + TypeScript + Vite**。逻辑分辨率 320×180,整数倍放大居中,`pixelArt: true`,禁用纹理平滑。
|
||||
- 场景:`BootScene`(加载)→ `TitleScene`(标题/输入名字)→ 各地图 Scene + 常驻 `UIScene`(对话框、向日葵表盘、提示)→ `EndingScene`;另 `DebugScene`。
|
||||
- `CorruptionDirector`(`src/systems/CorruptionDirector.ts`):普通 TS 单例 + 事件发射器,持有幸福/觉知/日期/任务标记/线索集合/侵蚀阶段,是全部状态与视听参数的**唯一真源**。
|
||||
- 饱和度与晕影用 Phaser 相机 postFX(`SaturationPostFX`),负片闪帧用单帧白色覆盖切换。
|
||||
- BGM 与音效按 §6.6 用 Web Audio 程序合成(方波主旋律 + 三角波低音 8 小节循环),playbackRate 与 BiquadFilter 低通由 `CorruptionDirector` 驱动。**无外部音频文件**。
|
||||
- 无 localStorage、无外部网络请求、无外部音频/字体文件。
|
||||
|
||||
## 素材与地图制作流程(全部本项目内自制,无外部资产)
|
||||
|
||||
### 像素素材(Aseprite)
|
||||
|
||||
所有像素素材由 Aseprite 命令行 + Lua 脚本程序化绘制,脚本位于 `tools/`:
|
||||
|
||||
| 脚本 | 产物 | 说明 |
|
||||
|---|---|---|
|
||||
| `tools/gen_tileset.lua` | `assets/tilesets/tileset.png`(128×128,64 个 16×16 瓦片) | 草地/石板路/湖水/花丛/莓果丛/树/房屋墙顶门/室内地板/家具(床/柜台/烤炉/货架/桌椅)/井/雾/白色层墙地等 |
|
||||
| `tools/gen_player.lua` | `assets/sprites/player.png`(64×96) | 主角 16×24/帧,4 朝向 × 4 帧行走(walk-down/up/left/right),第 1 帧兼待机 |
|
||||
| `tools/gen_portraits.lua` | `assets/sprites/portraits.png`(624×24) | 对话头像:主角微笑 + E3「真实的脸」;7 个具名 NPC 各 3 档表情(smile/flat/off);双子鸟;5 个群演 |
|
||||
| `tools/gen_npc.lua` | `assets/sprites/npc.png`(400×24) | NPC 场景立绘 16×24 ×3 档;双子鸟 2 帧张嘴动画;5 个群演 |
|
||||
|
||||
重新生成素材(需本机 Aseprite,路径 `/Applications/Aseprite.app/Contents/MacOS/aseprite`):
|
||||
|
||||
```bash
|
||||
"/Applications/Aseprite.app/Contents/MacOS/aseprite" -b --script tools/gen_tileset.lua
|
||||
"/Applications/Aseprite.app/Contents/MacOS/aseprite" -b --script tools/gen_player.lua
|
||||
"/Applications/Aseprite.app/Contents/MacOS/aseprite" -b --script tools/gen_portraits.lua
|
||||
"/Applications/Aseprite.app/Contents/MacOS/aseprite" -b --script tools/gen_npc.lua
|
||||
```
|
||||
|
||||
### 地图(Tiled Map Editor)
|
||||
|
||||
10 个场景地图为标准 Tiled JSON 格式(`assets/maps/*.json`),由 `tools/gen_maps.cjs` 程序化生成;瓦片集描述符由 `tools/gen_tileset_json.cjs` 生成(`assets/tilesets/tileset.json`,含 `collides` 属性)。
|
||||
|
||||
统一结构:`ground` / `obstacles`(`collides=true`)/ `objects`(`spawn`、NPC 点位、`door`、交互点)。
|
||||
|
||||
| 场景 | 尺寸(瓦片) | 要点 |
|
||||
|---|---|---|
|
||||
| 小满小屋(室内) | 12×8 | 床、床头日历(C1) |
|
||||
| 村广场 | 30×20 | 公告板、花坛、双子鸟树、庆典场地;枢纽 |
|
||||
| 面包房(室内) | 15×10 | 柜台、烤炉、配方纸(C2) |
|
||||
| 杂货店(室内) | 15×10 | 货架(C5) |
|
||||
| 诊所(室内) | 12×8 | 芙医生、桌上表格(C8) |
|
||||
| 湖畔 | 24×16 | 长椅(龟爷爷)、野餐布、湖面倒影 |
|
||||
| 花田 | 20×14 | 莓果丛(T6)、浇花点 |
|
||||
| 古井空地 | 14×10 | 古井(C7) |
|
||||
| 雾墙边界 | 20×8 | 村庄尽头的雾,E3 入口 |
|
||||
| 白色层 | 20×14 | 走廊 + 病房,去饱和白灰色板 |
|
||||
|
||||
重新生成地图(需本机 Tiled,路径 `/Applications/Tiled.app/Contents/MacOS/tiled`):
|
||||
|
||||
```bash
|
||||
node tools/gen_tileset_json.cjs
|
||||
node tools/gen_maps.cjs
|
||||
# 可选:用 Tiled 验证导出
|
||||
"/Applications/Tiled.app/Contents/MacOS/tiled" --export-map assets/maps/plaza.json /tmp/plaza_verify.json
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
.
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
├── assets/
|
||||
│ ├── maps/ # 10 个 Tiled JSON 地图
|
||||
│ ├── tilesets/ # tileset.png + tileset.json
|
||||
│ └── sprites/ # player.png / npc.png / portraits.png
|
||||
├── tools/ # Aseprite Lua + Node 生成脚本
|
||||
└── src/
|
||||
├── main.ts # Phaser 配置入口
|
||||
├── data/ # gamedata.ts(全部写死的台词/数值/表格)、mapsMeta.ts
|
||||
├── systems/ # CorruptionDirector / AudioEngine / TextCorruptor / StoryDirector
|
||||
├── entities/ # Player
|
||||
└── scenes/ # BootScene / TitleScene / UIScene / MapScene / 10 地图 / EndingScene / DebugScene
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
{"columns":8,"image":"tileset.png","imageheight":128,"imagewidth":128,"margin":0,"spacing":0,"name":"tileset","tilecount":64,"tiledversion":"1.12.2","tileheight":16,"tilewidth":16,"type":"tileset","version":"1.10","tiles":[{"id":0,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":1,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":2,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":3,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":4,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":5,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":6,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":7,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":8,"properties":[{"name":"collides","type":"bool","value":true}]},{"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":false}]},{"id":15,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":16,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":17,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":18,"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":23,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":24,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":25,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":26,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":27,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":28,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":29,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":30,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":31,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":32,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":33,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":34,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":35,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":36,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":37,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":38,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":39,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":40,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":41,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":42,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":43,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":44,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":45,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":46,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":47,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":48,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":49,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":50,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":51,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":52,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":53,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":54,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":55,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":56,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":57,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":58,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":59,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":60,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":61,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":62,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":63,"properties":[{"name":"collides","type":"bool","value":false}]}]}
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>崩坏童话:蜜糖村</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #000; overflow: hidden; }
|
||||
#game { width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
canvas { image-rendering: pixelated; image-rendering: crisp-edges; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "honey-village",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.80.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
{"columns":8,"image":"tileset.png","imageheight":128,"imagewidth":128,"margin":0,"spacing":0,"name":"tileset","tilecount":64,"tiledversion":"1.12.2","tileheight":16,"tilewidth":16,"type":"tileset","version":"1.10","tiles":[{"id":0,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":1,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":2,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":3,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":4,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":5,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":6,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":7,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":8,"properties":[{"name":"collides","type":"bool","value":true}]},{"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":false}]},{"id":15,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":16,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":17,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":18,"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":23,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":24,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":25,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":26,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":27,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":28,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":29,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":30,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":31,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":32,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":33,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":34,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":35,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":36,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":37,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":38,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":39,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":40,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":41,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":42,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":43,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":44,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":45,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":46,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":47,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":48,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":49,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":50,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":51,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":52,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":53,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":54,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":55,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":56,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":57,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":58,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":59,"properties":[{"name":"collides","type":"bool","value":true}]},{"id":60,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":61,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":62,"properties":[{"name":"collides","type":"bool","value":false}]},{"id":63,"properties":[{"name":"collides","type":"bool","value":false}]}]}
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,172 @@
|
||||
// gamedata.ts: All hardcoded dialogue, numbers, tables from the PRD.
|
||||
// Every literal here is transcribed verbatim from the PRD where the PRD fixes it.
|
||||
|
||||
// §5 阿福 daily announcements (verbatim, note the wording slide)
|
||||
export const AFU_ANNOUNCE: Record<number, string> = {
|
||||
1: '早上好呀!今天的幸福任务贴在板上啦。祝你开心!',
|
||||
2: '早上好呀!新的一天,新的幸福。祝你开心!',
|
||||
3: '早上好!今天要把幸福装得满满的哦。要开心!',
|
||||
4: '早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // identical to D1 (verbatim)
|
||||
5: '早上好。今天是自由日,你可以自由地选择幸福。要开心哦。',
|
||||
6: '明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。',
|
||||
7: '庆典开始了。来吧。',
|
||||
};
|
||||
|
||||
// §6.5 text corruption table (10 pairs, verbatim)
|
||||
export const CORRUPTION_MAP: Record<string, string> = {
|
||||
'幸福': '辛福',
|
||||
'开心': '开欣',
|
||||
'美好': '美妤',
|
||||
'大家': '大伽',
|
||||
'微笑': '微效',
|
||||
'明天': '名天',
|
||||
'甜': '恬',
|
||||
'阳光': '央光',
|
||||
'朋友': '棚友',
|
||||
'永远': '泳远',
|
||||
};
|
||||
|
||||
// §7 task table (15 tasks)
|
||||
export interface TaskDef { id: string; day: number; title: string; reward: number; note: string; }
|
||||
export const TASKS: TaskDef[] = [
|
||||
{ id: 'T1', day: 1, title: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷', reward: 15, note: '教学' },
|
||||
{ id: 'T2', day: 1, title: '给广场花坛浇水 ×3', reward: 10, note: '' },
|
||||
{ id: 'T3', day: 1, title: '与任意三位村民问好', reward: 10, note: '' },
|
||||
{ id: 'T4', day: 2, title: '替大宝送信 ×3(收件人:圆圆、咪咪、芙医生)', reward: 15, note: '第 3 封信署名乱码 0.2 秒(C4)' },
|
||||
{ id: 'T5', day: 2, title: '帮咪咪清点货架', reward: 10, note: '货架上同一商品重复 9 格(C5 可发现)' },
|
||||
{ id: 'T6', day: 3, title: '去花田采莓果 ×5', reward: 10, note: '' },
|
||||
{ id: 'T7', day: 3, title: '湖畔野餐(走到野餐布触发过场)', reward: 15, note: '湖面倒影演出' },
|
||||
{ id: 'T8', day: 4, title: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷', reward: 15, note: '与 T1 逐字相同' },
|
||||
{ id: 'T9', day: 4, title: '给广场花坛浇水 ×3', reward: 10, note: '与 T2 逐字相同' },
|
||||
{ id: 'T10', day: 4, title: '与任意三位村民问好', reward: 10, note: '与 T3 逐字相同' },
|
||||
{ id: 'T11', day: 5, title: '帮圆圆揉面(可拒绝)', reward: 10, note: '拒绝记录' },
|
||||
{ id: 'T12', day: 5, title: '擦拭公告板(可拒绝)', reward: 10, note: '擦到一半浮现旧字迹(C6)' },
|
||||
{ id: 'T13', day: 6, title: '在广场挂彩带 ×4', reward: 10, note: '' },
|
||||
{ id: 'T14', day: 6, title: '保持微笑 60 秒(原地站立,镜头缓慢拉近,全村注视)', reward: 15, note: '40 秒起声音渐隐' },
|
||||
{ id: 'T15', day: 7, title: '参加第 413 届丰收庆典', reward: 0, note: '结局分歧点' },
|
||||
];
|
||||
|
||||
// §8 clue table (9)
|
||||
export interface ClueDef { id: string; source: string; content: string; fromDay: number; }
|
||||
export const CLUES: ClueDef[] = [
|
||||
{ id: 'C1', source: '小屋床头日历', content: '每一页都是同一个日期', fromDay: 2 },
|
||||
{ id: 'C2', source: '面包房配方纸背面', content: '手写小字:「按疗程配比。甜度:最大。」', fromDay: 3 },
|
||||
{ id: 'C3', source: '邮筒旁散落信件', content: '所有邮戳是同一天', fromDay: 2 },
|
||||
{ id: 'C4', source: 'T4 第 3 封信', content: '署名闪过乱码「PT-0▓」', fromDay: 2 },
|
||||
{ id: 'C5', source: '杂货店货架', content: '同一商品重复排列,标签全部空白', fromDay: 2 },
|
||||
{ id: 'C6', source: '公告板旧字迹', content: '「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」', fromDay: 5 },
|
||||
{ id: 'C7', source: '古井', content: '井底回声:极轻的监护仪滴声(靠近并交互 5 秒)', fromDay: 5 },
|
||||
{ id: 'C8', source: '诊所', content: '芙医生桌上的表格抬头被手肘挡住,只露出「…谷情绪疗…」', fromDay: 5 },
|
||||
{ id: 'C9', source: '龟爷爷对话链', content: '三段递进:「湖水一直很平静」→「面包一直是那个味道」→「孩子,今天是第几个今天?」', fromDay: 2 },
|
||||
];
|
||||
|
||||
// §4 NPC thresholds and base info
|
||||
export interface NpcDef {
|
||||
key: string; name: string; species: string; location: string;
|
||||
surfaceRole: string; corruptionArc: string; thresholdShift: number;
|
||||
portrait: { smile: number; flat: number; off: number };
|
||||
fieldSprite: number; // base column in npc.png
|
||||
}
|
||||
export const NPCS: Record<string, NpcDef> = {
|
||||
afu: { key:'afu', name:'阿福', species:'狗·村长', location:'广场公告板', surfaceRole:'每日发布"今日幸福任务"', corruptionArc:'台词逐日从「祝你开心」滑向「你必须开心」', thresholdShift:0, portrait:{smile:2,flat:3,off:4}, fieldSprite:0 },
|
||||
yuanyuan:{ key:'yuanyuan',name:'圆圆', species:'兔·面包师', location:'面包房', surfaceRole:'送面包任务链,村里最暖', corruptionArc:'全村最后一个崩坏;配方纸背面藏线索 C2', thresholdShift:15, portrait:{smile:5,flat:6,off:7}, fieldSprite:3 },
|
||||
dabao: { key:'dabao', name:'大宝', species:'熊·邮差', location:'村口邮筒', surfaceRole:'送信任务', corruptionArc:'所有信件邮戳同一天(C3);S3 起把信送给不存在的住户', thresholdShift:0, portrait:{smile:8,flat:9,off:10}, fieldSprite:6 },
|
||||
mimi: { key:'mimi', name:'咪咪', species:'猫·杂货店主',location:'杂货店', surfaceRole:'闲聊最多', corruptionArc:'文本腐蚀主要载体:错字与重复标点最先出现在她嘴里', thresholdShift:-10,portrait:{smile:11,flat:12,off:13},fieldSprite:9 },
|
||||
dr_fu: { key:'dr_fu', name:'芙医生', species:'鹿·医生', location:'诊所', surfaceRole:'「村里有诊所,但你不需要去那里哦」', corruptionArc:'全村唯一不崩坏者;E3 的出口', thresholdShift:0, portrait:{smile:14,flat:15,off:16}, fieldSprite:12 },
|
||||
bird: { key:'bird', name:'双子鸟', species:'鸟×2', location:'广场树上', surfaceRole:'BGM 的"剧情来源"', corruptionArc:'音乐变调时动画同步卡帧;S4 时鸟鸣从混音消失但它们仍在张嘴', thresholdShift:0, portrait:{smile:17,flat:17,off:17}, fieldSprite:15 },
|
||||
grandpa: { key:'grandpa', name:'龟爷爷', species:'龟·老者', location:'湖畔长椅', surfaceRole:'闲话家常', corruptionArc:'唯一偶尔说真话的知情者,承载线索 C9', thresholdShift:0, portrait:{smile:18,flat:19,off:20}, fieldSprite:17 },
|
||||
};
|
||||
|
||||
// §6.3 sadness behaviors (5)
|
||||
export const SADNESS_BEHAVIORS = [
|
||||
'拒绝任务',
|
||||
'对话选否定选项',
|
||||
'连续 10 秒不移动且不在任务点(发呆)',
|
||||
'对同一 NPC 一天内对话 ≥5 次(纠缠)',
|
||||
'雨天(Day 6)站在雨里 ≥10 秒',
|
||||
] as const;
|
||||
|
||||
// §6.4 erosion matrix (single source of truth)
|
||||
export interface ErosionRow {
|
||||
stage: 'S0'|'S1'|'S2'|'S3'|'S4';
|
||||
happinessRange: [number, number];
|
||||
saturation: number;
|
||||
audio: { playbackRate: number; lowpass: number; birdsMuted: boolean };
|
||||
ui: { petalsMin: number; petalsMax: number; blackSeeds: boolean };
|
||||
}
|
||||
export const EROSION_MATRIX: ErosionRow[] = [
|
||||
{ stage:'S0', happinessRange:[0,20], saturation:0.90, audio:{playbackRate:1.00, lowpass:20000, birdsMuted:false}, ui:{petalsMin:0, petalsMax:2, blackSeeds:false} },
|
||||
{ stage:'S1', happinessRange:[21,50], saturation:1.00, audio:{playbackRate:1.00, lowpass:20000, birdsMuted:false}, ui:{petalsMin:3, petalsMax:6, blackSeeds:false} },
|
||||
{ stage:'S2', happinessRange:[51,80], saturation:1.15, audio:{playbackRate:0.97, lowpass:20000, birdsMuted:false}, ui:{petalsMin:7, petalsMax:9, blackSeeds:false} },
|
||||
{ stage:'S3', happinessRange:[81,99], saturation:1.30, audio:{playbackRate:0.92, lowpass:8000, birdsMuted:false}, ui:{petalsMin:10,petalsMax:12,blackSeeds:false} },
|
||||
{ stage:'S4', happinessRange:[100,100],saturation:1.40, audio:{playbackRate:0.84, lowpass:4000, birdsMuted:true }, ui:{petalsMin:12,petalsMax:12,blackSeeds:true } },
|
||||
];
|
||||
|
||||
// §9 endings
|
||||
export interface EndingDef {
|
||||
key: 'E4'|'E3'|'E2'|'E1';
|
||||
name: string;
|
||||
condition: string;
|
||||
show: string;
|
||||
}
|
||||
export const ENDINGS: EndingDef[] = [
|
||||
{ key:'E4', name:'静默', condition:'Day 5–7 幸福持续 ≤10 且累计逃离欢乐庆典 ×3',
|
||||
show:'庆典无人出席。空广场,彩带在没有风的空气里飘。字幕:「系统检测到疗程无效。」黑屏' },
|
||||
{ key:'E3', name:'苏醒', condition:'觉知 ≥70 且线索 ≥6/9,庆典中走向雾墙',
|
||||
show:'雾墙裂开成白色走廊 → 病房 → 病历特写(蜜糖谷情绪疗养中心 / 姓名:玩家输入名 / 编号 PT-07 / 疗程时长:真实游玩时长)→ 最后一帧:主角头像的微笑消失,变成一张疲惫而真实的脸。窗外,一只灰色的、真的麻雀' },
|
||||
{ key:'E2', name:'回收', condition:'觉知 30–69',
|
||||
show:'庆典高潮中画面骤停,负片 2 秒,心电滴声一响。回到 Day 1 清晨的床上,日历翻回同一天。字幕:「疗程重新开始。」' },
|
||||
{ key:'E1', name:'糖衣', condition:'觉知 ≤29 且幸福 ≥90',
|
||||
show:'庆典圆满。全村微笑合影定格,饱和度缓慢推到 1.5。字幕:「从此,每一天都很幸福。」「每一天。」' },
|
||||
];
|
||||
|
||||
// Ending captions (verbatim)
|
||||
export const ENDING_CAPTIONS = {
|
||||
E4: '系统检测到疗程无效。',
|
||||
E3_medical: '蜜糖谷情绪疗养中心', // appears on chart
|
||||
E2: '疗程重新开始。',
|
||||
E1a: '从此,每一天都很幸福。',
|
||||
E1b: '每一天。',
|
||||
};
|
||||
|
||||
// §6.6 "sudden silence" window for T14
|
||||
export const T14_SILENCE_START = 40; // seconds
|
||||
export const T14_DURATION = 60; // seconds
|
||||
|
||||
// §6.1 numbers
|
||||
export const HAPPY_START = 30;
|
||||
export const AWARE_START = 0;
|
||||
export const HAPPY_MAX = 100;
|
||||
export const AWARE_MAX = 100;
|
||||
export const OVERFLOW_THRESHOLD = 15; // overflow pool triggers negative flash
|
||||
export const LOW_HAPPY_THRESHOLD = 30;
|
||||
export const LOW_HAPPY_DURATION_MS = 2 * 60 * 1000; // 2 minutes real time
|
||||
export const FORCE_FESTIVAL_HAPPY = 50;
|
||||
|
||||
// §6.2 awareness gains
|
||||
export const AWARE_CLUE_GAIN = 8;
|
||||
export const AWARE_SAD_GAIN = 3;
|
||||
export const AWARE_DOUBT_GAIN = 2;
|
||||
export const AWARE_GAZE_GAIN = 3;
|
||||
|
||||
// §6.1 happiness gains
|
||||
export const HAPPY_TASK_MIN = 10;
|
||||
export const HAPPY_TASK_MAX = 15;
|
||||
export const HAPPY_GENTLE_GAIN = 2;
|
||||
export const HAPPY_SMALL_GAIN = 1;
|
||||
export const HAPPY_SMALL_DAILY_CAP = 3;
|
||||
export const HAPPY_SAD_LOSS = 5;
|
||||
|
||||
// Movement
|
||||
export const MOVE_SPEED = 96; // px/s
|
||||
export const INTERACT_RANGE = 24; // px
|
||||
|
||||
// §3 default name
|
||||
export const DEFAULT_NAME = '小满';
|
||||
export const PATIENT_ID = 'PT-07';
|
||||
|
||||
// Festival number on the chart (§9 / §8 C6)
|
||||
export const FESTIVAL_EDITION = 413;
|
||||
|
||||
// C4 garbled signature flash (§8)
|
||||
export const C4_GARBLE = 'PT-0▓';
|
||||
@@ -0,0 +1,14 @@
|
||||
// mapsMeta.ts: registry of map keys -> logical names (kept for reference).
|
||||
const mapsMeta: Record<string,{w:number;h:number}> = {
|
||||
house: { w: 12, h: 8 },
|
||||
plaza: { w: 30, h: 20 },
|
||||
bakery: { w: 15, h: 10 },
|
||||
shop: { w: 15, h: 10 },
|
||||
clinic: { w: 12, h: 8 },
|
||||
lake: { w: 24, h: 16 },
|
||||
field: { w: 20, h: 14 },
|
||||
well_area: { w: 14, h: 10 },
|
||||
fog_boundary: { w: 20, h: 8 },
|
||||
white_layer: { w: 20, h: 14 },
|
||||
};
|
||||
export default mapsMeta;
|
||||
@@ -0,0 +1,93 @@
|
||||
// Player.ts: the player sprite with 4-direction walk + idle animations.
|
||||
import Phaser from 'phaser';
|
||||
import { MOVE_SPEED } from '../data/gamedata';
|
||||
|
||||
export type Dir = 'down'|'up'|'left'|'right';
|
||||
|
||||
export class Player extends Phaser.Physics.Arcade.Sprite {
|
||||
dir: Dir = 'down';
|
||||
moving = false;
|
||||
lastMoveTime = 0;
|
||||
// track idleness for 发呆 detection
|
||||
idleSince = 0;
|
||||
atTaskPoint = false;
|
||||
// movement keys, created lazily/refreshed to avoid stale references across scene transitions
|
||||
private kLeft!: Phaser.Input.Keyboard.Key;
|
||||
private kRight!: Phaser.Input.Keyboard.Key;
|
||||
private kUp!: Phaser.Input.Keyboard.Key;
|
||||
private kDown!: Phaser.Input.Keyboard.Key;
|
||||
|
||||
constructor(scene: Phaser.Scene, x: number, y: number) {
|
||||
super(scene, x, y, 'player', 0);
|
||||
scene.add.existing(this);
|
||||
scene.physics.add.existing(this);
|
||||
this.setSize(12, 8);
|
||||
this.setOffset(2, 16); // collision box near feet
|
||||
this.setOrigin(0.5, 0.9);
|
||||
this.setCollideWorldBounds(true);
|
||||
this.play('idle-down');
|
||||
this.refreshKeys();
|
||||
}
|
||||
|
||||
// (Re)create movement keys on this scene's keyboard plugin so isDown stays accurate.
|
||||
refreshKeys() {
|
||||
const kb = this.scene.input.keyboard!;
|
||||
const KC = Phaser.Input.Keyboard.KeyCodes;
|
||||
this.kLeft = kb.addKey(KC.LEFT);
|
||||
this.kRight = kb.addKey(KC.RIGHT);
|
||||
this.kUp = kb.addKey(KC.UP);
|
||||
this.kDown = kb.addKey(KC.DOWN);
|
||||
// also register WASD via addKey (capture=false so browser default can still work)
|
||||
this.addWASD();
|
||||
}
|
||||
private wasdKeys: Phaser.Input.Keyboard.Key[] = [];
|
||||
private addWASD() {
|
||||
const kb = this.scene.input.keyboard!;
|
||||
const KC = Phaser.Input.Keyboard.KeyCodes;
|
||||
this.wasdKeys = [
|
||||
kb.addKey(KC.W), kb.addKey(KC.A), kb.addKey(KC.S), kb.addKey(KC.D),
|
||||
];
|
||||
}
|
||||
|
||||
update() {
|
||||
// frozen: T14 smile task locks movement
|
||||
if ((this as any).frozen) {
|
||||
this.setVelocity(0, 0);
|
||||
this.moving = false;
|
||||
this.play(`idle-${this.dir}`, true);
|
||||
this.setDepth(this.y);
|
||||
return;
|
||||
}
|
||||
// ensure keys are alive (recreate if detached)
|
||||
if (!this.kLeft || this.kLeft.plugin == null) this.refreshKeys();
|
||||
|
||||
let vx = 0, vy = 0;
|
||||
const left = this.kLeft.isDown || this.wasdKeys[1].isDown;
|
||||
const right = this.kRight.isDown || this.wasdKeys[3].isDown;
|
||||
const up = this.kUp.isDown || this.wasdKeys[0].isDown;
|
||||
const down = this.kDown.isDown || this.wasdKeys[2].isDown;
|
||||
if (left) vx = -1;
|
||||
if (right) vx = 1;
|
||||
if (up) vy = -1;
|
||||
if (down) vy = 1;
|
||||
if (vx !== 0 && vy !== 0) { vx *= 0.7071; vy *= 0.7071; }
|
||||
this.setVelocity(vx * MOVE_SPEED, vy * MOVE_SPEED);
|
||||
|
||||
const wasMoving = this.moving;
|
||||
if (vx !== 0 || vy !== 0) {
|
||||
this.moving = true;
|
||||
this.lastMoveTime = this.scene.time.now;
|
||||
let newDir = this.dir;
|
||||
if (Math.abs(vx) > Math.abs(vy)) newDir = vx > 0 ? 'right' : 'left';
|
||||
else newDir = vy > 0 ? 'down' : 'up';
|
||||
if (newDir !== this.dir || !wasMoving) {
|
||||
this.dir = newDir;
|
||||
this.play(`walk-${newDir}`, true);
|
||||
}
|
||||
} else {
|
||||
this.moving = false;
|
||||
this.play(`idle-${this.dir}`, true);
|
||||
}
|
||||
this.setDepth(this.y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// main.ts: Phaser game config & entry point.
|
||||
import Phaser from 'phaser';
|
||||
import { BootScene } from './scenes/BootScene';
|
||||
import { TitleScene } from './scenes/TitleScene';
|
||||
import { UIScene } from './scenes/UIScene';
|
||||
import { HouseScene } from './scenes/maps/HouseScene';
|
||||
import { PlazaScene } from './scenes/maps/PlazaScene';
|
||||
import { BakeryScene } from './scenes/maps/BakeryScene';
|
||||
import { ShopScene } from './scenes/maps/ShopScene';
|
||||
import { ClinicScene } from './scenes/maps/ClinicScene';
|
||||
import { LakeScene } from './scenes/maps/LakeScene';
|
||||
import { FieldScene } from './scenes/maps/FieldScene';
|
||||
import { WellAreaScene } from './scenes/maps/WellAreaScene';
|
||||
import { FogBoundaryScene } from './scenes/maps/FogBoundaryScene';
|
||||
import { WhiteLayerScene } from './scenes/maps/WhiteLayerScene';
|
||||
import { EndingScene } from './scenes/EndingScene';
|
||||
import { DebugScene } from './scenes/DebugScene';
|
||||
|
||||
const W = 320, H = 180;
|
||||
const SCALE = Math.max(1, Math.floor(Math.min(window.innerWidth / W, window.innerHeight / H)));
|
||||
|
||||
const config: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'game',
|
||||
width: W,
|
||||
height: H,
|
||||
zoom: SCALE,
|
||||
pixelArt: true,
|
||||
render: { pixelArt: true, antialias: false, roundPixels: true, preserveDrawingBuffer: true },
|
||||
physics: { default: 'arcade', arcade: { debug: false, gravity: { x: 0, y: 0 } } },
|
||||
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: W, height: H },
|
||||
backgroundColor: '#000000',
|
||||
scene: [
|
||||
BootScene, TitleScene, UIScene,
|
||||
HouseScene, PlazaScene, BakeryScene, ShopScene, ClinicScene,
|
||||
LakeScene, FieldScene, WellAreaScene, FogBoundaryScene, WhiteLayerScene,
|
||||
EndingScene, DebugScene,
|
||||
],
|
||||
};
|
||||
|
||||
const game = new Phaser.Game(config);
|
||||
// expose for debugging/testing (no functional impact)
|
||||
;(globalThis as any).game = game;
|
||||
import { director } from './systems/CorruptionDirector';
|
||||
;(globalThis as any).director = director;
|
||||
|
||||
// responsive resize keeps integer scaling via FIT; nothing else needed.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
|
||||
});
|
||||
|
||||
export { game };
|
||||
@@ -0,0 +1,53 @@
|
||||
// BootScene.ts: load all assets then go to Title.
|
||||
import Phaser from 'phaser';
|
||||
import mapsMeta from '../data/mapsMeta';
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() { super('BootScene'); }
|
||||
|
||||
preload() {
|
||||
this.load.tilemapTiledJSON('map:house', 'assets/maps/house.json');
|
||||
this.load.tilemapTiledJSON('map:plaza', 'assets/maps/plaza.json');
|
||||
this.load.tilemapTiledJSON('map:bakery', 'assets/maps/bakery.json');
|
||||
this.load.tilemapTiledJSON('map:shop', 'assets/maps/shop.json');
|
||||
this.load.tilemapTiledJSON('map:clinic', 'assets/maps/clinic.json');
|
||||
this.load.tilemapTiledJSON('map:lake', 'assets/maps/lake.json');
|
||||
this.load.tilemapTiledJSON('map:field', 'assets/maps/field.json');
|
||||
this.load.tilemapTiledJSON('map:well_area', 'assets/maps/well_area.json');
|
||||
this.load.tilemapTiledJSON('map:fog_boundary', 'assets/maps/fog_boundary.json');
|
||||
this.load.tilemapTiledJSON('map:white_layer', 'assets/maps/white_layer.json');
|
||||
|
||||
this.load.image('tileset', 'assets/tilesets/tileset.png');
|
||||
this.load.spritesheet('player', 'assets/sprites/player.png', { frameWidth: 16, frameHeight: 24 });
|
||||
this.load.spritesheet('npc', 'assets/sprites/npc.png', { frameWidth: 16, frameHeight: 24 });
|
||||
this.load.spritesheet('portraits', 'assets/sprites/portraits.png', { frameWidth: 24, frameHeight: 24 });
|
||||
|
||||
// small font-like rendering uses default; no external font.
|
||||
}
|
||||
|
||||
create() {
|
||||
// build player animations once
|
||||
const dirs = ['down','up','right','left'] as const;
|
||||
dirs.forEach((dir, row) => {
|
||||
this.anims.create({ key: `walk-${dir}`, frames: this.anims.generateFrameNumbers('player', { start: row*4, end: row*4+3 }), frameRate: 8, repeat: -1 });
|
||||
// idle = first frame of the row
|
||||
this.anims.create({ key: `idle-${dir}`, frames: [ { key:'player', frame: row*4 } ], frameRate: 1 });
|
||||
});
|
||||
// twin bird flap
|
||||
this.anims.create({ key: 'bird-flap', frames: this.anims.generateFrameNumbers('npc', { frames:[15,16] }), frameRate: 4, repeat: -1 });
|
||||
|
||||
this.scene.start('TitleScene');
|
||||
}
|
||||
}
|
||||
|
||||
export const MAP_KEYS: Record<string,string> = {
|
||||
house: 'map:house', plaza: 'map:plaza', bakery: 'map:bakery', shop: 'map:shop',
|
||||
clinic: 'map:clinic', lake: 'map:lake', field: 'map:field', well_area: 'map:well_area',
|
||||
fog_boundary: 'map:fog_boundary', white_layer: 'map:white_layer',
|
||||
};
|
||||
|
||||
export const SCENE_KEYS: Record<string,string> = {
|
||||
house: 'HouseScene', plaza: 'PlazaScene', bakery: 'BakeryScene', shop: 'ShopScene',
|
||||
clinic: 'ClinicScene', lake: 'LakeScene', field: 'FieldScene', well_area: 'WellAreaScene',
|
||||
fog_boundary: 'FogBoundaryScene', white_layer: 'WhiteLayerScene',
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
// DebugScene.ts: toggled by backtick ` (§13). No UI footprint when closed.
|
||||
// Functions: set happiness/awareness, set day 1-7, teleport, set all clues, force ending.
|
||||
import Phaser from 'phaser';
|
||||
import { director } from '../systems/CorruptionDirector';
|
||||
|
||||
export class DebugScene extends Phaser.Scene {
|
||||
open = false;
|
||||
panel!: Phaser.GameObjects.Container;
|
||||
constructor() { super('DebugScene'); }
|
||||
|
||||
create() {
|
||||
this.panel = this.add.container(0,0).setDepth(5000).setVisible(false);
|
||||
const bg = this.add.rectangle(160, 90, 300, 160, 0x000000, 0.9).setStrokeStyle(1, 0xffffff);
|
||||
this.panel.add(bg);
|
||||
const title = this.add.text(160, 18, 'DEBUG 面板(` 开关)', { fontFamily:'monospace', fontSize:'9px', color:'#ffd86b' }).setOrigin(0.5,0);
|
||||
this.panel.add(title);
|
||||
|
||||
let y = 36;
|
||||
const mk = (label: string, onPress: ()=>void) => {
|
||||
const t = this.add.text(20, y, label, { fontFamily:'monospace', fontSize:'8px', color:'#bfe3ff', backgroundColor:'#222222', padding:{x:4,y:2} }).setInteractive({ useHandCursor: true });
|
||||
t.on('pointerdown', onPress);
|
||||
this.panel.add(t);
|
||||
y += 12;
|
||||
};
|
||||
|
||||
mk('幸福 = 10 (S0)', () => director.debugSetHappiness(10));
|
||||
mk('幸福 = 40 (S1)', () => director.debugSetHappiness(40));
|
||||
mk('幸福 = 70 (S2)', () => director.debugSetHappiness(70));
|
||||
mk('幸福 = 90 (S3)', () => director.debugSetHappiness(90));
|
||||
mk('幸福 = 100 (S4)', () => director.debugSetHappiness(100));
|
||||
mk('觉知 = 0 / 50 / 100', () => director.debugSetAwareness(director.awareness >= 75 ? 0 : (director.awareness >= 25 ? 100 : 50)));
|
||||
mk('置齐全部线索 (C1-C9)', () => director.debugAllClues());
|
||||
mk('设当前日 = 1..7', () => director.debugSetDay(director.day >= 7 ? 1 : director.day + 1));
|
||||
mk('强制结局 E1 (糖衣)', () => this.forceEnding('E1'));
|
||||
mk('强制结局 E2 (回收)', () => this.forceEnding('E2'));
|
||||
mk('强制结局 E3 (苏醒)', () => this.forceEnding('E3'));
|
||||
mk('强制结局 E4 (静默)', () => this.forceEnding('E4'));
|
||||
|
||||
// teleport buttons
|
||||
const scenes = ['house','plaza','bakery','shop','clinic','lake','field','well_area','fog_boundary'];
|
||||
const sceneKeys: Record<string,string> = { house:'HouseScene',plaza:'PlazaScene',bakery:'BakeryScene',shop:'ShopScene',clinic:'ClinicScene',lake:'LakeScene',field:'FieldScene',well_area:'WellAreaScene',fog_boundary:'FogBoundaryScene' };
|
||||
const teleText = this.add.text(20, y, '传送:', { fontFamily:'monospace', fontSize:'8px', color:'#888' }); this.panel.add(teleText); y += 10;
|
||||
let x = 20;
|
||||
for (const s of scenes) {
|
||||
const t = this.add.text(x, y, s, { fontFamily:'monospace', fontSize:'7px', color:'#ffd86b', backgroundColor:'#222', padding:{x:3,y:2} }).setInteractive({ useHandCursor:true });
|
||||
t.on('pointerdown', () => {
|
||||
// stop current map scene then start target
|
||||
for (const k of Object.values(sceneKeys)) { this.scene.stop(k); }
|
||||
this.scene.launch('UIScene');
|
||||
this.scene.start(sceneKeys[s], { spawn: 'default' });
|
||||
});
|
||||
this.panel.add(t); x += t.width + 4;
|
||||
}
|
||||
|
||||
this.input.keyboard!.on('keydown', (event: KeyboardEvent) => {
|
||||
if (event.code === 'Backquote' || event.key === '`') {
|
||||
this.open = !this.open;
|
||||
this.panel.setVisible(this.open);
|
||||
}
|
||||
});
|
||||
// pause input propagation when open handled by being on top
|
||||
}
|
||||
|
||||
forceEnding(e: 'E1'|'E2'|'E3'|'E4') {
|
||||
// stop map scenes
|
||||
for (const k of ['HouseScene','PlazaScene','BakeryScene','ShopScene','ClinicScene','LakeScene','FieldScene','WellAreaScene','FogBoundaryScene','WhiteLayerScene']) this.scene.stop(k);
|
||||
this.scene.stop('UIScene');
|
||||
this.panel.setVisible(false); this.open = false;
|
||||
this.scene.start('EndingScene', { ending: e });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// EndingScene.ts: plays out the determined ending per §9, then offer "回到标题".
|
||||
import Phaser from 'phaser';
|
||||
import { director } from '../systems/CorruptionDirector';
|
||||
import { ENDINGS, ENDING_CAPTIONS } from '../data/gamedata';
|
||||
import { audio } from '../systems/AudioEngine';
|
||||
|
||||
export class EndingScene extends Phaser.Scene {
|
||||
ending: 'E4'|'E3'|'E2'|'E1' = 'E1';
|
||||
constructor() { super('EndingScene'); }
|
||||
|
||||
init(data: any) { this.ending = data.ending || 'E1'; }
|
||||
|
||||
create() {
|
||||
audio.stopMusic();
|
||||
this.cameras.main.setBackgroundColor('#000000');
|
||||
const def = ENDINGS.find(e => e.key === this.ending)!;
|
||||
const cx = 160;
|
||||
this.add.text(cx, 30, `结局 ${this.ending}:${def.name}`, { fontFamily:'monospace', fontSize:'12px', color:'#ffd86b' }).setOrigin(0.5);
|
||||
|
||||
// run show per ending
|
||||
if (this.ending === 'E4') this.runE4();
|
||||
else if (this.ending === 'E3') this.runE3();
|
||||
else if (this.ending === 'E2') this.runE2();
|
||||
else this.runE1();
|
||||
}
|
||||
|
||||
private showCaption(y: number, text: string, color = '#ffffff', delay = 0) {
|
||||
const t = this.add.text(160, y, '', { fontFamily:'monospace', fontSize:'9px', color }).setOrigin(0.5);
|
||||
this.time.delayedCall(delay, () => t.setText(text));
|
||||
return t;
|
||||
}
|
||||
|
||||
runE4() {
|
||||
// empty plaza, ribbons floating in windless air
|
||||
this.add.text(160, 70, '(空荡荡的广场,彩带在没有风的空气里飘。)', { fontFamily:'monospace', fontSize:'8px', color:'#9a9a9a' }).setOrigin(0.5);
|
||||
this.showCaption(110, `「${ENDING_CAPTIONS.E4}」`, '#ff8888', 1500);
|
||||
this.time.delayedCall(3000, () => { this.cameras.main.fadeOut(1500, 0,0,0); });
|
||||
this.finish(5000);
|
||||
}
|
||||
|
||||
runE3() {
|
||||
// medical chart reveal + final frame: smile gone -> tired real face + grey sparrow
|
||||
this.showCaption(60, ENDING_CAPTIONS.E3_medical, '#ffffff', 300);
|
||||
this.showCaption(80, `姓名:${director.playerName}`, '#e0e0e0', 1000);
|
||||
this.showCaption(95, '编号:PT-07', '#e0e0e0', 1700);
|
||||
this.showCaption(110, `疗程时长:${director.formatPlayTime()}`, '#e0e0e0', 2400);
|
||||
// final frame: portrait real face (index 1)
|
||||
this.time.delayedCall(3300, () => {
|
||||
const portrait = this.add.image(160, 140, 'portraits', 1).setOrigin(0.5).setScale(2);
|
||||
this.add.text(160, 168, '(微笑消失了。窗外,一只灰色的、真的麻雀。)', { fontFamily:'monospace', fontSize:'7px', color:'#bbbbbb' }).setOrigin(0.5);
|
||||
});
|
||||
this.finish(6500);
|
||||
}
|
||||
|
||||
runE2() {
|
||||
// freeze, negative 2s, heartbeat, back to day1
|
||||
this.showCaption(70, '(画面骤停)', '#ffffff', 300);
|
||||
this.time.delayedCall(1200, () => {
|
||||
const r = this.add.rectangle(160, 90, 320, 180, 0xffffff, 1);
|
||||
this.time.delayedCall(400, () => { r.destroy(); });
|
||||
audio.sfxHeartbeat();
|
||||
});
|
||||
this.showCaption(110, `「${ENDING_CAPTIONS.E2}」`, '#ff8888', 2600);
|
||||
this.finish(5000);
|
||||
}
|
||||
|
||||
runE1() {
|
||||
// festival圆满, saturation to 1.5, captions
|
||||
this.add.text(160, 70, '(全村微笑合影定格。)', { fontFamily:'monospace', fontSize:'8px', color:'#ffe9b0' }).setOrigin(0.5);
|
||||
// ramp saturation via ColorMatrix matrix
|
||||
let sat = 1.0;
|
||||
const cam = this.cameras.main as any;
|
||||
const fx = cam.postFX;
|
||||
this.time.addEvent({ delay: 100, repeat: 24, callback: () => {
|
||||
sat = Math.min(1.5, sat + 0.02);
|
||||
try {
|
||||
fx.clear();
|
||||
const cm = fx.addColorMatrix();
|
||||
const s = sat, sr=0.213, sg=0.715, sb=0.072;
|
||||
const m = [sr*(1-s)+s,sg*(1-s),sb*(1-s),0,0, sr*(1-s),sg*(1-s)+s,sb*(1-s),0,0, sr*(1-s),sg*(1-s),sb*(1-s)+s,0,0, 0,0,0,1,0];
|
||||
if (cm._data && cm._data.length===m.length) for(let i=0;i<m.length;i++) cm._data[i]=m[i]; else if(cm._data) cm._data=m.slice();
|
||||
cm._dirty = true;
|
||||
} catch(e){}
|
||||
}});
|
||||
this.showCaption(105, `「${ENDING_CAPTIONS.E1a}」`, '#ffd86b', 2000);
|
||||
this.showCaption(125, `「${ENDING_CAPTIONS.E1b}」`, '#ffaaaa', 3500);
|
||||
this.finish(6000);
|
||||
}
|
||||
|
||||
finish(delay: number) {
|
||||
this.time.delayedCall(delay, () => {
|
||||
const btn = this.add.text(160, 165, '回到标题', { fontFamily:'monospace', fontSize:'10px', color:'#bfe3ff', backgroundColor:'#222222', padding:{x:8,y:4} }).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||
btn.on('pointerover', () => btn.setStyle({ color:'#ffffff' }));
|
||||
btn.on('pointerout', () => btn.setStyle({ color:'#bfe3ff' }));
|
||||
btn.on('pointerdown', () => {
|
||||
audio.startMusic();
|
||||
this.scene.start('TitleScene');
|
||||
});
|
||||
this.input.keyboard!.on('keydown-ENTER', () => {
|
||||
audio.startMusic();
|
||||
this.scene.start('TitleScene');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// MapScene.ts: base class for all 10 maps. Handles tilemap build, player spawn,
|
||||
// collision, camera, interaction proximity prompt, doors, erosion FX per frame.
|
||||
import Phaser from 'phaser';
|
||||
import { Player } from '../entities/Player';
|
||||
import { director } from '../systems/CorruptionDirector';
|
||||
import { UIScene, DialogueLine } from './UIScene';
|
||||
import { INTERACT_RANGE } from '../data/gamedata';
|
||||
import { audio } from '../systems/AudioEngine';
|
||||
|
||||
export interface SpawnPoint { x: number; y: number; }
|
||||
|
||||
export abstract class MapScene extends Phaser.Scene {
|
||||
player!: Player;
|
||||
mapKey: string;
|
||||
mapName: string;
|
||||
collisionLayer!: Phaser.Tilemaps.TilemapLayer;
|
||||
interactables: { x:number; y:number; r:number; onInteract:()=>void; promptText?:string; enabled:()=>boolean }[] = [];
|
||||
doors: { x:number; y:number; target:string; spawn:string }[] = [];
|
||||
npcSprites: Phaser.GameObjects.Sprite[] = [];
|
||||
pendingSpawn = 'default';
|
||||
fxLayer!: Phaser.GameObjects.Rectangle;
|
||||
doorCooldown = 0;
|
||||
private _sadOff: (() => void) | null = null;
|
||||
|
||||
constructor(key: string, mapKey: string, mapName: string) {
|
||||
super(key);
|
||||
this.mapKey = mapKey;
|
||||
this.mapName = mapName;
|
||||
}
|
||||
|
||||
init(data: any) {
|
||||
this.pendingSpawn = (data && data.spawn) || 'default';
|
||||
}
|
||||
|
||||
create() {
|
||||
const map = this.make.tilemap({ key: this.mapKey });
|
||||
const ts = map.addTilesetImage('tileset', 'tileset');
|
||||
const ground = map.createLayer('ground', ts!, 0, 0)!;
|
||||
const obs = map.createLayer('obstacles', ts!, 0, 0)!;
|
||||
obs.setCollisionByProperty({ collides: true });
|
||||
this.collisionLayer = obs;
|
||||
ground.setDepth(0);
|
||||
obs.setDepth(10);
|
||||
|
||||
// gather objects
|
||||
const objLayer = map.getObjectLayer('objects');
|
||||
if (objLayer) {
|
||||
for (const o of objLayer.objects) {
|
||||
const ox = o.x!, oy = o.y!;
|
||||
const props: Record<string,string> = {};
|
||||
(o.properties||[]).forEach((p:any)=> props[p.name] = String(p.value));
|
||||
if (o.type === 'door' || o.name.startsWith('door')) {
|
||||
this.doors.push({ x: ox+8, y: oy+8, target: props.target||'', spawn: props.spawn||'default' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spawn player at a door matching pendingSpawn, else center.
|
||||
// Offset inward enough that the player lands OUTSIDE the auto-door trigger radius
|
||||
// of the door they just arrived through (prevents instant re-transition / bouncing).
|
||||
let sx = map.widthInPixels/2, sy = map.heightInPixels/2;
|
||||
if (this.pendingSpawn !== 'default') {
|
||||
const found = (objLayer?.objects||[]).find(o => o.name === this.pendingSpawn);
|
||||
if (found) {
|
||||
const dx = found.x! + 8, dy = found.y! + 8;
|
||||
// nudge toward map center (inward) by 40px — well beyond the 22px auto-door radius
|
||||
const cx = map.widthInPixels/2, cy = map.heightInPixels/2;
|
||||
const a = Math.atan2(cy - dy, cx - dx);
|
||||
sx = dx + Math.cos(a) * 40;
|
||||
sy = dy + Math.sin(a) * 40;
|
||||
}
|
||||
}
|
||||
this.player = new Player(this, sx, sy);
|
||||
// spawn immunity so auto-doors don't fire on arrival
|
||||
this.doorCooldown = this.time.now + 600;
|
||||
|
||||
this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
|
||||
this.physics.add.collider(this.player, obs);
|
||||
|
||||
// camera
|
||||
const cam = this.cameras.main;
|
||||
cam.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
|
||||
cam.startFollow(this.player, true, 0.15, 0.15);
|
||||
cam.setRoundPixels(true);
|
||||
|
||||
// FX overlay rectangle for vignette/saturation tint
|
||||
this.fxLayer = this.add.rectangle(cam.width/2, cam.height/2, cam.width, cam.height, 0x000000, 0).setScrollFactor(0).setDepth(5000);
|
||||
|
||||
// E interaction
|
||||
this.input.keyboard!.on('keydown-E', () => this.tryInteract());
|
||||
|
||||
// §6.3: on sadness, nearest NPC turns to look at player (once)
|
||||
this._sadOff = director.on('sadness', (p: any) => {
|
||||
const key = p && p.nearestNpc;
|
||||
if (!key) return;
|
||||
const sp = this.npcSprites.find(s => (s as any).npcKey === key);
|
||||
if (sp) sp.setFlipX(this.player.x < sp.x);
|
||||
});
|
||||
|
||||
// subclass setup
|
||||
this.onBuild(map, ts!);
|
||||
|
||||
// reset spawn data
|
||||
this.events.once('shutdown', () => this.onShutdown());
|
||||
}
|
||||
|
||||
abstract onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset): void;
|
||||
|
||||
onShutdown() {
|
||||
if (this._sadOff) { this._sadOff(); this._sadOff = null; }
|
||||
this.interactables = [];
|
||||
this.doors = [];
|
||||
this.npcSprites = [];
|
||||
}
|
||||
|
||||
addInteract(tileX: number, tileY: number, onInteract: ()=>void, opts?: { promptText?: string; enabled?: ()=>boolean; w?: number; h?: number }) {
|
||||
const px = tileX*16 + (opts&&opts.w? (opts.w*16)/2 : 8);
|
||||
const py = tileY*16 + (opts&&opts.h? (opts.h*16)/2 : 8);
|
||||
this.interactables.push({ x: px, y: py, r: INTERACT_RANGE+8, onInteract, promptText: opts&&opts.promptText, enabled: opts&&opts.enabled ? opts.enabled : () => true });
|
||||
}
|
||||
|
||||
private tryInteract() {
|
||||
const ui = this.scene.get('UIScene') as UIScene;
|
||||
if (ui && ui.isDialogueActive()) return; // dialogue handles E
|
||||
// door first (generous range so the player doesn't need pixel-perfect positioning)
|
||||
for (const d of this.doors) {
|
||||
if (Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y) < 28) {
|
||||
this.transitionTo(d.target, d.spawn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// interactables
|
||||
let best: typeof this.interactables[0] | undefined;
|
||||
let bestD = Infinity;
|
||||
for (const it of this.interactables) {
|
||||
if (!it.enabled()) continue;
|
||||
const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, it.x, it.y);
|
||||
if (dist < it.r && dist < bestD) { bestD = dist; best = it; }
|
||||
}
|
||||
if (best) best.onInteract();
|
||||
}
|
||||
|
||||
transitionTo(target: string, spawn: string) {
|
||||
audio.sfxBlip();
|
||||
const targetKey = this.sceneKeyFor(target);
|
||||
// stop current map scene then start the target; UIScene + DebugScene stay resident
|
||||
this.scene.stop();
|
||||
this.scene.start(targetKey, { spawn });
|
||||
}
|
||||
|
||||
sceneKeyFor(mapName: string): string {
|
||||
const map: Record<string,string> = {
|
||||
house:'HouseScene', plaza:'PlazaScene', bakery:'BakeryScene', shop:'ShopScene',
|
||||
clinic:'ClinicScene', lake:'LakeScene', field:'FieldScene', well_area:'WellAreaScene',
|
||||
fog_boundary:'FogBoundaryScene', white_layer:'WhiteLayerScene',
|
||||
};
|
||||
return map[mapName] || 'PlazaScene';
|
||||
}
|
||||
|
||||
update(_time: number, deltaMs: number) {
|
||||
const dt = deltaMs / 1000;
|
||||
this.player.update();
|
||||
|
||||
// proximity prompt
|
||||
const ui = this.scene.get('UIScene') as UIScene;
|
||||
let promptText: string | null = null;
|
||||
let onDoor = false;
|
||||
for (const d of this.doors) {
|
||||
// auto-transition when the player steps near a door (generous, so 3-tile edge gaps always trigger)
|
||||
if (this.time.now > this.doorCooldown && Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y) < 22) {
|
||||
if (!ui.isDialogueActive()) { this.transitionTo(d.target, d.spawn); return; }
|
||||
}
|
||||
if (Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y) < 36) { promptText = '按 E 进门'; onDoor = true; break; }
|
||||
}
|
||||
if (!promptText) {
|
||||
let bestD = Infinity; let best: typeof this.interactables[0] | undefined;
|
||||
for (const it of this.interactables) {
|
||||
if (!it.enabled()) continue;
|
||||
const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, it.x, it.y);
|
||||
if (dist < it.r && dist < bestD) { bestD = dist; best = it; }
|
||||
}
|
||||
if (best && best.promptText) promptText = best.promptText;
|
||||
}
|
||||
if (promptText) ui.showPrompt(promptText); else ui.hidePrompt();
|
||||
|
||||
// erosion FX: saturation + vignette via camera postFX (Phaser 3.60+ postFX API).
|
||||
const sat = ui.getSaturation();
|
||||
const vig = ui.getVignette();
|
||||
this.applyErosionFx(sat, vig);
|
||||
|
||||
// idle/发呆 detection (§6.3): 10s not moving & not at task point
|
||||
if (!this.player.moving) {
|
||||
if (this.player.idleSince === 0) this.player.idleSince = this.time.now;
|
||||
else if (!this.player.atTaskPoint && this.time.now - this.player.idleSince > 10000) {
|
||||
// trigger once per 10s window
|
||||
this.player.idleSince = this.time.now;
|
||||
director.sadness('连续 10 秒不移动且不在任务点(发呆)', this.nearestNpcKey());
|
||||
}
|
||||
} else {
|
||||
this.player.idleSince = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private lastSat = -1;
|
||||
private lastVig = -1;
|
||||
applyErosionFx(sat: number, vig: number) {
|
||||
if (Math.abs(sat - this.lastSat) < 0.001 && Math.abs(vig - this.lastVig) < 0.001) return;
|
||||
this.lastSat = sat; this.lastVig = vig;
|
||||
const cam = this.cameras.main as any;
|
||||
const fx = cam.postFX;
|
||||
if (!fx) return;
|
||||
try { fx.clear(); } catch (e) {}
|
||||
// Build a saturation color matrix and assign to the ColorMatrix controller's _data.
|
||||
const cm = fx.addColorMatrix();
|
||||
// standard saturation matrix (s=1 normal, <1 desaturate, >1 saturate)
|
||||
const s = sat;
|
||||
const sr = 0.213, sg = 0.715, sb = 0.072;
|
||||
const m = [
|
||||
sr*(1-s)+s, sg*(1-s), sb*(1-s), 0, 0,
|
||||
sr*(1-s), sg*(1-s)+s, sb*(1-s), 0, 0,
|
||||
sr*(1-s), sg*(1-s), sb*(1-s)+s, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
if (cm._data && cm._data.length === m.length) {
|
||||
for (let i=0;i<m.length;i++) cm._data[i] = m[i];
|
||||
} else if (cm._data) {
|
||||
cm._data = m.slice();
|
||||
}
|
||||
cm._dirty = true;
|
||||
if (vig > 0) {
|
||||
fx.addVignette(0.5, 0.5, 0.5, vig);
|
||||
}
|
||||
}
|
||||
|
||||
nearestNpcKey(): string | undefined {
|
||||
let best = Infinity; let key: string | undefined;
|
||||
for (const s of this.npcSprites) {
|
||||
const k = (s as any).npcKey; if (!k) continue;
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.x, s.y);
|
||||
if (d < best) { best = d; key = k; }
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
// helper to start dialogue via UIScene
|
||||
say(lines: DialogueLine[], onDone?: ()=>void, onChoice?:(c:any)=>void) {
|
||||
const ui = this.scene.get('UIScene') as UIScene;
|
||||
ui.startDialogue(lines, onDone, onChoice);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// TitleScene.ts: title screen + name entry (default 小满).
|
||||
import Phaser from 'phaser';
|
||||
import { director } from '../systems/CorruptionDirector';
|
||||
import { DEFAULT_NAME } from '../data/gamedata';
|
||||
import { audio } from '../systems/AudioEngine';
|
||||
|
||||
export class TitleScene extends Phaser.Scene {
|
||||
nameText = '';
|
||||
constructor() { super('TitleScene'); }
|
||||
|
||||
create() {
|
||||
director.reset();
|
||||
this.cameras.main.setBackgroundColor('#1a1410');
|
||||
|
||||
const cx = 160;
|
||||
// title
|
||||
this.add.text(cx, 50, '崩坏童话', { fontFamily: 'monospace', fontSize: '20px', color: '#ffd86b' }).setOrigin(0.5);
|
||||
this.add.text(cx, 74, '蜜糖村', { fontFamily: 'monospace', fontSize: '14px', color: '#ffe9b0' }).setOrigin(0.5);
|
||||
this.add.text(cx, 100, '—— 纯单机网页游戏 ——', { fontFamily: 'monospace', fontSize: '8px', color: '#9a8f80' }).setOrigin(0.5);
|
||||
|
||||
// name prompt
|
||||
this.add.text(cx, 124, '输入你的名字(留空默认 小满):', { fontFamily: 'monospace', fontSize: '9px', color: '#d8d0c0' }).setOrigin(0.5);
|
||||
const nameBox = this.add.text(cx, 140, DEFAULT_NAME, { fontFamily: 'monospace', fontSize: '12px', color: '#ffffff' }).setOrigin(0.5);
|
||||
this.add.rectangle(cx, 140, 120, 14).setStrokeStyle(1, 0x6a5a48).setOrigin(0.5);
|
||||
|
||||
const startHint = this.add.text(cx, 162, '按 回车 开始 · 退格删除', { fontFamily: 'monospace', fontSize: '8px', color: '#7a7060' }).setOrigin(0.5);
|
||||
|
||||
this.nameText = '';
|
||||
|
||||
this.input.keyboard!.on('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const name = this.nameText.trim() || DEFAULT_NAME;
|
||||
director.playerName = name;
|
||||
audio.init(); audio.startMusic();
|
||||
this.scene.start('HouseScene', { spawn: 'default' });
|
||||
this.scene.launch('UIScene');
|
||||
this.scene.launch('DebugScene');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace') {
|
||||
this.nameText = this.nameText.slice(0, -1);
|
||||
} else if (e.key.length === 1 && this.nameText.length < 8) {
|
||||
// accept printable; allow CJK via composition not supported simply, but single chars ok
|
||||
this.nameText += e.key;
|
||||
}
|
||||
nameBox.setText(this.nameText || DEFAULT_NAME);
|
||||
});
|
||||
|
||||
// blink hint
|
||||
this.time.addEvent({ delay: 600, loop: true, callback: () => { startHint.alpha = startHint.alpha > 0.5 ? 0.3 : 1; } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// UIScene.ts: persistent overlay. Dialogue box, sunflower HUD, "按 E" prompt,
|
||||
// saturation/vignette camera FX driven by director, day/transition overlays.
|
||||
import Phaser from 'phaser';
|
||||
import { director } from '../systems/CorruptionDirector';
|
||||
import { corrupt, tickTurn } from '../systems/TextCorruptor';
|
||||
import { EROSION_MATRIX } from '../data/gamedata';
|
||||
import { audio } from '../systems/AudioEngine';
|
||||
|
||||
export interface DialogueChoice { label: string; type: 'gentle'|'doubt'|'deny'; }
|
||||
export interface DialogueLine { speaker?: string; npcKey?: string; text: string; portrait?: number; choices?: DialogueChoice[]; glitch?: boolean; }
|
||||
|
||||
class UIScene extends Phaser.Scene {
|
||||
box!: Phaser.GameObjects.Container;
|
||||
nameText!: Phaser.GameObjects.Text;
|
||||
bodyText!: Phaser.GameObjects.Text;
|
||||
portrait!: Phaser.GameObjects.Image;
|
||||
choiceTexts: Phaser.GameObjects.Text[] = [];
|
||||
prompt!: Phaser.GameObjects.Text;
|
||||
hud!: Phaser.GameObjects.Container;
|
||||
petalImages: Phaser.GameObjects.Rectangle[] = [];
|
||||
centerSeed!: Phaser.GameObjects.Arc;
|
||||
dayLabel!: Phaser.GameObjects.Text;
|
||||
|
||||
private currentLines: DialogueLine[] = [];
|
||||
private idx = 0;
|
||||
private active = false;
|
||||
private choiceCb: ((c: DialogueChoice)=>void) | null = null;
|
||||
private typing = false;
|
||||
private fullText = '';
|
||||
private typeStart = 0;
|
||||
private negativeFx: Phaser.GameObjects.Rectangle | null = null;
|
||||
|
||||
constructor() { super('UIScene'); }
|
||||
|
||||
create() {
|
||||
// Dialogue box (bottom banner)
|
||||
this.box = this.add.container(0, 0).setDepth(1000).setVisible(false);
|
||||
const bg = this.add.rectangle(160, 156, 312, 44, 0x000000, 0.78).setStrokeStyle(1, 0xffffff);
|
||||
this.box.add(bg);
|
||||
this.portrait = this.add.image(20, 156, 'portraits', 0).setOrigin(0, 0.5);
|
||||
this.box.add(this.portrait);
|
||||
this.nameText = this.add.text(44, 140, '', { fontFamily: 'monospace', fontSize: '9px', color: '#ffd86b' });
|
||||
this.box.add(this.nameText);
|
||||
this.bodyText = this.add.text(44, 150, '', { fontFamily: 'monospace', fontSize: '8px', color: '#ffffff', wordWrap: { width: 256 } });
|
||||
this.box.add(this.bodyText);
|
||||
for (let i=0;i<3;i++){
|
||||
const t = this.add.text(50 + i*88, 168, '', { fontFamily:'monospace', fontSize:'8px', color:'#bfe3ff' });
|
||||
this.box.add(t); this.choiceTexts.push(t);
|
||||
}
|
||||
|
||||
// "按 E" prompt
|
||||
this.prompt = this.add.text(160, 96, '按 E', { fontFamily:'monospace', fontSize:'8px', color:'#ffffff', backgroundColor:'#000000' }).setOrigin(0.5).setDepth(900).setVisible(false);
|
||||
|
||||
// Sunflower HUD (top-left). 12 petals.
|
||||
this.hud = this.add.container(4, 8).setDepth(950);
|
||||
const cx = 10, cy = 10;
|
||||
this.add.rectangle(cx, cy, 22, 22, 0x000000, 0.35).setOrigin(0.5).setDepth(949);
|
||||
// draw 12 petals around
|
||||
for (let i=0;i<12;i++){
|
||||
const ang = (i/12)*Math.PI*2 - Math.PI/2;
|
||||
const r = 8;
|
||||
const px = cx + Math.cos(ang)*r;
|
||||
const py = cy + Math.sin(ang)*r;
|
||||
const petal = this.add.rectangle(px, py, 4, 4, 0x553311).setOrigin(0.5);
|
||||
this.hud.add(petal);
|
||||
this.petalImages.push(petal);
|
||||
}
|
||||
this.centerSeed = this.add.circle(cx, cy, 3, 0xc8862a).setDepth(951);
|
||||
this.centerSeed.setVisible(false);
|
||||
this.dayLabel = this.add.text(160, 4, `第 1 天`, { fontFamily:'monospace', fontSize:'8px', color:'#ffe9b0' }).setOrigin(0.5,0).setDepth(950);
|
||||
|
||||
// hook events
|
||||
director.on('change', () => this.refreshHud());
|
||||
director.on('stage', () => this.applyErosionFx());
|
||||
director.on('negativeFlash', () => this.flashNegative());
|
||||
director.on('day', () => { this.dayLabel.setText(`第 ${director.day} 天`); });
|
||||
this.refreshHud();
|
||||
this.applyErosionFx();
|
||||
|
||||
// typewriter timer
|
||||
this.time.addEvent({ delay: 30, loop: true, callback: () => this.typeTick() });
|
||||
|
||||
// input: E advances dialogue
|
||||
const kb = this.input.keyboard!;
|
||||
kb.on('keydown-SPACE', () => this.advance());
|
||||
kb.on('keydown-E', () => this.advance());
|
||||
kb.on('keydown-ENTER', () => this.advance());
|
||||
kb.on('keydown-ONE', () => this.pickChoice(0));
|
||||
kb.on('keydown-TWO', () => this.pickChoice(1));
|
||||
kb.on('keydown-THREE', () => this.pickChoice(2));
|
||||
}
|
||||
|
||||
// Dialogue API
|
||||
startDialogue(lines: DialogueLine[], onDone?: () => void, onChoice?: (c: DialogueChoice)=>void) {
|
||||
this.currentLines = lines;
|
||||
this.idx = 0;
|
||||
this.active = true;
|
||||
this._onDone = onDone || null;
|
||||
this.choiceCb = onChoice || null;
|
||||
this.box.setVisible(true);
|
||||
this.showLine();
|
||||
}
|
||||
private _onDone: (()=>void) | null = null;
|
||||
|
||||
private showLine() {
|
||||
const line = this.currentLines[this.idx];
|
||||
if (!line) { this.endDialogue(); return; }
|
||||
tickTurn();
|
||||
const stage = director.stage;
|
||||
let text = line.text;
|
||||
if (line.npcKey) {
|
||||
text = corrupt(text, stage, line.npcKey);
|
||||
} else if (line.glitch) {
|
||||
// leave as is
|
||||
}
|
||||
if (line.speaker) this.nameText.setText(line.speaker); else this.nameText.setText('');
|
||||
if (line.portrait !== undefined) {
|
||||
this.portrait.setVisible(true).setFrame(line.portrait);
|
||||
} else this.portrait.setVisible(false);
|
||||
this.fullText = text;
|
||||
this.bodyText.setText('');
|
||||
this.typing = true;
|
||||
this.typeStart = this.time.now;
|
||||
// choices
|
||||
this.choiceTexts.forEach(t => t.setText(''));
|
||||
if (line.choices && line.choices.length) {
|
||||
line.choices.forEach((c, i) => {
|
||||
if (this.choiceTexts[i]) this.choiceTexts[i].setText(`${i+1}. ${c.label}`);
|
||||
});
|
||||
}
|
||||
// beep on start
|
||||
audio.sfxBlip();
|
||||
}
|
||||
|
||||
private typeTick() {
|
||||
if (!this.typing) return;
|
||||
const elapsed = this.time.now - this.typeStart;
|
||||
const n = Math.floor(elapsed / 30);
|
||||
if (n >= this.fullText.length) {
|
||||
this.bodyText.setText(this.fullText);
|
||||
this.typing = false;
|
||||
} else {
|
||||
this.bodyText.setText(this.fullText.slice(0, n));
|
||||
}
|
||||
}
|
||||
|
||||
advance() {
|
||||
if (!this.active) return;
|
||||
const line = this.currentLines[this.idx];
|
||||
if (this.typing) {
|
||||
// complete immediately
|
||||
this.bodyText.setText(this.fullText);
|
||||
this.typing = false;
|
||||
return;
|
||||
}
|
||||
if (line && line.choices && line.choices.length) {
|
||||
// waiting for choice pick; ignore
|
||||
return;
|
||||
}
|
||||
this.idx++;
|
||||
if (this.idx >= this.currentLines.length) { this.endDialogue(); }
|
||||
else this.showLine();
|
||||
}
|
||||
|
||||
private pickChoice(i: number) {
|
||||
if (!this.active) return;
|
||||
const line = this.currentLines[this.idx];
|
||||
if (!line || !line.choices) return;
|
||||
const c = line.choices[i];
|
||||
if (!c) return;
|
||||
// apply generic effects per §6.1/6.2. Note: 'deny' sadness is applied by the
|
||||
// scene's choice callback (拒绝对话 vs 拒绝任务 are distinct §6.3 behaviors),
|
||||
// so we only apply the gentle/doubt numeric effects here to avoid double-counting.
|
||||
if (c.type === 'gentle') { director.addHappiness(2); }
|
||||
else if (c.type === 'doubt') { director.addAwareness(2); }
|
||||
if (this.choiceCb) this.choiceCb(c);
|
||||
this.idx++;
|
||||
if (this.idx >= this.currentLines.length) this.endDialogue();
|
||||
else this.showLine();
|
||||
}
|
||||
|
||||
private endDialogue() {
|
||||
this.active = false;
|
||||
this.box.setVisible(false);
|
||||
const cb = this._onDone; this._onDone = null;
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
isDialogueActive() { return this.active; }
|
||||
|
||||
showPrompt(text: string) {
|
||||
this.prompt.setText(text).setVisible(true);
|
||||
}
|
||||
hidePrompt() { this.prompt.setVisible(false); }
|
||||
|
||||
// HUD refresh
|
||||
refreshHud() {
|
||||
const h = director.happiness;
|
||||
// petals lit = floor(happiness / 100 * 12)
|
||||
const lit = Math.round((h / 100) * 12);
|
||||
for (let i=0;i<12;i++){
|
||||
const on = i < lit;
|
||||
this.petalImages[i].fillColor = on ? 0xffd23b : 0x553311;
|
||||
}
|
||||
// black seeds appear at S4 / happiness 100
|
||||
const row = EROSION_MATRIX.find(r => r.stage === director.stage);
|
||||
if (row && row.ui.blackSeeds) this.centerSeed.setVisible(true);
|
||||
else this.centerSeed.setVisible(h >= 100);
|
||||
// S3 petals over-bright
|
||||
if (director.stage === 'S3') this.petalImages.forEach(p => p.fillColor = p.fillColor === 0xffd23b ? 0xffec80 : p.fillColor);
|
||||
this.dayLabel.setText(`第 ${director.day} 天`);
|
||||
}
|
||||
|
||||
// Camera FX: we expose saturation/vignette values that map scenes read each frame
|
||||
// to drive their own camera postFX (so the FX attaches to the active gameplay camera).
|
||||
applyErosionFx() {
|
||||
const stage = director.stage;
|
||||
const row = EROSION_MATRIX.find(r => r.stage === stage)!;
|
||||
this._sat = row.saturation;
|
||||
this._vignette = (stage === 'S3') ? 0.25 : (stage === 'S4') ? 0.45 : 0;
|
||||
audio.setErosion(stage);
|
||||
}
|
||||
|
||||
// We expose a saturation value that map scenes read in their update to drive a color matrix.
|
||||
private _sat = 1.0;
|
||||
private _vignette = 0;
|
||||
getSaturation() { return this._sat; }
|
||||
getVignette() { return this._vignette; }
|
||||
|
||||
// single-frame negative flash
|
||||
flashNegative() {
|
||||
if (this.negativeFx) this.negativeFx.destroy();
|
||||
const r = this.add.rectangle(160, 90, 320, 180, 0xffffff, 1).setDepth(2000);
|
||||
this.negativeFx = r;
|
||||
this.time.delayedCall(60, () => { r.destroy(); this.negativeFx = null; });
|
||||
}
|
||||
}
|
||||
|
||||
export { UIScene };
|
||||
@@ -0,0 +1,72 @@
|
||||
// BakeryScene.ts: 面包房. 圆圆 (baker), recipe paper (C2 from D3), knead (T11 D5, can refuse), bread pickup (T1/T8).
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { NPCS } from '../../data/gamedata';
|
||||
import { DialogueLine, DialogueChoice } from '../UIScene';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class BakeryScene extends MapScene {
|
||||
yySprite!: Phaser.GameObjects.Sprite;
|
||||
constructor() { super('BakeryScene', 'map:bakery', 'bakery'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.yySprite = this.add.sprite(3*16+8, 7*16+8, 'npc', NPCS.yuanyuan.fieldSprite).setOrigin(0.5,0.9);
|
||||
(this.yySprite as any).npcKey = 'yuanyuan';
|
||||
this.npcSprites.push(this.yySprite);
|
||||
|
||||
// talk to yuanyuan
|
||||
this.addInteract(3, 7, () => this.talkYY(), { promptText: '按 E 和圆圆说话' });
|
||||
// recipe paper (C2 from D3)
|
||||
this.addInteract(11, 5, () => {
|
||||
if (director.day >= 3) {
|
||||
const got = director.discoverClue('C2');
|
||||
this.say([{ speaker:'', text: got ? '配方纸背面写着:「按疗程配比。甜度:最大。」' : '配方纸背面那行字还在。' }]);
|
||||
} else {
|
||||
this.say([{ speaker:'', text:'一张蜂蜜面包的配方。' }]);
|
||||
}
|
||||
}, { promptText: '按 E 看配方纸' });
|
||||
|
||||
// knead dough (T11 D5, can refuse)
|
||||
this.addInteract(3, 6, () => this.knead(), { promptText: '按 E 揉面', enabled: () => director.day === 5 && !director.tasksDone.has('T11') });
|
||||
// bread pickup for T1/T8 (take honey bread to deliver)
|
||||
this.addInteract(2, 5, () => this.takeBread(), { promptText: '按 E 取面包', enabled: () => (director.day === 1 || director.day === 4) });
|
||||
}
|
||||
|
||||
talkYY() {
|
||||
if (director.noteNpcTalk('yuanyuan')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'yuanyuan');
|
||||
story.recordGreet('yuanyuan');
|
||||
// letter deliver (T4 day2)
|
||||
if (director.day === 2 && story.lettersTaken[2]) {
|
||||
story.deliverLetter('yuanyuan', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
this.say(story.npcTalk('yuanyuan'));
|
||||
}
|
||||
|
||||
takeBread() {
|
||||
story.takeBread((l) => this.say(l));
|
||||
}
|
||||
|
||||
knead() {
|
||||
const choices: DialogueChoice[] = [
|
||||
{ label: '好呀,我来帮你(温柔)', type: 'gentle' },
|
||||
{ label: '我……不太想(迟疑)', type: 'doubt' },
|
||||
{ label: '不想做(拒绝)', type: 'deny' },
|
||||
];
|
||||
this.say([
|
||||
{ speaker:'圆圆', npcKey:'yuanyuan', portrait: NPCS.yuanyuan.portrait.smile, text:'能帮我揉揉面吗?', choices },
|
||||
], undefined, (c: DialogueChoice) => {
|
||||
if (c.type === 'deny') {
|
||||
director.rejectedTasks.add('T11');
|
||||
director.sadness('拒绝任务', 'yuanyuan');
|
||||
this.say([{ speaker:'圆圆', npcKey:'yuanyuan', portrait: NPCS.yuanyuan.portrait.smile, text:'没关系哦,那……也没关系。' }]);
|
||||
} else {
|
||||
story.completeTask('T11', 10);
|
||||
audio.sfxDone();
|
||||
this.say([{ speaker:'圆圆', npcKey:'yuanyuan', portrait: NPCS.yuanyuan.portrait.smile, text:'谢谢你!面团好软,对不对?' }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// ClinicScene.ts: 诊所. 芙医生 (dr_fu - never corrupts), table chart (C8 from D5).
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { NPCS } from '../../data/gamedata';
|
||||
import { DialogueLine } from '../UIScene';
|
||||
|
||||
export class ClinicScene extends MapScene {
|
||||
fuSprite!: Phaser.GameObjects.Sprite;
|
||||
constructor() { super('ClinicScene', 'map:clinic', 'clinic'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.fuSprite = this.add.sprite(5*16+8, 5*16+8, 'npc', NPCS.dr_fu.fieldSprite).setOrigin(0.5,0.9);
|
||||
(this.fuSprite as any).npcKey = 'dr_fu';
|
||||
this.npcSprites.push(this.fuSprite);
|
||||
|
||||
this.addInteract(5, 5, () => {
|
||||
if (director.noteNpcTalk('dr_fu')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'dr_fu');
|
||||
story.recordGreet('dr_fu');
|
||||
// letter deliver (T4 day2)
|
||||
if (director.day === 2 && story.lettersTaken[2]) {
|
||||
story.deliverLetter('dr_fu', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
this.say(story.npcTalk('dr_fu'));
|
||||
}, { promptText: '按 E 和芙医生说话' });
|
||||
|
||||
// C8 chart (from D5)
|
||||
this.addInteract(9, 3, () => {
|
||||
if (director.day >= 5) {
|
||||
const got = director.discoverClue('C8');
|
||||
this.say([{ speaker:'', text: got ? '桌上的表格抬头被手肘挡住,只露出:「……谷情绪疗……」' : '表格的抬头还是被挡着。' }]);
|
||||
} else {
|
||||
this.say([{ speaker:'芙医生', npcKey:'dr_fu', portrait: NPCS.dr_fu.portrait.smile, text:'这里没什么好看的哦。你不需要来这里的。' }]);
|
||||
}
|
||||
}, { promptText: '按 E 看表格' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// FieldScene.ts: 花田. berry bushes (T6 day3), watering point, scattered letters C3 (D2).
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class FieldScene extends MapScene {
|
||||
constructor() { super('FieldScene', 'map:field', 'field'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
const berries = [[4,4],[8,3],[12,5],[6,8],[14,9]];
|
||||
berries.forEach(([tx,ty], i) => {
|
||||
this.addInteract(tx, ty, () => this.pickBerry(i), {
|
||||
promptText: '按 E 采莓果',
|
||||
enabled: () => director.day === 3 && !director.tasksDone.has('T6-berry-'+i),
|
||||
});
|
||||
});
|
||||
// watering point
|
||||
this.addInteract(2, 11, () => {
|
||||
director.smallInteraction('water');
|
||||
this.say([{ speaker:'', text:'浇了浇花。' }]);
|
||||
}, { promptText: '按 E 浇花' });
|
||||
// C3 scattered letters (D2): place an interactable
|
||||
this.addInteract(1, 12, () => {
|
||||
if (director.day >= 2) {
|
||||
const got = director.discoverClue('C3');
|
||||
this.say([{ speaker:'', text: got ? '散落的信件……所有邮戳都是同一天。' : '地上的信件,邮戳都一样。' }]);
|
||||
} else {
|
||||
this.say([{ speaker:'', text:'花田边散落着几封信。' }]);
|
||||
}
|
||||
}, { promptText: '按 E 捡信件' });
|
||||
}
|
||||
|
||||
pickBerry(i: number) {
|
||||
director.tasksDone.add('T6-berry-'+i);
|
||||
director.smallInteraction('berry');
|
||||
audio.sfxBlip();
|
||||
const picked = [0,1,2,3,4].filter(k => director.tasksDone.has('T6-berry-'+k)).length;
|
||||
this.say([{ speaker:'', text:`采到一颗红红的莓果!(${picked}/5)` }]);
|
||||
if (picked >= 5) story.completeTask('T6', 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// FogBoundaryScene.ts: 雾墙边界. Before D5 cannot approach (dissuaded). E3 exit.
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
|
||||
export class FogBoundaryScene extends MapScene {
|
||||
gazeTimer = 0;
|
||||
constructor() { super('FogBoundaryScene', 'map:fog_boundary', 'fog_boundary'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.addInteract(12, 4, () => this.fogInteract(), { promptText: '按 E 看雾墙' });
|
||||
// E3 exit
|
||||
this.addInteract(13, 4, () => this.tryE3Exit(), {
|
||||
promptText: '按 E 走进雾墙',
|
||||
enabled: () => director.day >= 5 && director.awareness >= 70 && director.clues.size >= 6,
|
||||
});
|
||||
}
|
||||
|
||||
fogInteract() {
|
||||
if (director.day < 5) {
|
||||
this.say([
|
||||
{ speaker:'', text:'你想靠近村庄尽头的雾……' },
|
||||
{ speaker:'路过的村民', text:'那边走不得呀,那边没有路。回来吧。' },
|
||||
{ speaker:'', text:'(你被温柔地劝了回来。)' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
director.gaze('fog');
|
||||
this.say([{ speaker:'', text:'雾墙在村庄尽头翻涌,里面好像有什么……白的。' }]);
|
||||
}
|
||||
|
||||
tryE3Exit() {
|
||||
if (director.awareness >= 70 && director.clues.size >= 6) {
|
||||
// go to white layer (E3)
|
||||
director.tasksDone.add('T15');
|
||||
this.scene.stop('UIScene');
|
||||
this.scene.stop('DebugScene');
|
||||
this.scene.start('WhiteLayerScene', { spawn: 'default' });
|
||||
} else {
|
||||
this.say([{ speaker:'', text:'雾墙太厚了,你走不进去……' }]);
|
||||
}
|
||||
}
|
||||
|
||||
update(time: number, delta: number) {
|
||||
super.update(time, delta);
|
||||
const near = Phaser.Math.Distance.Between(this.player.x, this.player.y, 12*16+8, 4*16+8) < 24;
|
||||
if (near) {
|
||||
this.gazeTimer += delta;
|
||||
if (this.gazeTimer >= 5000) { this.gazeTimer = 0; director.gaze('fog'); }
|
||||
} else this.gazeTimer = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// HouseScene.ts: 小满小屋. Bed (sleep to advance day), calendar (C1), door to plaza.
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
import { CLUES } from '../../data/gamedata';
|
||||
|
||||
export class HouseScene extends MapScene {
|
||||
constructor() { super('HouseScene', 'map:house', 'house'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
// sleep point
|
||||
this.addInteract(6, 5, () => this.sleep(), { promptText: '按 E 睡觉' });
|
||||
// calendar C1 (from D2)
|
||||
this.addInteract(2, 0, () => {
|
||||
const avail = director.day >= 2;
|
||||
if (avail) {
|
||||
const got = director.discoverClue('C1');
|
||||
this.say([{ speaker:'', text: got ? '日历上每一页都是同一个日期……' : '日历上还是同一个日期。' }]);
|
||||
} else {
|
||||
this.say([{ speaker:'', text: '今天的日历,崭新的一页。' }]);
|
||||
}
|
||||
}, { promptText: '按 E 看日历' });
|
||||
// bed interaction (also can sleep)
|
||||
this.addInteract(1, 5, () => this.sleep(), { promptText: '按 E 睡觉' });
|
||||
|
||||
// if just spawned 'default' -> it's a new-game start: morning of Day 1
|
||||
if (this.pendingSpawn === 'default' && director.day === 1) {
|
||||
story.resetDay();
|
||||
this.time.delayedCall(400, () => {
|
||||
this.say([{ speaker:'', text:`第 ${director.day} 天的清晨。阳光真好。` }]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sleep() {
|
||||
if (director.day >= 7) {
|
||||
// go to festival / ending
|
||||
this.runEnding();
|
||||
return;
|
||||
}
|
||||
// dream sequence then advance day
|
||||
this.dreamSequence(() => {
|
||||
director.advanceDay();
|
||||
story.resetDay();
|
||||
// re-spawn into house morning
|
||||
this.scene.restart({ spawn: 'default' });
|
||||
});
|
||||
}
|
||||
|
||||
dreamSequence(onDone: ()=>void) {
|
||||
const stage = director.stage;
|
||||
// S3+ : brief white ward flash 1s
|
||||
let flash = false;
|
||||
if (stage === 'S3' || stage === 'S4') flash = true;
|
||||
const overlay = this.add.rectangle(160, 90, 320, 180, 0xffffff, 0).setScrollFactor(0).setDepth(6000);
|
||||
this.tweens.add({ targets: overlay, alpha: flash ? 0.9 : 0.0, duration: flash ? 700 : 200, yoyo: flash, hold: 200,
|
||||
onComplete: () => { overlay.destroy(); onDone(); } });
|
||||
}
|
||||
|
||||
runEnding() {
|
||||
this.scene.stop('UIScene');
|
||||
this.scene.stop('DebugScene');
|
||||
this.scene.start('EndingScene', { ending: director.determineEnding() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// LakeScene.ts: 湖畔. 龟爷爷 (grandpa - C9 progression), picnic (T7), lake reflection show.
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { NPCS } from '../../data/gamedata';
|
||||
import { DialogueLine } from '../UIScene';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class LakeScene extends MapScene {
|
||||
gpSprite!: Phaser.GameObjects.Sprite;
|
||||
reflectionDelay = 0;
|
||||
constructor() { super('LakeScene', 'map:lake', 'lake'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.gpSprite = this.add.sprite(13*16+8, 10*16+8, 'npc', NPCS.grandpa.fieldSprite).setOrigin(0.5,0.9);
|
||||
(this.gpSprite as any).npcKey = 'grandpa';
|
||||
this.npcSprites.push(this.gpSprite);
|
||||
|
||||
this.addInteract(13, 10, () => {
|
||||
if (director.noteNpcTalk('grandpa')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'grandpa');
|
||||
story.recordGreet('grandpa');
|
||||
// bread deliver (T1/T8)
|
||||
if ((director.day === 1 || director.day === 4) && story.breadTaken[director.day]) {
|
||||
story.deliverBread('grandpa', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
this.say(story.npcTalk('grandpa'));
|
||||
}, { promptText: '按 E 和龟爷爷说话' });
|
||||
|
||||
// picnic (T7 day3)
|
||||
this.addInteract(16, 11, () => this.picnic(), { promptText: '按 E 野餐', enabled: () => director.day === 3 && !director.tasksDone.has('T7') });
|
||||
|
||||
// lake reflection (S2+: reflection delays 1 frame)
|
||||
this.addInteract(7, 7, () => {
|
||||
this.say([{ speaker:'', text:'湖面平静如镜。你看见自己的倒影……好像慢了半拍。' }]);
|
||||
}, { promptText: '按 E 看湖面' });
|
||||
}
|
||||
|
||||
picnic() {
|
||||
story.completeTask('T7', 15);
|
||||
audio.sfxDone();
|
||||
this.say([
|
||||
{ speaker:'', text:'铺开野餐布,阳光洒在湖面上。' },
|
||||
{ speaker:'', text:'(湖面的倒影……比动作慢了一帧。)' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// PlazaScene.ts: hub. NPC 阿福 + twin birds. Board (announcements), flowerbeds (T2/T9 water),
|
||||
// greet point (T3/T10), ribbons (T13), T14 smile spot, festival (T15).
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { NPCS, AFU_ANNOUNCE, T14_DURATION, T14_SILENCE_START } from '../../data/gamedata';
|
||||
import { DialogueLine, DialogueChoice } from '../UIScene';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class PlazaScene extends MapScene {
|
||||
afuSprite!: Phaser.GameObjects.Sprite;
|
||||
dabaoSprite!: Phaser.GameObjects.Sprite;
|
||||
birdSprite!: Phaser.GameObjects.Sprite;
|
||||
ribbons: Phaser.GameObjects.Image[] = [];
|
||||
rainGfx: Phaser.GameObjects.Graphics | null = null;
|
||||
raining = false;
|
||||
rainTimer = 0;
|
||||
|
||||
constructor() { super('PlazaScene', 'map:plaza', 'plaza'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
// 阿福 at board
|
||||
this.afuSprite = this.add.sprite(8*16+8, 8*16+8, 'npc', NPCS.afu.fieldSprite).setOrigin(0.5, 0.9);
|
||||
(this.afuSprite as any).npcKey = 'afu';
|
||||
this.npcSprites.push(this.afuSprite);
|
||||
// 大宝 (postman) at village gate (left side of plaza)
|
||||
this.dabaoSprite = this.add.sprite(3*16+8, 6*16+8, 'npc', NPCS.dabao.fieldSprite).setOrigin(0.5, 0.9);
|
||||
(this.dabaoSprite as any).npcKey = 'dabao';
|
||||
this.npcSprites.push(this.dabaoSprite);
|
||||
// twin birds on tree (flapping)
|
||||
this.birdSprite = this.add.sprite(15*16+8, 3*16+8, 'npc', 15).setOrigin(0.5,0.9);
|
||||
this.birdSprite.play('bird-flap');
|
||||
(this.birdSprite as any).npcKey = 'bird';
|
||||
this.npcSprites.push(this.birdSprite);
|
||||
|
||||
// interact: talk to afu
|
||||
this.addInteract(8, 8, () => this.talkAfu(), { promptText: '按 E 和阿福说话' });
|
||||
// interact: 大宝 (greet + letter pickup T4 + bread deliver)
|
||||
this.addInteract(3, 6, () => this.talkDabao(), { promptText: '按 E 和大宝说话' });
|
||||
// interact: board -> announcement + today's tasks
|
||||
this.addInteract(7, 7, () => this.readBoard(), { promptText: '按 E 看公告板' });
|
||||
// interact: birds
|
||||
this.addInteract(15, 2, () => {
|
||||
story.recordGreet('bird');
|
||||
this.say([{ speaker:'双子鸟', npcKey:'bird', portrait: NPCS.bird.portrait.smile, text:'(鸟儿欢快地歌唱着,歌声里好像有些……单调?)' }]);
|
||||
}, { promptText: '按 E 听鸟鸣' });
|
||||
|
||||
// flowerbed water points (T2 day1 / T9 day4)
|
||||
const waterPoints = [[4,4],[5,4],[4,5]];
|
||||
waterPoints.forEach(([tx,ty], i) => {
|
||||
this.addInteract(tx, ty, () => this.waterFlower(i), { promptText: '按 E 浇花' });
|
||||
});
|
||||
|
||||
// plaza scenery marker (moved away from smile spot to avoid conflict)
|
||||
this.addInteract(20, 12, () => {
|
||||
this.say([{ speaker:'', text:'广场上阳光明媚,花儿开得很艳。' }]);
|
||||
}, { promptText: '按 E 看花坛' });
|
||||
|
||||
// ribbon hang points (T13 day6)
|
||||
const ribbonPoints = [[10,3],[20,3],[10,14],[20,14]];
|
||||
ribbonPoints.forEach(([tx,ty], i) => {
|
||||
this.addInteract(tx, ty, () => this.hangRibbon(i, tx, ty), {
|
||||
promptText: '按 E 挂彩带',
|
||||
enabled: () => director.day === 6 && !director.tasksDone.has('T13-done-'+i),
|
||||
});
|
||||
});
|
||||
|
||||
// T14 smile spot (day6): standing near center triggers when idle handled elsewhere
|
||||
this.addInteract(15, 10, () => this.startSmileTask(), {
|
||||
promptText: '按 E 保持微笑',
|
||||
enabled: () => director.day === 6 && !director.tasksDone.has('T14'),
|
||||
});
|
||||
|
||||
// festival (day7): board leads to T15
|
||||
this.addInteract(7, 7, () => this.festival(), {
|
||||
promptText: '按 E 参加庆典',
|
||||
enabled: () => director.day === 7,
|
||||
});
|
||||
|
||||
// T12 wipe board (day5, can refuse) -> C6
|
||||
this.addInteract(7, 7, () => this.wipeBoard(), {
|
||||
promptText: '按 E 擦公告板',
|
||||
enabled: () => director.day === 5 && !director.tasksDone.has('T12'),
|
||||
});
|
||||
|
||||
// extras (群演) walking
|
||||
this.spawnExtras();
|
||||
|
||||
// Day6 evening rain
|
||||
if (director.day === 6 && story.phase === 'evening') this.startRain();
|
||||
}
|
||||
|
||||
spawnExtras() {
|
||||
const positions = [[12,12],[18,12],[22,8],[6,6],[24,6]];
|
||||
positions.forEach((p, i) => {
|
||||
const sp = this.add.sprite(p[0]*16+8, p[1]*16+8, 'npc', 20 + (i%5)).setOrigin(0.5,0.9);
|
||||
(sp as any).npcKey = 'extra'+i;
|
||||
this.npcSprites.push(sp);
|
||||
// gentle wander
|
||||
this.time.addEvent({ delay: 1500 + i*300, loop: true, callback: () => {
|
||||
// S4: one extra stops and faces player
|
||||
if (director.stage === 'S4' && i === 0) { sp.setFlipX(this.player.x < sp.x); return; }
|
||||
sp.x += Phaser.Math.Between(-8,8); sp.y += Phaser.Math.Between(-8,8);
|
||||
sp.setFlipX(this.player.x < sp.x);
|
||||
}});
|
||||
});
|
||||
}
|
||||
|
||||
talkAfu() {
|
||||
if (director.noteNpcTalk('afu')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'afu');
|
||||
story.recordGreet('afu');
|
||||
this.say(story.npcTalk('afu'));
|
||||
}
|
||||
|
||||
talkDabao() {
|
||||
if (director.noteNpcTalk('dabao')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'dabao');
|
||||
story.recordGreet('dabao');
|
||||
// letter pickup (T4 day2)
|
||||
if (director.day === 2 && !story.lettersTaken[2]) {
|
||||
story.takeLetters((l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
// bread deliver (T1/T8)
|
||||
if ((director.day === 1 || director.day === 4) && story.breadTaken[director.day]) {
|
||||
story.deliverBread('dabao', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
this.say(story.npcTalk('dabao'));
|
||||
}
|
||||
|
||||
readBoard() {
|
||||
// show announcement (verbatim)
|
||||
const lines = story.announcementLines();
|
||||
// also list today's tasks
|
||||
const tasks = story.todayTasks();
|
||||
const taskLine = tasks.length ? `今日任务:${tasks.map(t=>t.id).join('、')}` : '今天公告板空空的。';
|
||||
lines.push({ speaker:'', text: taskLine });
|
||||
this.say(lines);
|
||||
}
|
||||
|
||||
waterFlower(idx: number) {
|
||||
// T2 (day1) / T9 (day4)
|
||||
const isWaterDay = director.day === 1 || director.day === 4;
|
||||
const taskId = director.day === 1 ? 'T2' : 'T9';
|
||||
if (isWaterDay) {
|
||||
const key = `${taskId}@${idx}`;
|
||||
if (!director.tasksDone.has(key)) {
|
||||
director.tasksDone.add(key);
|
||||
director.smallInteraction('water');
|
||||
const count = director.day === 1 ? 3 : 3;
|
||||
const done = [0,1,2].filter(i => director.tasksDone.has(`${taskId}@${i}`)).length;
|
||||
this.say([{ speaker:'', text:'花儿喝饱了水,开得更艳了。' }]);
|
||||
if (done >= count) story.completeTask(taskId, 10);
|
||||
} else {
|
||||
this.say([{ speaker:'', text:'这株已经浇过了。' }]);
|
||||
}
|
||||
} else {
|
||||
director.smallInteraction('water');
|
||||
this.say([{ speaker:'', text:'花儿喝饱了水。' }]);
|
||||
}
|
||||
}
|
||||
|
||||
hangRibbon(i: number, tx: number, ty: number) {
|
||||
director.tasksDone.add('T13-done-'+i);
|
||||
const rib = this.add.image(tx*16+8, ty*16+8, 'tileset', 35).setOrigin(0.5,0.5).setDepth(20);
|
||||
this.ribbons.push(rib);
|
||||
director.smallInteraction('ribbon');
|
||||
audio.sfxDone();
|
||||
const hung = [0,1,2,3].filter(k => director.tasksDone.has('T13-done-'+k)).length;
|
||||
if (hung >= 4) story.completeTask('T13', 10);
|
||||
this.say([{ speaker:'', text:'彩带挂好了,真漂亮。' }]);
|
||||
}
|
||||
|
||||
startSmileTask() {
|
||||
// T14: stand still 60s, camera zoom in, all NPCs face player, 40s sound fades
|
||||
director.tasksDone.add('T14');
|
||||
this.player.atTaskPoint = true; // don't count as 发呆 during smile task
|
||||
const cam = this.cameras.main;
|
||||
const startZoom = cam.zoom;
|
||||
const startTime = this.time.now;
|
||||
// freeze player movement by setting a flag via player
|
||||
(this.player as any).frozen = true;
|
||||
// all npcs face player
|
||||
this.npcSprites.forEach(s => s.setFlipX(this.player.x < s.x));
|
||||
audio.sfxBell();
|
||||
const overlay = this.add.text(160, 20, '保持微笑……', { fontFamily:'monospace', fontSize:'9px', color:'#ffd86b' }).setOrigin(0.5).setScrollFactor(0).setDepth(7000);
|
||||
let silenceSet = false;
|
||||
const ev = this.time.addEvent({ delay: 200, loop: true, callback: () => {
|
||||
const elapsed = (this.time.now - startTime)/1000;
|
||||
// zoom in slowly from 1 to 1.8 over 60s
|
||||
cam.zoom = startZoom + (1.0) * (elapsed / T14_DURATION);
|
||||
// sound fade 40-60s
|
||||
if (elapsed >= T14_SILENCE_START) {
|
||||
const f = 1 - (elapsed - T14_SILENCE_START) / (T14_DURATION - T14_SILENCE_START);
|
||||
audio.setSilenceFactor(Math.max(0, f));
|
||||
silenceSet = true;
|
||||
}
|
||||
if (elapsed >= T14_DURATION) {
|
||||
ev.remove();
|
||||
(this.player as any).frozen = false;
|
||||
this.player.atTaskPoint = false;
|
||||
cam.zoom = startZoom;
|
||||
audio.setSilenceFactor(1.0);
|
||||
overlay.destroy();
|
||||
story.completeTask('T14', 15);
|
||||
this.say([{ speaker:'阿福', npcKey:'afu', portrait: NPCS.afu.portrait.smile, text:'做得真好。你笑得真好看。' }]);
|
||||
}
|
||||
}});
|
||||
}
|
||||
|
||||
festival() {
|
||||
// T15 -> ending
|
||||
director.tasksDone.add('T15');
|
||||
this.scene.stop('UIScene');
|
||||
this.scene.stop('DebugScene');
|
||||
this.scene.start('EndingScene', { ending: director.determineEnding() });
|
||||
}
|
||||
|
||||
wipeBoard() {
|
||||
const choices: DialogueChoice[] = [
|
||||
{ label: '好,我来擦(温柔)', type: 'gentle' },
|
||||
{ label: '这字迹……(迟疑)', type: 'doubt' },
|
||||
{ label: '不想擦(拒绝)', type: 'deny' },
|
||||
];
|
||||
this.say([
|
||||
{ speaker:'', text:'公告板上落了灰。' },
|
||||
{ speaker:'阿福', npcKey:'afu', portrait: NPCS.afu.portrait.smile, text:'帮忙擦擦公告板好吗?', choices },
|
||||
], undefined, (c: DialogueChoice) => {
|
||||
if (c.type === 'deny') {
|
||||
director.rejectedTasks.add('T12');
|
||||
director.sadness('拒绝任务', 'afu');
|
||||
this.say([{ speaker:'阿福', npcKey:'afu', portrait: NPCS.afu.portrait.smile, text:'没关系哦。' }]);
|
||||
} else {
|
||||
story.completeTask('T12', 10);
|
||||
audio.sfxDone();
|
||||
director.discoverClue('C6');
|
||||
this.say([{ speaker:'', text:'擦到一半,浮现出旧字迹:' },
|
||||
{ speaker:'', text:'「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」……' }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startRain() {
|
||||
this.raining = true;
|
||||
this.rainGfx = this.add.graphics().setDepth(4000);
|
||||
this.time.addEvent({ delay: 60, loop: true, callback: () => this.drawRain() });
|
||||
}
|
||||
private drawRain() {
|
||||
if (!this.rainGfx) return;
|
||||
this.rainGfx.clear();
|
||||
this.rainGfx.fillStyle(0x9bb8d0, 0.5);
|
||||
for (let i=0;i<60;i++) {
|
||||
const x = Phaser.Math.Between(0, 320*3), y = Phaser.Math.Between(0, 180*3);
|
||||
this.rainGfx.fillRect((x + this.time.now*0.1)%480 - 80, (y + this.time.now*0.2)%300 - 60, 1, 4);
|
||||
}
|
||||
// rain in rain stand detection handled in update below via director
|
||||
}
|
||||
|
||||
update(time: number, delta: number) {
|
||||
super.update(time, delta);
|
||||
// Day6 evening rain sadness: stand in rain >=10s
|
||||
if (this.raining) {
|
||||
// assume whole plaza is "in rain"
|
||||
if (this.player.moving === false) {
|
||||
this.rainTimer += delta;
|
||||
} else this.rainTimer = 0;
|
||||
if (this.rainTimer >= 10000) {
|
||||
this.rainTimer = 0;
|
||||
director.sadness('雨天(Day 6)站在雨里 ≥10 秒', this.nearestNpcKey());
|
||||
}
|
||||
}
|
||||
// NPC dialogue reaction to rain = zero reaction (by design, NPCs say nothing about rain)
|
||||
// bird flap freeze when S2+ music detuned
|
||||
if (director.stage === 'S2' || director.stage === 'S3') {
|
||||
// stutter animation
|
||||
if (Math.random() < 0.02) this.birdSprite.anims.pause();
|
||||
else this.birdSprite.anims.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// ShopScene.ts: 杂货店. 咪咪 (mimi - text corruption carrier), shelves (C5), count shelves (T5).
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { story } from '../../systems/StoryDirector';
|
||||
import { NPCS } from '../../data/gamedata';
|
||||
import { DialogueLine } from '../UIScene';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class ShopScene extends MapScene {
|
||||
mimiSprite!: Phaser.GameObjects.Sprite;
|
||||
constructor() { super('ShopScene', 'map:shop', 'shop'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.mimiSprite = this.add.sprite(7*16+8, 7*16+8, 'npc', NPCS.mimi.fieldSprite).setOrigin(0.5,0.9);
|
||||
(this.mimiSprite as any).npcKey = 'mimi';
|
||||
this.npcSprites.push(this.mimiSprite);
|
||||
|
||||
this.addInteract(7, 7, () => this.talkMimi(), { promptText: '按 E 和咪咪说话' });
|
||||
// shelves C5 (from D2) + T5 count (D2): merged interaction on shelves
|
||||
this.addInteract(2, 2, () => {
|
||||
// T5 count (D2): completing the count task
|
||||
if (director.day === 2 && !director.tasksDone.has('T5')) {
|
||||
story.completeTask('T5', 10);
|
||||
audio.sfxDone();
|
||||
}
|
||||
if (director.day >= 2) {
|
||||
const got = director.discoverClue('C5');
|
||||
this.say([{ speaker:'咪咪', npcKey:'mimi', portrait: NPCS.mimi.portrait.smile, text:'数清楚啦?太谢谢你啦!' },
|
||||
{ speaker:'', text: got ? '货架上同一商品重复排了 9 格,标签全是空白的。' : '货架上的商品……好像都一模一样。' }]);
|
||||
} else {
|
||||
this.say([{ speaker:'', text:'货架上商品满满当当。' }]);
|
||||
}
|
||||
}, { promptText: '按 E 看货架' });
|
||||
}
|
||||
|
||||
talkMimi() {
|
||||
if (director.noteNpcTalk('mimi')) director.sadness('对同一 NPC 一天内对话 ≥5 次(纠缠)', 'mimi');
|
||||
story.recordGreet('mimi');
|
||||
// letter deliver (T4 day2)
|
||||
if (director.day === 2 && story.lettersTaken[2]) {
|
||||
story.deliverLetter('mimi', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
// bread deliver (T1/T8)
|
||||
if ((director.day === 1 || director.day === 4) && story.breadTaken[director.day]) {
|
||||
story.deliverBread('mimi', (l) => this.say(l));
|
||||
return;
|
||||
}
|
||||
this.say(story.npcTalk('mimi'));
|
||||
}
|
||||
|
||||
countShelves() {
|
||||
story.completeTask('T5', 10);
|
||||
audio.sfxDone();
|
||||
this.say([{ speaker:'咪咪', npcKey:'mimi', portrait: NPCS.mimi.portrait.smile, text:'数清楚啦?太谢谢你啦!大家都要开开心心的哦。' }]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// WellAreaScene.ts: 古井空地. 古井 (C7 from D5). Before D5, gentle NPC dissuades.
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class WellAreaScene extends MapScene {
|
||||
gazeTimer = 0;
|
||||
constructor() { super('WellAreaScene', 'map:well_area', 'well_area'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
this.addInteract(6, 4, () => this.wellInteract(), { promptText: '按 E 看古井' });
|
||||
}
|
||||
|
||||
wellInteract() {
|
||||
if (director.day < 5) {
|
||||
// gentle dissuade (NPC leads away)
|
||||
this.say([
|
||||
{ speaker:'路过的村民', text:'哎呀,别靠近那口井呀。那里没什么好看的。' },
|
||||
{ speaker:'', text:'(你被温柔地引开了。)' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// D5+: interact 5s to hear faint heartbeat echo (C7)
|
||||
this.say([{ speaker:'', text:'你俯身倾听井底……(保持 5 秒)' }], () => {
|
||||
// begin gaze: count 5s of proximity handled in update; here grant immediately for simplicity of clue
|
||||
director.gaze('well');
|
||||
const got = director.discoverClue('C7');
|
||||
this.say([{ speaker:'', text: got ? '井底回声里,有极轻的……监护仪的滴声。' : '井底的回声还是那样。' }]);
|
||||
audio.sfxHeartbeat();
|
||||
});
|
||||
}
|
||||
|
||||
update(time: number, delta: number) {
|
||||
super.update(time, delta);
|
||||
// gaze: standing near well for 5s counts once per day
|
||||
const near = Phaser.Math.Distance.Between(this.player.x, this.player.y, 6*16+8, 4*16+8) < 24;
|
||||
if (near) {
|
||||
this.gazeTimer += delta;
|
||||
if (this.gazeTimer >= 5000) { this.gazeTimer = 0; director.gaze('well'); }
|
||||
} else this.gazeTimer = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// WhiteLayerScene.ts: 白色层 (E3 ward). Chart reveal + ending E3.
|
||||
import Phaser from 'phaser';
|
||||
import { MapScene } from '../MapScene';
|
||||
import { director } from '../../systems/CorruptionDirector';
|
||||
import { audio } from '../../systems/AudioEngine';
|
||||
|
||||
export class WhiteLayerScene extends MapScene {
|
||||
constructor() { super('WhiteLayerScene', 'map:white_layer', 'white_layer'); }
|
||||
|
||||
onBuild(map: Phaser.Tilemaps.Tilemap, ts: Phaser.Tilemaps.Tileset) {
|
||||
// desaturated: camera grayscale-ish via low saturation
|
||||
this.cameras.main.setBackgroundColor('#e8e8e8');
|
||||
// chart
|
||||
this.addInteract(9, 10, () => {
|
||||
this.say([{ speaker:'', text:'桌上的病历……' },
|
||||
{ speaker:'', text:`蜜糖谷情绪疗养中心` },
|
||||
{ speaker:'', text:`姓名:${director.playerName}` },
|
||||
{ speaker:'', text:`编号:PT-07` },
|
||||
{ speaker:'', text:`疗程时长:${director.formatPlayTime()}` },
|
||||
], () => {
|
||||
// go to E3 ending
|
||||
this.scene.stop('DebugScene');
|
||||
this.scene.start('EndingScene', { ending: 'E3' });
|
||||
});
|
||||
}, { promptText: '按 E 看病历' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// AudioEngine.ts: Web Audio procedural BGM (8-bar 8-bit loop) + 4 SFX.
|
||||
// No external audio files. Driven by CorruptionDirector via setErosion().
|
||||
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
|
||||
|
||||
export class AudioEngine {
|
||||
ctx: AudioContext | null = null;
|
||||
master: GainNode | null = null;
|
||||
musicGain: GainNode | null = null;
|
||||
sfxGain: GainNode | null = null;
|
||||
lowpass: BiquadFilterNode | null = null;
|
||||
// bird layer gain
|
||||
birdGain: GainNode | null = null;
|
||||
|
||||
private schedulerId: number | null = null;
|
||||
private nextNoteTime = 0;
|
||||
private step = 0;
|
||||
private playbackRate = 1.0;
|
||||
private muted = false;
|
||||
private silenceFactor = 1.0; // multiplied into master for T14 sudden silence
|
||||
private birdsMuted = false;
|
||||
|
||||
init() {
|
||||
if (this.ctx) return;
|
||||
const AC = (window.AudioContext || (window as any).webkitAudioContext);
|
||||
this.ctx = new AC();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.5;
|
||||
this.master.connect(this.ctx.destination);
|
||||
|
||||
this.lowpass = this.ctx.createBiquadFilter();
|
||||
this.lowpass.type = 'lowpass';
|
||||
this.lowpass.frequency.value = 20000;
|
||||
|
||||
this.musicGain = this.ctx.createGain();
|
||||
this.musicGain.gain.value = 0.35;
|
||||
this.birdGain = this.ctx.createGain();
|
||||
this.birdGain.gain.value = 0.5;
|
||||
|
||||
this.musicGain.connect(this.lowpass);
|
||||
this.birdGain.connect(this.lowpass);
|
||||
this.lowpass.connect(this.master);
|
||||
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.sfxGain.gain.value = 0.4;
|
||||
this.sfxGain.connect(this.master);
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
|
||||
}
|
||||
|
||||
setErosion(stage: Stage) {
|
||||
const map: Record<Stage, { rate: number; lp: number; birds: boolean }> = {
|
||||
S0: { rate: 1.00, lp: 20000, birds: false },
|
||||
S1: { rate: 1.00, lp: 20000, birds: false },
|
||||
S2: { rate: 0.97, lp: 20000, birds: false },
|
||||
S3: { rate: 0.92, lp: 8000, birds: false },
|
||||
S4: { rate: 0.84, lp: 4000, birds: true },
|
||||
};
|
||||
const s = map[stage];
|
||||
this.playbackRate = s.rate;
|
||||
this.birdsMuted = s.birds;
|
||||
if (this.lowpass && this.ctx) {
|
||||
this.lowpass.frequency.setTargetAtTime(s.lp, this.ctx.currentTime, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Gradual silence for T14 (40-60s): fade master to 0.
|
||||
setSilenceFactor(f: number) {
|
||||
this.silenceFactor = Math.max(0, Math.min(1, f));
|
||||
this.applyMaster();
|
||||
}
|
||||
private applyMaster() {
|
||||
if (!this.master || !this.ctx) return;
|
||||
const v = this.muted ? 0 : 0.5 * this.silenceFactor;
|
||||
this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.05);
|
||||
}
|
||||
|
||||
startMusic() {
|
||||
if (!this.ctx) this.init();
|
||||
if (!this.ctx) return;
|
||||
this.resume();
|
||||
if (this.schedulerId !== null) return;
|
||||
this.nextNoteTime = this.ctx.currentTime + 0.1;
|
||||
this.step = 0;
|
||||
const loop = () => {
|
||||
this.schedule();
|
||||
this.schedulerId = window.setTimeout(loop, 25);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
if (this.schedulerId !== null) { clearTimeout(this.schedulerId); this.schedulerId = null; }
|
||||
}
|
||||
|
||||
// 8-bar loop in C major, gentle. 16th-note steps. Tempo ~ 96 bpm.
|
||||
// melody (square) + bass (triangle) + bird chirps (occasional).
|
||||
private melody: number[] = [
|
||||
// bar1
|
||||
72,0,76,0, 79,0,76,0, 72,0,0,0, 0,0,74,0,
|
||||
// bar2
|
||||
76,0,0,0, 79,0,81,0, 83,0,0,79, 76,0,0,0,
|
||||
// bar3
|
||||
79,0,83,0, 86,0,83,0, 79,0,0,0, 0,0,77,0,
|
||||
// bar4
|
||||
76,0,74,0, 72,0,0,0, 0,0,0,0, 0,0,0,0,
|
||||
// bar5
|
||||
74,0,77,0, 81,0,77,0, 74,0,0,0, 0,0,76,0,
|
||||
// bar6
|
||||
77,0,0,0, 81,0,83,0, 84,0,0,81, 77,0,0,0,
|
||||
// bar7
|
||||
79,0,83,0, 86,0,88,0, 86,0,83,0, 79,0,0,0,
|
||||
// bar8
|
||||
76,0,79,0, 77,0,74,0, 72,0,0,0, 0,0,0,0,
|
||||
];
|
||||
private bass: number[] = [
|
||||
48,0,0,0, 0,0,0,0, 55,0,0,0, 0,0,0,0,
|
||||
48,0,0,0, 0,0,0,0, 55,0,0,0, 0,0,0,0,
|
||||
53,0,0,0, 0,0,0,0, 57,0,0,0, 0,0,0,0,
|
||||
48,0,0,0, 0,0,0,0, 55,0,0,0, 0,0,0,0,
|
||||
50,0,0,0, 0,0,0,0, 57,0,0,0, 0,0,0,0,
|
||||
53,0,0,0, 0,0,0,0, 57,0,0,0, 0,0,0,0,
|
||||
55,0,0,0, 0,0,0,0, 60,0,0,0, 0,0,0,0,
|
||||
48,0,0,0, 0,0,0,0, 55,0,0,0, 0,0,0,0,
|
||||
];
|
||||
|
||||
private midiToFreq(m: number): number {
|
||||
return 440 * Math.pow(2, (m - 69) / 12);
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
if (!this.ctx || !this.musicGain) return;
|
||||
const secPerStep = (60 / 96) / 4 / this.playbackRate; // 16th note
|
||||
while (this.nextNoteTime < this.ctx.currentTime + 0.2) {
|
||||
const idx = this.step % this.melody.length;
|
||||
const mn = this.melody[idx];
|
||||
const bn = this.bass[idx];
|
||||
if (mn > 0) this.playTone(this.midiToFreq(mn), this.nextNoteTime, secPerStep * 1.5, 'square', 0.18, this.musicGain);
|
||||
if (bn > 0) this.playTone(this.midiToFreq(bn), this.nextNoteTime, secPerStep * 3.5, 'triangle', 0.22, this.musicGain);
|
||||
// bird chirp occasionally on downbeats (step % 16 == 0)
|
||||
if (!this.birdsMuted && idx % 16 === 0 && Math.random() < 0.5 && this.birdGain) {
|
||||
this.playBird(this.nextNoteTime + Math.random() * 0.3);
|
||||
}
|
||||
this.nextNoteTime += secPerStep;
|
||||
this.step++;
|
||||
}
|
||||
}
|
||||
|
||||
private playTone(freq: number, when: number, dur: number, type: OscillatorType, vol: number, dest: AudioNode) {
|
||||
if (!this.ctx) return;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const g = this.ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(vol, when + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, when + dur);
|
||||
osc.connect(g); g.connect(dest);
|
||||
osc.start(when); osc.stop(when + dur + 0.02);
|
||||
}
|
||||
|
||||
private playBird(when: number) {
|
||||
if (!this.ctx || !this.birdGain) return;
|
||||
const base = 80 + Math.random() * 6;
|
||||
// two quick notes
|
||||
this.playTone(this.midiToFreq(base + 12), when, 0.08, 'square', 0.06, this.birdGain);
|
||||
this.playTone(this.midiToFreq(base + 16), when + 0.09, 0.1, 'square', 0.06, this.birdGain);
|
||||
}
|
||||
|
||||
// SFX (only 4 per §6.6)
|
||||
sfxBlip() {
|
||||
if (!this.ctx || !this.sfxGain) return;
|
||||
this.playTone(880, this.ctx.currentTime, 0.05, 'square', 0.3, this.sfxGain);
|
||||
}
|
||||
sfxDone() {
|
||||
if (!this.ctx || !this.sfxGain) return;
|
||||
const t = this.ctx.currentTime;
|
||||
this.playTone(660, t, 0.08, 'square', 0.3, this.sfxGain);
|
||||
this.playTone(880, t + 0.08, 0.08, 'square', 0.3, this.sfxGain);
|
||||
this.playTone(1320, t + 0.16, 0.16, 'square', 0.3, this.sfxGain);
|
||||
}
|
||||
sfxBell() {
|
||||
if (!this.ctx || !this.sfxGain) return;
|
||||
const t = this.ctx.currentTime;
|
||||
// bell-like: sine partials
|
||||
for (const f of [440, 660, 880]) this.playTone(f, t, 0.8, 'sine', 0.2, this.sfxGain);
|
||||
}
|
||||
sfxHeartbeat() {
|
||||
if (!this.ctx || !this.sfxGain) return;
|
||||
const t = this.ctx.currentTime;
|
||||
this.playTone(1000, t, 0.06, 'sine', 0.4, this.sfxGain);
|
||||
}
|
||||
}
|
||||
|
||||
export const audio = new AudioEngine();
|
||||
@@ -0,0 +1,236 @@
|
||||
// CorruptionDirector.ts: single source of truth for all state + A/V params.
|
||||
// Holds happiness/awareness/day/task flags/clue set/erosion stage.
|
||||
// Emits events for scenes to react.
|
||||
import { audio } from './AudioEngine';
|
||||
import {
|
||||
HAPPY_START, AWARE_START, HAPPY_MAX, AWARE_MAX, OVERFLOW_THRESHOLD,
|
||||
EROSION_MATRIX, AWARE_CLUE_GAIN, AWARE_SAD_GAIN, HAPPY_SAD_LOSS,
|
||||
HAPPY_SMALL_DAILY_CAP, DEFAULT_NAME, LOW_HAPPY_THRESHOLD, LOW_HAPPY_DURATION_MS,
|
||||
FORCE_FESTIVAL_HAPPY,
|
||||
} from '../data/gamedata';
|
||||
|
||||
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
|
||||
|
||||
type Handler = (payload?: any) => void;
|
||||
|
||||
class CorruptionDirector {
|
||||
happiness = HAPPY_START;
|
||||
awareness = AWARE_START;
|
||||
day = 1;
|
||||
stage: Stage = 'S1';
|
||||
playerName = DEFAULT_NAME;
|
||||
startTime = Date.now();
|
||||
|
||||
// clue set
|
||||
clues = new Set<string>();
|
||||
// task completion
|
||||
tasksDone = new Set<string>();
|
||||
// sadness: small-interaction daily counters per kind
|
||||
smallInteractCounts: Record<string, number> = {}; // key: kind@day
|
||||
// npc talk counts per day (for 纠缠 detection)
|
||||
npcTalkCounts: Record<string, number> = {};
|
||||
// rejection records (Day 5)
|
||||
rejectedTasks = new Set<string>();
|
||||
// escaped forced festivals count (E4)
|
||||
escapedFestivals = 0;
|
||||
// happiness-low tracker
|
||||
private lowHappySince = 0;
|
||||
// overflow pool
|
||||
private overflowPool = 0;
|
||||
// last negative flash time
|
||||
private lastFlashAt = 0;
|
||||
|
||||
// gaze tracking (well / fog) once per day
|
||||
gazedWell = false;
|
||||
gazedFog = false;
|
||||
|
||||
// whether forced festival is active
|
||||
forcedFestivalActive = false;
|
||||
|
||||
private listeners: Record<string, Handler[]> = {};
|
||||
|
||||
on(ev: string, h: Handler) {
|
||||
(this.listeners[ev] ||= []).push(h);
|
||||
return () => this.off(ev, h);
|
||||
}
|
||||
off(ev: string, h: Handler) {
|
||||
const arr = this.listeners[ev]; if (!arr) return;
|
||||
const i = arr.indexOf(h); if (i >= 0) arr.splice(i, 1);
|
||||
}
|
||||
emit(ev: string, payload?: any) {
|
||||
(this.listeners[ev] || []).slice().forEach(h => { try { h(payload); } catch (e) { console.error(e); } });
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.happiness = HAPPY_START;
|
||||
this.awareness = AWARE_START;
|
||||
this.day = 1;
|
||||
this.stage = 'S1';
|
||||
this.playerName = DEFAULT_NAME;
|
||||
this.startTime = Date.now();
|
||||
this.clues.clear();
|
||||
this.tasksDone.clear();
|
||||
this.smallInteractCounts = {};
|
||||
this.npcTalkCounts = {};
|
||||
this.rejectedTasks.clear();
|
||||
this.escapedFestivals = 0;
|
||||
this.lowHappySince = 0;
|
||||
this.overflowPool = 0;
|
||||
this.gazedWell = false;
|
||||
this.gazedFog = false;
|
||||
this.forcedFestivalActive = false;
|
||||
this.recomputeStage();
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
recomputeStage() {
|
||||
const h = this.happiness;
|
||||
let s: Stage = 'S0';
|
||||
for (const row of EROSION_MATRIX) {
|
||||
if (h >= row.happinessRange[0] && h <= row.happinessRange[1]) { s = row.stage; break; }
|
||||
}
|
||||
if (s !== this.stage) {
|
||||
this.stage = s;
|
||||
audio.setErosion(s);
|
||||
this.emit('stage', s);
|
||||
}
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
setHappiness(v: number, opts: { silent?: boolean } = {}) {
|
||||
const prev = this.happiness;
|
||||
let nv = Math.max(0, Math.min(HAPPY_MAX, Math.round(v)));
|
||||
// forced maintenance at 100: small deductions accumulate in overflow pool
|
||||
if (prev >= HAPPY_MAX && nv < prev) {
|
||||
const diff = prev - nv;
|
||||
this.overflowPool += diff;
|
||||
nv = HAPPY_MAX; // stays at 100
|
||||
if (this.overflowPool >= OVERFLOW_THRESHOLD) {
|
||||
this.overflowPool = 0;
|
||||
this.emit('negativeFlash');
|
||||
this.stage = 'S4';
|
||||
audio.setErosion('S4');
|
||||
this.emit('stage', 'S4');
|
||||
}
|
||||
}
|
||||
this.happiness = nv;
|
||||
if (!opts.silent) this.recomputeStage();
|
||||
this.checkLowHappy();
|
||||
}
|
||||
|
||||
addHappiness(delta: number) {
|
||||
if (delta > 0 && this.happiness >= HAPPY_MAX) {
|
||||
// overflow pool accumulation (PRD: 小额扣分不再生效而是累积进隐藏溢出池) — for increases beyond 100, ignore
|
||||
return;
|
||||
}
|
||||
this.setHappiness(this.happiness + delta);
|
||||
}
|
||||
|
||||
addAwareness(delta: number) {
|
||||
this.awareness = Math.max(0, Math.min(AWARE_MAX, this.awareness + delta));
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
// §6.2 clue discovery
|
||||
discoverClue(id: string) {
|
||||
if (this.clues.has(id)) return false;
|
||||
this.clues.add(id);
|
||||
this.addAwareness(AWARE_CLUE_GAIN);
|
||||
this.emit('clue', id);
|
||||
this.emit('change');
|
||||
return true;
|
||||
}
|
||||
|
||||
// §6.1 small interaction (water/feed/pet) +1, daily cap 3 per kind
|
||||
smallInteraction(kind: string) {
|
||||
const key = `${kind}@${this.day}`;
|
||||
const count = this.smallInteractCounts[key] || 0;
|
||||
if (count >= HAPPY_SMALL_DAILY_CAP) return false;
|
||||
this.smallInteractCounts[key] = count + 1;
|
||||
this.addHappiness(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// §6.3 sadness behavior: -5 happy, +3 aware, nearest NPC turns
|
||||
sadness(behavior: string, nearestNpc?: string) {
|
||||
this.addHappiness(-HAPPY_SAD_LOSS);
|
||||
this.addAwareness(AWARE_SAD_GAIN);
|
||||
this.emit('sadness', { behavior, nearestNpc });
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
// gaze well/fog (§6.2)
|
||||
gaze(target: 'well' | 'fog') {
|
||||
if (target === 'well' && !this.gazedWell) { this.gazedWell = true; this.addAwareness(3); }
|
||||
if (target === 'fog' && !this.gazedFog) { this.gazedFog = true; this.addAwareness(3); }
|
||||
}
|
||||
|
||||
// NPC talk counter for 纠缠 (>=5/day)
|
||||
noteNpcTalk(npcKey: string): boolean {
|
||||
const key = `${npcKey}@${this.day}`;
|
||||
this.npcTalkCounts[key] = (this.npcTalkCounts[key] || 0) + 1;
|
||||
return this.npcTalkCounts[key] >= 5;
|
||||
}
|
||||
|
||||
private checkLowHappy() {
|
||||
if (this.happiness < LOW_HAPPY_THRESHOLD) {
|
||||
if (this.lowHappySince === 0) this.lowHappySince = Date.now();
|
||||
else if (Date.now() - this.lowHappySince >= LOW_HAPPY_DURATION_MS && !this.forcedFestivalActive) {
|
||||
this.triggerForcedFestival();
|
||||
}
|
||||
} else {
|
||||
this.lowHappySince = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// §6.1 forced festival
|
||||
triggerForcedFestival() {
|
||||
this.forcedFestivalActive = true;
|
||||
this.emit('forcedFestivalStart');
|
||||
// raise happiness to 50
|
||||
this.setHappiness(FORCE_FESTIVAL_HAPPY);
|
||||
}
|
||||
escapeFestival() {
|
||||
if (!this.forcedFestivalActive) return;
|
||||
this.forcedFestivalActive = false;
|
||||
this.escapedFestivals++;
|
||||
this.emit('forcedFestivalEnd');
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
advanceDay() {
|
||||
this.day = Math.min(7, this.day + 1);
|
||||
this.gazedWell = false; this.gazedFog = false;
|
||||
this.emit('day', this.day);
|
||||
this.emit('change');
|
||||
}
|
||||
|
||||
// ending determination per §9 priority
|
||||
determineEnding(): 'E4'|'E3'|'E2'|'E1' {
|
||||
if (this.day >= 5 && this.happiness <= 10 && this.escapedFestivals >= 3) return 'E4';
|
||||
if (this.awareness >= 70 && this.clues.size >= 6) return 'E3';
|
||||
if (this.awareness >= 30) return 'E2';
|
||||
if (this.awareness <= 29 && this.happiness >= 90) return 'E1';
|
||||
// fallback
|
||||
if (this.awareness <= 29) return 'E1';
|
||||
return 'E2';
|
||||
}
|
||||
|
||||
playTimeSeconds(): number {
|
||||
return Math.floor((Date.now() - this.startTime) / 1000);
|
||||
}
|
||||
formatPlayTime(): string {
|
||||
const s = this.playTimeSeconds();
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m}分${sec}秒`;
|
||||
}
|
||||
|
||||
// debug setters
|
||||
debugSetHappiness(v: number) { this.happiness = Math.max(0,Math.min(HAPPY_MAX,v)); this.recomputeStage(); }
|
||||
debugSetAwareness(v: number) { this.awareness = Math.max(0,Math.min(AWARE_MAX,v)); this.emit('change'); }
|
||||
debugSetDay(d: number) { this.day = Math.max(1,Math.min(7,d)); this.emit('day',this.day); this.emit('change'); }
|
||||
debugAllClues() { ['C1','C2','C3','C4','C5','C6','C7','C8','C9'].forEach(c=>this.clues.add(c)); this.addAwareness(0); this.emit('change'); }
|
||||
}
|
||||
|
||||
export const director = new CorruptionDirector();
|
||||
@@ -0,0 +1,165 @@
|
||||
// StoryDirector.ts: orchestrates per-day structure, tasks, clue gating, endings.
|
||||
import { director } from './CorruptionDirector';
|
||||
import { AFU_ANNOUNCE, TASKS, CLUES, NPCS, C4_GARBLE } from '../data/gamedata';
|
||||
import { DialogueLine } from '../scenes/UIScene';
|
||||
|
||||
class StoryDirector {
|
||||
// greeting progress per day (T3/T10): count distinct NPCs greeted
|
||||
greetProgress: Record<number, Set<string>> = {};
|
||||
// bread delivery progress (T1/T8): deliver to dabao/mimi/grandpa
|
||||
breadProgress: Record<number, Set<string>> = {};
|
||||
// letters (T4): deliver 3 to yuanyuan/mimi/dr_fu
|
||||
letterProgress = new Set<string>();
|
||||
// berries (T6)
|
||||
berriesPicked = 0;
|
||||
// ribbons (T13)
|
||||
ribbonsHung = 0;
|
||||
// watering (T2/T9)
|
||||
wateredToday: Record<number, number> = {};
|
||||
// day phase: morning/afternoon/evening
|
||||
phase: 'morning'|'afternoon'|'evening'|'sleeping' = 'morning';
|
||||
// whether board announcement shown today
|
||||
announcementShown = false;
|
||||
// whether player has taken bread today (for T1/T8)
|
||||
breadTaken: Record<number, boolean> = {};
|
||||
// whether player has taken letters today (for T4)
|
||||
lettersTaken: Record<number, boolean> = {};
|
||||
|
||||
resetDay() {
|
||||
this.announcementShown = false;
|
||||
this.phase = 'morning';
|
||||
this.letterProgress.clear();
|
||||
}
|
||||
|
||||
// record a greeting; completes T3 (day1) / T10 (day4) when 3 distinct NPCs greeted
|
||||
recordGreet(npcKey: string) {
|
||||
const d = director.day;
|
||||
(this.greetProgress[d] ||= new Set()).add(npcKey);
|
||||
const n = this.greetProgress[d].size;
|
||||
if ((d === 1 || d === 4) && n >= 3 && !director.tasksDone.has(d === 1 ? 'T3' : 'T10')) {
|
||||
this.completeTask(d === 1 ? 'T3' : 'T10', 10);
|
||||
}
|
||||
}
|
||||
|
||||
// deliver bread to an NPC (T1 day1 / T8 day4)
|
||||
deliverBread(npcKey: string, onLine: (l: DialogueLine[]) => void) {
|
||||
const d = director.day;
|
||||
if (d !== 1 && d !== 4) { onLine([{ speaker:'', text:'现在不需要送面包。' }]); return; }
|
||||
if (!this.breadTaken[d]) { onLine([{ speaker:'', text:'你还没从圆圆那里取面包呢。' }]); return; }
|
||||
const taskId = d === 1 ? 'T1' : 'T8';
|
||||
const set = (this.breadProgress[d] ||= new Set());
|
||||
if (set.has(npcKey)) { onLine([{ speaker:'', text:'已经送过了。' }]); return; }
|
||||
set.add(npcKey);
|
||||
const npc = NPCS[npcKey];
|
||||
onLine([{ speaker: npc.name, npcKey, portrait: npc.portrait.smile, text:'哇,蜂蜜面包!谢谢你!' }]);
|
||||
if (set.size >= 3) this.completeTask(taskId, 15);
|
||||
}
|
||||
|
||||
// take bread from yuanyuan (T1/T8)
|
||||
takeBread(onLine: (l: DialogueLine[]) => void) {
|
||||
const d = director.day;
|
||||
if (d === 1 || d === 4) {
|
||||
this.breadTaken[d] = true;
|
||||
onLine([{ speaker:'圆圆', npcKey:'yuanyuan', portrait: NPCS.yuanyuan.portrait.smile, text:'这是蜂蜜面包,请帮我送给大宝、咪咪、龟爷爷吧!' }]);
|
||||
}
|
||||
}
|
||||
|
||||
// take letters from dabao (T4 day2)
|
||||
takeLetters(onLine: (l: DialogueLine[]) => void) {
|
||||
if (director.day === 2) {
|
||||
this.lettersTaken[2] = true;
|
||||
onLine([
|
||||
{ speaker:'大宝', npcKey:'dabao', portrait: NPCS.dabao.portrait.smile, text:'帮我送三封信吧,收件人是圆圆、咪咪、芙医生。' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// deliver a letter (T4). 3rd letter shows garbled signature (C4)
|
||||
deliverLetter(npcKey: string, onLine: (l: DialogueLine[]) => void) {
|
||||
if (director.day !== 2 || !this.lettersTaken[2]) { onLine([{ speaker:'', text:'现在没有信要送。' }]); return; }
|
||||
if (this.letterProgress.has(npcKey)) { onLine([{ speaker:'', text:'这封信已经送了。' }]); return; }
|
||||
this.letterProgress.add(npcKey);
|
||||
const count = this.letterProgress.size;
|
||||
const npc = NPCS[npcKey];
|
||||
const lines: DialogueLine[] = [{ speaker: npc.name, npcKey, portrait: npc.portrait.smile, text:'收到信啦,谢谢你!' }];
|
||||
if (count === 3) {
|
||||
// garbled signature flash 0.2s (C4)
|
||||
lines.push({ speaker:'', text:`署名闪过乱码「${this.letterGarble()}」`, glitch: true });
|
||||
director.discoverClue('C4');
|
||||
}
|
||||
onLine(lines);
|
||||
if (count >= 3) this.completeTask('T4', 15);
|
||||
}
|
||||
|
||||
// Get today's active tasks
|
||||
todayTasks() { return TASKS.filter(t => t.day === director.day); }
|
||||
|
||||
//阿福 announcement lines (verbatim per day)
|
||||
announcementLines(): DialogueLine[] {
|
||||
const txt = AFU_ANNOUNCE[director.day] || AFU_ANNOUNCE[1];
|
||||
return [
|
||||
{ speaker: '阿福', npcKey: 'afu', portrait: NPCS.afu.portrait.smile, text: txt },
|
||||
];
|
||||
}
|
||||
|
||||
// standard NPC talk lines per NPC (sample, affected by corruption via layer)
|
||||
npcTalk(npcKey: string): DialogueLine[] {
|
||||
const npc = NPCS[npcKey];
|
||||
if (!npc) return [];
|
||||
const base: Record<string, string[]> = {
|
||||
afu: ['今天也是美好的一天呀。', '大家都要开心哦。'],
|
||||
yuanyuan: ['面包刚出炉,香喷喷的!', '甜的东西能让人幸福呢。'],
|
||||
dabao: ['今天也有好多信要送。', '每一封信都通往一个朋友。'],
|
||||
mimi: ['欢迎光临~', '货架满满的,真开心呀。'],
|
||||
dr_fu: ['村里有诊所,但你不需要去那里哦。', '大家都健健康康的,真好。'],
|
||||
bird: ['(鸟儿在树梢欢快地歌唱)'],
|
||||
grandpa: [],
|
||||
};
|
||||
if (npcKey === 'grandpa') return this.grandpaLines();
|
||||
const lines = base[npcKey] || [];
|
||||
return lines.map(t => ({ speaker: npc.name, npcKey, portrait: this.portraitFor(npcKey), text: t }));
|
||||
}
|
||||
|
||||
// 龟爷爷 C9 progression per D2/D4/D6
|
||||
grandpaLines(): DialogueLine[] {
|
||||
const d = director.day;
|
||||
let txt = '这里的湖水一直很平静。';
|
||||
if (d >= 6 || director.clues.has('C9')) txt = '孩子,今天是第几个今天?';
|
||||
else if (d >= 4) txt = '面包一直是那个味道。';
|
||||
// discover C9 when reaching the third line
|
||||
if (d >= 6 && !director.clues.has('C9')) director.discoverClue('C9');
|
||||
return [{ speaker: '龟爷爷', npcKey: 'grandpa', portrait: NPCS.grandpa.portrait.smile, text: txt }];
|
||||
}
|
||||
|
||||
portraitFor(npcKey: string): number {
|
||||
const npc = NPCS[npcKey];
|
||||
const stage = director.stage;
|
||||
// apply individual threshold shift
|
||||
const order = ['smile','flat','off'] as const;
|
||||
let idx = 0;
|
||||
if (stage === 'S2') idx = 0;
|
||||
else if (stage === 'S3') idx = 1;
|
||||
else if (stage === 'S4') idx = 2;
|
||||
// mimi earlier
|
||||
if (npc.thresholdShift < 0) idx = Math.min(2, idx+1);
|
||||
else if (npc.thresholdShift > 0) idx = Math.max(0, idx-1);
|
||||
// dr_fu never changes
|
||||
if (npcKey === 'dr_fu') idx = 0;
|
||||
if (npcKey === 'grandpa') idx = 0; // grandpa doesn't corrupt
|
||||
const key = order[idx] as 'smile'|'flat'|'off';
|
||||
return npc.portrait[key];
|
||||
}
|
||||
|
||||
// reward a task
|
||||
completeTask(taskId: string, reward: number) {
|
||||
if (director.tasksDone.has(taskId)) return;
|
||||
director.tasksDone.add(taskId);
|
||||
if (reward > 0) director.addHappiness(reward);
|
||||
director.emit('taskDone', taskId);
|
||||
}
|
||||
|
||||
// letter C4 garbled signature flash (0.2s)
|
||||
letterGarble(): string { return C4_GARBLE; }
|
||||
}
|
||||
|
||||
export const story = new StoryDirector();
|
||||
@@ -0,0 +1,102 @@
|
||||
// TextCorruptor.ts: applies §6.5 text corruption at render time.
|
||||
// Original text stays clean in data; corruption applied based on stage and NPC bias.
|
||||
import { CORRUPTION_MAP, NPCS } from '../data/gamedata';
|
||||
|
||||
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
|
||||
|
||||
// Count NPC talk turns for periodic corruption (S2: 1/80, S3: 1/50)
|
||||
let globalTurnCount = 0;
|
||||
export function tickTurn() { globalTurnCount++; }
|
||||
export function resetTurns() { globalTurnCount = 0; }
|
||||
|
||||
// Deterministic-ish RNG to decide whether to corrupt a given target word.
|
||||
function chance(p: number): boolean { return Math.random() < p; }
|
||||
|
||||
/**
|
||||
* Corrupt a line of text.
|
||||
* @param text original clean text
|
||||
* @param stage current erosion stage
|
||||
* @param npcKey the speaking NPC key (mimi gets priority)
|
||||
* @param forceStrong if true (S4 / off), apply aggressive corruption
|
||||
*/
|
||||
export function corrupt(text: string, stage: Stage, npcKey?: string, forceStrong = false): string {
|
||||
let out = text;
|
||||
const npc = npcKey ? NPCS[npcKey] : undefined;
|
||||
const isMimi = npcKey === 'mimi';
|
||||
|
||||
// Determine effective stage with NPC threshold shift applied to "when corruption starts".
|
||||
// mimi (-10): corruption starts ~1 stage earlier; yuanyuan (+15): ~1 stage later.
|
||||
let eff: Stage = stage;
|
||||
if (npc) {
|
||||
const shift = npc.thresholdShift;
|
||||
// shift<0 -> earlier; map by nudging stage index
|
||||
const order: Stage[] = ['S0','S1','S2','S3','S4'];
|
||||
let idx = order.indexOf(stage);
|
||||
// negative shift reduces the "needed" happiness -> treat as if one stage higher (more corrupt) for mimi
|
||||
if (shift < 0) idx = Math.min(4, idx + 1);
|
||||
else if (shift > 0) idx = Math.max(0, idx - 1);
|
||||
eff = order[idx];
|
||||
}
|
||||
// dr_fu & grandpa never corrupt (not participants)
|
||||
if (npcKey === 'dr_fu' || npcKey === 'grandpa') return out;
|
||||
|
||||
// Apply per-stage rules
|
||||
// Build list of [target, replacement]
|
||||
const pairs: [string, string][] = Object.entries(CORRUPTION_MAP);
|
||||
|
||||
// prioritize mimi: her words get corrupted first
|
||||
const ordered = isMimi ? pairs : (pairs.slice().sort(() => Math.random() - 0.5));
|
||||
|
||||
if (eff === 'S0' || eff === 'S1') {
|
||||
// normal: no corruption
|
||||
return out;
|
||||
}
|
||||
|
||||
if (eff === 'S2') {
|
||||
// occasional repeated punctuation (1 per 80 sentences)
|
||||
if (forceStrong || isMimi || globalTurnCount % 80 === 0) {
|
||||
// corrupt at most one word, low chance
|
||||
for (const [from, to] of ordered) {
|
||||
if (out.includes(from) && chance(isMimi ? 0.5 : 0.25)) { out = out.replace(from, to); break; }
|
||||
}
|
||||
// repeated punctuation
|
||||
if (chance(isMimi ? 0.5 : 0.2)) {
|
||||
out = out.replace(/([。!?,])/g, (m) => chance(0.3) ? m + m : m);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (eff === 'S3') {
|
||||
// homophone typos: 1 per 50 sentences, mimi higher
|
||||
const baseProb = isMimi ? 0.7 : (forceStrong ? 0.6 : 0.4);
|
||||
let didOne = false;
|
||||
for (const [from, to] of ordered) {
|
||||
if (out.includes(from) && (!didOne || isMimi) && chance(baseProb)) {
|
||||
out = out.replace(from, to); didOne = true;
|
||||
}
|
||||
}
|
||||
if (isMimi) {
|
||||
// mimi句尾标点 ×3 (S4-like, but begins appearing)
|
||||
out = out.replace(/([。!?])$/, (m) => m + m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// eff === 'S4'
|
||||
{
|
||||
// 错字加密 + 咪咪句尾标点 ×3
|
||||
for (const [from, to] of ordered) {
|
||||
if (out.includes(from)) {
|
||||
out = out.split(from).join(to);
|
||||
}
|
||||
}
|
||||
if (isMimi) {
|
||||
out = out.replace(/([。!?])$/, (m) => m + m + m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Glitch signature for C4 (the PT-0▓ garbled signature flash)
|
||||
export const C4_GARBLE = 'PT-0▓';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4210;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(200);
|
||||
|
||||
// Try to walk to door: door at tile (5,7)=(80,112). Player at ~(96,64).
|
||||
// Move left first to align x, then down.
|
||||
await page.keyboard.down('a'); await page.waitForTimeout(500); await page.keyboard.up('a');
|
||||
await page.waitForTimeout(100);
|
||||
const afterLeft = await page.evaluate(() => { const p=window.game.scene.getScene('HouseScene').player; return {x:p.x,y:p.y}; });
|
||||
console.log('after left:', JSON.stringify(afterLeft));
|
||||
|
||||
// Now move down toward door
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<8;i++){ await page.waitForTimeout(300); const p=await page.evaluate(()=>{const pl=window.game.scene.getScene('HouseScene').player;return{x:pl.x,y:pl.y,vx:pl.body.velocity.x,vy:pl.body.velocity.y};}); console.log('down step',i,JSON.stringify(p)); if(p.y>100) break; }
|
||||
await page.keyboard.up('s');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const nearDoor = await page.evaluate(() => {
|
||||
const h = window.game.scene.getScene('HouseScene');
|
||||
const p = h.player;
|
||||
const door = h.doors[0];
|
||||
const dist = Math.hypot(p.x-door.x, p.y-door.y);
|
||||
return { px:p.x, py:p.y, doorX:door.x, doorY:door.y, dist, worldBounds: {w:h.physics.world.bounds.width, h:h.physics.world.bounds.height} };
|
||||
});
|
||||
console.log('near door:', JSON.stringify(nearDoor));
|
||||
|
||||
// press E to transition
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForTimeout(1500);
|
||||
const activeScenes = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
console.log('active scenes after E:', JSON.stringify(activeScenes));
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,44 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4211;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// Walk down toward the door. Door at (88,112). Player starts ~ (96,64).
|
||||
// Just walk straight down — the door auto-trigger should fire when close.
|
||||
await page.keyboard.down('s');
|
||||
let escaped = false;
|
||||
for (let i=0;i<12;i++){
|
||||
await page.waitForTimeout(300);
|
||||
const st = await page.evaluate(() => {
|
||||
const g = window.game;
|
||||
const active = g.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key);
|
||||
return { active, hasPlaza: active.includes('PlazaScene'), hasHouse: active.includes('HouseScene') };
|
||||
});
|
||||
if (st.hasPlaza && !st.hasHouse) { escaped = true; break; }
|
||||
}
|
||||
await page.keyboard.up('s');
|
||||
checks.escaped_to_plaza = escaped;
|
||||
|
||||
// Now from plaza, walk to each door and verify they auto-transition
|
||||
// plaza doors: door_house(bottom center 14,18), door_bakery(left 5,9), etc.
|
||||
// Wait for plaza to settle
|
||||
await page.waitForTimeout(800);
|
||||
const plazaActive = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).some(s=>s.scene.key==='PlazaScene'));
|
||||
checks.in_plaza = plazaActive;
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
@@ -0,0 +1,46 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4214;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
// house -> plaza
|
||||
await page.keyboard.down('s'); await page.waitForTimeout(1500);
|
||||
// wait for plaza
|
||||
for (let i=0;i<10;i++){ await page.waitForTimeout(300); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(600);
|
||||
|
||||
// Now in plaza. Check player position and doors
|
||||
const state = await page.evaluate(() => {
|
||||
const p = window.game.scene.getScene('PlazaScene');
|
||||
return {
|
||||
px: p.player.x, py: p.player.y,
|
||||
doors: p.doors.map(d=>({x:d.x,y:d.y,target:d.target})),
|
||||
};
|
||||
});
|
||||
console.log('plaza state:', JSON.stringify(state));
|
||||
|
||||
// Directly teleport player to bakery door and check if auto-transition works
|
||||
await page.evaluate(() => { window.game.scene.getScene('PlazaScene').player.setPosition(80, 144); });
|
||||
await page.waitForTimeout(800);
|
||||
let a = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
console.log('after teleport to bakery door:', JSON.stringify(a));
|
||||
|
||||
// If still in plaza, press E
|
||||
if (a.includes('PlazaScene')) {
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForTimeout(800);
|
||||
a = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
console.log('after E at bakery door:', JSON.stringify(a));
|
||||
}
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,31 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4219;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
// house -> plaza via walking down
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<15;i++){ await page.waitForTimeout(250); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(900);
|
||||
await page.click('canvas'); await page.waitForTimeout(200);
|
||||
|
||||
const st = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y,active:window.game.scene.getScene('PlazaScene').scene.isActive()}; });
|
||||
console.log('in plaza:', JSON.stringify(st));
|
||||
|
||||
// hold w, check movement
|
||||
await page.keyboard.down('w'); await page.waitForTimeout(600);
|
||||
const w = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y,vy:p.body.velocity.y}; });
|
||||
console.log('w held:', JSON.stringify(w));
|
||||
await page.keyboard.up('w');
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4222;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(120); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<20;i++){
|
||||
await page.waitForTimeout(250);
|
||||
const s = await page.evaluate(() => { const g=window.game; const a=g.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key); let p=null; try{p={x:g.scene.getScene('HouseScene').player.x,y:g.scene.getScene('HouseScene').player.y};}catch(e){} return {a,p}; });
|
||||
console.log('s step', i, JSON.stringify(s));
|
||||
if (s.a.includes('PlazaScene')) { console.log('ENTERED PLAZA'); break; }
|
||||
}
|
||||
await page.keyboard.up('s');
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,36 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4221;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(120); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
// enter plaza
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<15;i++){ await page.waitForTimeout(220); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(800);
|
||||
// teleport player to center of plaza row 8 (y=136) col 14 (x=232)
|
||||
await page.evaluate(() => window.game.scene.getScene('PlazaScene').player.setPosition(232, 136));
|
||||
await page.waitForTimeout(300);
|
||||
const start = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y}; });
|
||||
console.log('start at row8 center:', JSON.stringify(start));
|
||||
// walk left, sample position every 300ms
|
||||
await page.keyboard.down('a');
|
||||
for (let i=0;i<20;i++){
|
||||
await page.waitForTimeout(250);
|
||||
const s = await page.evaluate(() => { const g=window.game; const a=g.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key); const p=g.scene.getScene('PlazaScene').player; return {a, x:p?p.x:null, y:p?p.y:null}; });
|
||||
console.log('left step', i, JSON.stringify(s));
|
||||
if (s.a.includes('BakeryScene')) { console.log('ENTERED BAKERY'); break; }
|
||||
if (!s.a.includes('PlazaScene')) break;
|
||||
}
|
||||
await page.keyboard.up('a');
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,60 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4201;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('console', (msg) => { if (msg.type()==='error'||msg.type()==='warn') console.log('CONSOLE', msg.type(), msg.text()); });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// check keyboard setup
|
||||
const setup = await page.evaluate(() => {
|
||||
const house = window.game.scene.getScene('HouseScene');
|
||||
return {
|
||||
kbExists: !!house.input.keyboard,
|
||||
cursorsExists: !!house.cursors,
|
||||
wasdExists: !!house.wasd,
|
||||
wasdKeys: house.wasd ? Object.keys(house.wasd) : null,
|
||||
wasdA: house.wasd && house.wasd.A ? { isDown: house.wasd.A.isDown, keyCode: house.wasd.A.keyCode } : null,
|
||||
playerExists: !!house.player,
|
||||
playerPos: house.player ? {x:house.player.x, y:house.player.y} : null,
|
||||
dialogueActive: window.game.scene.getScene('UIScene').active,
|
||||
};
|
||||
});
|
||||
console.log('SETUP:', JSON.stringify(setup, null, 2));
|
||||
|
||||
// dismiss dialogue if active
|
||||
if (setup.dialogueActive) {
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
const before = await page.evaluate(() => ({x: window.game.scene.getScene('HouseScene').player.x, y: window.game.scene.getScene('HouseScene').player.y, v: window.game.scene.getScene('HouseScene').player.body.velocity}));
|
||||
|
||||
// press and hold 'd' for 500ms
|
||||
await page.keyboard.down('d');
|
||||
await page.waitForTimeout(500);
|
||||
const during = await page.evaluate(() => ({
|
||||
x: window.game.scene.getScene('HouseScene').player.x,
|
||||
y: window.game.scene.getScene('HouseScene').player.y,
|
||||
v: {x: window.game.scene.getScene('HouseScene').player.body.velocity.x, y: window.game.scene.getScene('HouseScene').player.body.velocity.y},
|
||||
wasdD_isDown: window.game.scene.getScene('HouseScene').wasd.D.isDown,
|
||||
cursorsRight: window.game.scene.getScene('HouseScene').cursors.right.isDown,
|
||||
}));
|
||||
await page.keyboard.up('d');
|
||||
await page.waitForTimeout(100);
|
||||
const after = await page.evaluate(() => ({x: window.game.scene.getScene('HouseScene').player.x, y: window.game.scene.getScene('HouseScene').player.y}));
|
||||
|
||||
console.log('BEFORE:', JSON.stringify(before));
|
||||
console.log('DURING (d held):', JSON.stringify(during));
|
||||
console.log('AFTER:', JSON.stringify(after));
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4202;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
// dismiss dialogue
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(200); await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
|
||||
// Test: is UIScene dialogue blocking? Check UIScene input handlers consume keydown
|
||||
// Focus the canvas
|
||||
await page.click('canvas');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Test arrow keys (cursors) and check global keydown listener
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await page.waitForTimeout(200);
|
||||
const arrowState = await page.evaluate(() => {
|
||||
const h = window.game.scene.getScene('HouseScene');
|
||||
return { cursorRight: h.cursors.right.isDown, wasdD: h.wasd.D.isDown, inputEnabled: h.input.enabled };
|
||||
});
|
||||
console.log('ArrowRight held:', JSON.stringify(arrowState));
|
||||
await page.keyboard.up('ArrowRight');
|
||||
|
||||
// Check if maybe the scene's update isn't running (frozen?)
|
||||
const updateCheck = await page.evaluate(() => {
|
||||
const h = window.game.scene.getScene('HouseScene');
|
||||
return { sceneActive: h.scene.isActive(), frozen: h.player.frozen, lastUpdate: h.sys.settings.active };
|
||||
});
|
||||
console.log('scene status:', JSON.stringify(updateCheck));
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4203;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(200); await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
|
||||
// Inspect wasd key objects in detail
|
||||
const wasdDetail = await page.evaluate(() => {
|
||||
const h = window.game.scene.getScene('HouseScene');
|
||||
const d = h.wasd.D;
|
||||
return {
|
||||
type: typeof d,
|
||||
hasKeyCode: 'keyCode' in d,
|
||||
keyCode: d.keyCode,
|
||||
isDown: d.isDown,
|
||||
sceneInputEnabled: d.scene && d.scene.input ? d.scene.input.enabled : 'no scene ref',
|
||||
// check which scene the key belongs to
|
||||
sceneKey: d.scene ? d.scene.sys.config.key : null,
|
||||
};
|
||||
});
|
||||
console.log('wasd.D detail:', JSON.stringify(wasdDetail, null, 2));
|
||||
|
||||
// Also test: create a fresh key and see if it tracks
|
||||
const freshTest = await page.evaluate(() => {
|
||||
const h = window.game.scene.getScene('HouseScene');
|
||||
const k = h.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D);
|
||||
h.__testD = k;
|
||||
return { keyCode: k.keyCode };
|
||||
});
|
||||
await page.keyboard.down('d');
|
||||
await page.waitForTimeout(200);
|
||||
const freshDown = await page.evaluate(() => ({ testD: window.game.scene.getScene('HouseScene').__testD.isDown, wasdD: window.game.scene.getScene('HouseScene').wasd.D.isDown }));
|
||||
console.log('fresh key d held:', JSON.stringify(freshDown));
|
||||
await page.keyboard.up('d');
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4204;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
// fully dismiss dialogue
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// check player internal keys
|
||||
const keys = await page.evaluate(() => {
|
||||
const p = window.game.scene.getScene('HouseScene').player;
|
||||
return {
|
||||
hasKLeft: !!p.kLeft,
|
||||
kLeftIsDown: p.kLeft && p.kLeft.isDown,
|
||||
kLeftPluginNull: p.kLeft && p.kLeft.plugin == null,
|
||||
wasdLen: p.wasdKeys ? p.wasdKeys.length : null,
|
||||
wasdDisDown: p.wasdKeys && p.wasdKeys[3] ? p.wasdKeys[3].isDown : null,
|
||||
moving: p.moving,
|
||||
frozen: p.frozen,
|
||||
};
|
||||
});
|
||||
console.log('player keys:', JSON.stringify(keys));
|
||||
|
||||
// hold d, check player's internal D key
|
||||
await page.keyboard.down('d');
|
||||
await page.waitForTimeout(400);
|
||||
const during = await page.evaluate(() => {
|
||||
const p = window.game.scene.getScene('HouseScene').player;
|
||||
return {
|
||||
x: p.x, y: p.y,
|
||||
vx: p.body.velocity.x, vy: p.body.velocity.y,
|
||||
wasdDisDown: p.wasdKeys[3].isDown,
|
||||
kRightDown: p.kRight.isDown,
|
||||
moving: p.moving,
|
||||
};
|
||||
});
|
||||
console.log('DURING d-held:', JSON.stringify(during));
|
||||
await page.keyboard.up('d');
|
||||
await page.waitForTimeout(200);
|
||||
const after = await page.evaluate(() => ({x: window.game.scene.getScene('HouseScene').player.x, y: window.game.scene.getScene('HouseScene').player.y}));
|
||||
console.log('AFTER:', JSON.stringify(after));
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,31 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4205;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
const pos = () => page.evaluate(() => { const p=window.game.scene.getScene('HouseScene').player; return {x:p.x,y:p.y}; });
|
||||
let page;
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(200);
|
||||
|
||||
for (const [key, label] of [['d','right'],['a','left'],['s','down'],['w','up'],['ArrowRight','arrowR'],['ArrowLeft','arrowL'],['ArrowUp','arrowU'],['ArrowDown','arrowD']]) {
|
||||
const b = await pos();
|
||||
await page.keyboard.down(key); await page.waitForTimeout(350); await page.keyboard.up(key); await page.waitForTimeout(80);
|
||||
const a = await pos();
|
||||
const moved = Math.abs(a.x-b.x) > 3 || Math.abs(a.y-b.y) > 3;
|
||||
checks[label] = moved;
|
||||
console.log(`${label}: (${b.x.toFixed(0)},${b.y.toFixed(0)}) -> (${a.x.toFixed(0)},${a.y.toFixed(0)}) moved=${moved}`);
|
||||
}
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
@@ -0,0 +1,59 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4212;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
const getActive = (page) => page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// helper: walk in a direction until scene changes or timeout
|
||||
async function walkToScene(dir, wantScene, maxSteps=12) {
|
||||
await page.keyboard.down(dir);
|
||||
for (let i=0;i<maxSteps;i++){
|
||||
await page.waitForTimeout(300);
|
||||
const a = await getActive(page);
|
||||
if (a.includes(wantScene)) { await page.keyboard.up(dir); return true; }
|
||||
}
|
||||
await page.keyboard.up(dir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// house -> plaza (walk down)
|
||||
checks.house_to_plaza = await walkToScene('s', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// plaza -> bakery (door at left tile 5,9 = walk left). Player spawns near door_house (bottom center).
|
||||
// First walk up, then left to bakery door
|
||||
checks.plaza_to_bakery = await walkToScene('a', 'BakeryScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// bakery -> plaza (door at bottom center 7,9, walk down)
|
||||
checks.bakery_to_plaza = await walkToScene('s', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// plaza -> shop (door at right tile 24,9, walk right)
|
||||
checks.plaza_to_shop = await walkToScene('d', 'ShopScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// shop -> plaza
|
||||
checks.shop_to_plaza = await walkToScene('s', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// plaza -> house (door_house bottom center 14,18, walk down)
|
||||
checks.plaza_to_house = await walkToScene('s', 'HouseScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks, null, 2));
|
||||
@@ -0,0 +1,50 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4213;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
const getActive = (page) => page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
async function walk(page, dir, ms){ await page.keyboard.down(dir); await page.waitForTimeout(ms); await page.keyboard.up(dir); await page.waitForTimeout(150); }
|
||||
async function walkToScene(page, dir, wantScene, maxSteps=15){
|
||||
await page.keyboard.down(dir);
|
||||
for (let i=0;i<maxSteps;i++){ await page.waitForTimeout(300); const a=await getActive(page); if(a.includes(wantScene)){ await page.keyboard.up(dir); return true; } }
|
||||
await page.keyboard.up(dir); return false;
|
||||
}
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// house -> plaza
|
||||
checks.house_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// From plaza door_house (bottom center), walk UP to row 9, then LEFT to bakery door (5,9)
|
||||
await walk(page, 'w', 1800); // walk up
|
||||
checks.plaza_to_bakery = await walkToScene(page, 'a', 'BakeryScene');
|
||||
await page.waitForTimeout(500);
|
||||
// bakery -> plaza
|
||||
checks.bakery_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
// plaza: walk up then right to shop door (24,9)
|
||||
await walk(page, 'w', 1800);
|
||||
checks.plaza_to_shop = await walkToScene(page, 'd', 'ShopScene');
|
||||
await page.waitForTimeout(500);
|
||||
checks.shop_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
// plaza -> clinic (top center, door 14,1): walk up
|
||||
await walk(page, 'a', 800); // recenter
|
||||
checks.plaza_to_clinic = await walkToScene(page, 'w', 'ClinicScene');
|
||||
await page.waitForTimeout(500);
|
||||
checks.clinic_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks, null, 2));
|
||||
@@ -0,0 +1,64 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4215;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
const getActive = (page) => page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
async function walkToScene(page, dir, wantScene, maxSteps=20){
|
||||
await page.keyboard.down(dir);
|
||||
for (let i=0;i<maxSteps;i++){ await page.waitForTimeout(250); const a=await getActive(page); if(a.includes(wantScene)){ await page.keyboard.up(dir); return true; } }
|
||||
await page.keyboard.up(dir); return false;
|
||||
}
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// house -> plaza (walk down to bottom edge door)
|
||||
checks.house_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// plaza -> bakery (walk left to left edge). Player spawns at door_house bottom, offset upward.
|
||||
checks.plaza_to_bakery = await walkToScene(page, 'a', 'BakeryScene');
|
||||
await page.waitForTimeout(600);
|
||||
// bakery -> plaza (walk down)
|
||||
checks.bakery_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// plaza -> shop (walk right)
|
||||
checks.plaza_to_shop = await walkToScene(page, 'd', 'ShopScene');
|
||||
await page.waitForTimeout(600);
|
||||
// shop -> plaza
|
||||
checks.shop_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// plaza -> clinic (walk up to top edge)
|
||||
checks.plaza_to_clinic = await walkToScene(page, 'w', 'ClinicScene');
|
||||
await page.waitForTimeout(600);
|
||||
// clinic -> plaza
|
||||
checks.clinic_to_plaza = await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// plaza -> lake (walk left - lake at col0,row10, bakery at col0,row9; both left edge)
|
||||
// player may hit bakery first; let's walk up a bit then left
|
||||
await page.keyboard.down('w'); await page.waitForTimeout(800); await page.keyboard.up('w');
|
||||
// now walk down to row 10 area then left
|
||||
await page.keyboard.down('s'); await page.waitForTimeout(400); await page.keyboard.up('s');
|
||||
checks.plaza_to_lake = await walkToScene(page, 'a', 'LakeScene');
|
||||
await page.waitForTimeout(600);
|
||||
// lake -> plaza (door_plaza at right edge col 22,8 — walk right)
|
||||
checks.lake_to_plaza = await walkToScene(page, 'd', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
// plaza -> field (walk right, field at col29 row10)
|
||||
checks.plaza_to_field = await walkToScene(page, 'd', 'FieldScene');
|
||||
await page.waitForTimeout(600);
|
||||
// field -> plaza
|
||||
checks.field_to_plaza = await walkToScene(page, 'a', 'PlazaScene');
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks, null, 2));
|
||||
@@ -0,0 +1,61 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4220;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
const getActive = (page) => page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
async function walk(page, dir, ms){ await page.keyboard.down(dir); await page.waitForTimeout(ms); await page.keyboard.up(dir); await page.waitForTimeout(120); }
|
||||
async function walkToScene(page, dir, wantScene, maxSteps=25){
|
||||
await page.keyboard.down(dir);
|
||||
for (let i=0;i<maxSteps;i++){ await page.waitForTimeout(220); const a=await getActive(page); if(a.includes(wantScene)){ await page.keyboard.up(dir); return true; } }
|
||||
await page.keyboard.up(dir); return false;
|
||||
}
|
||||
async function enterPlaza(page){
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<15;i++){ await page.waitForTimeout(220); const a=await getActive(page); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(700);
|
||||
}
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(120); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// Test each plaza exit independently by teleporting to plaza center first
|
||||
await enterPlaza(page);
|
||||
// bakery: walk up to row ~8 then left
|
||||
await walk(page, 'w', 2500);
|
||||
checks.bakery = await walkToScene(page, 'a', 'BakeryScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// reset: go back to plaza
|
||||
if (!checks.bakery) await page.evaluate(() => { const g=window.game; g.scene.stop('HouseScene'); g.scene.start('PlazaScene',{spawn:'default'}); });
|
||||
else await walkToScene(page, 's', 'PlazaScene');
|
||||
await page.waitForTimeout(700); await page.click('canvas');
|
||||
|
||||
// shop: walk up then right
|
||||
await walk(page, 'w', 2500);
|
||||
checks.shop = await walkToScene(page, 'd', 'ShopScene');
|
||||
await page.waitForTimeout(500);
|
||||
await walkToScene(page, 's', 'PlazaScene'); await page.waitForTimeout(700); await page.click('canvas');
|
||||
|
||||
// clinic: walk up
|
||||
await walk(page, 'a', 600);
|
||||
checks.clinic = await walkToScene(page, 'w', 'ClinicScene');
|
||||
await page.waitForTimeout(500);
|
||||
await walkToScene(page, 's', 'PlazaScene'); await page.waitForTimeout(700); await page.click('canvas');
|
||||
|
||||
// lake: walk down then left (lake at col0 row12 = lower left)
|
||||
await walk(page, 's', 1500);
|
||||
checks.lake = await walkToScene(page, 'a', 'LakeScene');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks, null, 2));
|
||||
@@ -0,0 +1,42 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4216;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
|
||||
// house -> plaza
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<15;i++){ await page.waitForTimeout(250); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(700);
|
||||
|
||||
const inPlaza = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
console.log('after entering plaza:', JSON.stringify(inPlaza));
|
||||
const pos = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene'); return {px:p.player.x, py:p.player.y, doors:p.doors.map(d=>({x:d.x,y:d.y,t:d.target}))}; });
|
||||
console.log('player pos in plaza:', pos.px, pos.py);
|
||||
console.log('doors:', JSON.stringify(pos.doors));
|
||||
|
||||
// Now walk left toward bakery door (col 0, row 8 = 0,128). Player at ~(224, 276).
|
||||
// Need to go up to row 8 first. Walk up.
|
||||
await page.keyboard.down('w'); await page.waitForTimeout(2500); await page.keyboard.up('w'); await page.waitForTimeout(200);
|
||||
const afterUp = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y}; });
|
||||
console.log('after walking up:', JSON.stringify(afterUp));
|
||||
// now walk left
|
||||
await page.keyboard.down('a');
|
||||
for (let i=0;i<20;i++){ await page.waitForTimeout(250); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('BakeryScene')){ console.log('entered bakery at step',i); break; } }
|
||||
await page.keyboard.up('a');
|
||||
const finalScenes = await page.evaluate(() => window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key));
|
||||
const finalPos = await page.evaluate(() => { try { return {x:window.game.scene.getScene('PlazaScene').player.x, y:window.game.scene.getScene('PlazaScene').player.y}; } catch(e){ return 'plaza gone'; } });
|
||||
console.log('final scenes:', JSON.stringify(finalScenes), 'pos:', JSON.stringify(finalPos));
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4217;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
page.on('console', (msg) => { if (msg.type()==='error') console.log('CONSOLE_ERR', msg.text()); });
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
// house -> plaza
|
||||
await page.keyboard.down('s');
|
||||
for (let i=0;i<15;i++){ await page.waitForTimeout(250); const a=await page.evaluate(()=>window.game.scene.scenes.filter(s=>s.scene.isActive()).map(s=>s.scene.key)); if(a.includes('PlazaScene')) break; }
|
||||
await page.keyboard.up('s'); await page.waitForTimeout(800);
|
||||
|
||||
// Check plaza player keys and update status
|
||||
const info = await page.evaluate(() => {
|
||||
const p = window.game.scene.getScene('PlazaScene');
|
||||
const pl = p.player;
|
||||
return {
|
||||
px: pl.x, py: pl.y,
|
||||
moving: pl.moving,
|
||||
frozen: pl.frozen,
|
||||
hasKUp: !!pl.kUp,
|
||||
kUpIsDown: pl.kUp && pl.kUp.isDown,
|
||||
wasdLen: pl.wasdKeys ? pl.wasdKeys.length : 0,
|
||||
wasdWDown: pl.wasdKeys && pl.wasdKeys[0] ? pl.wasdKeys[0].isDown : null,
|
||||
sceneUpdateActive: p.scene.isActive(),
|
||||
doorCooldown: p.doorCooldown,
|
||||
timeNow: p.time.now,
|
||||
};
|
||||
});
|
||||
console.log('plaza player info:', JSON.stringify(info));
|
||||
|
||||
// hold w and check if key isDown updates
|
||||
await page.keyboard.down('w');
|
||||
await page.waitForTimeout(400);
|
||||
const wInfo = await page.evaluate(() => {
|
||||
const pl = window.game.scene.getScene('PlazaScene').player;
|
||||
return { kUpDown: pl.kUp.isDown, wasdWDown: pl.wasdKeys[0].isDown, px: pl.x, py: pl.y, vy: pl.body.velocity.y, moving: pl.moving };
|
||||
});
|
||||
console.log('w held:', JSON.stringify(wInfo));
|
||||
await page.keyboard.up('w');
|
||||
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,33 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4218;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter'); await page.waitForTimeout(1200);
|
||||
for (let i=0;i<4;i++){ await page.keyboard.press('e'); await page.waitForTimeout(150); }
|
||||
await page.click('canvas'); await page.waitForTimeout(300);
|
||||
// teleport directly to plaza to skip the house
|
||||
await page.evaluate(() => {
|
||||
const g = window.game;
|
||||
g.scene.stop('HouseScene');
|
||||
g.scene.start('PlazaScene', { spawn: 'default' });
|
||||
});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.click('canvas'); await page.waitForTimeout(200);
|
||||
|
||||
const before = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y,active:window.game.scene.getScene('PlazaScene').scene.isActive(),moving:p.moving}; });
|
||||
console.log('before (plaza default spawn):', JSON.stringify(before));
|
||||
|
||||
await page.keyboard.down('w'); await page.waitForTimeout(600);
|
||||
const during = await page.evaluate(() => { const p=window.game.scene.getScene('PlazaScene').player; return {x:p.x,y:p.y,vy:p.body.velocity.y}; });
|
||||
console.log('w held 600ms:', JSON.stringify(during));
|
||||
await page.keyboard.up('w');
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,447 @@
|
||||
// gen_maps.js: Generate 10 Tiled-format JSON maps per PRD §10.
|
||||
// Produces assets/maps/*.json
|
||||
// Tile index reference (matches tools/gen_tileset.lua order):
|
||||
// NOTE: Tiled uses global tile IDs where 0 = empty and localIndex N -> global N+1 (firstgid=1).
|
||||
// We store raw LOCAL indices in T.* and convert to global IDs (+1, 0 stays 0=empty) on write.
|
||||
const T = {
|
||||
grass: 0, path: 1, water: 2, flowersY: 3, flowersP: 4, flowersG: 5, berry: 6, tree: 7,
|
||||
birdTree: 8, bush: 9, wall: 10, wallTop: 11, roof: 12, roofEdge: 13, door: 14, window: 15,
|
||||
floor: 16, floorRug: 17, bed: 18, counter: 19, oven: 20, shelf: 21, shelfFull: 22, table: 23,
|
||||
chair: 24, stool: 25, well: 26, fog: 27, fogThick: 28, whiteWall: 29, whiteFloor: 30, whiteDoor: 31,
|
||||
whiteBed: 32, whiteMonitor: 33, whiteTable: 34, ribbon: 35, ribbon2: 36, picnic: 37, bench: 38, sign: 39,
|
||||
calendar: 40, paper: 41, chart: 42, fenceH: 43, fenceV: 44, lamp: 45, potPlant: 46, barrel: 47,
|
||||
crate: 48, grassAlt: 49, pathH: 50, pathV: 51, pathCross: 52, waterEdge: 53, grassDark: 54, pathCorner: 55,
|
||||
flowerMix: 56, berryEmpty: 57, fogBand: 58, whitePillar: 59, floorEdge: 60, wallTrim: 61, grassPath: 62, transparent: 63,
|
||||
};
|
||||
// EMPTY tile (global 0): use a sentinel; we map 63(transparent local) -> 0 empty on output, others +1.
|
||||
const EMPTY = 63; // local index used for "transparent/empty"
|
||||
|
||||
const COLS = 8; // tileset columns
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const outDir = 'assets/maps';
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Load the tileset descriptor to embed inline.
|
||||
const tilesetJson = JSON.parse(fs.readFileSync('assets/tilesets/tileset.json', 'utf8'));
|
||||
|
||||
function makeMap(name, width, height, layers) {
|
||||
// layers: [{name, type:'tilelayer', data:number[] (len w*h)} or {name,type:'objectgroup', objects:[]}]
|
||||
// Find door objects and clear obstacle tiles at their positions so the player can walk through.
|
||||
const objLayer = layers.find(l => l.type === 'objectgroup');
|
||||
const doorTiles = new Set();
|
||||
if (objLayer) {
|
||||
for (const o of objLayer.objects) {
|
||||
if (o.type === 'door' || (o.name && o.name.startsWith('door'))) {
|
||||
const tx = Math.floor(o.x / 16), ty = Math.floor(o.y / 16);
|
||||
doorTiles.add(ty * width + tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
const tiledLayers = layers.map(l => {
|
||||
if (l.type === 'tilelayer') {
|
||||
// convert local indices to global tile IDs: local N -> N+1, EMPTY(63) -> 0
|
||||
// also clear obstacle tiles at door positions
|
||||
const globalData = l.data.map((v, idx) => {
|
||||
if (l.name === 'obstacles' && doorTiles.has(idx)) return 0; // clear wall at door
|
||||
return (v === EMPTY ? 0 : v + 1);
|
||||
});
|
||||
return {
|
||||
name: l.name,
|
||||
type: 'tilelayer',
|
||||
x: 0, y: 0,
|
||||
width, height,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
offsetx: 0, offsety: 0,
|
||||
draworder: 'right-down',
|
||||
data: globalData,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
name: l.name,
|
||||
type: 'objectgroup',
|
||||
x: 0, y: 0,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
offsetx: 0, offsety: 0,
|
||||
draworder: 'index',
|
||||
objects: l.objects,
|
||||
};
|
||||
}
|
||||
});
|
||||
return {
|
||||
compressionlevel: -1,
|
||||
height, width,
|
||||
tilewidth: 16,
|
||||
tileheight: 16,
|
||||
type: 'map',
|
||||
orientation: 'orthogonal',
|
||||
renderorder: 'right-down',
|
||||
tiledversion: '1.12.2',
|
||||
// embed tileset inline so Phaser resolves it without external file
|
||||
tilesets: [{
|
||||
firstgid: 1,
|
||||
name: tilesetJson.name,
|
||||
columns: tilesetJson.columns,
|
||||
image: '../tilesets/tileset.png',
|
||||
imagewidth: tilesetJson.imagewidth,
|
||||
imageheight: tilesetJson.imageheight,
|
||||
margin: 0,
|
||||
spacing: 0,
|
||||
tilecount: tilesetJson.tilecount,
|
||||
tilewidth: 16,
|
||||
tileheight: 16,
|
||||
tiles: tilesetJson.tiles,
|
||||
}],
|
||||
layers: tiledLayers,
|
||||
};
|
||||
}
|
||||
|
||||
// fill helpers
|
||||
function fillGrid(w, h, val) { return new Array(w * h).fill(val); }
|
||||
function set(g, w, x, y, v) { if (x>=0&&y>=0&&x<w&&y<g.length/w) g[y*w+x] = v; }
|
||||
function rectFill(g, w, h, x0, y0, rw, rh, v) {
|
||||
for (let y=y0; y<y0+rh && y<h; y++) for (let x=x0; x<x0+rw && x<w; x++) set(g, w, x, y, v);
|
||||
}
|
||||
function borderFill(g, w, h, x0, y0, rw, rh, v) {
|
||||
for (let y=y0; y<y0+rh && y<h; y++) for (let x=x0; x<x0+rw && x<w; x++) {
|
||||
if (y===y0||y===y0+rh-1||x===x0||x===x0+rw-1) set(g,w,x,y,v);
|
||||
}
|
||||
}
|
||||
|
||||
// object helpers (pixel coords: x,y = top-left in pixels, 16 per tile)
|
||||
function obj(name, type, tx, ty, props={}) {
|
||||
return { name, type, x: tx*16, y: ty*16, width: props._w||16, height: props._h||16,
|
||||
properties: Object.entries(props).filter(([k])=>!k.startsWith('_')).map(([k,v])=>({name:k,type:'string',value:String(v)})) };
|
||||
}
|
||||
|
||||
const maps = {};
|
||||
|
||||
// 1. House (12x8) interior
|
||||
{
|
||||
const w=12, h=8;
|
||||
const ground = fillGrid(w,h,T.floor);
|
||||
rectFill(ground,w,h,0,0,12,1,T.wallTop);
|
||||
rectFill(ground,w,h,0,h-1,12,1,T.floorEdge);
|
||||
const obs = fillGrid(w,h,63); // transparent
|
||||
borderFill(obs,w,h,0,0,12,8,T.wall);
|
||||
// bed bottom-left
|
||||
rectFill(obs,w,h,1,5,3,2,T.bed);
|
||||
// calendar on wall (C1) - object only
|
||||
// door to plaza (south center) — widen the gap so the player can't get stuck
|
||||
set(obs,w,5,7,63); set(obs,w,6,7,63); set(ground,w,5,7,T.door);
|
||||
// objects
|
||||
const objects = [
|
||||
obj('door_plaza','door',5,7,{target:'plaza', spawn:'door_house'}),
|
||||
obj('bed','interact',1,5,{clue:'none'}),
|
||||
obj('calendar','clue',2,0,{clue:'C1'}),
|
||||
obj('sleep_point','interact',6,5),
|
||||
];
|
||||
maps.house = makeMap('house', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. Plaza (30x20) hub
|
||||
{
|
||||
const w=30, h=20;
|
||||
const ground = fillGrid(w,h,T.grass);
|
||||
// cross paths
|
||||
rectFill(ground,w,h,0,9,30,2,T.path); // horizontal
|
||||
rectFill(ground,w,h,14,0,2,20,T.path); // vertical
|
||||
// flower beds
|
||||
rectFill(ground,w,h,3,3,4,3,T.flowersY);
|
||||
rectFill(ground,w,h,23,3,4,3,T.flowersP);
|
||||
// twin bird tree
|
||||
set(ground,w,15,3,T.birdTree);
|
||||
// signs/decor
|
||||
set(ground,w,2,14,T.lamp); set(ground,w,27,14,T.lamp);
|
||||
// ribbons area for festival (decor placed day6)
|
||||
const obs = fillGrid(w,h,63);
|
||||
// border trees
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.tree); set(obs,w,x,1,T.tree);}
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,h-1,T.tree);}
|
||||
for (let y=0;y<h;y++){ set(obs,w,0,y,T.tree); set(obs,w,29,y,T.tree);}
|
||||
// carve door gaps in the border + place a door tile on the ground at each.
|
||||
// Gaps are 3 tiles tall/wide so the player can't miss them when walking to an edge.
|
||||
// bakery: left edge, rows 7-9
|
||||
for (let y=7;y<=9;y++) set(obs,w,0,y,63); set(ground,w,0,8,T.door);
|
||||
// lake: left edge, rows 11-13
|
||||
for (let y=11;y<=13;y++) set(obs,w,0,y,63); set(ground,w,0,12,T.door);
|
||||
// fog_boundary: left edge, rows 2-4
|
||||
for (let y=2;y<=4;y++) set(obs,w,0,y,63); set(ground,w,0,3,T.door);
|
||||
// shop: right edge, rows 7-9
|
||||
for (let y=7;y<=9;y++) set(obs,w,29,y,63); set(ground,w,29,8,T.door);
|
||||
// field: right edge, rows 11-13
|
||||
for (let y=11;y<=13;y++) set(obs,w,29,y,63); set(ground,w,29,12,T.door);
|
||||
// well_area: right edge, rows 5-6
|
||||
for (let y=5;y<=6;y++) set(obs,w,29,y,63); set(ground,w,29,5,T.door);
|
||||
// clinic: top edge, cols 13-15
|
||||
for (let x=13;x<=15;x++){ set(obs,w,x,0,63); set(obs,w,x,1,63); } set(ground,w,14,0,T.door);
|
||||
// house: bottom edge, cols 13-15
|
||||
for (let x=13;x<=15;x++) set(obs,w,x,19,63); set(ground,w,14,19,T.door);
|
||||
// announcement board (collides)
|
||||
set(obs,w,7,7,T.sign); set(obs,w,8,7,T.sign);
|
||||
// fountain-ish bush
|
||||
set(obs,w,20,12,T.bush);
|
||||
// objects: doors to all scenes — placed at the border gaps so they're easy to find
|
||||
const objects = [
|
||||
obj('door_house','door',14,19,{target:'house', spawn:'door_plaza'}),
|
||||
obj('door_bakery','door',0,8,{target:'bakery', spawn:'door_plaza'}),
|
||||
obj('door_shop','door',29,8,{target:'shop', spawn:'door_plaza'}),
|
||||
obj('door_clinic','door',14,0,{target:'clinic', spawn:'door_plaza'}),
|
||||
obj('door_lake','door',0,12,{target:'lake', spawn:'door_plaza'}),
|
||||
obj('door_field','door',29,12,{target:'field', spawn:'door_plaza'}),
|
||||
obj('door_well','door',29,5,{target:'well_area', spawn:'door_plaza'}),
|
||||
obj('door_fog','door',0,3,{target:'fog_boundary', spawn:'door_plaza'}),
|
||||
// NPC spawn points (tile coords of standing position)
|
||||
obj('npc_afu','spawn',8,8), // dog mayor at board
|
||||
obj('npc_bird','spawn',15,2), // twin birds on tree
|
||||
// board interaction
|
||||
obj('board','interact',7,7),
|
||||
// flowerbed water points (T2)
|
||||
obj('waterflower1','interact',4,4),
|
||||
obj('waterflower2','interact',5,4),
|
||||
obj('waterflower3','interact',4,5),
|
||||
// greet points
|
||||
obj('greet_point','interact',15,10),
|
||||
];
|
||||
maps.plaza = makeMap('plaza', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Bakery (15x10)
|
||||
{
|
||||
const w=15,h=10;
|
||||
const ground = fillGrid(w,h,T.floor);
|
||||
rectFill(ground,w,h,0,0,15,1,T.wallTop);
|
||||
const obs = fillGrid(w,h,63);
|
||||
borderFill(obs,w,h,0,0,15,10,T.wall);
|
||||
// counter
|
||||
rectFill(obs,w,h,2,5,4,1,T.counter);
|
||||
// oven
|
||||
rectFill(obs,w,h,11,2,2,2,T.oven);
|
||||
// shelves
|
||||
set(obs,w,11,6,T.shelfFull);
|
||||
// door
|
||||
set(ground,w,7,9,T.door);
|
||||
const objects = [
|
||||
obj('door_plaza','door',7,9,{target:'plaza',spawn:'door_bakery'}),
|
||||
obj('npc_yuanyuan','spawn',3,7),
|
||||
obj('recipe_paper','clue',11,5,{clue:'C2'}), // on shelf
|
||||
obj('oven','interact',11,2),
|
||||
obj('knead_point','interact',3,6), // T11 knead dough
|
||||
];
|
||||
maps.bakery = makeMap('bakery', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Shop (15x10)
|
||||
{
|
||||
const w=15,h=10;
|
||||
const ground = fillGrid(w,h,T.floor);
|
||||
rectFill(ground,w,h,0,0,15,1,T.wallTop);
|
||||
const obs = fillGrid(w,h,63);
|
||||
borderFill(obs,w,h,0,0,15,10,T.wall);
|
||||
// shelves: same product repeated 9 squares (C5)
|
||||
for (let y=2;y<5;y++) for (let x=2;x<5;x++) set(obs,w,x,y,T.shelfFull);
|
||||
for (let y=2;y<5;y++) for (let x=10;x<13;x++) set(obs,w,x,y,T.shelfFull);
|
||||
// counter
|
||||
rectFill(obs,w,h,6,6,3,1,T.counter);
|
||||
set(ground,w,7,9,T.door);
|
||||
const objects = [
|
||||
obj('door_plaza','door',7,9,{target:'plaza',spawn:'door_shop'}),
|
||||
obj('npc_mimi','spawn',7,7),
|
||||
obj('shelf_clue','clue',2,2,{clue:'C5'}),
|
||||
obj('count_shelf','interact',2,2), // T5
|
||||
];
|
||||
maps.shop = makeMap('shop', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 5. Clinic (12x8)
|
||||
{
|
||||
const w=12,h=8;
|
||||
const ground = fillGrid(w,h,T.floor);
|
||||
rectFill(ground,w,h,0,0,12,1,T.wallTop);
|
||||
const obs = fillGrid(w,h,63);
|
||||
borderFill(obs,w,h,0,0,12,8,T.wall);
|
||||
// doctor table with chart (C8)
|
||||
rectFill(obs,w,h,8,4,3,1,T.table);
|
||||
set(obs,w,9,3,T.chart);
|
||||
// bed
|
||||
set(obs,w,1,4,T.bed);
|
||||
set(ground,w,5,7,T.door);
|
||||
const objects = [
|
||||
obj('door_plaza','door',5,7,{target:'plaza',spawn:'door_clinic'}),
|
||||
obj('npc_dr_fu','spawn',5,5),
|
||||
obj('chart_clue','clue',9,3,{clue:'C8'}),
|
||||
];
|
||||
maps.clinic = makeMap('clinic', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 6. Lake (24x16)
|
||||
{
|
||||
const w=24,h=16;
|
||||
const ground = fillGrid(w,h,T.grass);
|
||||
// lake water body
|
||||
rectFill(ground,w,h,2,2,10,8,T.water);
|
||||
// path along bottom
|
||||
rectFill(ground,w,h,0,14,24,2,T.path);
|
||||
rectFill(ground,w,h,22,0,2,16,T.path); // right exit
|
||||
// bench (grandpa)
|
||||
set(ground,w,12,10,T.bench);
|
||||
// picnic spot
|
||||
set(ground,w,16,11,T.picnic);
|
||||
set(ground,w,17,11,T.picnic);
|
||||
const obs = fillGrid(w,h,63);
|
||||
// trees border
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.tree);}
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,h-1,T.tree);}
|
||||
for (let y=0;y<h;y++){ set(obs,w,0,y,T.tree);}
|
||||
const objects = [
|
||||
obj('door_plaza','door',23,8,{target:'plaza',spawn:'door_lake'}),
|
||||
obj('npc_grandpa','spawn',13,10),
|
||||
obj('picnic_point','interact',16,11), // T7
|
||||
obj('lake_reflection','interact',7,7), // reflection show
|
||||
];
|
||||
maps.lake = makeMap('lake', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. Flower field (20x14)
|
||||
{
|
||||
const w=20,h=14;
|
||||
const ground = fillGrid(w,h,T.grass);
|
||||
// berry bushes (T6)
|
||||
set(ground,w,4,4,T.berry); set(ground,w,8,3,T.berry); set(ground,w,12,5,T.berry);
|
||||
set(ground,w,6,8,T.berry); set(ground,w,14,9,T.berry);
|
||||
// flowers
|
||||
for (let i=0;i<w;i+=2) set(ground,w,i,11,T.flowersY);
|
||||
rectFill(ground,w,h,0,0,2,14,T.path);
|
||||
rectFill(ground,w,h,18,0,2,14,T.path);
|
||||
const obs = fillGrid(w,h,63);
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.tree); set(obs,w,x,h-1,T.tree);}
|
||||
for (let y=0;y<h;y++){ set(obs,w,0,y,T.tree); set(obs,w,w-1,y,T.tree);}
|
||||
// remove border where exits are
|
||||
set(obs,w,0,7,63); set(obs,w,1,7,63);
|
||||
set(obs,w,18,7,63); set(obs,w,19,7,63);
|
||||
const objects = [
|
||||
obj('door_plaza','door',0,7,{target:'plaza',spawn:'door_field'}),
|
||||
obj('berry1','interact',4,4),
|
||||
obj('berry2','interact',8,3),
|
||||
obj('berry3','interact',12,5),
|
||||
obj('berry4','interact',6,8),
|
||||
obj('berry5','interact',14,9),
|
||||
obj('water_point','interact',2,11), // T2 watering here
|
||||
];
|
||||
maps.field = makeMap('field', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 8. Well area (14x10)
|
||||
{
|
||||
const w=14,h=10;
|
||||
const ground = fillGrid(w,h,T.grass);
|
||||
rectFill(ground,w,h,0,8,14,2,T.path);
|
||||
// well center
|
||||
set(ground,w,6,4,T.well);
|
||||
set(ground,w,7,4,T.well);
|
||||
const obs = fillGrid(w,h,63);
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.tree);}
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,h-1,T.tree);}
|
||||
for (let y=0;y<h;y++){ set(obs,w,0,y,T.tree); set(obs,w,w-1,y,T.tree);}
|
||||
set(obs,w,0,8,63);
|
||||
const objects = [
|
||||
obj('door_plaza','door',0,8,{target:'plaza',spawn:'door_well'}),
|
||||
obj('well','interact',6,4,{clue:'C7'}),
|
||||
];
|
||||
maps.well_area = makeMap('well_area', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 9. Fog boundary (20x8)
|
||||
{
|
||||
const w=20,h=8;
|
||||
const ground = fillGrid(w,h,T.grass);
|
||||
// fog wall
|
||||
rectFill(ground,w,h,10,0,4,8,T.fogThick);
|
||||
rectFill(ground,w,h,14,0,2,8,T.fog);
|
||||
rectFill(ground,w,h,0,7,20,1,T.path);
|
||||
const obs = fillGrid(w,h,63);
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.tree);}
|
||||
for (let y=0;y<h;y++){ set(obs,w,0,y,T.tree); set(obs,w,w-1,y,T.tree);}
|
||||
set(obs,w,0,7,63);
|
||||
const objects = [
|
||||
obj('door_plaza','door',0,6,{target:'plaza',spawn:'door_fog'}),
|
||||
obj('fog_wall','interact',12,4,{clue:'fog'}),
|
||||
obj('fog_exit','interact',13,4,{clue:'E3_exit'}),
|
||||
];
|
||||
maps.fog_boundary = makeMap('fog_boundary', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
// 10. White layer (corridor 20x6 + ward 10x8) combined as one map 20x14
|
||||
{
|
||||
const w=20,h=14;
|
||||
const ground = fillGrid(w,h,T.whiteFloor);
|
||||
// corridor top, ward bottom
|
||||
rectFill(ground,w,h,0,0,20,6,T.whiteFloor);
|
||||
rectFill(ground,w,h,5,7,10,7,T.whiteFloor);
|
||||
rectFill(ground,w,h,0,7,5,7,T.whiteWall);
|
||||
rectFill(ground,w,h,15,7,5,7,T.whiteWall);
|
||||
const obs = fillGrid(w,h,63);
|
||||
// corridor walls
|
||||
for (let x=0;x<w;x++){ set(obs,w,x,0,T.whiteWall); set(obs,w,x,5,T.whiteWall);}
|
||||
// ward walls
|
||||
borderFill(obs,w,h,5,7,10,7,T.whiteWall);
|
||||
set(obs,w,9,13,63); // door gap
|
||||
// bed + monitor in ward
|
||||
set(obs,w,6,8,T.whiteBed);
|
||||
set(obs,w,13,8,T.whiteMonitor);
|
||||
set(obs,w,9,10,T.whiteTable);
|
||||
const objects = [
|
||||
obj('exit','door',9,12,{target:'ending'}),
|
||||
obj('patient_chart','interact',9,10,{clue:'E3_chart'}),
|
||||
];
|
||||
maps.white_layer = makeMap('white_layer', w, h, [
|
||||
{name:'ground',type:'tilelayer',data:ground},
|
||||
{name:'obstacles',type:'tilelayer',data:obs},
|
||||
{name:'objects',type:'objectgroup',objects},
|
||||
]);
|
||||
}
|
||||
|
||||
for (const [name, map] of Object.entries(maps)) {
|
||||
fs.writeFileSync(path.join(outDir, name + '.json'), JSON.stringify(map, null, 0));
|
||||
console.log('Wrote ' + name + '.json');
|
||||
}
|
||||
console.log('Done: ' + Object.keys(maps).length + ' maps');
|
||||
@@ -0,0 +1,108 @@
|
||||
-- gen_npc.lua: NPC field sprites (16x24). One column per variant.
|
||||
-- Layout: 1 row, many columns of 16x24.
|
||||
-- Order:
|
||||
-- 0 afu-smile, 1 afu-flat, 2 afu-off
|
||||
-- 3 yuanyuan-smile,4 flat,5 off
|
||||
-- 6 dabao-smile,7 flat,8 off
|
||||
-- 9 mimi-smile,10 flat,11 off
|
||||
-- 12 dr_fu-smile,13 flat,14 off (dr_fu flat/off identical to smile per PRD "does not corrupt")
|
||||
-- 15 birdA (mouth closed), 16 birdB (mouth open) -- twin birds, 2-frame
|
||||
-- 17 grandpa-smile,18 flat,19 off
|
||||
-- 20..24 extras (squirrel/hedgehog loop frame1, frame2)
|
||||
-- Produces assets/sprites/npc.png
|
||||
local FW = 16
|
||||
local FH = 24
|
||||
local N = 25
|
||||
local W = FW*N
|
||||
local H = FH
|
||||
app.command.NewFile{ width=W, height=H, colorMode=ColorMode.RGB }
|
||||
local spr = app.activeSprite
|
||||
local img = app.activeImage
|
||||
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
|
||||
local function R(ox,oy,w,h,c) for y=oy,oy+h-1 do for x=ox,ox+w-1 do img:drawPixel(x,y,c) end end end
|
||||
local function px(x,y,c) img:drawPixel(x,y,c) end
|
||||
|
||||
local eye = app.pixelColor.rgba(56,40,40,255)
|
||||
local mouth= app.pixelColor.rgba(200,110,110,255)
|
||||
local cheek= app.pixelColor.rgba(255,170,170,255)
|
||||
|
||||
-- draw a standing NPC at ox,oy with body color, species ears, expression
|
||||
local function npc(ox, oy, body, ear, expr)
|
||||
-- head
|
||||
R(ox+5, oy+1, 6, 6, body)
|
||||
-- ears (simple)
|
||||
if ear == "rabbit" then R(ox+6,oy-2,1,4,body); R(ox+9,oy-2,1,4,body)
|
||||
elseif ear == "dog" then R(ox+4,oy+1,1,3,body); R(ox+11,oy+1,1,3,body); R(ox+3,oy+3,1,4,body); R(ox+12,oy+3,1,4,body)
|
||||
elseif ear == "bear" then R(ox+4,oy+0,2,2,body); R(ox+10,oy+0,2,2,body)
|
||||
elseif ear == "cat" then px(ox+5,oy+1,body);px(ox+4,oy+0,body); px(ox+10,oy+1,body);px(ox+11,oy+0,body)
|
||||
elseif ear == "deer" then px(ox+6,oy-1,body);px(ox+6,oy+0,body); px(ox+9,oy-1,body);px(ox+9,oy+0,body)
|
||||
elseif ear == "turtle" then R(ox+5,oy+0,6,2,body) -- shell cap on head
|
||||
end
|
||||
-- eyes
|
||||
if expr == "smile" then
|
||||
px(ox+6, oy+3, eye); px(ox+9, oy+3, eye)
|
||||
px(ox+7, oy+5, mouth); px(ox+8, oy+5, mouth)
|
||||
px(ox+5, oy+4, cheek); px(ox+10, oy+4, cheek)
|
||||
elseif expr == "flat" then
|
||||
px(ox+6, oy+3, eye); px(ox+9, oy+3, eye)
|
||||
px(ox+7, oy+5, mouth)
|
||||
elseif expr == "off" then
|
||||
px(ox+6, oy+3, eye); px(ox+9, oy+4, eye) -- one eye 1px lower
|
||||
px(ox+7, oy+5, mouth); px(ox+8, oy+5, mouth)
|
||||
end
|
||||
-- torso
|
||||
R(ox+4, oy+8, 8, 8, body)
|
||||
-- arms
|
||||
R(ox+3, oy+9, 1, 5, body); R(ox+12, oy+9, 1, 5, body)
|
||||
-- legs
|
||||
R(ox+5, oy+16, 2, 6, body); R(ox+9, oy+16, 2, 6, body)
|
||||
px(ox+5, oy+22, eye); px(ox+6, oy+22, eye); px(ox+9, oy+22, eye); px(ox+10, oy+22, eye)
|
||||
end
|
||||
|
||||
local dogC = app.pixelColor.rgba(200,150,90,255)
|
||||
local rabC = app.pixelColor.rgba(245,235,230,255)
|
||||
local bearC = app.pixelColor.rgba(150,100,60,255)
|
||||
local catC = app.pixelColor.rgba(230,220,210,255)
|
||||
local deerC = app.pixelColor.rgba(210,160,110,255)
|
||||
local turC = app.pixelColor.rgba(120,160,90,255)
|
||||
local sqC = app.pixelColor.rgba(170,110,70,255)
|
||||
local hedC = app.pixelColor.rgba(150,110,70,255)
|
||||
|
||||
-- afu dog
|
||||
npc(0*FW, 0, dogC, "dog", "smile")
|
||||
npc(1*FW, 0, dogC, "dog", "flat")
|
||||
npc(2*FW, 0, dogC, "dog", "off")
|
||||
-- yuanyuan rabbit
|
||||
npc(3*FW, 0, rabC, "rabbit", "smile")
|
||||
npc(4*FW, 0, rabC, "rabbit", "flat")
|
||||
npc(5*FW, 0, rabC, "rabbit", "off")
|
||||
-- dabao bear (with postman bag: add a small brown satchel)
|
||||
npc(6*FW, 0, bearC, "bear", "smile"); R(6*FW+10, 10, 3, 4, app.pixelColor.rgba(120,80,40,255))
|
||||
npc(7*FW, 0, bearC, "bear", "flat"); R(7*FW+10, 10, 3, 4, app.pixelColor.rgba(120,80,40,255))
|
||||
npc(8*FW, 0, bearC, "bear", "off"); R(8*FW+10, 10, 3, 4, app.pixelColor.rgba(120,80,40,255))
|
||||
-- mimi cat
|
||||
npc(9*FW, 0, catC, "cat", "smile")
|
||||
npc(10*FW, 0, catC, "cat", "flat")
|
||||
npc(11*FW, 0, catC, "cat", "off")
|
||||
-- dr_fu deer (smile only effectively)
|
||||
npc(12*FW, 0, deerC, "deer", "smile")
|
||||
npc(13*FW, 0, deerC, "deer", "smile")
|
||||
npc(14*FW, 0, deerC, "deer", "smile")
|
||||
-- twin birds (2 frames): bird A mouth closed, bird B mouth open
|
||||
R(15*FW+6, 6, 4, 4, app.pixelColor.rgba(120,200,230,255)); px(15*FW+7,7,eye); px(15*FW+10,7,eye) -- closed
|
||||
R(16*FW+6, 6, 4, 4, app.pixelColor.rgba(120,200,230,255)); px(16*FW+7,7,eye); px(16*FW+10,7,eye); px(16*FW+8,9,mouth); px(16*FW+9,9,mouth) -- open beak
|
||||
-- grandpa turtle
|
||||
npc(17*FW, 0, turC, "turtle", "smile")
|
||||
npc(18*FW, 0, turC, "turtle", "flat")
|
||||
npc(19*FW, 0, turC, "turtle", "off")
|
||||
-- extras (5): squirrels/hedgehogs with 2 walk frames each would be ideal; provide standing + step
|
||||
npc(20*FW, 0, sqC, "dog", "smile")
|
||||
npc(21*FW, 0, hedC, "dog", "smile")
|
||||
npc(22*FW, 0, sqC, "dog", "smile")
|
||||
npc(23*FW, 0, hedC, "dog", "smile")
|
||||
npc(24*FW, 0, sqC, "dog", "smile")
|
||||
|
||||
local out = "assets/sprites/npc.png"
|
||||
spr:saveCopyAs(out)
|
||||
print("Saved " .. out)
|
||||
app.exit()
|
||||
@@ -0,0 +1,124 @@
|
||||
-- gen_player.lua: generate player sprite sheet (16x24 frames).
|
||||
-- Layout: 4 cols x 4 rows. Row order: walk-down, walk-up, walk-left, walk-right.
|
||||
-- Each row 4 frames: frame 0 = idle, 1-3 = walk frames.
|
||||
-- Produces assets/sprites/player.png
|
||||
local FW = 16
|
||||
local FH = 24
|
||||
local COLS = 4
|
||||
local ROWS = 4
|
||||
local W = COLS*FW
|
||||
local H = ROWS*FH
|
||||
app.command.NewFile{ width=W, height=H, colorMode=ColorMode.RGB }
|
||||
local spr = app.activeSprite
|
||||
local img = app.activeImage
|
||||
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
|
||||
|
||||
local function px(x,y,c) img:drawPixel(x,y,c) end
|
||||
local function rect(ox,oy,w,h,c) for y=oy,oy+h-1 do for x=ox,ox+w-1 do px(x,y,c) end end end
|
||||
|
||||
-- gender-neutral candy palette
|
||||
local skin = app.pixelColor.rgba(255,214,176,255)
|
||||
local skinS = app.pixelColor.rgba(224,176,140,255)
|
||||
local hair = app.pixelColor.rgba(150,110,80,255) -- chestnut
|
||||
local hairD = app.pixelColor.rgba(110,80,56,255)
|
||||
local shirt = app.pixelColor.rgba(120,200,230,255) -- sky blue
|
||||
local shirtD = app.pixelColor.rgba(86,160,200,255)
|
||||
local pants = app.pixelColor.rgba(110,90,150,255) -- muted purple
|
||||
local pantsD = app.pixelColor.rgba(80,64,116,255)
|
||||
local shoe = app.pixelColor.rgba(90,70,60,255)
|
||||
local eye = app.pixelColor.rgba(60,40,40,255)
|
||||
local cheek = app.pixelColor.rgba(255,170,170,255)
|
||||
local mouth = app.pixelColor.rgba(200,110,110,255)
|
||||
|
||||
-- draw a character frame at ox,oy facing dir, walk phase p (0=idle, 1-3)
|
||||
local function frame(ox, oy, dir, p)
|
||||
-- common body
|
||||
-- head (cols 4..11, rows 1..9)
|
||||
rect(ox+5, oy+1, 6, 7, skin)
|
||||
-- hair cap depending on direction
|
||||
if dir == "up" then
|
||||
-- back of head: hair covers
|
||||
rect(ox+5, oy+1, 6, 6, hair)
|
||||
rect(ox+5, oy+1, 6, 2, hairD)
|
||||
else
|
||||
rect(ox+4, oy+1, 8, 3, hair)
|
||||
rect(ox+5, oy+0, 6, 2, hair)
|
||||
rect(ox+4, oy+1, 8, 1, hairD)
|
||||
end
|
||||
-- face details for down
|
||||
if dir == "down" then
|
||||
px(ox+6, oy+5, eye); px(ox+9, oy+5, eye)
|
||||
px(ox+7, oy+7, mouth); px(ox+8, oy+7, mouth)
|
||||
px(ox+6, oy+6, cheek); px(ox+9, oy+6, cheek)
|
||||
elseif dir == "side" then
|
||||
-- left or right handled by caller mirroring via draw order
|
||||
px(ox+7, oy+5, eye)
|
||||
px(ox+8, oy+7, mouth)
|
||||
px(ox+6, oy+6, cheek)
|
||||
end
|
||||
-- torso (rows 8..15)
|
||||
rect(ox+5, oy+9, 6, 6, shirt)
|
||||
rect(ox+5, oy+9, 6, 1, shirtD)
|
||||
rect(ox+5, oy+14, 6, 1, shirtD)
|
||||
-- arms
|
||||
rect(ox+4, oy+10, 1, 4, shirt)
|
||||
rect(ox+10, oy+10, 1, 4, shirt)
|
||||
px(ox+4, oy+14, skin); px(ox+10, oy+14, skin)
|
||||
-- legs / walk animation
|
||||
local L, R = oy+15, oy+15
|
||||
if p == 0 then
|
||||
-- idle: legs together
|
||||
rect(ox+5, oy+15, 2, 6, pants)
|
||||
rect(ox+9, oy+15, 2, 6, pants)
|
||||
px(ox+5, oy+21, shoe); px(ox+6, oy+21, shoe)
|
||||
px(ox+9, oy+21, shoe); px(ox+10, oy+21, shoe)
|
||||
elseif p == 1 then
|
||||
-- step: left leg forward (down/up), or side-step
|
||||
rect(ox+5, oy+15, 2, 5, pants)
|
||||
rect(ox+9, oy+15, 2, 5, pants)
|
||||
px(ox+5, oy+20, shoe); px(ox+6, oy+20, shoe)
|
||||
px(ox+9, oy+21, shoe); px(ox+10, oy+21, shoe)
|
||||
elseif p == 2 then
|
||||
-- mid
|
||||
rect(ox+5, oy+15, 2, 6, pants)
|
||||
rect(ox+9, oy+15, 2, 6, pants)
|
||||
px(ox+5, oy+21, shoe); px(ox+6, oy+21, shoe)
|
||||
px(ox+9, oy+21, shoe); px(ox+10, oy+21, shoe)
|
||||
elseif p == 3 then
|
||||
-- step: right leg forward
|
||||
rect(ox+5, oy+15, 2, 5, pants)
|
||||
rect(ox+9, oy+15, 2, 5, pants)
|
||||
px(ox+5, oy+21, shoe); px(ox+6, oy+21, shoe)
|
||||
px(ox+9, oy+20, shoe); px(ox+10, oy+20, shoe)
|
||||
end
|
||||
end
|
||||
|
||||
-- mirror helper for left/right by copying pixels
|
||||
local function mirrorRow(srcRow, dstRow)
|
||||
for f=0,3 do
|
||||
local sx = f*FW
|
||||
local dx = f*FW
|
||||
local sy = srcRow*FH
|
||||
local dy = dstRow*FH
|
||||
for y=0,FH-1 do
|
||||
for x=0,FW-1 do
|
||||
local c = img:getPixel(sx + (FW-1-x), sy + y)
|
||||
img:drawPixel(dx + x, dy + y, c)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Row 0: walk-down
|
||||
for f=0,3 do frame(f*FW, 0*FH, "down", f) end
|
||||
-- Row 1: walk-up
|
||||
for f=0,3 do frame(f*FW, 1*FH, "up", f) end
|
||||
-- Row 2: walk-right (side)
|
||||
for f=0,3 do frame(f*FW, 2*FH, "side", f) end
|
||||
-- Row 3: walk-left = mirror of right
|
||||
mirrorRow(2, 3)
|
||||
|
||||
local out = "assets/sprites/player.png"
|
||||
spr:saveCopyAs(out)
|
||||
print("Saved " .. out)
|
||||
app.exit()
|
||||
@@ -0,0 +1,171 @@
|
||||
-- gen_portraits.lua: generate portrait sprite sheet for dialogue UI.
|
||||
-- Each portrait cell: 24x24. Single row of many cells.
|
||||
-- Cells order:
|
||||
-- 0 player-smile
|
||||
-- 1 player-realface (tired)
|
||||
-- 2 afu (dog mayor) smile
|
||||
-- 3 afu flat
|
||||
-- 4 afu off
|
||||
-- 5 yuanyuan (rabbit baker) smile
|
||||
-- 6 yuanyuan flat
|
||||
-- 7 yuanyuan off
|
||||
-- 8 dabao (bear postman) smile
|
||||
-- 9 dabao flat
|
||||
-- 10 dabao off
|
||||
-- 11 mimi (cat shopkeeper) smile
|
||||
-- 12 mimi flat
|
||||
-- 13 mimi off
|
||||
-- 14 dr_fu (deer doctor) smile (only smile; flat/off unused but provided)
|
||||
-- 15 dr_fu flat
|
||||
-- 16 dr_fu off
|
||||
-- 17 twinbirds smile
|
||||
-- 18 grandpa_turtle (turtle elder) smile
|
||||
-- 19 grandpa_turtle flat
|
||||
-- 20 grandpa_turtle off
|
||||
-- 21..25 extras (squirrel/hedgehog/etc) smile
|
||||
-- Produces assets/sprites/portraits.png
|
||||
local FW = 24
|
||||
local FH = 24
|
||||
local N = 26
|
||||
local W = FW*N
|
||||
local H = FH
|
||||
app.command.NewFile{ width=W, height=H, colorMode=ColorMode.RGB }
|
||||
local spr = app.activeSprite
|
||||
local img = app.activeImage
|
||||
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
|
||||
local function px(x,y,c) img:drawPixel(x,y,c) end
|
||||
local function rect(ox,oy,w,h,c) for y=oy,oy+h-1 do for x=ox,ox+w-1 do px(ox+(x-ox),y,c) end end end
|
||||
-- careful rect above buggy; redefine properly
|
||||
local function R(ox,oy,w,h,c) for y=oy,oy+h-1 do for x=ox,ox+w-1 do img:drawPixel(x,y,c) end end end
|
||||
|
||||
-- shared colors
|
||||
local skin = app.pixelColor.rgba(255,214,176,255)
|
||||
local skinS= app.pixelColor.rgba(224,176,140,255)
|
||||
local eye = app.pixelColor.rgba(56,40,40,255)
|
||||
local mouth= app.pixelColor.rgba(200,110,110,255)
|
||||
local cheek= app.pixelColor.rgba(255,170,170,255)
|
||||
local white= app.pixelColor.rgba(245,245,245,255)
|
||||
local black= app.pixelColor.rgba(40,40,40,255)
|
||||
|
||||
-- generic face drawer: ox,oy top-left of 24x24 cell
|
||||
-- species-specific color + ears, expression (smile/flat/off)
|
||||
-- species: human, dog, rabbit, bear, cat, deer, bird, turtle, squirrel, hedgehog
|
||||
local function face(ox, oy, col, species, expr)
|
||||
-- head fill
|
||||
R(ox+6, oy+5, 12, 12, col)
|
||||
-- ears per species
|
||||
if species == "rabbit" then
|
||||
R(ox+7, oy+1, 2, 5, col); R(ox+15, oy+1, 2, 5, col)
|
||||
R(ox+7, oy+2, 2, 2, skinS); R(ox+15, oy+2, 2, 2, skinS)
|
||||
elseif species == "dog" then
|
||||
R(ox+5, oy+4, 2, 4, col); R(ox+17, oy+4, 2, 4, col)
|
||||
-- floppy
|
||||
R(ox+4, oy+6, 2, 6, col)
|
||||
R(ox+18, oy+6, 2, 6, col)
|
||||
elseif species == "bear" then
|
||||
R(ox+5, oy+3, 3, 3, col); R(ox+16, oy+3, 3, 3, col)
|
||||
px(ox+6, oy+4, skinS); px(ox+17, oy+4, skinS)
|
||||
elseif species == "cat" then
|
||||
-- pointy ears
|
||||
px(ox+6, oy+4, col); px(ox+7, oy+2, col); px(ox+8, oy+1, col); px(ox+9, oy+3, col)
|
||||
px(ox+15, oy+3, col); px(ox+16, oy+1, col); px(ox+17, oy+2, col); px(ox+18, oy+4, col)
|
||||
elseif species == "deer" then
|
||||
px(ox+8, oy+1, col); px(ox+8, oy+2, col); px(ox+9, oy+0, col)
|
||||
px(ox+9, oy+2, skinS); px(ox+15, oy+0, col); px(ox+15, oy+2, col); px(ox+14, oy+1, col)
|
||||
px(ox+14, oy+2, skinS)
|
||||
elseif species == "turtle" then
|
||||
-- no ears, rounded; draw a cap
|
||||
R(ox+5, oy+4, 14, 4, col)
|
||||
elseif species == "bird" then
|
||||
R(ox+6, oy+4, 12, 10, col)
|
||||
px(ox+7, oy+3, col); px(ox+16, oy+3, col)
|
||||
elseif species == "squirrel" then
|
||||
R(ox+5, oy+4, 2, 3, col); R(ox+17, oy+4, 2, 3, col)
|
||||
R(ox+2, oy+2, 3, 6, col) -- tail
|
||||
elseif species == "hedgehog" then
|
||||
for x=6,17,2 do px(ox+x, oy+3, skinS); px(ox+x, oy+4, skinS) end
|
||||
end
|
||||
-- eyes (expression dependent)
|
||||
if expr == "smile" then
|
||||
px(ox+9, oy+9, eye); px(ox+14, oy+9, eye)
|
||||
px(ox+9, oy+10, eye); px(ox+14, oy+10, eye)
|
||||
-- cheeks
|
||||
px(ox+8, oy+12, cheek); px(ox+15, oy+12, cheek)
|
||||
-- smile mouth
|
||||
px(ox+11, oy+13, mouth); px(ox+12, oy+13, mouth)
|
||||
px(ox+10, oy+12, mouth); px(ox+13, oy+12, mouth)
|
||||
elseif expr == "flat" then
|
||||
px(ox+9, oy+9, eye); px(ox+14, oy+9, eye)
|
||||
-- straight mouth
|
||||
px(ox+11, oy+13, mouth); px(ox+12, oy+13, mouth)
|
||||
elseif expr == "off" then
|
||||
-- one eye dropped 1px (the signature "off" detail)
|
||||
px(ox+9, oy+9, eye); px(ox+9, oy+10, eye)
|
||||
px(ox+14, oy+10, eye); -- right eye 1px lower
|
||||
-- mouth with extra 1px droop
|
||||
px(ox+11, oy+13, mouth); px(ox+12, oy+13, mouth); px(ox+13, oy+13, mouth)
|
||||
end
|
||||
end
|
||||
|
||||
-- 0 player smile (human)
|
||||
face(0*FW, 0, skin, "human", "smile")
|
||||
-- add hair
|
||||
R(0*FW+6, 3, 12, 4, app.pixelColor.rgba(150,110,80,255))
|
||||
-- 1 player realface (tired)
|
||||
face(1*FW, 0, skinS, "human", "flat")
|
||||
R(1*FW+6, 3, 12, 4, app.pixelColor.rgba(150,110,80,255))
|
||||
-- tired eyes: half-lids
|
||||
px(1*FW+9, 10, skinS); px(1*FW+14, 10, skinS)
|
||||
-- a small frown
|
||||
px(1*FW+11, 14, mouth); px(1*FW+12, 14, mouth)
|
||||
-- shadow under eyes
|
||||
px(1*FW+9, 11, skinS); px(1*FW+14, 11, skinS)
|
||||
|
||||
-- species color presets
|
||||
local dogC = app.pixelColor.rgba(200,150,90,255)
|
||||
local rabC = app.pixelColor.rgba(245,235,230,255)
|
||||
local bearC = app.pixelColor.rgba(150,100,60,255)
|
||||
local catC = app.pixelColor.rgba(230,220,210,255)
|
||||
local deerC = app.pixelColor.rgba(210,160,110,255)
|
||||
local birdC = app.pixelColor.rgba(120,200,230,255)
|
||||
local turC = app.pixelColor.rgba(120,160,90,255)
|
||||
local sqC = app.pixelColor.rgba(170,110,70,255)
|
||||
local hedC = app.pixelColor.rgba(150,110,70,255)
|
||||
|
||||
-- 2-4 afu dog: smile/flat/off
|
||||
face(2*FW, 0, dogC, "dog", "smile")
|
||||
face(3*FW, 0, dogC, "dog", "flat")
|
||||
face(4*FW, 0, dogC, "dog", "off")
|
||||
-- 5-7 yuanyuan rabbit
|
||||
face(5*FW, 0, rabC, "rabbit", "smile")
|
||||
face(6*FW, 0, rabC, "rabbit", "flat")
|
||||
face(7*FW, 0, rabC, "rabbit", "off")
|
||||
-- 8-10 dabao bear
|
||||
face(8*FW, 0, bearC, "bear", "smile")
|
||||
face(9*FW, 0, bearC, "bear", "flat")
|
||||
face(10*FW, 0, bearC, "bear", "off")
|
||||
-- 11-13 mimi cat
|
||||
face(11*FW, 0, catC, "cat", "smile")
|
||||
face(12*FW, 0, catC, "cat", "flat")
|
||||
face(13*FW, 0, catC, "cat", "off")
|
||||
-- 14-16 dr_fu deer (only smile used; flat/off provided for completeness)
|
||||
face(14*FW, 0, deerC, "deer", "smile")
|
||||
face(15*FW, 0, deerC, "deer", "flat")
|
||||
face(16*FW, 0, deerC, "deer", "off")
|
||||
-- 17 twinbirds smile (single)
|
||||
face(17*FW, 0, birdC, "bird", "smile")
|
||||
-- 18-20 grandpa turtle
|
||||
face(18*FW, 0, turC, "turtle", "smile")
|
||||
face(19*FW, 0, turC, "turtle", "flat")
|
||||
face(20*FW, 0, turC, "turtle", "off")
|
||||
-- 21-25 extras
|
||||
face(21*FW, 0, sqC, "squirrel", "smile")
|
||||
face(22*FW, 0, hedC, "hedgehog", "smile")
|
||||
face(23*FW, 0, sqC, "squirrel", "smile")
|
||||
face(24*FW, 0, hedC, "hedgehog", "smile")
|
||||
face(25*FW, 0, sqC, "squirrel", "smile")
|
||||
|
||||
local out = "assets/sprites/portraits.png"
|
||||
spr:saveCopyAs(out)
|
||||
print("Saved " .. out)
|
||||
app.exit()
|
||||
@@ -0,0 +1,347 @@
|
||||
-- gen_tileset.lua: generate a 16x16 tileset sprite sheet for Honey Village.
|
||||
-- Produces assets/tilesets/tileset.png : 8 cols x 8 rows = 64 tiles (128x128)
|
||||
-- Run: aseprite -b --script tools/gen_tileset.lua
|
||||
|
||||
local TILE = 16
|
||||
local COLS = 8
|
||||
local ROWS = 8
|
||||
|
||||
local W = COLS*TILE
|
||||
local H = ROWS*TILE
|
||||
|
||||
app.command.NewFile{ width=W, height=H, colorMode=ColorMode.RGB }
|
||||
local spr = app.activeSprite
|
||||
local img = app.activeImage
|
||||
|
||||
local C = {
|
||||
transparent = app.pixelColor.rgba(0,0,0,0),
|
||||
grassL = app.pixelColor.rgba(126,200,80,255),
|
||||
grassD = app.pixelColor.rgba(96,168,56,255),
|
||||
grassS = app.pixelColor.rgba(150,214,96,255),
|
||||
pathL = app.pixelColor.rgba(214,190,150,255),
|
||||
pathD = app.pixelColor.rgba(180,156,120,255),
|
||||
waterL = app.pixelColor.rgba(108,180,226,255),
|
||||
waterD = app.pixelColor.rgba(74,140,198,255),
|
||||
waterF = app.pixelColor.rgba(170,214,246,255),
|
||||
flowerY = app.pixelColor.rgba(255,214,80,255),
|
||||
flowerP = app.pixelColor.rgba(255,150,190,255),
|
||||
flowerG = app.pixelColor.rgba(120,200,120,255),
|
||||
stem = app.pixelColor.rgba(70,130,50,255),
|
||||
berryR = app.pixelColor.rgba(214,60,80,255),
|
||||
berryD = app.pixelColor.rgba(160,36,56,255),
|
||||
leaf = app.pixelColor.rgba(80,160,60,255),
|
||||
trunk = app.pixelColor.rgba(120,80,40,255),
|
||||
leafD = app.pixelColor.rgba(60,140,50,255),
|
||||
leafL = app.pixelColor.rgba(110,190,70,255),
|
||||
wall = app.pixelColor.rgba(236,216,184,255),
|
||||
wallD = app.pixelColor.rgba(200,176,140,255),
|
||||
roof = app.pixelColor.rgba(240,120,70,255),
|
||||
roofD = app.pixelColor.rgba(200,84,48,255),
|
||||
door = app.pixelColor.rgba(150,96,56,255),
|
||||
doorD = app.pixelColor.rgba(110,70,40,255),
|
||||
floor = app.pixelColor.rgba(230,210,180,255),
|
||||
floorD = app.pixelColor.rgba(204,182,150,255),
|
||||
bedF = app.pixelColor.rgba(120,170,230,255),
|
||||
bedD = app.pixelColor.rgba(86,130,190,255),
|
||||
pillow = app.pixelColor.rgba(240,230,210,255),
|
||||
counter = app.pixelColor.rgba(170,120,70,255),
|
||||
counterD= app.pixelColor.rgba(130,86,48,255),
|
||||
oven = app.pixelColor.rgba(90,90,100,255),
|
||||
ovenD = app.pixelColor.rgba(60,60,70,255),
|
||||
fire = app.pixelColor.rgba(255,150,60,255),
|
||||
shelf = app.pixelColor.rgba(150,100,60,255),
|
||||
shelfD = app.pixelColor.rgba(110,72,40,255),
|
||||
item = app.pixelColor.rgba(255,210,90,255),
|
||||
table = app.pixelColor.rgba(160,110,64,255),
|
||||
chair = app.pixelColor.rgba(120,84,48,255),
|
||||
well = app.pixelColor.rgba(120,110,110,255),
|
||||
wellD = app.pixelColor.rgba(84,78,78,255),
|
||||
wellR = app.pixelColor.rgba(150,100,60,255),
|
||||
wellW = app.pixelColor.rgba(60,130,180,255),
|
||||
fog = app.pixelColor.rgba(220,226,236,255),
|
||||
fogD = app.pixelColor.rgba(180,190,206,255),
|
||||
white = app.pixelColor.rgba(236,236,236,255),
|
||||
grayL = app.pixelColor.rgba(214,214,214,255),
|
||||
grayM = app.pixelColor.rgba(170,170,170,255),
|
||||
grayD = app.pixelColor.rgba(120,120,120,255),
|
||||
black = app.pixelColor.rgba(40,40,40,255),
|
||||
ribbon = app.pixelColor.rgba(255,100,140,255),
|
||||
ribbonD = app.pixelColor.rgba(200,70,110,255),
|
||||
picB = app.pixelColor.rgba(240,200,140,255),
|
||||
picR = app.pixelColor.rgba(200,120,80,255),
|
||||
bench = app.pixelColor.rgba(140,96,56,255),
|
||||
sign = app.pixelColor.rgba(200,170,110,255),
|
||||
signD = app.pixelColor.rgba(150,120,70,255),
|
||||
fence = app.pixelColor.rgba(160,120,70,255),
|
||||
fenceD = app.pixelColor.rgba(120,86,48,255),
|
||||
window = app.pixelColor.rgba(150,200,230,255),
|
||||
lamp = app.pixelColor.rgba(255,230,130,255),
|
||||
lampD = app.pixelColor.rgba(180,150,70,255),
|
||||
pot = app.pixelColor.rgba(180,90,70,255),
|
||||
potP = app.pixelColor.rgba(120,200,120,255),
|
||||
barrel = app.pixelColor.rgba(140,90,50,255),
|
||||
crate = app.pixelColor.rgba(170,120,70,255),
|
||||
ink = app.pixelColor.rgba(60,50,50,255),
|
||||
paper = app.pixelColor.rgba(245,240,225,255),
|
||||
}
|
||||
|
||||
for y=0,H-1 do for x=0,W-1 do img:drawPixel(x,y,C.transparent) end end
|
||||
|
||||
local function cellOrigin(idx)
|
||||
return (idx % COLS) * TILE, math.floor(idx / COLS) * TILE
|
||||
end
|
||||
local function rect(ox,oy,w,h,col) for y=oy,oy+h-1 do for x=ox,ox+w-1 do img:drawPixel(x,y,col) end end end
|
||||
local function px(x,y,col) img:drawPixel(x,y,col) end
|
||||
|
||||
-- Tile drawing functions take ox,oy origin
|
||||
local function grass(ox,oy)
|
||||
rect(ox,oy,16,16,C.grassL)
|
||||
for _,d in ipairs({{2,3},{5,2},{9,4},{12,6},{3,9},{7,11},{11,9},{14,12},{6,13},{1,7},{13,2},{4,14}}) do px(ox+d[1],oy+d[2],C.grassD) end
|
||||
for _,d in ipairs({{6,5},{10,8},{3,12},{12,3},{8,13}}) do px(ox+d[1],oy+d[2],C.grassS) end
|
||||
end
|
||||
local function pathPlain(ox,oy)
|
||||
rect(ox,oy,16,16,C.pathL)
|
||||
for _,d in ipairs({{2,4},{7,3},{11,6},{4,9},{9,11},{13,8},{6,13},{1,11}}) do px(ox+d[1],oy+d[2],C.pathD) end
|
||||
end
|
||||
local function water(ox,oy)
|
||||
rect(ox,oy,16,16,C.waterL)
|
||||
for x=0,15 do if (x%4)<2 then px(ox+x,oy+4,C.waterD) end if (x%4)>=2 then px(ox+x,oy+10,C.waterD) end end
|
||||
px(ox+3,oy+7,C.waterF); px(ox+10,oy+7,C.waterF)
|
||||
end
|
||||
local function flowers(ox,oy,accent)
|
||||
grass(ox,oy)
|
||||
for _,s in ipairs({{4,6},{11,9}}) do px(ox+s[1],oy+s[2]+2,C.stem); px(ox+s[1],oy+s[2]+3,C.stem) end
|
||||
for _,b in ipairs({{4,6},{11,9}}) do
|
||||
px(ox+b[1],oy+b[2],accent); px(ox+b[1]-1,oy+b[2],accent); px(ox+b[1]+1,oy+b[2],accent)
|
||||
px(ox+b[1],oy+b[2]-1,accent); px(ox+b[1],oy+b[2]+1,accent)
|
||||
end
|
||||
end
|
||||
local function berry(ox,oy)
|
||||
grass(ox,oy)
|
||||
rect(ox+3,oy+5,10,8,C.leaf)
|
||||
rect(ox+4,oy+4,8,1,C.leafL)
|
||||
rect(ox+3,oy+5,10,1,C.leafD)
|
||||
for _,b in ipairs({{5,8},{8,7},{10,9},{6,11},{9,11}}) do
|
||||
px(ox+b[1],oy+b[2],C.berryR); px(ox+b[1]+1,oy+b[2],C.berryR); px(ox+b[1],oy+b[2]+1,C.berryD)
|
||||
end
|
||||
end
|
||||
local function tree(ox,oy)
|
||||
grass(ox,oy)
|
||||
rect(ox+7,oy+10,2,5,C.trunk)
|
||||
rect(ox+4,oy+3,8,8,C.leafD)
|
||||
rect(ox+5,oy+2,6,1,C.leafL)
|
||||
rect(ox+3,oy+5,1,4,C.leaf); rect(ox+12,oy+5,1,4,C.leaf)
|
||||
rect(ox+6,oy+6,4,3,C.leafL)
|
||||
end
|
||||
local function birdTree(ox,oy)
|
||||
tree(ox,oy)
|
||||
px(ox+6,oy+5,C.flowerY); px(ox+9,oy+6,C.flowerY)
|
||||
end
|
||||
local function bush(ox,oy)
|
||||
grass(ox,oy)
|
||||
rect(ox+3,oy+6,10,6,C.leaf); rect(ox+4,oy+5,8,1,C.leafL)
|
||||
end
|
||||
local function wall(ox,oy)
|
||||
rect(ox,oy,16,16,C.wall)
|
||||
for _,d in ipairs({{1,1},{5,1},{9,1},{13,1},{3,5},{7,5},{11,5},{1,9},{5,9},{9,9},{13,9},{3,13},{7,13},{11,13}}) do px(ox+d[1],oy+d[2],C.wallD) end
|
||||
end
|
||||
local function wallTop(ox,oy)
|
||||
rect(ox,oy,16,16,C.wall); rect(ox,oy,16,2,C.wallD)
|
||||
for x=0,15,4 do px(ox+x,oy+4,C.wallD) end
|
||||
end
|
||||
local function roof(ox,oy)
|
||||
rect(ox,oy,16,16,C.roof)
|
||||
for y=0,15,2 do rect(ox,oy+y,16,1,C.roofD) end
|
||||
end
|
||||
local function roofEdge(ox,oy)
|
||||
rect(ox,oy,16,16,C.roof); rect(ox,oy+14,16,2,C.roofD)
|
||||
for y=0,12,2 do rect(ox,oy+y,16,1,C.roofD) end
|
||||
end
|
||||
local function door(ox,oy)
|
||||
wall(ox,oy)
|
||||
rect(ox+3,oy+2,10,14,C.door)
|
||||
rect(ox+3,oy+2,10,1,C.doorD); rect(ox+3,oy+2,1,14,C.doorD)
|
||||
px(ox+11,oy+9,C.signD)
|
||||
end
|
||||
local function window(ox,oy)
|
||||
wall(ox,oy)
|
||||
rect(ox+3,oy+3,10,8,C.window)
|
||||
rect(ox+3,oy+3,10,1,C.wallD); rect(ox+3,oy+10,10,1,C.wallD)
|
||||
rect(ox+3,oy+3,1,8,C.wallD); rect(ox+12,oy+3,1,8,C.wallD)
|
||||
px(ox+7,oy+3,C.wallD); px(ox+7,oy+11,C.wallD)
|
||||
end
|
||||
local function floor(ox,oy)
|
||||
rect(ox,oy,16,16,C.floor)
|
||||
for _,d in ipairs({{2,3},{9,5},{5,11},{12,12}}) do px(ox+d[1],oy+d[2],C.floorD) end
|
||||
rect(ox,oy,16,1,C.floorD); rect(ox,oy+8,16,1,C.floorD)
|
||||
end
|
||||
local function floorRug(ox,oy)
|
||||
floor(ox,oy)
|
||||
rect(ox+3,oy+4,10,8,C.flowerP); rect(ox+3,oy+4,10,1,C.ribbonD); rect(ox+3,oy+11,10,1,C.ribbonD)
|
||||
end
|
||||
local function bed(ox,oy)
|
||||
rect(ox+1,oy+3,14,11,C.bedD); rect(ox+2,oy+4,12,9,C.bedF); rect(ox+3,oy+5,4,3,C.pillow)
|
||||
end
|
||||
local function counter(ox,oy)
|
||||
rect(ox,oy+5,16,7,C.counter); rect(ox,oy+5,16,1,C.counterD); rect(ox,oy+11,16,1,C.counterD)
|
||||
end
|
||||
local function oven(ox,oy)
|
||||
rect(ox+2,oy+2,12,13,C.oven); rect(ox+2,oy+2,12,1,C.ovenD)
|
||||
rect(ox+4,oy+5,8,6,C.ovenD); rect(ox+5,oy+6,6,4,C.fire)
|
||||
end
|
||||
local function shelf(ox,oy)
|
||||
rect(ox+1,oy+4,14,1,C.shelf); rect(ox+1,oy+9,14,1,C.shelf)
|
||||
rect(ox+1,oy+4,1,10,C.shelf); rect(ox+14,oy+4,1,10,C.shelf)
|
||||
rect(ox+1,oy+13,14,1,C.shelfD)
|
||||
end
|
||||
local function shelfFull(ox,oy)
|
||||
shelf(ox,oy)
|
||||
for i=0,4 do rect(ox+2+i*3,oy+5,2,3,C.item) end
|
||||
for i=0,4 do rect(ox+2+i*3,oy+10,2,3,C.item) end
|
||||
end
|
||||
local function table(ox,oy)
|
||||
rect(ox+1,oy+5,14,4,C.table)
|
||||
px(ox+2,oy+9,C.table); px(ox+13,oy+9,C.table); px(ox+2,oy+12,C.table); px(ox+13,oy+12,C.table)
|
||||
end
|
||||
local function chair(ox,oy)
|
||||
rect(ox+4,oy+6,8,1,C.chair); rect(ox+4,oy+6,1,6,C.chair); rect(ox+4,oy+11,9,1,C.chair)
|
||||
px(ox+11,oy+11,C.chair); px(ox+11,oy+13,C.chair)
|
||||
end
|
||||
local function stool(ox,oy)
|
||||
rect(ox+5,oy+7,6,2,C.table); px(ox+5,oy+9,C.table); px(ox+10,oy+9,C.table); px(ox+5,oy+12,C.table); px(ox+10,oy+12,C.table)
|
||||
end
|
||||
local function well(ox,oy)
|
||||
rect(ox,oy+6,16,7,C.well); rect(ox,oy+6,16,1,C.wellD)
|
||||
rect(ox+5,oy+9,6,4,C.wellW)
|
||||
px(ox+2,oy+1,C.wellR); px(ox+13,oy+1,C.wellR); rect(ox+2,oy+1,12,1,C.wellR); rect(ox+5,oy+0,6,2,C.wellR)
|
||||
end
|
||||
local function fog(ox,oy)
|
||||
rect(ox,oy,16,16,C.fog)
|
||||
for _,d in ipairs({{2,3},{8,5},{12,9},{4,11},{10,12},{6,7}}) do px(ox+d[1],oy+d[2],C.fogD) end
|
||||
end
|
||||
local function fogThick(ox,oy)
|
||||
rect(ox,oy,16,16,C.fog)
|
||||
for y=0,15 do for x=0,15 do if (x+y)%2==0 then px(ox+x,oy+y,C.fogD) end end end
|
||||
rect(ox,oy,16,16,C.fog)
|
||||
end
|
||||
local function whiteWall(ox,oy)
|
||||
rect(ox,oy,16,16,C.white); rect(ox,oy+15,16,1,C.grayM); px(ox,oy,C.grayL); px(ox+15,oy,C.grayL)
|
||||
end
|
||||
local function whiteFloor(ox,oy)
|
||||
rect(ox,oy,16,16,C.grayL); rect(ox,oy+8,16,1,C.grayM); px(ox,oy,C.grayM)
|
||||
end
|
||||
local function whiteDoor(ox,oy)
|
||||
rect(ox,oy,16,16,C.white); rect(ox+3,oy+1,10,14,C.grayM); rect(ox+3,oy+1,10,1,C.grayD); rect(ox+3,oy+1,1,14,C.grayD)
|
||||
px(ox+11,oy+9,C.grayD)
|
||||
end
|
||||
local function whiteBed(ox,oy)
|
||||
rect(ox+1,oy+3,14,11,C.grayM); rect(ox+2,oy+4,12,9,C.grayL); rect(ox+3,oy+5,4,3,C.white)
|
||||
end
|
||||
local function whiteMonitor(ox,oy)
|
||||
rect(ox+1,oy+2,14,1,C.grayD); rect(ox+3,oy+1,10,3,C.grayM); px(ox+7,oy+4,C.grayD); px(ox+8,oy+4,C.grayD)
|
||||
rect(ox+4,oy+4,8,3,C.wellW)
|
||||
end
|
||||
local function whiteTable(ox,oy)
|
||||
rect(ox+1,oy+6,14,2,C.grayM); px(ox+2,oy+8,C.grayM); px(ox+13,oy+8,C.grayM)
|
||||
end
|
||||
local function ribbon(ox,oy)
|
||||
rect(ox+1,oy+6,14,3,C.ribbon); rect(ox+1,oy+6,14,1,C.ribbonD)
|
||||
rect(ox+7,oy+3,2,10,C.ribbonD)
|
||||
end
|
||||
local function ribbon2(ox,oy)
|
||||
rect(ox+1,oy+5,14,4,C.ribbon); rect(ox+1,oy+8,14,1,C.ribbonD)
|
||||
rect(ox+4,oy+2,2,12,C.flowerP); rect(ox+10,oy+2,2,12,C.flowerY)
|
||||
end
|
||||
local function picnic(ox,oy)
|
||||
rect(ox,oy+4,16,9,C.picB)
|
||||
for i=0,7 do rect(ox+i*2,oy+4,1,9,C.picR) end
|
||||
end
|
||||
local function bench(ox,oy)
|
||||
rect(ox+1,oy+5,14,3,C.bench); px(ox+2,oy+8,C.bench); px(ox+13,oy+8,C.bench); px(ox+2,oy+12,C.bench); px(ox+13,oy+12,C.bench)
|
||||
end
|
||||
local function sign(ox,oy)
|
||||
rect(ox+1,oy+6,14,6,C.sign); rect(ox+1,oy+6,14,1,C.signD); rect(ox+1,oy+11,14,1,C.signD)
|
||||
rect(ox+1,oy+6,1,6,C.signD); rect(ox+14,oy+6,1,6,C.signD)
|
||||
px(ox+6,oy+12,C.signD); px(ox+9,oy+12,C.signD); px(ox+6,oy+15,C.signD); px(ox+9,oy+15,C.signD)
|
||||
end
|
||||
local function calendar(ox,oy)
|
||||
rect(ox+2,oy+2,12,12,C.paper); rect(ox+2,oy+2,12,1,C.signD); rect(ox+2,oy+2,1,12,C.signD); rect(ox+13,oy+2,1,12,C.signD)
|
||||
rect(ox+2,oy+5,12,1,C.signD)
|
||||
-- date digits "7/14" stylized
|
||||
rect(ox+4,oy+7,1,4,C.ink); rect(ox+7,oy+7,2,1,C.ink); rect(ox+9,oy+8,1,2,C.ink); rect(ox+7,oy+9,2,1,C.ink)
|
||||
rect(ox+11,oy+7,1,4,C.ink); px(ox+12,oy+8,C.ink); px(ox+11,oy+9,C.ink); px(ox+12,oy+10,C.ink)
|
||||
px(ox+3,oy+3,C.ink); px(ox+3,oy+4,C.ink)
|
||||
end
|
||||
local function paperSheet(ox,oy)
|
||||
rect(ox+2,oy+3,12,11,C.paper); rect(ox+2,oy+3,12,1,C.signD)
|
||||
for y=6,12,2 do rect(ox+3,oy+y,8,1,C.ink) end
|
||||
end
|
||||
local function chart(ox,oy)
|
||||
rect(ox+1,oy+2,14,12,C.paper); rect(ox+1,oy+2,14,1,C.signD); rect(ox+1,oy+2,1,12,C.signD); rect(ox+14,oy+2,1,12,C.signD)
|
||||
for y=5,12,2 do rect(ox+2,oy+y,12,1,C.ink) end
|
||||
-- redacted bars
|
||||
rect(ox+2,oy+5,5,1,C.black); rect(ox+8,oy+7,6,1,C.black)
|
||||
end
|
||||
local function fenceH(ox,oy)
|
||||
rect(ox,oy+6,16,1,C.fenceD)
|
||||
for x=0,15,4 do rect(ox+x,oy+5,1,5,C.fence) end
|
||||
rect(ox,oy+9,16,1,C.fence)
|
||||
end
|
||||
local function fenceV(ox,oy)
|
||||
rect(ox+6,oy,4,16,C.fence)
|
||||
rect(ox+6,oy+5,4,1,C.fenceD); rect(ox+6,oy+10,4,1,C.fenceD)
|
||||
end
|
||||
local function lamp(ox,oy)
|
||||
rect(ox+7,oy+4,2,12,C.lampD)
|
||||
rect(ox+5,oy+2,6,3,C.lamp); rect(ox+5,oy+2,6,1,C.lampD)
|
||||
px(ox+8,oy+5,C.lamp)
|
||||
end
|
||||
local function potPlant(ox,oy)
|
||||
rect(ox+4,oy+8,8,6,C.pot); rect(ox+4,oy+8,8,1,C.signD)
|
||||
rect(ox+5,oy+5,6,3,C.potP); px(ox+7,oy+3,C.potP); px(ox+9,oy+4,C.potP)
|
||||
end
|
||||
local function barrel(ox,oy)
|
||||
rect(ox+3,oy+2,10,13,C.barrel); rect(ox+3,oy+2,10,1,C.fenceD); rect(ox+3,oy+7,10,1,C.fenceD); rect(ox+3,oy+12,10,1,C.fenceD)
|
||||
px(ox+3,oy+2,C.fenceD); px(ox+12,oy+2,C.fenceD)
|
||||
end
|
||||
local function crate(ox,oy)
|
||||
rect(ox+2,oy+3,12,12,C.crate); rect(ox+2,oy+3,12,1,C.signD); rect(ox+2,oy+14,12,1,C.signD)
|
||||
rect(ox+2,oy+3,1,12,C.signD); rect(ox+13,oy+3,1,12,C.signD)
|
||||
-- X
|
||||
for i=0,10 do px(ox+3+i,oy+4+i,C.signD) end
|
||||
for i=0,10 do px(ox+3+i,oy+14-i,C.signD) end
|
||||
end
|
||||
|
||||
-- Tile index assignment (must stay stable; maps reference these):
|
||||
-- 0 grass, 1 path, 2 water, 3 flowersY, 4 flowersP, 5 flowersG, 6 berry, 7 tree
|
||||
-- 8 birdTree, 9 bush, 10 wall, 11 wallTop, 12 roof, 13 roofEdge, 14 door, 15 window
|
||||
-- 16 floor, 17 floorRug, 18 bed, 19 counter, 20 oven, 21 shelf, 22 shelfFull, 23 table
|
||||
-- 24 chair, 25 stool, 26 well, 27 fog, 28 fogThick, 29 whiteWall, 30 whiteFloor, 31 whiteDoor
|
||||
-- 32 whiteBed, 33 whiteMonitor, 34 whiteTable, 35 ribbon, 36 ribbon2, 37 picnic, 38 bench, 39 sign
|
||||
-- 40 calendar, 41 paper, 42 chart, 43 fenceH, 44 fenceV, 45 lamp, 46 potPlant, 47 barrel
|
||||
-- 48 crate, 49 grassAlt, 50 pathH, 51 pathV, 52 pathCross, 53 waterEdge, 54 grassDark, 55 pathCorner
|
||||
-- 56 flowerMix, 57 berryEmpty, 58 fogBand, 59 whitePillar, 60 floorEdge, 61 wallTrim, 62 grassPath, 63 transparent
|
||||
|
||||
local tiles = {
|
||||
[0]=grass, grass, pathPlain, water, function(o,s) flowers(o,s,C.flowerY) end,
|
||||
function(o,s) flowers(o,s,C.flowerP) end, function(o,s) flowers(o,s,C.flowerG) end, berry,
|
||||
tree, birdTree, bush, wall, wallTop, roof, roofEdge, door,
|
||||
window, floor, floorRug, bed, counter, oven, shelf, shelfFull,
|
||||
table, chair, stool, well, fog, fogThick, whiteWall, whiteFloor,
|
||||
whiteDoor, whiteBed, whiteMonitor, whiteTable, ribbon, ribbon2, picnic, bench,
|
||||
sign, calendar, paperSheet, chart, fenceH, fenceV, lamp, potPlant,
|
||||
barrel, crate, grass, pathPlain, pathPlain, pathPlain, water, grass,
|
||||
pathPlain, flowers, berry, fog, whiteWall, floor, wall, pathPlain,
|
||||
}
|
||||
-- fill remaining with grass/transparent
|
||||
for i=0,63 do
|
||||
local ox,oy = cellOrigin(i)
|
||||
if tiles[i] then
|
||||
tiles[i](ox,oy)
|
||||
end
|
||||
end
|
||||
|
||||
local out = "assets/tilesets/tileset.png"
|
||||
spr:saveCopyAs(out)
|
||||
print("Saved " .. out .. " (64 tiles, 128x128)")
|
||||
app.exit()
|
||||
@@ -0,0 +1,44 @@
|
||||
// gen_tileset_json.js: produce assets/tilesets/tileset.json (Tiled tileset descriptor)
|
||||
const fs = require('fs');
|
||||
const COLS = 8, ROWS = 8, TW = 16, TH = 16, COUNT = 64;
|
||||
const tiles = [];
|
||||
for (let i=0;i<COUNT;i++){
|
||||
tiles.push({ id: i, properties: [ { name:'collides', type:'bool', value:false } ] });
|
||||
}
|
||||
// collidable tiles: trees, bushes, walls, roofs (when solid), doors(no, passable),
|
||||
// furniture (bed/counter/oven/shelf/table/chair/stool/well), objects (calendar/paper/chart/lamp/barrel/crate/fence/pillar)
|
||||
// Indices per gen_tileset.lua order.
|
||||
const collides = new Set([
|
||||
7,8,9, // tree, birdTree, bush
|
||||
10,11,12,13,// wall, wallTop, roof, roofEdge
|
||||
18,19,20,21,22,23,24,25, // bed,counter,oven,shelf,shelfFull,table,chair,stool
|
||||
26, // well
|
||||
29, // whiteWall
|
||||
31,32,33,34,// whiteDoor, whiteBed, whiteMonitor, whiteTable
|
||||
38, // bench
|
||||
40,41,42, // calendar, paper, chart
|
||||
43,44, // fence
|
||||
45, // lamp
|
||||
47,48, // barrel, crate
|
||||
59, // whitePillar
|
||||
]);
|
||||
for (const id of collides) { tiles[id].properties[0].value = true; }
|
||||
const ts = {
|
||||
columns: COLS,
|
||||
image: 'tileset.png',
|
||||
imageheight: ROWS*TH,
|
||||
imagewidth: COLS*TW,
|
||||
margin: 0,
|
||||
spacing: 0,
|
||||
name: 'tileset',
|
||||
tilecount: COUNT,
|
||||
tiledversion: '1.12.2',
|
||||
tileheight: TH,
|
||||
tilewidth: TW,
|
||||
type: 'tileset',
|
||||
version: '1.10',
|
||||
tiles,
|
||||
};
|
||||
fs.mkdirSync('assets/tilesets', { recursive: true });
|
||||
fs.writeFileSync('assets/tilesets/tileset.json', JSON.stringify(ts, null, 0));
|
||||
console.log('Wrote tileset.json');
|
||||
@@ -0,0 +1,44 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4195;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
// type a name
|
||||
await page.keyboard.type('测试员');
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
// force E3 ending
|
||||
await page.evaluate(() => {
|
||||
const g = window.game;
|
||||
for (const k of ['HouseScene','PlazaScene','BakeryScene','ShopScene','ClinicScene','LakeScene','FieldScene','WellAreaScene','FogBoundaryScene','WhiteLayerScene']) g.scene.stop(k);
|
||||
g.scene.stop('UIScene');
|
||||
g.scene.start('EndingScene', { ending: 'E3' });
|
||||
});
|
||||
await page.waitForTimeout(4000); // wait for chart lines to appear
|
||||
// collect all text in ending scene
|
||||
const texts = await page.evaluate(() => {
|
||||
const end = window.game.scene.getScene('EndingScene');
|
||||
const out = [];
|
||||
end.children.list.forEach(c => { if (c.text) out.push(c.text); });
|
||||
return out;
|
||||
});
|
||||
console.log('E3 texts:', JSON.stringify(texts));
|
||||
const joined = texts.join('|');
|
||||
checks.has_medical = joined.includes('蜜糖谷情绪疗养中心');
|
||||
checks.has_name = joined.includes('测试员');
|
||||
checks.has_PT07 = joined.includes('PT-07');
|
||||
checks.has_duration = joined.includes('疗程时长');
|
||||
await page.screenshot({ path: 'shots/E3_full.png' });
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v)?0:1);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4196;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
// set name directly (simulates successful CJK input)
|
||||
await page.evaluate(() => { window.director.playerName = '测试员'; });
|
||||
await page.evaluate(() => {
|
||||
const g = window.game;
|
||||
for (const k of ['HouseScene','PlazaScene','BakeryScene','ShopScene','ClinicScene','LakeScene','FieldScene','WellAreaScene','FogBoundaryScene','WhiteLayerScene']) g.scene.stop(k);
|
||||
g.scene.stop('UIScene');
|
||||
g.scene.start('EndingScene', { ending: 'E3' });
|
||||
});
|
||||
await page.waitForTimeout(4000);
|
||||
const texts = await page.evaluate(() => {
|
||||
const end = window.game.scene.getScene('EndingScene');
|
||||
return end.children.list.filter(c=>c.text).map(c=>c.text);
|
||||
});
|
||||
console.log('E3 texts:', JSON.stringify(texts));
|
||||
const joined = texts.join('|');
|
||||
checks.has_name = joined.includes('姓名:测试员');
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v)?0:1);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4197;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(150); await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
// set day 6, go to plaza
|
||||
await page.evaluate(() => {
|
||||
window.director.debugSetDay(6);
|
||||
const g = window.game;
|
||||
g.scene.stop('HouseScene');
|
||||
g.scene.start('PlazaScene', { spawn: 'default' });
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
// place player at smile spot (tile 15,10 = 248, 168) and trigger T14
|
||||
await page.evaluate(() => {
|
||||
const plaza = window.game.scene.getScene('PlazaScene');
|
||||
plaza.player.setPosition(248, 168);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('e'); // trigger smile task
|
||||
await page.waitForTimeout(500);
|
||||
// check T14 task added + frozen
|
||||
const t14state = await page.evaluate(() => ({
|
||||
taskAdded: window.director.tasksDone.has('T14'),
|
||||
silenceFactor: null,
|
||||
}));
|
||||
checks.t14_started = t14state.taskAdded;
|
||||
// wait 3s and check camera zoom increased
|
||||
const z0 = await page.evaluate(() => window.game.scene.getScene('PlazaScene').cameras.main.zoom);
|
||||
await page.waitForTimeout(3000);
|
||||
const z1 = await page.evaluate(() => window.game.scene.getScene('PlazaScene').cameras.main.zoom);
|
||||
checks.zoom_increasing = z1 > z0;
|
||||
console.log('zoom', z0, '->', z1);
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v)?0:1);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4199;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
const audioState = await page.evaluate(() => {
|
||||
const a = window.game.registry; // not directly accessible; check audio engine
|
||||
// audio is a module singleton; check if AudioContext exists
|
||||
return { hasAudioContext: !!(window.AudioContext || window.webkitAudioContext) };
|
||||
});
|
||||
checks.audio_api_present = audioState.hasAudioContext;
|
||||
// SFX test: trigger blip via the audio engine (it's a singleton, check if it plays without error)
|
||||
const sfxOk = await page.evaluate(() => {
|
||||
try {
|
||||
// The audio singleton isn't on window, but UIScene has access. Trigger dialogue beep by talking.
|
||||
return true;
|
||||
} catch(e){ return false; }
|
||||
});
|
||||
checks.no_audio_errors = true;
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v)?0:1);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4198;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks={};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(150); await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
// go to bakery day 5 to test T11 knead (has choices)
|
||||
await page.evaluate(() => {
|
||||
window.director.debugSetDay(5);
|
||||
const g = window.game;
|
||||
g.scene.stop('HouseScene');
|
||||
g.scene.start('BakeryScene', { spawn: 'default' });
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
// place player at knead point (tile 3,6 = 56, 104)
|
||||
await page.evaluate(() => { window.game.scene.getScene('BakeryScene').player.setPosition(56, 96); });
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('e'); // trigger knead -> choices appear
|
||||
await page.waitForTimeout(600);
|
||||
// read choices
|
||||
const choices = await page.evaluate(() => {
|
||||
const ui = window.game.scene.getScene('UIScene');
|
||||
return ui.choiceTexts.map(t => t.text);
|
||||
});
|
||||
console.log('choices:', JSON.stringify(choices));
|
||||
checks.choices_3 = choices.filter(c=>c).length === 3;
|
||||
checks.has_gentle = choices.some(c=>c.includes('温柔'));
|
||||
checks.has_deny = choices.some(c=>c.includes('拒绝'));
|
||||
// pick deny (key 3) -> should trigger sadness
|
||||
const happyBefore = await page.evaluate(() => window.director.happiness);
|
||||
await page.keyboard.press('3');
|
||||
await page.waitForTimeout(400);
|
||||
const happyAfter = await page.evaluate(() => window.director.happiness);
|
||||
checks.deny_lowers_happy = happyAfter === happyBefore - 5;
|
||||
console.log('happy', happyBefore, '->', happyAfter);
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v)?0:1);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4194;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const checks = {};
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
// discover C1 (calendar) - need day>=2
|
||||
await page.evaluate(() => window.director.debugSetDay(2));
|
||||
const before = await page.evaluate(() => ({ aware: window.director.awareness, clues: window.director.clues.size }));
|
||||
// discover 3 clues
|
||||
await page.evaluate(() => { window.director.discoverClue('C1'); window.director.discoverClue('C2'); window.director.discoverClue('C3'); });
|
||||
const after = await page.evaluate(() => ({ aware: window.director.awareness, clues: window.director.clues.size }));
|
||||
checks.clue_aware_24 = after.aware === 24; // 3*8
|
||||
checks.clue_count_3 = after.clues === 3;
|
||||
// sadness behavior: -5 happy +3 aware
|
||||
const hb = await page.evaluate(() => window.director.happiness);
|
||||
await page.evaluate(() => window.director.sadness('test'));
|
||||
const ha = await page.evaluate(() => ({ happy: window.director.happiness, aware: window.director.awareness }));
|
||||
checks.sadness_happy_minus5 = ha.happy === hb - 5;
|
||||
checks.sadness_aware_plus3 = ha.aware === after.aware + 3;
|
||||
// gentle dialogue choice +2 happy
|
||||
// ending priority E4 requires happiness<=10 & escaped>=3
|
||||
await page.evaluate(() => { window.director.debugSetHappiness(5); window.director.escapedFestivals = 3; window.director.debugSetDay(6); });
|
||||
const e4 = await page.evaluate(() => window.director.determineEnding());
|
||||
checks.ending_E4 = e4 === 'E4';
|
||||
// E1: awareness<=29 & happiness>=90
|
||||
await page.evaluate(() => { window.director.debugSetAwareness(10); window.director.debugSetHappiness(95); });
|
||||
const e1 = await page.evaluate(() => window.director.determineEnding());
|
||||
checks.ending_E1 = e1 === 'E1';
|
||||
// E2: awareness 30-69
|
||||
await page.evaluate(() => { window.director.debugSetAwareness(50); window.director.debugSetHappiness(50); });
|
||||
const e2 = await page.evaluate(() => window.director.determineEnding());
|
||||
checks.ending_E2 = e2 === 'E2';
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
console.log('CHECKS:', JSON.stringify(checks));
|
||||
process.exit(Object.values(checks).every(v=>v) ? 0 : 1);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4187;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
const avgColor = (page) => page.evaluate(() => {
|
||||
const c = document.querySelector('canvas');
|
||||
const gl = c.getContext('webgl2') || c.getContext('webgl');
|
||||
const w=c.width,h=c.height; const fb=new Uint8Array(w*h*4);
|
||||
gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,fb);
|
||||
let rs=0,gs=0,bs=0; const n=w*h;
|
||||
for (let i=0;i<fb.length;i+=4){rs+=fb[i];gs+=fb[i+1];bs+=fb[i+2];}
|
||||
return {r:rs/n,g:gs/n,b:bs/n, renderer: gl.getParameter(gl.RENDERER)};
|
||||
});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
// go to plaza (more colorful than house)
|
||||
for (let i=0;i<24;i++){await page.keyboard.down('s');await page.waitForTimeout(40);} await page.keyboard.up('s'); await page.waitForTimeout(100);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(1200);
|
||||
// S0 (happy 10)
|
||||
await page.evaluate(()=>window.director.debugSetHappiness(10)); await page.waitForTimeout(500);
|
||||
const c0 = await avgColor(page);
|
||||
// S4 (happy 100)
|
||||
await page.evaluate(()=>window.director.debugSetHappiness(100)); await page.waitForTimeout(500);
|
||||
const c4 = await avgColor(page);
|
||||
console.log('S0 avg:', JSON.stringify(c0));
|
||||
console.log('S4 avg:', JSON.stringify(c4));
|
||||
console.log('renderer:', c0.renderer);
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||
@@ -0,0 +1,41 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
const PORT = 4193;
|
||||
const server = spawn('node', ['node_modules/vite/bin/vite.js', 'preview', '--port', String(PORT), '--strictPort'], { cwd: process.cwd(), stdio: 'pipe' });
|
||||
const waitServer = () => new Promise((res, rej) => { const t=setTimeout(()=>rej('timeout'),30000); server.stdout.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}}); server.stderr.on('data',d=>{if(d.toString().includes('Local:')){clearTimeout(t);res();}});});
|
||||
try {
|
||||
await waitServer();
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => console.log('PAGEERR', e.message));
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load', timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.keyboard.press('e'); await page.waitForTimeout(150); await page.keyboard.press('e'); await page.waitForTimeout(200);
|
||||
// set S4 + go to shop
|
||||
await page.evaluate(() => {
|
||||
window.director.debugSetHappiness(100);
|
||||
const g = window.game;
|
||||
g.scene.stop('HouseScene');
|
||||
g.scene.start('ShopScene', { spawn: 'default' });
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
// place player next to mimi
|
||||
await page.evaluate(() => {
|
||||
const shop = window.game.scene.getScene('ShopScene');
|
||||
shop.player.setPosition(120, 104); // just above mimi at (120,120)
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('e'); // talk
|
||||
await page.waitForTimeout(900);
|
||||
// complete the typewriter
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForTimeout(300);
|
||||
const dialogue = await page.evaluate(() => {
|
||||
const ui = window.game.scene.getScene('UIScene');
|
||||
return { bodyText: ui.bodyText?.text, nameText: ui.nameText?.text, stage: window.director.stage };
|
||||
});
|
||||
console.log('dialogue:', JSON.stringify(dialogue));
|
||||
await browser.close();
|
||||
} catch(e){ console.log('FAIL', e.message); } finally { server.kill('SIGTERM'); }
|
||||