chore: CLAUDE 移至 .claude、清理 obsolete 文件、火山销售系统更新

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-12 20:59:13 +08:00
co-authored by Cursor
parent 4ee9cc647f
commit fb0f4658b8
5 changed files with 172 additions and 1032 deletions
+172 -3
View File
@@ -1,5 +1,174 @@
# AIBIS Dream - Project Context
<!-- # CLAUDE.md
Full project documentation is in the repository root:
Claude Code 与 Cursor 的项目上下文,对话开始时自动加载。
@../CLAUDE.md
## 项目概览
AIBIS Dream(又名 AllOurBrokenParts)是一款基于 Unity 的叙事冒险游戏,带有调查元素。使用 Yarn Spinner 管理对话,采用自定义模块化框架,包含多个小游戏(核心之一是火山 HuoShan 的语言粒子系统)。
**Unity 版本:** Unity 2022.3.x LTSUniversal Render Pipeline
## 架构
### 核心框架(Assets/Scripts/Framework/
- **ActionKit**:动作序列与命令模式
- **AudioKit**:音频管理与音效
- **Config**:配置与设置
- **Core**:核心系统与工具
- **EventSystemKit**:解耦事件系统
- **PoolKit**:对象池
- **ResourceKit**:资源加载
- **SingletonKit**:单例基类
- **StateMachineKit**:状态机
- **TimelineKit**Timeline 事件序列
### 主要游戏系统
- **Dialog System**Assets/Scripts/Dialog System/):Yarn Spinner 对话
- **Clue System**Assets/Scripts/Clue/):调查与证据收集
- **MiniGame System**Assets/Scripts/MiniGame/):小游戏框架
- **HuoShan(火山)**:语言粒子系统,用于情绪表达玩法
- **SceneManagement**:场景与流程
- **FixSystem**:互动维修/解谜
### Web 原型
位于 `web-prototype/` 与 `WebPrototype/`,用于在 Unity 实现前验证玩法。
### 设计文档(Docs
**任何开发工作,按需阅读对应的设计文档。** 文档采用单源真相,定义只维护一份。
| 索引 | 说明 |
|------|------|
| `Docs/HuoShan/INDEX.md` | 火山文档入口 |
## 常用开发命令
### Unity 构建
```bash
# Windows 64-bit
"C:\Program Files\Unity\Hub\Editor\2022.3.x\Editor\Unity.exe" -batchmode -quit -projectPath . -buildWindows64Player "Build/AIBIS_Dream.exe" -logFile Build/log.txt
# WebGL
"C:\Program Files\Unity\Hub\Editor\2022.3.x\Editor\Unity.exe" -batchmode -quit -projectPath . -executeMethod BuildScript.WebGL -logFile Build/webgl_log.txt
```
### 资源与工程
```bash
# 重新生成 Visual Studio 工程
Assets\Open C# Project.regenerate-sln.bat
# 刷新资源库:菜单 Assets > Refresh 或 Ctrl+R
```
## 关键依赖
### Unity 包
- Yarn Spinner、TextMesh Pro、URP、2D Animation、Cinemachine、Timeline、Localization
### 第三方资源
- More Mountains Feedbacks、FMOD、DOTween、Shapes、QFramework、Destructible 2D
## 开发规范
### 代码组织
1. 各系统放在 `Assets/Scripts/` 下独立文件夹
2. 可复用工具以 Kit 后缀放在 `Framework/`
3. 全局管理类使用 Singleton
4. 组件优先于继承
### 场景结构
- 场景在 `Assets/Scenes/`
- 主场景命名:`{角色}_{Day}{时间}`(如 `Fiction_Day1_begin`
- 小游戏场景:`HuoShan*`、`languageTest*` 等
- 需加入 EditorBuildSettings
### Yarn 对话文件
- 位于 `Assets/Resources/Yarn/`,按角色与日期组织
- 文件命名:`{角色}_{阶段}.yarn`
- 使用 UTF-8
### 资源命名
- **脚本**PascalCase,带用途(如 `DialogController.cs`
- **Prefab**PascalCase 加类型后缀
- **场景**Snake_Case
- **Resources**:按系统/类型分组
## 配置要点
### 编辑器
- 文本编码:UTF-8(尤其中文)
- 行尾:C# 使用 Windows (CRLF),保持一致
### 构建
- 平台:Windows、WebGL
- Scripting BackendIL2CPPWindows)、EmscriptenWebGL
- API.NET Standard 2.1
### Git
- LFS:大文件(.unity、贴图、音频)
- 忽略:Library/、Temp/、Logs/、Build/、*.csproj.user
## 测试与迭代
### 小游戏独立测试
- 火山语言游戏:`Assets/Scenes/languageTest.unity`
- 火山维修:`Assets/Scenes/HuoShanFixScene.unity`
- 表情测试:`Assets/Scenes/HuoShanExpressionTest.unity`
### Web 原型
1. 在 `web-prototype/` 建 HTML/JS 原型
2. 逻辑与 Unity 实现保持一致
3. 浏览器快速验证
## 常见任务
### 新增小游戏
1. 建文件夹 `Assets/Scripts/MiniGame/{名称}/`
2. 用 Framework 实现逻辑
3. 建测试场景 `{名称}Test.unity`
4. 资源放 `Assets/Resources/{名称}/`
5. 在 `README_{名称}.md` 中记录
### 添加 Yarn 对话
1. 在 `Assets/Resources/Yarn/{角色}/` 建 `.yarn`
2. 通过 Resources.Load 加载
3. 挂到场景的 DialogueRunner
4. 用 DialogueController 测试
### 调试
- LogKit 做分类日志
- ProjectSettings/LogKit 可调详细级别
- 粒子多时用 Unity Profiler 排查
## 性能
- 频繁创建对象用 PoolKit
- 资源加载用 ResourceKit 异步
- 粒子系统注意 overdraw
- 音效用池,复杂音频用 FMOD
## 重要提醒
- 以叙事为核心,对话和剧情优先
- 火山语言粒子是核心玩法,改动需保持情绪表达逻辑
- Web 原型仅供验证,正式实现用 Unity/C#
- 保持 Windows 与 WebGL 兼容
- **开发前按需读 `Docs/` 下对应索引,避免与设计偏离** -->
-12
View File
@@ -1,12 +0,0 @@
---
description: AIBIS Dream 项目核心上下文
alwaysApply: true
---
# AIBIS Dream - 项目概览
Unity 叙事冒险游戏(AllOurBrokenParts),使用 Yarn Spinner 对话。
**Unity**: 2022.3.x LTS (URP) | **关键路径**: `Assets/Scripts/Framework/`、`MiniGame/HuoShan/`、`Resources/Yarn/`
完整文档见项目根目录 `CLAUDE.md`。
-174
View File
@@ -1,174 +0,0 @@
<!-- # CLAUDE.md
Claude Code 与 Cursor 的项目上下文,对话开始时自动加载。
## 项目概览
AIBIS Dream(又名 AllOurBrokenParts)是一款基于 Unity 的叙事冒险游戏,带有调查元素。使用 Yarn Spinner 管理对话,采用自定义模块化框架,包含多个小游戏(核心之一是火山 HuoShan 的语言粒子系统)。
**Unity 版本:** Unity 2022.3.x LTSUniversal Render Pipeline
## 架构
### 核心框架(Assets/Scripts/Framework/
- **ActionKit**:动作序列与命令模式
- **AudioKit**:音频管理与音效
- **Config**:配置与设置
- **Core**:核心系统与工具
- **EventSystemKit**:解耦事件系统
- **PoolKit**:对象池
- **ResourceKit**:资源加载
- **SingletonKit**:单例基类
- **StateMachineKit**:状态机
- **TimelineKit**Timeline 事件序列
### 主要游戏系统
- **Dialog System**Assets/Scripts/Dialog System/):Yarn Spinner 对话
- **Clue System**Assets/Scripts/Clue/):调查与证据收集
- **MiniGame System**Assets/Scripts/MiniGame/):小游戏框架
- **HuoShan(火山)**:语言粒子系统,用于情绪表达玩法
- **SceneManagement**:场景与流程
- **FixSystem**:互动维修/解谜
### Web 原型
位于 `web-prototype/` 与 `WebPrototype/`,用于在 Unity 实现前验证玩法。
### 设计文档(Docs
**任何开发工作,按需阅读对应的设计文档。** 文档采用单源真相,定义只维护一份。
| 索引 | 说明 |
|------|------|
| `Docs/HuoShan/INDEX.md` | 火山文档入口 |
## 常用开发命令
### Unity 构建
```bash
# Windows 64-bit
"C:\Program Files\Unity\Hub\Editor\2022.3.x\Editor\Unity.exe" -batchmode -quit -projectPath . -buildWindows64Player "Build/AIBIS_Dream.exe" -logFile Build/log.txt
# WebGL
"C:\Program Files\Unity\Hub\Editor\2022.3.x\Editor\Unity.exe" -batchmode -quit -projectPath . -executeMethod BuildScript.WebGL -logFile Build/webgl_log.txt
```
### 资源与工程
```bash
# 重新生成 Visual Studio 工程
Assets\Open C# Project.regenerate-sln.bat
# 刷新资源库:菜单 Assets > Refresh 或 Ctrl+R
```
## 关键依赖
### Unity 包
- Yarn Spinner、TextMesh Pro、URP、2D Animation、Cinemachine、Timeline、Localization
### 第三方资源
- More Mountains Feedbacks、FMOD、DOTween、Shapes、QFramework、Destructible 2D
## 开发规范
### 代码组织
1. 各系统放在 `Assets/Scripts/` 下独立文件夹
2. 可复用工具以 Kit 后缀放在 `Framework/`
3. 全局管理类使用 Singleton
4. 组件优先于继承
### 场景结构
- 场景在 `Assets/Scenes/`
- 主场景命名:`{角色}_{Day}{时间}`(如 `Fiction_Day1_begin`
- 小游戏场景:`HuoShan*`、`languageTest*` 等
- 需加入 EditorBuildSettings
### Yarn 对话文件
- 位于 `Assets/Resources/Yarn/`,按角色与日期组织
- 文件命名:`{角色}_{阶段}.yarn`
- 使用 UTF-8
### 资源命名
- **脚本**PascalCase,带用途(如 `DialogController.cs`
- **Prefab**PascalCase 加类型后缀
- **场景**Snake_Case
- **Resources**:按系统/类型分组
## 配置要点
### 编辑器
- 文本编码:UTF-8(尤其中文)
- 行尾:C# 使用 Windows (CRLF),保持一致
### 构建
- 平台:Windows、WebGL
- Scripting BackendIL2CPPWindows)、EmscriptenWebGL
- API.NET Standard 2.1
### Git
- LFS:大文件(.unity、贴图、音频)
- 忽略:Library/、Temp/、Logs/、Build/、*.csproj.user
## 测试与迭代
### 小游戏独立测试
- 火山语言游戏:`Assets/Scenes/languageTest.unity`
- 火山维修:`Assets/Scenes/HuoShanFixScene.unity`
- 表情测试:`Assets/Scenes/HuoShanExpressionTest.unity`
### Web 原型
1. 在 `web-prototype/` 建 HTML/JS 原型
2. 逻辑与 Unity 实现保持一致
3. 浏览器快速验证
## 常见任务
### 新增小游戏
1. 建文件夹 `Assets/Scripts/MiniGame/{名称}/`
2. 用 Framework 实现逻辑
3. 建测试场景 `{名称}Test.unity`
4. 资源放 `Assets/Resources/{名称}/`
5. 在 `README_{名称}.md` 中记录
### 添加 Yarn 对话
1. 在 `Assets/Resources/Yarn/{角色}/` 建 `.yarn`
2. 通过 Resources.Load 加载
3. 挂到场景的 DialogueRunner
4. 用 DialogueController 测试
### 调试
- LogKit 做分类日志
- ProjectSettings/LogKit 可调详细级别
- 粒子多时用 Unity Profiler 排查
## 性能
- 频繁创建对象用 PoolKit
- 资源加载用 ResourceKit 异步
- 粒子系统注意 overdraw
- 音效用池,复杂音频用 FMOD
## 重要提醒
- 以叙事为核心,对话和剧情优先
- 火山语言粒子是核心玩法,改动需保持情绪表达逻辑
- Web 原型仅供验证,正式实现用 Unity/C#
- 保持 Windows 与 WebGL 兼容
- **开发前按需读 `Docs/` 下对应索引,避免与设计偏离** -->
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Split Commit Tool - 交互式拆分未提交变更为多个原子提交
"""
import os
import sys
import subprocess
from pathlib import Path
# 配置
PROTECTED_BRANCHES = ['main', 'master', 'release/', 'hotfix/']
REPO_ROOT = Path(__file__).parent.absolute()
os.chdir(REPO_ROOT)
def run_command(cmd, capture_output=True):
"""执行Git命令"""
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=capture_output,
text=True,
encoding='utf-8'
)
return result.returncode == 0, result.stdout, result.stderr
except Exception as e:
return False, "", str(e)
def is_protected_branch(branch_name):
"""检查是否为保护分支"""
for protected in PROTECTED_BRANCHES:
if protected.endswith('/') and branch_name.startswith(protected):
return True
if branch_name == protected:
return True
return False
def get_current_branch():
"""获取当前分支"""
success, stdout, stderr = run_command("git branch --show-current")
return stdout.strip() if success else ""
def analyze_changes():
"""分析变更"""
print("=" * 60)
print("步骤 1: 分析变更")
print("=" * 60)
# git status
print("\n[git status]")
success, stdout, stderr = run_command("git status")
print(stdout or stderr)
# git diff --stat
print("\n[git diff --stat]")
success, stdout, stderr = run_command("git diff --stat")
print(stdout or stderr)
return True
def create_commit(msg):
"""使用msg.txt创建提交(避免中文乱码)"""
msg_file = REPO_ROOT / "msg.txt"
try:
with open(msg_file, 'w', encoding='utf-8') as f:
f.write(msg)
f.write("\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>\n")
success, stdout, stderr = run_command("git commit -F msg.txt")
if not success:
print(f"❌ 提交失败: {stderr}")
return False
print(f"✅ 提交成功: {msg}")
return True
finally:
if msg_file.exists():
msg_file.unlink()
def commit_all_changes():
"""一次性提交所有变更"""
print("\n" + "=" * 60)
print("一次性提交所有变更")
print("=" * 60)
# 处理删除的文件
run_command("git ls-files --deleted -z | xargs -0 git rm", capture_output=False)
# 添加所有变更
run_command("git add -A", capture_output=False)
# 创建提交
create_commit("chore: 批量提交变更")
# 显示提交历史
print("\n[提交历史]")
run_command("git log --oneline -5", capture_output=False)
def interactive_split():
"""交互式拆分提交"""
print("\n" + "=" * 60)
print("步骤 2: 分组变更")
print("=" * 60)
# 获取所有变更
success, status_output, _ = run_command("git status --short")
if not success:
print("❌ 无法获取变更状态")
return False
# 分析变更类型
staged_files = []
modified_files = []
deleted_files = []
untracked_files = []
for line in status_output.strip().split('\n'):
if not line:
continue
status = line[:2]
file_path = line[3:]
if status == 'M ':
staged_files.append(file_path)
elif status == ' M':
modified_files.append(file_path)
elif status == 'D ':
deleted_files.append(file_path)
elif status == ' D':
deleted_files.append(file_path)
elif status == '??':
untracked_files.append(file_path)
elif status == 'MM':
staged_files.append(file_path)
modified_files.append(file_path)
# 显示分组建议
groups = []
print("\n[变更分组方案]")
order = 1
# 删除文件
if deleted_files:
groups.append({
'order': order,
'files': deleted_files,
'message': '删除旧文件',
'type': 'delete'
})
print(f"{order}. 删除 {len(deleted_files)} 个文件")
for f in deleted_files[:3]:
print(f" - {f}")
if len(deleted_files) > 3:
print(f" ... 还有 {len(deleted_files) - 3} 个文件")
order += 1
# 修改的meta文件
meta_files = [f for f in modified_files if f.endswith('.meta')]
if meta_files:
groups.append({
'order': order,
'files': meta_files,
'message': '更新.meta文件',
'type': 'meta'
})
print(f"{order}. 修改 {len(meta_files)} 个.meta文件")
for f in meta_files[:3]:
print(f" - {f}")
if len(meta_files) > 3:
print(f" ... 还有 {len(meta_files) - 3} 个文件")
order += 1
# 场景文件
scene_files = [f for f in modified_files if f.endswith('.unity')]
if scene_files:
groups.append({
'order': order,
'files': scene_files,
'message': '更新场景',
'type': 'scene'
})
print(f"{order}. 修改 {len(scene_files)} 个场景文件")
for f in scene_files:
print(f" - {f}")
order += 1
# 脚本文件
script_files = [f for f in modified_files if f.endswith('.cs')]
if script_files:
for script_file in script_files:
groups.append({
'order': order,
'files': [script_file],
'message': f'更新脚本: {Path(script_file).name}',
'type': 'script'
})
print(f"{order}. 修改脚本: {script_file}")
order += 1
# 未跟踪文件
if untracked_files:
groups.append({
'order': order,
'files': untracked_files,
'message': '添加新文件',
'type': 'untracked'
})
print(f"{order}. 添加 {len(untracked_files)} 个新文件")
for f in untracked_files[:3]:
print(f" - {f}")
if len(untracked_files) > 3:
print(f" ... 还有 {len(untracked_files) - 3} 个文件")
print("\n" + "=" * 60)
print("步骤 3: 确认拆分方案")
print("=" * 60)
choice = input("\n是否开始拆分? [1] 开始拆分 [2] 取消拆分 [3] 自定义分组: ")
if choice == '2':
print("\n已取消拆分")
return False
if choice == '3':
print("\n自定义分组功能暂未实现,请使用自动分组")
return False
# 开始拆分
print("\n" + "=" * 60)
print("步骤 4: 执行拆分")
print("=" * 60)
for group in groups:
print(f"\n>>> 处理第 {group['order']}")
# 根据类型执行不同的git操作
if group['type'] == 'delete':
# 删除文件
for file_path in group['files']:
run_command(f'git rm "{file_path}"', capture_output=False)
elif group['type'] == 'untracked':
# 添加新文件
run_command('git add -A', capture_output=False)
else:
# 修改文件
for file_path in group['files']:
run_command(f'git add "{file_path}"', capture_output=False)
# 创建提交
success = create_commit(group['message'])
if not success:
print(f"❌ 第 {group['order']} 组提交失败,拆分中止")
return False
input("\n按回车继续下一组...")
print("\n" + "=" * 60)
print("步骤 5: 完成")
print("=" * 60)
print("\n[提交历史]")
success, stdout, _ = run_command("git log --oneline -" + str(len(groups)))
print(stdout)
print(f"\n✅ 拆分完成!共创建 {len(groups)} 个原子提交")
return True
def main():
"""主函数"""
print("=" * 60)
print("Split Commit - 交互式拆分提交工具")
print("=" * 60)
# 检查保护分支
current_branch = get_current_branch()
print(f"\n当前分支: {current_branch}")
if is_protected_branch(current_branch):
print("\n❌ 当前为保护分支,无法直接拆分")
print("\n选项:")
print("[1] 创建新分支再拆分 (推荐)")
print("[2] 压成单commit并移到新分支")
print("[3] 取消")
choice = input("\n请选择: ")
if choice == '1':
branch_name = input("请输入新分支名: ")
if not branch_name:
print("❌ 分支名不能为空")
return
print("\n执行:")
print("1. git stash push --include-untracked")
print(f"2. git checkout -b {branch_name}")
print("3. git stash pop")
confirm = input("\n确认执行?(y/n): ")
if confirm.lower() == 'y':
run_command("git stash push --include-untracked", capture_output=False)
run_command(f"git checkout -b {branch_name}", capture_output=False)
run_command("git stash pop", capture_output=False)
print(f"\n✅ 已切换到新分支: {branch_name}")
print("请重新运行此工具进行拆分")
elif choice == '2':
print("\n功能暂未实现")
return
# 非保护分支,继续拆分流程
print("\n✅ 非保护分支,可以进行拆分")
# 分析变更
if not analyze_changes():
return
# 选择模式
print("\n" + "=" * 60)
print("选择拆分模式:")
print("=" * 60)
print("[1] 交互式拆分 (推荐)")
print("[2] 一次性提交所有变更")
mode = input("\n请选择: ")
if mode == '1':
interactive_split()
elif mode == '2':
commit_all_changes()
else:
print("❌ 无效选择")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n已取消操作")
sys.exit(0)
except Exception as e:
print(f"\n❌ 发生错误: {e}")
sys.exit(1)
-497
View File
@@ -1,497 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>思维侧写</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;500&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
background: #000;
overflow: hidden;
font-family: 'Noto Serif SC', serif;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
#stage {
position: relative;
width: 100%;
height: 100%;
}
#analysis-canvas {
display: none;
}
/* 最终图片 */
#final-image-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 420px;
height: 520px;
z-index: 5;
pointer-events: none;
opacity: 0;
}
.real-photo {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 漂浮的句子 */
.clue {
position: absolute;
color: rgba(220, 200, 180, 0.5);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
cursor: crosshair;
transition: color 0.15s, text-shadow 0.15s;
animation: float 4s ease-in-out infinite alternate;
z-index: 50;
letter-spacing: 1px;
}
.clue:hover {
color: rgba(255, 245, 230, 1);
text-shadow: 0 0 15px rgba(255, 200, 150, 0.6);
}
.clue.triggered {
pointer-events: none;
animation: none;
transition: opacity 0.2s ease;
opacity: 0;
}
/* ASCII字符层 */
#ascii-layer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
pointer-events: none;
}
/* 单个ASCII字符 */
.char {
position: absolute;
font-family: 'Noto Serif SC', serif;
pointer-events: none;
opacity: 0;
text-align: center;
transition: opacity 0.2s ease;
line-height: 1;
}
.char.placed {
opacity: 1;
}
.char.flying {
transition: left 0.35s cubic-bezier(0.22, 1, 0.36, 1),
top 0.35s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.15s ease;
}
@keyframes float {
0% { transform: translateY(0) rotate(-0.5deg); }
100% { transform: translateY(-6px) rotate(0.5deg); }
}
/* 进度 */
#progress {
position: fixed;
bottom: 30px;
right: 30px;
color: rgba(255,255,255,0.3);
font-size: 12px;
font-family: 'Courier New', monospace;
z-index: 200;
}
/* 提示 */
#hint {
position: fixed;
top: 30px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
letter-spacing: 5px;
transition: opacity 1s;
}
#hint.hidden { opacity: 0; }
/* 结局文字 */
#reveal-text {
position: absolute;
bottom: 5%;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 240, 220, 0);
font-size: 14px;
letter-spacing: 12px;
z-index: 100;
transition: color 2s ease 0.5s;
}
#reveal-text.show {
color: rgba(255, 240, 220, 0.85);
}
</style>
</head>
<body>
<div id="stage">
<canvas id="analysis-canvas"></canvas>
<div id="ascii-layer"></div>
<div id="final-image-container">
<img id="source-image"
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?q=80&w=800&auto=format&fit=crop"
crossorigin="anonymous"
class="real-photo" alt="">
</div>
<div id="reveal-text">就 是 她</div>
</div>
<div id="progress">0%</div>
<div id="hint">移动鼠标 · 拼凑真相</div>
<script>
// 80句
const vocab = [
"黑色风衣女人", "长发遮半边脸", "雨夜独自站", "高跟鞋声响",
"淡淡的烟味", "红唇微颤抖", "沉默的背影", "躲闪的眼神",
"昨晚十点整", "最后的渡轮", "她在说谎", "左手的戒指",
"指尖有泥土", "侧脸的轮廓", "电话突然断", "车票的日期",
"颤抖的双手", "欲言又止", "匆忙离现场", "丝巾遮脖颈",
"眼角的泪痕", "紧握的拳头", "深夜的来访", "无法解释的",
"那晚她在场", "目击者证词", "最后见到她", "消失的证据",
"她知道真相", "不在场证明", "香水的味道", "雨中的身影",
"码头的尽头", "转身的瞬间", "路灯下剪影", "低语的声音",
"犹豫的脚步", "紧锁的眉头", "冰冷的手指", "破碎的谎言",
"隐藏的秘密", "午夜的电话", "模糊的记忆", "真相的碎片",
"无声的控诉", "逃离的背影", "最后的晚餐", "致命的证据",
"她就是凶手", "就是她", "那个女人", "雨夜的秘密",
"无人知晓的", "深藏的恐惧", "午夜的访客", "不可告人的",
"最后的线索", "关键的证人", "遗失的记忆", "隐藏的伤痕",
"沉默的真相", "破碎的承诺", "无法回头的", "命运的交点",
"时间的裂缝", "被遗忘的夜", "最后的告白", "无声的呐喊",
"迷失的方向", "黑暗中的影", "月光下的她", "寂静的街道",
"最后的机会", "不能说的秘", "被掩盖的罪", "午夜的约定"
];
// 配置
const IMG_WIDTH = 420;
const IMG_HEIGHT = 520;
const CELL_SIZE = 10; // 更小的网格 = 更密集
const totalClues = 300; // 120个句子
let collectedCount = 0;
let isRevealed = false;
let gridData = [];
let gridIndex = 0;
const stage = document.getElementById('stage');
const asciiLayer = document.getElementById('ascii-layer');
const finalImageContainer = document.getElementById('final-image-container');
const sourceImage = document.getElementById('source-image');
const canvas = document.getElementById('analysis-canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const progress = document.getElementById('progress');
const hint = document.getElementById('hint');
const revealText = document.getElementById('reveal-text');
// 分析图像
function analyzeImage() {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
canvas.width = IMG_WIDTH;
canvas.height = IMG_HEIGHT;
// 强对比度 + 灰度
ctx.filter = 'contrast(1.8) brightness(1.0) saturate(0)';
ctx.drawImage(img, 0, 0, IMG_WIDTH, IMG_HEIGHT);
ctx.filter = 'none';
const imageData = ctx.getImageData(0, 0, IMG_WIDTH, IMG_HEIGHT);
const data = imageData.data;
// 构建亮度图并找极值
let brightnessMap = [];
let minB = 255, maxB = 0;
for (let y = 0; y < IMG_HEIGHT; y++) {
brightnessMap[y] = [];
for (let x = 0; x < IMG_WIDTH; x++) {
const i = (y * IMG_WIDTH + x) * 4;
const b = data[i] * 0.299 + data[i+1] * 0.587 + data[i+2] * 0.114;
brightnessMap[y][x] = b;
if (b < minB) minB = b;
if (b > maxB) maxB = b;
}
}
// 归一化
const range = maxB - minB || 1;
for (let y = 0; y < IMG_HEIGHT; y++) {
for (let x = 0; x < IMG_WIDTH; x++) {
brightnessMap[y][x] = ((brightnessMap[y][x] - minB) / range) * 255;
}
}
// 生成网格数据
gridData = [];
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = col * CELL_SIZE + CELL_SIZE / 2;
const cy = row * CELL_SIZE + CELL_SIZE / 2;
// 采样3x3区域取平均,更平滑
let sum = 0, count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const sy = Math.floor(cy) + dy;
const sx = Math.floor(cx) + dx;
if (sy >= 0 && sy < IMG_HEIGHT && sx >= 0 && sx < IMG_WIDTH) {
sum += brightnessMap[sy][sx];
count++;
}
}
}
const brightness = sum / count;
const darkness = 255 - brightness;
// 更低的阈值,捕获更多细节
if (darkness > 15) {
// 非线性映射,增强对比
const d = darkness / 255;
const dd = Math.pow(d, 0.8); // 提升暗部
const size = 5 + dd * 9; // 5-14px
const alpha = 0.08 + dd * 0.9; // 0.08-0.98
gridData.push({
x: cx,
y: cy,
size: size,
alpha: alpha,
darkness: darkness
});
}
}
}
// 按暗度排序
gridData.sort((a, b) => b.darkness - a.darkness);
// 分块打乱
const n = gridData.length;
const c1 = shuffleArray(gridData.slice(0, n * 0.2));
const c2 = shuffleArray(gridData.slice(n * 0.2, n * 0.4));
const c3 = shuffleArray(gridData.slice(n * 0.4, n * 0.6));
const c4 = shuffleArray(gridData.slice(n * 0.6, n * 0.8));
const c5 = shuffleArray(gridData.slice(n * 0.8));
gridData = [...c1, ...c2, ...c3, ...c4, ...c5];
console.log(`生成了 ${gridData.length} 个网格位置`);
resolve();
};
img.onerror = () => {
generateFallbackGrid();
resolve();
};
img.src = sourceImage.src;
});
}
function generateFallbackGrid() {
gridData = [];
const cols = Math.floor(IMG_WIDTH / CELL_SIZE);
const rows = Math.floor(IMG_HEIGHT / CELL_SIZE);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const cx = col * CELL_SIZE + CELL_SIZE / 2;
const cy = row * CELL_SIZE + CELL_SIZE / 2;
const dx = (cx - IMG_WIDTH/2) / (IMG_WIDTH * 0.4);
const dy = (cy - IMG_HEIGHT/2) / (IMG_HEIGHT * 0.48);
if (dx*dx + dy*dy < 1) {
const d = 1 - Math.sqrt(dx*dx + dy*dy);
gridData.push({
x: cx, y: cy,
size: 6 + d * 8,
alpha: 0.2 + d * 0.6,
darkness: d * 200
});
}
}
}
gridData = shuffleArray(gridData);
}
function shuffleArray(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function getNextGridCells(count) {
const cells = [];
for (let i = 0; i < count && gridIndex < gridData.length; i++) {
cells.push(gridData[gridIndex++]);
}
return cells;
}
async function init() {
await analyzeImage();
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
asciiLayer.style.width = IMG_WIDTH + 'px';
asciiLayer.style.height = IMG_HEIGHT + 'px';
// 创建更多句子
for (let i = 0; i < totalClues; i++) {
const el = document.createElement('div');
el.classList.add('clue');
const text = vocab[i % vocab.length];
el.innerText = text;
el.dataset.text = text;
const angle = (i / totalClues) * Math.PI * 2 + Math.random() * 0.3;
const dist = 280 + Math.random() * 280;
const startX = centerX + Math.cos(angle) * dist;
const startY = centerY + Math.sin(angle) * dist;
el.style.left = startX + 'px';
el.style.top = startY + 'px';
el.dataset.startX = startX;
el.dataset.startY = startY;
el.style.fontSize = (11 + Math.random() * 3) + 'px';
el.style.animationDelay = (Math.random() * 3) + 's';
el.addEventListener('mouseenter', handleHover);
stage.appendChild(el);
}
}
function handleHover(e) {
const el = e.target;
if (el.classList.contains('triggered') || isRevealed) return;
el.classList.add('triggered');
const text = el.dataset.text;
const startX = parseFloat(el.dataset.startX);
const startY = parseFloat(el.dataset.startY);
const imgRect = finalImageContainer.getBoundingClientRect();
const imgCenterX = imgRect.left + imgRect.width / 2;
const imgCenterY = imgRect.top + imgRect.height / 2;
const chars = text.split('');
const cells = getNextGridCells(chars.length);
chars.forEach((char, i) => {
if (i >= cells.length) return;
const cell = cells[i];
const charEl = document.createElement('div');
charEl.classList.add('char', 'flying');
charEl.innerText = char;
const startPosX = startX - imgCenterX + IMG_WIDTH/2;
const startPosY = startY - imgCenterY + IMG_HEIGHT/2;
charEl.style.left = startPosX + 'px';
charEl.style.top = startPosY + 'px';
charEl.style.fontSize = cell.size + 'px';
charEl.style.color = `rgba(255, 255, 255, ${cell.alpha})`;
charEl.style.width = CELL_SIZE + 'px';
charEl.style.height = CELL_SIZE + 'px';
charEl.style.lineHeight = CELL_SIZE + 'px';
asciiLayer.appendChild(charEl);
setTimeout(() => {
charEl.style.left = cell.x + 'px';
charEl.style.top = cell.y + 'px';
charEl.classList.add('placed');
}, 5 + i * 10);
});
collectedCount++;
const pct = Math.round((gridIndex / gridData.length) * 100);
progress.textContent = pct + '%';
if (collectedCount > 5) hint.classList.add('hidden');
// 90% 格子填满触发结局
if (gridIndex >= gridData.length * 0.9) {
revealIdentity();
}
}
function revealIdentity() {
if (isRevealed) return;
isRevealed = true;
const allChars = document.querySelectorAll('.char.placed');
allChars.forEach((el, i) => {
setTimeout(() => {
el.style.transition = 'opacity 1.5s ease';
el.style.opacity = '0.03';
}, i * 0.3);
});
setTimeout(() => {
finalImageContainer.style.transition = 'opacity 2s ease';
finalImageContainer.style.opacity = 1;
}, 100);
setTimeout(() => {
revealText.classList.add('show');
}, 800);
document.querySelectorAll('.clue:not(.triggered)').forEach(el => {
el.style.transition = 'opacity 0.3s';
el.style.opacity = 0;
});
progress.style.transition = 'opacity 0.5s';
progress.style.opacity = 0;
}
window.onload = init;
</script>
</body>
</html>