feat: add cs-dust2 React case with two model takes + react kind support

New case cs-dust2 (kind:react): a from-scratch React + three.js 5v5 Dust2 FPS.
Two takes registered: Claude Fable 5 / Claude Code / High (with a hand fix for a
64Hz fixed-step firing bug on high-refresh displays) and Claude Opus 4.8 /
Claude Code / Max (dust2-arena, one-shot). React cases commit source only; the
single file is built from source at deploy time. validate-data.ts now accepts a
React project directory as the declared artifact. AGENTS.md rewritten
English-only with React-case, bare-harness (no skills/plugins) and
artifact-anonymity rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse ZhangandClaude Opus 4.8 committed 2026-07-14 00:08:28 +01:00
1 parent c4cc293748
commit cbfce3b7cc
62 files changed
+13583 -43

No files matched your search

+51 -37
View File
@@ -1,18 +1,15 @@
# AGENTS.md # AGENTS.md
Guidance for humans and coding agents contributing to **NAGI BENCH** data Guidance for humans and coding agents contributing to **NAGI BENCH** data
(https://bench.nagi.fun/). Read this before opening a PR. 中文说明见每节。 (https://bench.nagi.fun/). Read this before opening a PR.
## What this repo is / 这个仓库是什么 ## What this repo is
The **public data repo**: community-contributed *one-shot* LLM eval artifacts. The **public data repo**: community-contributed *one-shot* LLM eval artifacts.
You add a model's raw output for a case; the site renders it verbatim. This repo You add a model's raw output for a case; the site renders it verbatim. This repo
holds **data only** — no site code, no build of the page you see. holds **data only** — no site code, no build of the page you see.
公开**数据仓库**:社区贡献的「一次生成」LLM 测评产物。你提交某模型在某个 case 上的 ## A contribution is exactly two files
原始输出,站点会原样渲染。这里只有**数据**,没有站点代码。
## A contribution is exactly two files / 一次贡献就两个文件
1. **The artifact** — the raw model output, dropped at 1. **The artifact** — the raw model output, dropped at
`outputs/<artifactDir>/<case-id>.<ext>` (e.g. a single-file `.html` or `.svg`). `outputs/<artifactDir>/<case-id>.<ext>` (e.g. a single-file `.html` or `.svg`).
@@ -22,8 +19,8 @@ Do **not** touch anything else. In particular:
- **Do not edit `README.md` / `README.en.md`.** The Registry tables are - **Do not edit `README.md` / `README.en.md`.** The Registry tables are
generated from `models/*.json` and synced automatically after merge. Hand generated from `models/*.json` and synced automatically after merge. Hand
edits are overwritten. (**不要改 README**,注册表是自动生成并在合并后自动同步的。) edits are overwritten.
- **Do not edit `cases.json`.** Cases are maintainer-owned.(case 由维护者管理。) - **Do not edit `cases.json`.** Cases are maintainer-owned.
## `models/<model-id>.json` schema ## `models/<model-id>.json` schema
@@ -47,25 +44,23 @@ Do **not** touch anything else. In particular:
} }
``` ```
### Naming rules / 命名规则 ### Naming rules
- **The filename IS the entry id** — lowercase letters, digits and dashes only - **The filename IS the entry id** — lowercase letters, digits and dashes only
(`deepseek-v4-flash-reasonix`). It is the **stable voting key** and the default (`deepseek-v4-flash-reasonix`). It is the **stable voting key** and the default
`outputs/` folder, so name it after the **model**, not the harness. Bad: `outputs/` folder, so name it after the **model**, not the harness. Bad:
`reasonix-reasonix-max`. Good: `deepseek-v4-flash-reasonix`. `reasonix-reasonix-max`. Good: `deepseek-v4-flash-reasonix`.
(文件名即条目 id,是稳定投票主键——按**模型**命名,别按 harness 命名。)
- `artifactDir` may be one or two dash-case segments - `artifactDir` may be one or two dash-case segments
(`deepseek-v4-flash/reasonix-max`); set it when several entries share a model (`deepseek-v4-flash/reasonix-max`); set it when several entries share a model
folder. Leave it out to default to the id. folder. Leave it out to default to the id.
### One entry per (model × harness × effort) / 一个组合一个条目 ### One entry per (model × harness × effort)
The same model under a different **harness** or **effort** is a **separate** The same model under a different **harness** or **effort** is a **separate**
`models/*.json` file, not an extra variant inside an existing one. "Codex", `models/*.json` file, not an extra variant inside an existing one. "Codex",
"Cursor", "Claude Code", "AntiGravity" name the **harness**, never the model. "Cursor", "Claude Code", "AntiGravity" name the **harness**, never the model.
(同一模型换 harness 或 effort = **新建** JSON,不要塞进现有条目。)
### Multiple takes of one case / 同一 case 留多版 ### Multiple takes of one case
Ran the **same** model × harness × effort on a case more than once and want to Ran the **same** model × harness × effort on a case more than once and want to
keep several outputs? Make `runs.<case-id>` an **array** — this is the *only* keep several outputs? Make `runs.<case-id>` an **array** — this is the *only*
@@ -89,30 +84,51 @@ still a new file, per above):
- Each variant carries its own `note` and may set its own `contributor`, so - Each variant carries its own `note` and may set its own `contributor`, so
different people can add takes to the same model entry. different people can add takes to the same model entry.
(同一 **model × harness × effort** 想留多张,就把 `runs.<case>` 写成**数组**,别新建 ### The `note` is required and bilingual
JSON——这是唯一能把多份产物塞进一个条目的情形。第一版可省略 `file`,其余每版都要有
各自不同、描述性的 dash-case `file` 名;**别用 `index.html`**(会被当成目录默认页)。
每版各自带 note,可各自署名 contributor。)
### The `note` is required and bilingual / note 必须双语
Every run needs `note.zh` + `note.en` documenting provenance: which harness, Every run needs `note.zh` + `note.en` documenting provenance: which harness,
which effort, one-shot or fixed. If you applied any fix, say where the bug was which effort, one-shot or fixed. If you applied any fix, say where the bug was
and what you changed. **No emojis anywhere** (hard rule). zh first. and what you changed. **No emojis anywhere** (hard rule). zh first.
### Run the harness as it ships / 用 harness 开箱即用的默认形态 ### Run the bare harness — NO skills, NO plugins
Use the harness in its **default, out-of-the-box** configuration: no extra Run the model with the harness's default model and effort, but otherwise
skills, plugins, MCP servers or custom system prompts beyond what it bundles. **stripped down**: **disable every skill and every plugin — including the ones
These shift the output as much as swapping the harness does, so such a run is the harness ships with and would auto-invoke** — and add no MCP servers and no
not comparable to others under the same harness and **will not be accepted**. custom system prompt. A skill or plugin (a built-in `design`, `gsap`, game, or
Disclosing it in `note` does not fix this: the leaderboard identity is only diagram helper, an added toolset, etc.) rewrites the output far more than the
model × harness × effort, so a note gives the modified setup no separate model itself does, so a run with any of them active measures the tooling, not the
identity — it would still be mixed into an Agent row it is not comparable with. model × harness × effort. It is not comparable to other entries and **will not be
(用 harness 的**默认形态**:不要额外加载 harness 自带以外的 skill / 插件 / accepted**. Disclosing it in `note` does not fix this: leaderboard identity is
MCP / 自定义系统提示——它们对产出的影响不亚于换 harness,这类 run 与同一 only model × harness × effort, so a modified setup gets no separate identity — it
harness 下其它条目不可比,**不予收录**。在 `note` 里写明也没用:榜单身份只看 would just be mixed into a row it is not comparable with.
模型 × harness × effort,note 不另立身份,它仍会混进本不该可比的那一行。)
### Keep the artifact anonymous — no identity leaks
**The output must not reveal which model, vendor, or harness produced it.** Strip
any `<!-- Generated by ... -->` header, watermark, "as an AI / as a large
language model" phrasing, or signature in visible text or code comments before
you submit. Blind Arena battles depend on outputs being anonymous, so a
self-identifying artifact invalidates the entry.
## React cases — contribute `src/` only
Some cases (marked `kind: "react"` in `cases.json`) ask for a real multi-file
React project instead of a single file. For these your artifact is **the `src/`
tree only** — multiple files and nested folders are fine — dropped at
`outputs/<artifactDir>/src/`. The site owns the whole toolchain and builds every
React case with **one shared scaffold** (its own installed React + Vite), so
there is no per-project dependency setup:
- **Do NOT include `package.json`, a lockfile, `node_modules`, `index.html`, or
any build / Vite / TS config.** The scaffold supplies all of it.
- **Use only libraries the site already ships** (React + ReactDOM, plus whatever
is in its `package.json`). The build installs nothing new — you cannot pull in
arbitrary npm packages.
- **The entry point is `src/main.tsx`**, mounting your app into `<div id="root">`.
- At build time the `src/` is bundled and inlined into **one self-contained HTML
file** that the normal viewer serves verbatim. The anonymity rule above still
applies: no model / vendor / harness identity anywhere in the code.
## Review guidelines ## Review guidelines
@@ -133,7 +149,7 @@ Do not ask contributors to improve or repair the submitted artifacts themselves.
If an artifact bug matters, mention it only as optional context for maintainers, If an artifact bug matters, mention it only as optional context for maintainers,
not as required PR feedback. not as required PR feedback.
## Before you open the PR / 提交前 ## Before you open the PR
```bash ```bash
bun scripts/validate-data.ts # the exact schema check CI runs bun scripts/validate-data.ts # the exact schema check CI runs
@@ -143,18 +159,16 @@ This enforces: dash-only ids, required bilingual `note`, declared artifacts
exist and are non-empty, no orphan files under `outputs/`. Green here = green CI. exist and are non-empty, no orphan files under `outputs/`. Green here = green CI.
You do **not** need to run `update-registry.ts` — README is synced for you after You do **not** need to run `update-registry.ts` — README is synced for you after
merge.(不需要手动跑 update-registry,README 合并后自动同步。) merge.
### Attach a screenshot / 附一张截图 ### Attach a screenshot
The PR template asks for a **screenshot of the generation** — your harness/IDE The PR template asks for a **screenshot of the generation** — your harness/IDE
showing the model name + effort while producing this artifact. It is the proof showing the model name + effort while producing this artifact. It is the proof
that the declared `harness` / `model` / `effort` are real. Paste it into the PR that the declared `harness` / `model` / `effort` are real. Paste it into the PR
body (GitHub hosts it); **do not commit image files** into the repo. body (GitHub hosts it); **do not commit image files** into the repo.
(PR 里贴一张**生成过程截图**作为「确实用了这套 harness + 模型 + effort」的凭证,
直接贴进 PR 正文,不要把图片提交进仓库。)
## Conventions / 约定 ## Conventions
- No emojis in any data, note, or UI-bound text. - No emojis in any data, note, or UI-bound text.
- `outputs/` files are served verbatim in a sandboxed iframe. Keep them - `outputs/` files are served verbatim in a sandboxed iframe. Keep them
+52 -6
View File
@@ -3,7 +3,10 @@
"id": "mythos-craft", "id": "mythos-craft",
"index": "01", "index": "01",
"kind": "html", "kind": "html",
"title": { "zh": "神话境界", "en": "Mythos Craft" }, "title": {
"zh": "神话境界",
"en": "Mythos Craft"
},
"tagline": { "tagline": {
"zh": "单个 HTML 文件,一次生成一款《我的世界》式的可玩 3D 体素世界。", "zh": "单个 HTML 文件,一次生成一款《我的世界》式的可玩 3D 体素世界。",
"en": "A playable Minecraft-style 3D voxel world, generated as a single HTML file in one shot." "en": "A playable Minecraft-style 3D voxel world, generated as a single HTML file in one shot."
@@ -12,13 +15,21 @@
"zh": "你是一个顶级的资深游戏开发者。请为我使用单 HTML5 和 Canvas(或 Three.js)编写一个《我的世界》克隆版。请包含以下核心功能:\n\n1. 一个基于柏林噪声(Perlin Noise)生成的无限或可滚动的 3D 地形。\n2. 第一人称视角,支持使用 WASD 移动和鼠标环视。\n3. 支持至少 3 种方块类型(如:泥土、草地、石头等),并支持鼠标左键破坏方块,右键放置方块。材质要尽可能还原。\n4. 要有一个内置的、极其丰富的神话幻想中的建筑和地形等,由你自行设计。", "zh": "你是一个顶级的资深游戏开发者。请为我使用单 HTML5 和 Canvas(或 Three.js)编写一个《我的世界》克隆版。请包含以下核心功能:\n\n1. 一个基于柏林噪声(Perlin Noise)生成的无限或可滚动的 3D 地形。\n2. 第一人称视角,支持使用 WASD 移动和鼠标环视。\n3. 支持至少 3 种方块类型(如:泥土、草地、石头等),并支持鼠标左键破坏方块,右键放置方块。材质要尽可能还原。\n4. 要有一个内置的、极其丰富的神话幻想中的建筑和地形等,由你自行设计。",
"en": "You are a top-tier veteran game developer. Build me a Minecraft clone using a single HTML5 file and Canvas (or Three.js). Include the following core features:\n\n1. An infinite or scrollable 3D terrain generated from Perlin noise.\n2. A first-person view with WASD movement and mouse look.\n3. At least 3 block types (e.g. dirt, grass, stone), with left-click to break and right-click to place blocks. Keep the textures as faithful as possible.\n4. A built-in, extremely rich set of mythic-fantasy buildings and terrain, designed at your own discretion." "en": "You are a top-tier veteran game developer. Build me a Minecraft clone using a single HTML5 file and Canvas (or Three.js). Include the following core features:\n\n1. An infinite or scrollable 3D terrain generated from Perlin noise.\n2. A first-person view with WASD movement and mouse look.\n3. At least 3 block types (e.g. dirt, grass, stone), with left-click to break and right-click to place blocks. Keep the textures as faithful as possible.\n4. A built-in, extremely rich set of mythic-fantasy buildings and terrain, designed at your own discretion."
}, },
"tags": ["HTML", "3D", "GAME", "ONE-SHOT"] "tags": [
"HTML",
"3D",
"GAME",
"ONE-SHOT"
]
}, },
{ {
"id": "pelican-cycling", "id": "pelican-cycling",
"index": "02", "index": "02",
"kind": "svg", "kind": "svg",
"title": { "zh": "鹈鹕海边骑行", "en": "Pelican by the Sea" }, "title": {
"zh": "鹈鹕海边骑行",
"en": "Pelican by the Sea"
},
"tagline": { "tagline": {
"zh": "经典 SVG 测评题:让模型用代码「画」出一只骑自行车的鹈鹕。", "zh": "经典 SVG 测评题:让模型用代码「画」出一只骑自行车的鹈鹕。",
"en": "The classic SVG eval: make the model \"draw\" a pelican riding a bicycle, in code." "en": "The classic SVG eval: make the model \"draw\" a pelican riding a bicycle, in code."
@@ -27,13 +38,20 @@
"zh": "制作一个逼真的、生动的、在海边骑着自行车的鹈鹕 <svg>", "zh": "制作一个逼真的、生动的、在海边骑着自行车的鹈鹕 <svg>",
"en": "Create a realistic, lively <svg> of a pelican riding a bicycle by the sea." "en": "Create a realistic, lively <svg> of a pelican riding a bicycle by the sea."
}, },
"tags": ["SVG", "ILLUSTRATION", "ONE-SHOT"] "tags": [
"SVG",
"ILLUSTRATION",
"ONE-SHOT"
]
}, },
{ {
"id": "skeleton-watch", "id": "skeleton-watch",
"index": "03", "index": "03",
"kind": "html", "kind": "html",
"title": { "zh": "镂空机械表", "en": "Skeleton Watch" }, "title": {
"zh": "镂空机械表",
"en": "Skeleton Watch"
},
"tagline": { "tagline": {
"zh": "单个 HTML 文件,一次生成一只可旋转、可爆炸视图的 3D 镂空机械腕表,机芯全可见。", "zh": "单个 HTML 文件,一次生成一只可旋转、可爆炸视图的 3D 镂空机械腕表,机芯全可见。",
"en": "A single HTML file: a rotatable, exploded-view 3D skeleton mechanical watch with a fully visible movement, generated in one shot." "en": "A single HTML file: a rotatable, exploded-view 3D skeleton mechanical watch with a fully visible movement, generated in one shot."
@@ -42,6 +60,34 @@
"zh": "创建一个精美、高细节的交互式 3D 镂空机械表。\n\n核心要求:\n• 完全可见的复杂机芯:包括主发条、齿轮系、带游丝的摆轮、擒纵机构、夹板、红宝石轴承等。\n• 真实的金属材质、蓝宝石水晶玻璃、反射、阴影,以及奢华腕表摄影级别的照明效果。\n• 荧光指针与刻度。\n• 交互式控制:支持旋转手表、切换爆炸视图、悬停/点击时高亮显示的零件标签、机芯信息面板。\n• 流畅的动画,达到专业级产品展示质量。\n• 深色优雅背景,带有细微的反射效果。\n\n在一次回复中尽可能真实且令人惊叹地完成。输出为一个完整的、自包含的单 HTML 文件,使用 Three.js (CDN),所有代码均采用内联形式。", "zh": "创建一个精美、高细节的交互式 3D 镂空机械表。\n\n核心要求:\n• 完全可见的复杂机芯:包括主发条、齿轮系、带游丝的摆轮、擒纵机构、夹板、红宝石轴承等。\n• 真实的金属材质、蓝宝石水晶玻璃、反射、阴影,以及奢华腕表摄影级别的照明效果。\n• 荧光指针与刻度。\n• 交互式控制:支持旋转手表、切换爆炸视图、悬停/点击时高亮显示的零件标签、机芯信息面板。\n• 流畅的动画,达到专业级产品展示质量。\n• 深色优雅背景,带有细微的反射效果。\n\n在一次回复中尽可能真实且令人惊叹地完成。输出为一个完整的、自包含的单 HTML 文件,使用 Three.js (CDN),所有代码均采用内联形式。",
"en": "Create an exquisite, highly detailed, interactive 3D skeleton mechanical watch.\n\nCore requirements:\n• A fully visible, complex movement: mainspring, gear train, balance wheel with hairspring, escapement, bridges, ruby bearings, and so on.\n• Realistic metal materials, sapphire crystal glass, reflections, shadows, and luxury-watch photography-grade lighting.\n• Luminous hands and markers.\n• Interactive controls: rotate the watch, toggle an exploded view, part labels highlighted on hover/click, and a movement info panel.\n• Smooth animation at professional product-showcase quality.\n• A dark, elegant background with subtle reflections.\n\nMake it as realistic and stunning as possible in a single response. Output a complete, self-contained single HTML file using Three.js (CDN), with all code inline." "en": "Create an exquisite, highly detailed, interactive 3D skeleton mechanical watch.\n\nCore requirements:\n• A fully visible, complex movement: mainspring, gear train, balance wheel with hairspring, escapement, bridges, ruby bearings, and so on.\n• Realistic metal materials, sapphire crystal glass, reflections, shadows, and luxury-watch photography-grade lighting.\n• Luminous hands and markers.\n• Interactive controls: rotate the watch, toggle an exploded view, part labels highlighted on hover/click, and a movement info panel.\n• Smooth animation at professional product-showcase quality.\n• A dark, elegant background with subtle reflections.\n\nMake it as realistic and stunning as possible in a single response. Output a complete, self-contained single HTML file using Three.js (CDN), with all code inline."
}, },
"tags": ["HTML", "3D", "THREE.JS", "ONE-SHOT"] "tags": [
"HTML",
"3D",
"THREE.JS",
"ONE-SHOT"
]
},
{
"id": "cs-dust2",
"index": "04",
"kind": "react",
"title": {
"zh": "CS Dust2 5v5",
"en": "CS Dust2 5v5"
},
"tagline": {
"zh": "React + three.js 从零手写的 3D 第一人称射击原型,复刻 Dust2 的 5v5 对战。",
"en": "A from-scratch 3D FPS in React + three.js: a 5v5 battle on a rebuilt Dust2."
},
"prompt": {
"zh": "用 **React + TypeScript + three.js** 从零开发一个可在浏览器直接运行的 3D 第一人称射击原型:复刻 Dust2 核心区域的 5v5 对战。你可以自行搭建项目、安装依赖、运行调试并迭代,直到它能正常游玩。\n\n## 交付形式\n\n- 一个标准 **Vite + React 18 + TypeScript** 项目,含完整 `package.json`、`vite.config.ts`、`index.html`、`src/`,`npm install && npm run dev` 即可打开游玩。\n- **不使用任何外部资产文件**(3D 模型、贴图、音频一律不用)。地图、角色、武器全部在代码里用几何体 procedural 生成,声音用 Web Audio 程序化合成。第三方库只用 three / react / react-dom;寻路、状态机、物理、渲染循环都自己实现,不引入现成游戏引擎。\n\n## 1. Dust2 地图\n\n在 3D 世界里搭出空间尺度合理的 Dust2 经典区域,**必须完整包含**:T 出生点、CT 出生点、A大、A点、中门(含可穿过的中门门体)、猫道、B洞、B点,并保证这些区域按 Dust2 的实际走位彼此连通。要么全部包含,不要交出缺区域的残缺版本。所有墙体、箱子都有物理碰撞,玩家和 AI 都无法穿墙。\n\n## 2. 角色\n\n在没有外部模型的前提下,用代码搭出**类人型角色**:必须有独立的头、躯干、双臂、双腿——**严禁用胶囊体或圆柱体等基础几何体敷衍成一根柱子**。CT 与 T 两个阵营在外观上要能一眼区分,角色默认是持枪姿态,武器随角色朝向移动。\n\n## 3. 武器系统\n\n做一套可扩展的武器数据系统,支持在主武器 / 副武器 / 近战之间切换,并有对应的第一人称手持模型。至少还原以下手感差异:\n\n- **AK-47**:高伤害,开火后坐力剧烈(准星扩散 + 视角上扬明显)。\n- **M4A4**:中等伤害,后坐力低,射速高。\n- **AWP**:单发致命,射速极低,**右键开镜**(3D 视野拉近 + 2D 瞄准镜遮罩覆盖)。\n- **手枪**:至少 Glock(T 默认)、USP(CT 默认)、沙鹰,用于手枪局;伤害低于步枪、沙鹰介于两者之间且弹匣小。\n- **近战刀**。\n\n命中判定按 **Hitbox 分区**(头 / 胸 / 腹 / 手臂 / 腿),伤害各不相同,**爆头伤害是身体的两倍**。有护甲时按护甲减伤。\n\n## 4. 手枪局\n\n支持\"手枪局\"开局:全员只带默认手枪、没有主武器、护甲按手枪局配置。这是标准 CS 的第一回合形态。\n\n## 5. C4 炸弹与回合胜负\n\n- 回合开始时 C4 交给某个 T;持包者阵亡后炸弹掉落,可被其他 T 捡起。\n- **下包**:持包 T 在 A点或 B点范围内可安放 C4,安放后进入倒计时。\n- **拆包**:CT 站在已安放的 C4 处,持续一段时间完成拆除。\n- **回合结算**:T 清空 CT、或 C4 爆炸 → T 胜;CT 清空 T(且未下包)、或成功拆包 → CT 胜。\n- 玩家操控的角色阵亡后,可切换到存活队友的视角 / 接管其操控(bot)。\n\n## 6. 5v5 AI 战场\n\n场上共 10 人:1 名玩家 + 4 名 AI 队友,对抗 5 名 AI 敌人,分属 CT / T 两阵营。AI 需要:能在地图内自主寻路巡逻、不卡墙;有视野索敌判定(被墙挡住看不见);遭遇敌方阵营自动开火交战;并参与 C4 的下包 / 拆包流程(进攻方推包安放、防守方前往拆除)。\n\n## 7. 操作与 HUD\n\n- 流畅的第一人称控制:WASD 移动、空格跳跃、指针锁定鼠标转视角。\n- 现代 2D UI 覆盖层:十字准星、生命值 / 护甲、动态弹药面板(开火递减、换弹恢复)、右上角实时击杀播报(Killfeed)。\n- **小地图(minimap)**:常驻显示 Dust2 俯视轮廓,实时标出自己、队友、被看见的敌人的点位,以及 C4 的位置。\n\n## 8. 声音\n\n游戏要有音效反馈。由于不使用外部音频文件,用 **Web Audio API 程序化合成** 声音即可。至少包含:不同武器的开火声(AK / M4 / AWP / 手枪要有区分)、换弹声、脚步声、AWP 开镜声、命中反馈音、C4 安放 / 拆除 / 爆炸声,以及击杀提示音。\n\n## 架构要求\n\n这是工业级挑战。你要独立完成技术选型、架构设计、数据结构封装,以及渲染循环、物理帧更新、React 状态生命周期三者之间的数据同步。全局状态引擎、武器系统、AI 状态机、3D 渲染与物理组件等关键系统都要真正实现,而非留空占位。",
"en": "Build a browser-playable 3D first-person shooter prototype from scratch using **React + TypeScript + three.js**: a 5v5 battle recreating the core areas of Dust2. You may set up the project, install dependencies, run, debug and iterate until it is properly playable.\n\n## Deliverable\n\n- A standard **Vite + React 18 + TypeScript** project with a complete `package.json`, `vite.config.ts`, `index.html` and `src/`; `npm install && npm run dev` opens it to play.\n- **No external asset files whatsoever** (no 3D models, textures or audio). The map, characters and weapons are all generated procedurally from geometry in code, and sound is synthesized programmatically with Web Audio. The only third-party libraries are three / react / react-dom; pathfinding, state machines, physics and the render loop are all implemented yourself, with no off-the-shelf game engine.\n\n## 1. Dust2 map\n\nBuild the classic Dust2 areas at a sensible spatial scale in the 3D world, and you **must fully include**: T spawn, CT spawn, Long A, A site, Mid (with a passable set of Mid Doors), Catwalk, B tunnels and B site, connected to one another along Dust2's real routes. Include them all — do not submit a partial version missing areas. All walls and boxes have physical collision; neither players nor AI can pass through walls.\n\n## 2. Characters\n\nWith no external models, build **humanoid characters** in code: each must have a distinct head, torso, both arms and both legs — **do NOT cheat with a capsule or cylinder as a single column body**. The CT and T teams must be distinguishable at a glance; characters default to a weapon-holding pose and the weapon moves with the character's facing.\n\n## 3. Weapon system\n\nBuild an extensible weapon data system supporting switching between primary / secondary / melee, each with its own first-person held model. Reproduce at least these handling differences:\n\n- **AK-47**: high damage, heavy recoil after firing (pronounced crosshair spread + upward view kick).\n- **M4A4**: medium damage, low recoil, high fire rate.\n- **AWP**: one-shot lethal, very low fire rate, **right-click to scope** (3D zoom-in + a 2D scope overlay).\n- **Pistols**: at least Glock (T default), USP (CT default) and Deagle for pistol rounds; lower damage than rifles, Deagle in between with a small magazine.\n- **A melee knife**.\n\nHit detection uses **hitbox zones** (head / chest / stomach / arm / leg) with different damage each, and **headshot damage is twice body damage**. Armor reduces damage when present.\n\n## 4. Pistol round\n\nSupport a \"pistol round\" opening: everyone carries only their default pistol, no primary weapon, armor per pistol-round rules. This is the standard CS first round.\n\n## 5. C4 bomb and round win/loss\n\n- At round start the C4 is given to a T; when the carrier dies the bomb drops and can be picked up by another T.\n- **Plant**: a T carrying the bomb can plant the C4 within the A or B site, starting a countdown after planting.\n- **Defuse**: a CT standing at a planted C4 defuses it over a set time.\n- **Round resolution**: T wins by eliminating all CTs or the C4 exploding; CT wins by eliminating all Ts (with no plant) or by successfully defusing.\n- After your controlled character dies, you can switch to a living teammate's view / take over that bot.\n\n## 6. 5v5 AI battlefield\n\nTen players on the field: 1 human player + 4 AI teammates against 5 AI enemies, split into CT / T teams. The AI must: navigate and patrol the map autonomously without getting stuck on walls; have line-of-sight target detection (blocked by walls); automatically open fire on the enemy team on contact; and take part in the C4 plant / defuse flow (attackers push and plant, defenders go to defuse).\n\n## 7. Controls and HUD\n\n- Smooth first-person controls: WASD movement, space to jump, pointer-locked mouse to look.\n- A modern 2D UI overlay: crosshair, health / armor, a dynamic ammo panel (decrements on fire, restored on reload), and a live kill feed in the top-right.\n- A **minimap**: a persistent top-down Dust2 outline marking your own, teammates' and visible enemies' positions in real time, plus the C4's location.\n\n## 8. Sound\n\nThe game needs sound feedback. Since no external audio files are used, synthesize sound programmatically with the **Web Audio API**. Include at least: distinct firing sounds per weapon (AK / M4 / AWP / pistols differentiated), reload, footsteps, AWP scope-in, hit feedback, C4 plant / defuse / explosion, and a kill sound.\n\n## Architecture\n\nThis is an industrial-grade challenge. You own the technology choices, architecture, and data-structure design, plus the data sync between the render loop, the physics frame update, and the React state lifecycle. The global state engine, weapon system, AI state machine, and 3D-rendering and physics components must be genuinely implemented, not left as placeholders."
},
"tags": [
"REACT",
"3D",
"THREE.JS",
"GAME"
]
} }
] ]
+17
View File
@@ -0,0 +1,17 @@
{
"label": "Claude Fable 5",
"vendor": "Anthropic",
"harness": "Claude Code",
"effort": "High",
"artifactDir": "claude-fable-5/claude-code-high",
"order": 11,
"runs": {
"cs-dust2": {
"note": {
"zh": "在 Claude Code 中以 Claude Fable 5(High 思考配额)生成的完整 Vite + React 18 + three.js 工程;本页为其构建产物(部署时由源码打包为单文件)。手动修复了一处开火 bug:模拟以固定 64Hz 步长推进,高刷屏(渲染帧率高于 64)下某一帧可能不推进任何 fixed step,原实现按帧写入并清零 fireTap 等 edge 触发输入,会在该帧把点击吞掉、导致偶发不开火;修复改为用 || 将这些输入锁存至被某个 fixed step 消费为止(src/game/engine.ts),使开火与刷新率无关。",
"en": "A complete Vite + React 18 + three.js project generated by Claude Fable 5 (High effort) in Claude Code; this page is its build output (bundled from source into a single file at deploy time). One firing bug was fixed by hand: the simulation advances in fixed 64Hz steps, and on a high-refresh display (render fps above 64) a frame may run zero fixed steps, so the original per-frame assign-and-clear of edge-triggered inputs like fireTap could swallow a click on that frame and drop the shot; the fix latches those inputs with || until a fixed step consumes them (src/game/engine.ts), making firing refresh-rate-independent."
},
"contributor": "Nagi-ovo"
}
}
}
+8
View File
@@ -46,6 +46,14 @@
"en": "Generated in one shot by Claude Opus 4.8 (Max effort) inside Claude Code; unmodified original output. An interactive 3D skeleton mechanical watch on Three.js — orbit rotation, exploded view, highlighted part labels and a movement info panel, with escapement, balance wheel and other movement parts." "en": "Generated in one shot by Claude Opus 4.8 (Max effort) inside Claude Code; unmodified original output. An interactive 3D skeleton mechanical watch on Three.js — orbit rotation, exploded view, highlighted part labels and a movement info panel, with escapement, balance wheel and other movement parts."
}, },
"contributor": "Nagi-ovo" "contributor": "Nagi-ovo"
},
"cs-dust2": {
"file": "dust2-arena",
"note": {
"zh": "在 Claude Code 中以 Claude Opus 4.8(Max 思考配额)一次生成的完整 Vite + React 18 + three.js 工程,未经修改;本页为其构建产物(部署时由源码打包为单文件)。与 Claude Fable 5 条目同一道 cs-dust2 题、不同模型——可对比同题下两个模型的工程实现。",
"en": "A complete Vite + React 18 + three.js project generated in one shot by Claude Opus 4.8 (Max effort) in Claude Code, unmodified; this page is its build output (bundled from source into a single file at deploy time). Same cs-dust2 case as the Claude Fable 5 entry but a different model — compare how the two implement the same task."
},
"contributor": "Nagi-ovo"
} }
} }
} }
@@ -0,0 +1,267 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "cs2-dust2-sim",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.170.0",
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.170.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.170.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@webgpu/types": ["@webgpu/types@0.1.71", "", {}, "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DUST II — 5v5 模拟器</title>
<style>
html, body, #root { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0e12; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,24 @@
{
"name": "cs2-dust2-sim",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.170.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.170.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
@@ -0,0 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { Engine } from './game/engine'
import HUD from './ui/HUD'
export default function App() {
const viewportRef = useRef<HTMLDivElement>(null)
const [engine, setEngine] = useState<Engine | null>(null)
useEffect(() => {
const e = new Engine(viewportRef.current!)
setEngine(e)
;(window as unknown as { __engine?: Engine }).__engine = e
if (new URLSearchParams(location.search).has('auto')) e.start('CT', true)
return () => e.dispose()
}, [])
return (
<div className="app">
<div ref={viewportRef} className="viewport" />
{engine && <HUD engine={engine} />}
</div>
)
}
@@ -0,0 +1,386 @@
import * as THREE from 'three'
import type { GameMap } from './map'
import type { Nav } from './nav'
import type { PhysicsWorld } from './physics'
import type { Soldier } from './soldier'
import { EYE_HEIGHT } from './types'
export type Duty = 'none' | 'plant' | 'defuse' | 'pickup'
export interface Mission {
anchors: THREE.Vector3[]
final: THREE.Vector3
look: THREE.Vector3 | null
duty: Duty
}
export interface AIWorld {
map: GameMap
nav: Nav
physics: PhysicsWorld
soldiers: Soldier[]
time: number
bombPlantedPos: THREE.Vector3 | null
bombDroppedPos: THREE.Vector3 | null
}
const TWO_PI = Math.PI * 2
function angleLerp(a: number, target: number, maxStep: number): number {
let d = (target - a) % TWO_PI
if (d > Math.PI) d -= TWO_PI
if (d < -Math.PI) d += TWO_PI
return a + THREE.MathUtils.clamp(d, -maxStep, maxStep)
}
function angleDiff(a: number, b: number): number {
let d = (b - a) % TWO_PI
if (d > Math.PI) d -= TWO_PI
if (d < -Math.PI) d += TWO_PI
return d
}
/**
* Per-soldier AI: waypoint navigation over the grid, FOV+LOS target acquisition
* with reaction delay, burst-fire combat, plant/defuse/pickup duties.
*/
export class AIBrain {
s: Soldier
private skill: { err: number; react: number; aggro: number; headRate: number }
mission: Mission | null = null
private missionIdx = 0
private arrived = false
private path: THREE.Vector3[] | null = null
private pathIdx = 0
private pathGoal = new THREE.Vector3()
private repathAt = 0
target: Soldier | null = null
private targetSince = 0
private reactUntil = 0
private lastSeenPos = new THREE.Vector3()
private lastSeenAt = -99
private alertUntil = 0
private alertPos: THREE.Vector3 | null = null
private nextThink = 0
private burstUntil = 0
private burstPauseUntil = 0
private strafeDir = 0
private strafeUntil = 0
private scopeStart = 0
private stuckPos = new THREE.Vector3()
private stuckAt = 0
private wanderAt = 0
private lookYawTarget: number | null = null
constructor(s: Soldier) {
this.s = s
this.skill = {
err: 1.1 + Math.random() * 0.9,
react: 0.35 + Math.random() * 0.4,
aggro: Math.random(),
headRate: 0.04 + Math.random() * 0.1,
}
}
reset() {
this.mission = null
this.path = null
this.target = null
this.arrived = false
this.lastSeenAt = -99
this.alertPos = null
this.lookYawTarget = null
}
setMission(anchors: THREE.Vector3[], final: THREE.Vector3, look: THREE.Vector3 | null, duty: Duty) {
this.mission = { anchors: [...anchors], final, look, duty }
this.missionIdx = 0
this.arrived = false
this.path = null
this.lookYawTarget = null
}
heard(pos: THREE.Vector3, time: number) {
if (this.target) return
this.alertUntil = time + 3
this.alertPos = pos.clone()
}
update(dt: number, w: AIWorld) {
const s = this.s
const c = s.control
c.moveX = 0; c.moveZ = 0
c.fire = false; c.fireTap = false; c.altTap = false
c.jump = false; c.use = false; c.reload = false; c.slot = null
if (!s.alive) return
if (w.time >= this.nextThink) {
this.nextThink = w.time + 0.12 + Math.random() * 0.06
this.think(w)
}
if (this.target && w.time >= this.reactUntil) {
this.combat(dt, w)
} else if (this.target) {
this.facePoint(this.target.pos, dt) // reacting: turn but hold fire
} else if (this.lastSeenAt > w.time - 2.5 && this.skill.aggro > 0.35) {
this.chase(dt, w)
} else {
this.missionMove(dt, w)
}
this.dutyBehavior(w)
}
// -------------------------------------------------------------------------
private think(w: AIWorld) {
const s = this.s
// perception
let best: Soldier | null = null
let bestDist = Infinity
const eye = s.eyePos
for (const e of w.soldiers) {
if (!e.alive || e.team === s.team) continue
const dist = e.pos.distanceTo(s.pos)
if (dist > 70 || dist >= bestDist) continue
const dirYaw = Math.atan2(-(e.pos.x - s.pos.x), -(e.pos.z - s.pos.z))
const inFov = Math.abs(angleDiff(s.yaw, dirYaw)) < (w.time < this.alertUntil ? Math.PI : 0.95)
if (!inFov && dist > 3) continue
const targetEye = new THREE.Vector3(e.pos.x, e.pos.y + EYE_HEIGHT * 0.8, e.pos.z)
if (!w.physics.hasLOS(eye, targetEye)) continue
best = e
bestDist = dist
}
if (best) {
if (this.target !== best) {
if (!this.target) this.reactUntil = w.time + this.skill.react
this.target = best
this.targetSince = w.time
}
this.lastSeenPos.copy(best.pos)
this.lastSeenAt = w.time
best.lastSeenBy[this.s.team] = w.time
} else if (this.target) {
if (w.time - this.lastSeenAt > 0.5) this.target = null
}
// reload when safe or empty
const wpn = s.weapon
if (!wpn.isKnife) {
if (wpn.mag === 0 && (wpn.reserve > 0)) s.control.reload = true
else if (!this.target && wpn.mag < wpn.def.magSize * 0.35 && wpn.reserve > 0) s.control.reload = true
// completely dry: go knife
if (wpn.mag === 0 && wpn.reserve === 0) {
if (s.slot === 'primary' && s.weapons.secondary && (s.weapons.secondary.mag > 0 || s.weapons.secondary.reserve > 0)) s.control.slot = 'secondary'
else s.control.slot = 'knife'
}
}
// stuck detection while pathing
if (this.path && !this.arrived) {
if (w.time - this.stuckAt > 1.3) {
if (s.pos.distanceTo(this.stuckPos) < 0.35) {
this.path = null // force repath
this.repathAt = 0
}
this.stuckPos.copy(s.pos)
this.stuckAt = w.time
}
} else {
this.stuckPos.copy(s.pos)
this.stuckAt = w.time
}
}
private combat(dt: number, w: AIWorld) {
const s = this.s
const t = this.target!
const c = s.control
const dist = t.pos.distanceTo(s.pos)
const wpn = s.weapon
// knife rush when using knife
if (wpn.isKnife) {
this.moveToward(t.pos, dt, w, true)
this.facePoint(t.pos, dt)
if (dist < 2.0) c.fire = true
return
}
// aim: chest, occasionally head
const settle = w.time - this.targetSince
const aimY = Math.random() < this.skill.headRate && settle > 0.8 ? 1.62 : 1.15
const aimPoint = new THREE.Vector3(t.pos.x, t.pos.y + aimY, t.pos.z)
const errScale = this.skill.err * (0.45 + 0.55 * Math.exp(-settle * 1.1)) * (0.7 + dist / 55)
const wobble = Math.sin(w.time * 7 + s.id * 3.1) * 0.02 * errScale
aimPoint.x += wobble * dist * 0.5
aimPoint.y += Math.cos(w.time * 6 + s.id) * 0.016 * errScale * dist * 0.5
const desiredYaw = Math.atan2(-(aimPoint.x - s.pos.x), -(aimPoint.z - s.pos.z))
const flatDist = Math.hypot(aimPoint.x - s.pos.x, aimPoint.z - s.pos.z)
const desiredPitch = Math.atan2(aimPoint.y - s.eyePos.y, flatDist)
const turnRate = 6.5 * dt
s.yaw = angleLerp(s.yaw, desiredYaw, turnRate)
s.pitch = THREE.MathUtils.clamp(angleLerp(s.pitch, desiredPitch, turnRate), -1.5, 1.5)
const aligned = Math.abs(angleDiff(s.yaw, desiredYaw)) < 0.055 && Math.abs(s.pitch - desiredPitch) < 0.05
const isAwp = wpn.def.id === 'awp'
if (isAwp) {
if (!s.scoped && aligned) {
c.altTap = true // scope in
this.scopeStart = w.time
}
if (s.scoped && aligned && w.time - this.scopeStart > 0.75) c.fireTap = true
return // AWP AI stands still
}
// burst fire
if (aligned && wpn.mag > 0 && !wpn.reloading) {
if (w.time > this.burstPauseUntil) {
if (this.burstUntil < w.time) {
this.burstUntil = w.time + (wpn.def.auto ? 0.12 + Math.random() * 0.25 : 0.05)
this.burstPauseUntil = this.burstUntil + 0.25 + Math.random() * 0.5
}
}
if (w.time < this.burstUntil) {
c.fire = true
c.fireTap = true
}
}
// strafe jiggle for rifles at medium range
if (dist > 6 && dist < 40 && w.time > this.strafeUntil) {
this.strafeDir = Math.random() < 0.4 ? 0 : (Math.random() < 0.5 ? -1 : 1)
this.strafeUntil = w.time + 0.5 + Math.random() * 0.6
}
c.moveX = this.strafeDir * 0.6
if (dist > 45) c.moveZ = 0.7 // push closer at long range
}
private chase(dt: number, w: AIWorld) {
this.moveToward(this.lastSeenPos, dt, w, false)
this.facePoint(this.lastSeenPos, dt)
if (this.s.pos.distanceTo(this.lastSeenPos) < 2.5) this.lastSeenAt = -99
}
private missionMove(dt: number, w: AIWorld) {
const s = this.s
// face heard noises while holding
if (this.arrived) {
if (this.alertPos && w.time < this.alertUntil) {
this.facePoint(this.alertPos, dt)
} else if (this.mission?.look) {
this.facePoint(this.mission.look, dt)
} else if (this.lookYawTarget !== null) {
s.yaw = angleLerp(s.yaw, this.lookYawTarget, 3 * dt)
}
// occasional gaze wander + micro reposition
if (w.time > this.wanderAt) {
this.wanderAt = w.time + 3 + Math.random() * 4
this.lookYawTarget = s.yaw + (Math.random() - 0.5) * 1.6
if (this.mission?.look && Math.random() < 0.6) this.lookYawTarget = null
}
s.pitch = angleLerp(s.pitch, 0, 2 * dt)
return
}
if (!this.mission) return
const waypoint = this.missionIdx < this.mission.anchors.length
? this.mission.anchors[this.missionIdx]
: this.mission.final
if (this.moveToward(waypoint, dt, w, false)) {
if (this.missionIdx < this.mission.anchors.length) {
this.missionIdx++
this.path = null
} else {
this.arrived = true
this.path = null
}
}
}
/** Steers along an A* path toward `goal`. Returns true when within reach. */
private moveToward(goal: THREE.Vector3, dt: number, w: AIWorld, sprint: boolean): boolean {
const s = this.s
const dGoal = Math.hypot(goal.x - s.pos.x, goal.z - s.pos.z)
if (dGoal < 1.0) return true
if (!this.path || this.pathGoal.distanceTo(goal) > 2 || w.time > this.repathAt) {
this.path = w.nav.findPath(s.pos, goal)
this.pathIdx = 0
this.pathGoal.copy(goal)
this.repathAt = w.time + 3 + Math.random()
if (!this.path || this.path.length === 0) return true
}
let wp = this.path[this.pathIdx]
while (wp && Math.hypot(wp.x - s.pos.x, wp.z - s.pos.z) < 0.7) {
this.pathIdx++
if (this.pathIdx >= this.path.length) return true
wp = this.path[this.pathIdx]
}
if (!wp) return true
const dir = new THREE.Vector3(wp.x - s.pos.x, 0, wp.z - s.pos.z).normalize()
// convert world dir to local move axes
const fwd = new THREE.Vector3(-Math.sin(s.yaw), 0, -Math.cos(s.yaw))
const right = s.rightDir()
s.control.moveZ = dir.dot(fwd)
s.control.moveX = dir.dot(right)
if (!this.target) {
const wantYaw = Math.atan2(-dir.x, -dir.z)
s.yaw = angleLerp(s.yaw, wantYaw, (sprint ? 10 : 6) * dt)
s.pitch = angleLerp(s.pitch, 0, 3 * dt)
}
return false
}
private facePoint(p: THREE.Vector3, dt: number) {
const s = this.s
const wantYaw = Math.atan2(-(p.x - s.pos.x), -(p.z - s.pos.z))
s.yaw = angleLerp(s.yaw, wantYaw, 8 * dt)
}
private dutyBehavior(w: AIWorld) {
const s = this.s
if (!this.mission) return
const c = s.control
switch (this.mission.duty) {
case 'plant': {
if (!s.hasBomb) break
if (this.target) break // fight first
if (w.map.plantSiteAt(s.pos.x, s.pos.z) && this.arrived && s.onGround) {
c.use = true
c.moveX = 0; c.moveZ = 0
}
break
}
case 'defuse': {
if (!w.bombPlantedPos) break
const d = Math.hypot(w.bombPlantedPos.x - s.pos.x, w.bombPlantedPos.z - s.pos.z)
if (d < 1.3 && (!this.target || w.time - this.targetSince < 0.1)) {
c.use = true
c.moveX = 0; c.moveZ = 0
}
break
}
case 'pickup':
break // walking over the bomb picks it up (engine)
case 'none':
break
}
}
}
@@ -0,0 +1,245 @@
import * as THREE from 'three'
type ShotSound = 'ak' | 'm4' | 'awp' | 'pistol' | 'glock' | 'deagle' | 'knife'
/**
* All game audio is synthesized with the Web Audio API — no audio files.
* World sounds are attenuated by distance and stereo-panned relative to the camera.
*/
export class AudioMan {
private ctx: AudioContext | null = null
private master: GainNode | null = null
private noise: AudioBuffer | null = null
private listenerPos = new THREE.Vector3()
private listenerRight = new THREE.Vector3(1, 0, 0)
resume() {
if (!this.ctx) {
this.ctx = new AudioContext()
this.master = this.ctx.createGain()
this.master.gain.value = 0.5
this.master.connect(this.ctx.destination)
const len = this.ctx.sampleRate
this.noise = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
const data = this.noise.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
}
if (this.ctx.state === 'suspended') void this.ctx.resume()
}
setListener(pos: THREE.Vector3, right: THREE.Vector3) {
this.listenerPos.copy(pos)
this.listenerRight.copy(right)
}
private out(pos: THREE.Vector3 | null, gain: number): GainNode | null {
if (!this.ctx || !this.master) return null
const g = this.ctx.createGain()
if (pos) {
const d = pos.distanceTo(this.listenerPos)
const att = 1 / (1 + d * 0.05)
g.gain.value = gain * att
const dir = new THREE.Vector3().subVectors(pos, this.listenerPos)
const dl = dir.length()
const pan = this.ctx.createStereoPanner()
pan.pan.value = dl > 0.5 ? THREE.MathUtils.clamp(dir.dot(this.listenerRight) / dl, -0.85, 0.85) : 0
g.connect(pan)
pan.connect(this.master)
} else {
g.gain.value = gain
g.connect(this.master)
}
return g
}
private playNoise(opts: {
pos: THREE.Vector3 | null, gain: number, dur: number,
filter?: BiquadFilterType, freq?: number, q?: number, freqEnd?: number,
rate?: number, delay?: number,
}) {
if (!this.ctx || !this.noise) return
const dst = this.out(opts.pos, 1)
if (!dst) return
const t0 = this.ctx.currentTime + (opts.delay ?? 0)
const src = this.ctx.createBufferSource()
src.buffer = this.noise
src.loop = true
src.playbackRate.value = opts.rate ?? 1
const env = this.ctx.createGain()
env.gain.setValueAtTime(opts.gain, t0)
env.gain.exponentialRampToValueAtTime(0.001, t0 + opts.dur)
let node: AudioNode = src
if (opts.filter) {
const f = this.ctx.createBiquadFilter()
f.type = opts.filter
f.frequency.setValueAtTime(opts.freq ?? 1000, t0)
if (opts.freqEnd) f.frequency.exponentialRampToValueAtTime(opts.freqEnd, t0 + opts.dur)
f.Q.value = opts.q ?? 1
node.connect(f)
node = f
}
node.connect(env)
env.connect(dst)
src.start(t0, Math.random() * 0.5)
src.stop(t0 + opts.dur + 0.05)
}
private playTone(opts: {
pos: THREE.Vector3 | null, gain: number, dur: number,
freq: number, freqEnd?: number, type?: OscillatorType, delay?: number,
}) {
if (!this.ctx) return
const dst = this.out(opts.pos, 1)
if (!dst) return
const t0 = this.ctx.currentTime + (opts.delay ?? 0)
const osc = this.ctx.createOscillator()
osc.type = opts.type ?? 'sine'
osc.frequency.setValueAtTime(opts.freq, t0)
if (opts.freqEnd) osc.frequency.exponentialRampToValueAtTime(Math.max(1, opts.freqEnd), t0 + opts.dur)
const env = this.ctx.createGain()
env.gain.setValueAtTime(opts.gain, t0)
env.gain.exponentialRampToValueAtTime(0.001, t0 + opts.dur)
osc.connect(env)
env.connect(dst)
osc.start(t0)
osc.stop(t0 + opts.dur + 0.05)
}
shot(kind: ShotSound, pos: THREE.Vector3 | null) {
const r = 0.94 + Math.random() * 0.12
switch (kind) {
case 'ak':
this.playTone({ pos, gain: 0.55, dur: 0.1, freq: 165 * r, freqEnd: 85, type: 'square' })
this.playNoise({ pos, gain: 0.5, dur: 0.11, filter: 'bandpass', freq: 1300, q: 0.7, rate: r })
this.playNoise({ pos, gain: 0.3, dur: 0.025, filter: 'highpass', freq: 4800 })
break
case 'm4':
this.playTone({ pos, gain: 0.4, dur: 0.06, freq: 210 * r, freqEnd: 120, type: 'square' })
this.playNoise({ pos, gain: 0.42, dur: 0.07, filter: 'bandpass', freq: 2100, q: 0.8, rate: r })
this.playNoise({ pos, gain: 0.24, dur: 0.02, filter: 'highpass', freq: 6000 })
break
case 'awp':
this.playTone({ pos, gain: 0.9, dur: 0.35, freq: 92 * r, freqEnd: 42, type: 'sine' })
this.playNoise({ pos, gain: 0.85, dur: 0.32, filter: 'lowpass', freq: 900, freqEnd: 160, rate: r })
this.playNoise({ pos, gain: 0.45, dur: 0.05, filter: 'highpass', freq: 3000 })
break
case 'deagle':
this.playTone({ pos, gain: 0.7, dur: 0.13, freq: 135 * r, freqEnd: 65, type: 'square' })
this.playNoise({ pos, gain: 0.55, dur: 0.13, filter: 'bandpass', freq: 950, q: 0.7, rate: r })
break
case 'pistol':
this.playTone({ pos, gain: 0.32, dur: 0.045, freq: 290 * r, freqEnd: 150, type: 'square' })
this.playNoise({ pos, gain: 0.3, dur: 0.05, filter: 'bandpass', freq: 2400, q: 1, rate: r })
break
case 'glock':
this.playTone({ pos, gain: 0.3, dur: 0.05, freq: 330 * r, freqEnd: 170, type: 'square' })
this.playNoise({ pos, gain: 0.28, dur: 0.055, filter: 'bandpass', freq: 2800, q: 1, rate: r })
break
case 'knife':
this.playNoise({ pos, gain: 0.18, dur: 0.12, filter: 'bandpass', freq: 1800, freqEnd: 600, q: 2 })
break
}
}
knifeHit(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.35, dur: 0.08, filter: 'lowpass', freq: 700 })
this.playTone({ pos, gain: 0.2, dur: 0.07, freq: 220, freqEnd: 90 })
}
reload(pos: THREE.Vector3 | null, duration: number) {
const click = (delay: number, freq: number) => {
this.playTone({ pos, gain: 0.18, dur: 0.03, freq, type: 'square', delay })
this.playNoise({ pos, gain: 0.12, dur: 0.03, filter: 'highpass', freq: 2500, delay })
}
click(0.05, 700)
click(duration * 0.45, 500)
click(duration * 0.85, 900)
}
footstep(pos: THREE.Vector3 | null, vol: number) {
this.playNoise({
pos, gain: 0.16 * vol, dur: 0.055,
filter: 'lowpass', freq: 420 + Math.random() * 160, rate: 0.8 + Math.random() * 0.3,
})
}
scope(on: boolean) {
this.playTone({ pos: null, gain: 0.12, dur: 0.05, freq: on ? 1400 : 900, freqEnd: on ? 1900 : 600, type: 'sine' })
}
drawWeapon() {
this.playNoise({ pos: null, gain: 0.1, dur: 0.04, filter: 'highpass', freq: 2000 })
this.playTone({ pos: null, gain: 0.08, dur: 0.03, freq: 600, type: 'square', delay: 0.03 })
}
hit(headshot: boolean) {
if (headshot) {
this.playTone({ pos: null, gain: 0.25, dur: 0.1, freq: 1700, freqEnd: 1400, type: 'triangle' })
this.playTone({ pos: null, gain: 0.15, dur: 0.12, freq: 2550, freqEnd: 2100, type: 'sine', delay: 0.01 })
} else {
this.playTone({ pos: null, gain: 0.18, dur: 0.05, freq: 1150, freqEnd: 900, type: 'triangle' })
}
}
kill() {
this.playTone({ pos: null, gain: 0.2, dur: 0.07, freq: 880, type: 'sine' })
this.playTone({ pos: null, gain: 0.2, dur: 0.1, freq: 1320, type: 'sine', delay: 0.07 })
}
death(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.3, dur: 0.2, filter: 'lowpass', freq: 500, freqEnd: 120 })
}
damage() {
this.playNoise({ pos: null, gain: 0.2, dur: 0.09, filter: 'lowpass', freq: 800 })
}
bombBeep(pos: THREE.Vector3 | null, urgent: boolean) {
this.playTone({ pos, gain: urgent ? 0.3 : 0.22, dur: 0.07, freq: urgent ? 2900 : 2500, type: 'square' })
}
bombPlanted() {
for (let i = 0; i < 3; i++) {
this.playTone({ pos: null, gain: 0.16, dur: 0.06, freq: 2100, type: 'square', delay: i * 0.11 })
}
}
plantTick(pos: THREE.Vector3 | null) {
this.playTone({ pos, gain: 0.1, dur: 0.03, freq: 1600, type: 'square' })
}
defuseTick(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.14, dur: 0.04, filter: 'highpass', freq: 3200 })
}
bombDefused() {
this.playTone({ pos: null, gain: 0.22, dur: 0.12, freq: 900, type: 'sine' })
this.playTone({ pos: null, gain: 0.22, dur: 0.2, freq: 1350, type: 'sine', delay: 0.12 })
}
explosion(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 1.4, dur: 1.3, filter: 'lowpass', freq: 2800, freqEnd: 60 })
this.playTone({ pos, gain: 1.0, dur: 1.1, freq: 55, freqEnd: 24, type: 'sine' })
this.playNoise({ pos, gain: 0.5, dur: 0.4, filter: 'bandpass', freq: 500, q: 0.5, delay: 0.06 })
}
doorSlide(pos: THREE.Vector3 | null) {
this.playNoise({ pos, gain: 0.16, dur: 0.35, filter: 'bandpass', freq: 300, freqEnd: 500, q: 2 })
}
roundStart() {
this.playTone({ pos: null, gain: 0.18, dur: 0.1, freq: 660, type: 'sine' })
this.playTone({ pos: null, gain: 0.18, dur: 0.16, freq: 990, type: 'sine', delay: 0.1 })
}
roundEnd(win: boolean) {
if (win) {
this.playTone({ pos: null, gain: 0.2, dur: 0.12, freq: 784, type: 'triangle' })
this.playTone({ pos: null, gain: 0.2, dur: 0.12, freq: 988, type: 'triangle', delay: 0.12 })
this.playTone({ pos: null, gain: 0.2, dur: 0.24, freq: 1319, type: 'triangle', delay: 0.24 })
} else {
this.playTone({ pos: null, gain: 0.2, dur: 0.16, freq: 494, type: 'triangle' })
this.playTone({ pos: null, gain: 0.2, dur: 0.3, freq: 330, type: 'triangle', delay: 0.16 })
}
}
}
@@ -0,0 +1,232 @@
import * as THREE from 'three'
import type { Team, WeaponId } from './types'
type GunKind = 'rifle' | 'awp' | 'pistol' | 'knife'
export function gunKindOf(id: WeaponId): GunKind {
if (id === 'awp') return 'awp'
if (id === 'ak47' || id === 'm4a4') return 'rifle'
if (id === 'knife') return 'knife'
return 'pistol'
}
function mat(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.9 })
}
function box(w: number, h: number, d: number, m: THREE.MeshStandardMaterial): THREE.Mesh {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m)
mesh.castShadow = true
return mesh
}
/**
* Procedural humanoid: separate head / torso / two arms / two legs,
* built entirely from code. Faces -Z. Feet at origin.
*/
export class CharacterRig {
root = new THREE.Group()
private body = new THREE.Group()
private legL: THREE.Mesh
private legR: THREE.Mesh
private head: THREE.Group
private aim = new THREE.Group()
private guns: Record<GunKind, THREE.Group>
private bombPack: THREE.Mesh
private mats: THREE.MeshStandardMaterial[] = []
private walkPhase = 0
private dead = false
private deathT = 0
private fallAxis = 1
constructor(team: Team) {
const skin = mat(0xd8b094)
const shirt = team === 'CT' ? mat(0x3d5a73) : mat(0xa08752)
const pants = team === 'CT' ? mat(0x2c3e50) : mat(0x55483a)
const gear = team === 'CT' ? mat(0x232f3a) : mat(0x4a3b28)
const headgear = team === 'CT' ? mat(0x46586b) : mat(0x7d3030)
this.mats.push(skin, shirt, pants, gear, headgear)
// legs (pivot at hip)
const mkLeg = (x: number) => {
const leg = box(0.17, 0.9, 0.2, pants)
leg.geometry.translate(0, -0.45, 0)
leg.position.set(x, 0.9, 0)
return leg
}
this.legL = mkLeg(-0.12)
this.legR = mkLeg(0.12)
this.body.add(this.legL, this.legR)
// torso + vest
const torso = box(0.46, 0.52, 0.24, shirt)
torso.position.y = 1.21
const vest = box(0.5, 0.3, 0.28, gear)
vest.position.y = 1.14
this.body.add(torso, vest)
// bomb backpack (visible when carrying C4)
this.bombPack = box(0.3, 0.36, 0.16, mat(0x8a2e2e))
this.bombPack.position.set(0, 1.22, 0.22)
this.bombPack.visible = false
this.body.add(this.bombPack)
// head (pivot at neck)
this.head = new THREE.Group()
this.head.position.y = 1.5
const skull = box(0.26, 0.26, 0.26, skin)
skull.position.y = 0.16
this.head.add(skull)
if (team === 'CT') {
const helmet = box(0.3, 0.13, 0.3, headgear)
helmet.position.y = 0.28
this.head.add(helmet)
const visor = box(0.22, 0.06, 0.02, mat(0x111111))
visor.position.set(0, 0.19, -0.14)
this.head.add(visor)
} else {
const wrap = box(0.28, 0.1, 0.28, headgear)
wrap.position.y = 0.27
this.head.add(wrap)
const tail = box(0.06, 0.16, 0.03, headgear)
tail.position.set(0.08, 0.18, 0.15)
this.head.add(tail)
}
this.body.add(this.head)
// aim group: both arms + weapon, pitches with view
this.aim.position.y = 1.42
const mkArm = (x: number, rotX: number, rotZ: number) => {
const arm = box(0.13, 0.42, 0.13, shirt)
arm.geometry.translate(0, -0.21, 0)
const hand = box(0.09, 0.1, 0.09, skin)
hand.position.y = -0.44
arm.add(hand)
arm.position.set(x, 0, 0)
arm.rotation.set(rotX, 0, rotZ)
return arm
}
this.aim.add(mkArm(-0.28, 1.15, 0.4), mkArm(0.28, 1.05, -0.3))
// world-model guns
this.guns = {
rifle: this.buildRifle(),
awp: this.buildAwp(),
pistol: this.buildPistol(),
knife: this.buildKnife(),
}
for (const g of Object.values(this.guns)) {
g.position.set(0.05, -0.24, -0.35)
g.visible = false
this.aim.add(g)
}
this.guns.rifle.visible = true
this.body.add(this.aim)
this.root.add(this.body)
}
private buildRifle(): THREE.Group {
const g = new THREE.Group()
const metal = mat(0x3a3a3a)
const wood = mat(0x6b4a2f)
const body = box(0.06, 0.1, 0.55, metal); body.position.z = -0.15
const barrel = box(0.035, 0.035, 0.35, metal); barrel.position.z = -0.55
const stock = box(0.05, 0.09, 0.22, wood); stock.position.z = 0.2
const magM = box(0.04, 0.16, 0.09, wood); magM.position.set(0, -0.12, -0.1); magM.rotation.x = 0.35
g.add(body, barrel, stock, magM)
return g
}
private buildAwp(): THREE.Group {
const g = new THREE.Group()
const olive = mat(0x4a5d3a)
const metal = mat(0x2e2e2e)
const body = box(0.06, 0.1, 0.7, olive); body.position.z = -0.2
const barrel = box(0.03, 0.03, 0.5, metal); barrel.position.z = -0.78
const scope = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.28, 8), metal)
scope.rotation.x = Math.PI / 2
scope.position.set(0, 0.08, -0.2)
scope.castShadow = true
const stock = box(0.05, 0.11, 0.24, olive); stock.position.z = 0.25
g.add(body, barrel, scope, stock)
return g
}
private buildPistol(): THREE.Group {
const g = new THREE.Group()
const metal = mat(0x333338)
const slide = box(0.045, 0.06, 0.24, metal); slide.position.z = -0.06
const grip = box(0.04, 0.13, 0.06, metal); grip.position.set(0, -0.08, 0.05); grip.rotation.x = 0.25
g.add(slide, grip)
return g
}
private buildKnife(): THREE.Group {
const g = new THREE.Group()
const blade = box(0.015, 0.045, 0.3, mat(0xcfd6dd)); blade.position.z = -0.15
const handle = box(0.03, 0.05, 0.12, mat(0x2f2f33)); handle.position.z = 0.06
g.add(blade, handle)
return g
}
setWeapon(id: WeaponId) {
const kind = gunKindOf(id)
for (const [k, g] of Object.entries(this.guns)) g.visible = k === kind
}
setBomb(visible: boolean) {
this.bombPack.visible = visible
}
spawnReset() {
this.dead = false
this.deathT = 0
this.walkPhase = 0
this.body.rotation.set(0, 0, 0)
this.body.position.set(0, 0, 0)
for (const m of this.mats) { m.transparent = false; m.opacity = 1 }
this.root.visible = true
}
die() {
if (this.dead) return
this.dead = true
this.deathT = 0
this.fallAxis = Math.random() < 0.5 ? 1 : -1
}
update(dt: number, pos: THREE.Vector3, yaw: number, pitch: number, speed: number, onGround: boolean) {
this.root.position.copy(pos)
this.root.rotation.y = yaw
if (this.dead) {
this.deathT += dt
const k = Math.min(1, this.deathT / 0.4)
this.body.rotation.z = this.fallAxis * (Math.PI / 2) * k * k * (3 - 2 * k)
this.body.position.y = 0.12 * k
if (this.deathT > 6) {
const fade = Math.max(0, 1 - (this.deathT - 6) / 1.5)
for (const m of this.mats) { m.transparent = true; m.opacity = fade }
if (fade <= 0) this.root.visible = false
}
return
}
const speedRatio = Math.min(1, speed / 4.7)
this.walkPhase += speed * dt * 2.2
const swing = onGround ? Math.sin(this.walkPhase) * 0.7 * speedRatio : 0.25
this.legL.rotation.x = swing
this.legR.rotation.x = -swing
this.body.position.y = onGround ? Math.abs(Math.sin(this.walkPhase)) * 0.045 * speedRatio : 0.05
this.aim.rotation.x = pitch
this.head.rotation.x = pitch * 0.55
}
dispose(scene: THREE.Scene) {
scene.remove(this.root)
this.root.traverse(o => {
if (o instanceof THREE.Mesh) o.geometry.dispose()
})
for (const m of this.mats) m.dispose()
}
}
@@ -0,0 +1,200 @@
import * as THREE from 'three'
const MAX_TRACERS = 96
const MAX_SPARKS = 768
const MAX_BLOOD = 384
interface Particle { life: number; max: number; vx: number; vy: number; vz: number; gravity: number }
class ParticlePool {
points: THREE.Points
pos: Float32Array
col: Float32Array
parts: Particle[]
baseCol: Float32Array
private cursor = 0
constructor(count: number, size: number, additive: boolean) {
this.pos = new Float32Array(count * 3).fill(-999)
this.col = new Float32Array(count * 3)
this.baseCol = new Float32Array(count * 3)
this.parts = Array.from({ length: count }, () => ({ life: 0, max: 1, vx: 0, vy: 0, vz: 0, gravity: 0 }))
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3))
geo.setAttribute('color', new THREE.BufferAttribute(this.col, 3))
const matP = new THREE.PointsMaterial({
size, vertexColors: true, transparent: true, depthWrite: false,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
sizeAttenuation: true,
})
this.points = new THREE.Points(geo, matP)
this.points.frustumCulled = false
}
spawn(p: THREE.Vector3, vel: THREE.Vector3, life: number, color: THREE.Color, gravity: number) {
const i = this.cursor
this.cursor = (this.cursor + 1) % this.parts.length
this.pos[i * 3] = p.x; this.pos[i * 3 + 1] = p.y; this.pos[i * 3 + 2] = p.z
this.baseCol[i * 3] = color.r; this.baseCol[i * 3 + 1] = color.g; this.baseCol[i * 3 + 2] = color.b
const part = this.parts[i]
part.life = life; part.max = life
part.vx = vel.x; part.vy = vel.y; part.vz = vel.z
part.gravity = gravity
}
update(dt: number) {
for (let i = 0; i < this.parts.length; i++) {
const p = this.parts[i]
if (p.life <= 0) continue
p.life -= dt
if (p.life <= 0) {
this.pos[i * 3 + 1] = -999
this.col[i * 3] = this.col[i * 3 + 1] = this.col[i * 3 + 2] = 0
continue
}
p.vy -= p.gravity * dt
this.pos[i * 3] += p.vx * dt
this.pos[i * 3 + 1] += p.vy * dt
this.pos[i * 3 + 2] += p.vz * dt
const f = p.life / p.max
this.col[i * 3] = this.baseCol[i * 3] * f
this.col[i * 3 + 1] = this.baseCol[i * 3 + 1] * f
this.col[i * 3 + 2] = this.baseCol[i * 3 + 2] * f
}
this.points.geometry.attributes.position.needsUpdate = true
this.points.geometry.attributes.color.needsUpdate = true
}
}
export class Effects {
private scene: THREE.Scene
private tracerGeo: THREE.BufferGeometry
private tracerPos: Float32Array
private tracerCol: Float32Array
private tracers: { life: number; max: number }[]
private tracerCursor = 0
private sparks: ParticlePool
private blood: ParticlePool
private muzzleLight: THREE.PointLight
private muzzleT = 0
private explosionLight: THREE.PointLight
private explosionT = 0
private rings: { mesh: THREE.Mesh; life: number }[] = []
shake = 0
constructor(scene: THREE.Scene) {
this.scene = scene
this.tracerPos = new Float32Array(MAX_TRACERS * 6).fill(-999)
this.tracerCol = new Float32Array(MAX_TRACERS * 6)
this.tracers = Array.from({ length: MAX_TRACERS }, () => ({ life: 0, max: 1 }))
this.tracerGeo = new THREE.BufferGeometry()
this.tracerGeo.setAttribute('position', new THREE.BufferAttribute(this.tracerPos, 3))
this.tracerGeo.setAttribute('color', new THREE.BufferAttribute(this.tracerCol, 3))
const lines = new THREE.LineSegments(this.tracerGeo, new THREE.LineBasicMaterial({
vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}))
lines.frustumCulled = false
scene.add(lines)
this.sparks = new ParticlePool(MAX_SPARKS, 0.09, true)
this.blood = new ParticlePool(MAX_BLOOD, 0.11, false)
scene.add(this.sparks.points, this.blood.points)
this.muzzleLight = new THREE.PointLight(0xffc070, 0, 14)
this.explosionLight = new THREE.PointLight(0xff8030, 0, 60)
scene.add(this.muzzleLight, this.explosionLight)
}
tracer(a: THREE.Vector3, b: THREE.Vector3) {
const i = this.tracerCursor
this.tracerCursor = (this.tracerCursor + 1) % MAX_TRACERS
this.tracerPos.set([a.x, a.y, a.z, b.x, b.y, b.z], i * 6)
this.tracers[i].life = this.tracers[i].max = 0.07
}
muzzle(pos: THREE.Vector3) {
this.muzzleLight.position.copy(pos)
this.muzzleLight.intensity = 6
this.muzzleT = 0.05
}
impact(pos: THREE.Vector3) {
const c = new THREE.Color(0xffcc88)
for (let i = 0; i < 7; i++) {
const vel = new THREE.Vector3((Math.random() - 0.5) * 5, Math.random() * 4, (Math.random() - 0.5) * 5)
this.sparks.spawn(pos, vel, 0.22 + Math.random() * 0.18, c, 12)
}
}
bloodBurst(pos: THREE.Vector3) {
const c = new THREE.Color(0x8a1515)
for (let i = 0; i < 10; i++) {
const vel = new THREE.Vector3((Math.random() - 0.5) * 3.4, Math.random() * 2.6 - 0.4, (Math.random() - 0.5) * 3.4)
this.blood.spawn(pos, vel, 0.3 + Math.random() * 0.25, c, 9)
}
}
explosion(pos: THREE.Vector3) {
const fire = new THREE.Color(0xff9040)
const smoke = new THREE.Color(0x553311)
for (let i = 0; i < 130; i++) {
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random() * 0.8, Math.random() - 0.5).normalize()
const sp = 4 + Math.random() * 16
this.sparks.spawn(pos.clone().add(new THREE.Vector3(0, 0.5, 0)), dir.multiplyScalar(sp), 0.5 + Math.random() * 0.9, fire, 6)
}
for (let i = 0; i < 60; i++) {
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random(), Math.random() - 0.5).normalize()
this.blood.spawn(pos.clone(), dir.multiplyScalar(2 + Math.random() * 6), 1 + Math.random(), smoke, -1.5)
}
this.explosionLight.position.copy(pos).add(new THREE.Vector3(0, 2, 0))
this.explosionLight.intensity = 80
this.explosionT = 0.9
this.shake = Math.max(this.shake, 1)
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.5, 1.2, 32),
new THREE.MeshBasicMaterial({ color: 0xffbb66, transparent: true, opacity: 0.9, side: THREE.DoubleSide, depthWrite: false }),
)
ring.rotation.x = -Math.PI / 2
ring.position.copy(pos).add(new THREE.Vector3(0, 0.3, 0))
this.scene.add(ring)
this.rings.push({ mesh: ring, life: 0.8 })
}
update(dt: number) {
for (let i = 0; i < MAX_TRACERS; i++) {
const t = this.tracers[i]
if (t.life <= 0) continue
t.life -= dt
const f = Math.max(0, t.life / t.max)
const r = 1 * f, g2 = 0.85 * f, b = 0.5 * f
this.tracerCol.set([r, g2, b, r * 0.6, g2 * 0.6, b * 0.6], i * 6)
if (t.life <= 0) this.tracerPos.set([-999, -999, -999, -999, -999, -999], i * 6)
}
this.tracerGeo.attributes.position.needsUpdate = true
this.tracerGeo.attributes.color.needsUpdate = true
this.sparks.update(dt)
this.blood.update(dt)
this.muzzleT -= dt
if (this.muzzleT <= 0) this.muzzleLight.intensity = 0
if (this.explosionT > 0) {
this.explosionT -= dt
this.explosionLight.intensity = Math.max(0, 80 * (this.explosionT / 0.9))
}
for (let i = this.rings.length - 1; i >= 0; i--) {
const r = this.rings[i]
r.life -= dt
const s = 1 + (0.8 - r.life) * 30
r.mesh.scale.set(s, s, 1)
;(r.mesh.material as THREE.MeshBasicMaterial).opacity = Math.max(0, r.life)
if (r.life <= 0) {
this.scene.remove(r.mesh)
r.mesh.geometry.dispose()
this.rings.splice(i, 1)
}
}
this.shake *= Math.exp(-4 * dt)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,429 @@
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import type { Team } from './types'
export const CELL = 2.5
export const GRID = 52
export const WALL_H = 4
const HALF = GRID / 2 // world origin at grid center
export const enum Code { Wall = 0, Floor = 1, TSpawn = 2, CTSpawn = 3, ASite = 4, BSite = 5, Door = 6 }
// ---------------------------------------------------------------------------
// Layout: carved rectangles on a 52x52 grid (col, row; row 0 = north = -Z).
// Regions: T spawn, CT spawn, Long A, A site, mid + mid doors, catwalk (short),
// B tunnels, B site — wired up per the real Dust2 topology.
// ---------------------------------------------------------------------------
type Rect = [x1: number, y1: number, x2: number, y2: number, code: Code]
const RECTS: Rect[] = [
[2, 2, 13, 12, Code.BSite], // B 点
[6, 13, 9, 30, Code.Floor], // B洞 竖段
[6, 28, 20, 31, Code.Floor], // B洞 横段
[17, 31, 20, 41, Code.Floor], // B洞 → T 出生点
[18, 42, 34, 49, Code.TSpawn], // T 出生点
[24, 38, 29, 42, Code.Floor], // T → 中路
[24, 10, 29, 38, Code.Floor], // 中路
[22, 3, 34, 10, Code.CTSpawn], // CT 出生点
[13, 10, 25, 13, Code.Floor], // B门通道 (CT ↔ B)
[29, 20, 38, 23, Code.Floor], // 猫道横段
[35, 10, 38, 23, Code.Floor], // A小 竖段
[36, 2, 47, 9, Code.ASite], // A 点
[44, 9, 47, 34, Code.Floor], // A大 竖段
[30, 34, 47, 38, Code.Floor], // A大 外围横段
[30, 38, 34, 42, Code.Floor], // T → A大
[34, 3, 36, 7, Code.Floor], // CT → A 匝道
]
// tunnels get ceilings so B洞 reads as an actual tunnel
const TUNNEL_RECTS: Rect[] = [
[6, 13, 9, 30, Code.Floor],
[6, 28, 20, 31, Code.Floor],
]
const CRATES: [c: number, r: number, stack: number][] = [
// A site
[40, 4, 2], [41, 4, 1], [40, 5, 1], [44, 7, 1], [37, 3, 1],
// B site
[5, 4, 1], [6, 4, 2], [10, 9, 1], [11, 9, 1], [4, 10, 1],
// mid boxes
[25, 34, 1], [26, 34, 2],
// catwalk xbox
[31, 21, 1],
// long corner
[46, 35, 1], [46, 36, 2],
// tunnels + spawns
[8, 21, 1], [20, 43, 1], [32, 4, 1],
]
export interface AABB { min: THREE.Vector3; max: THREE.Vector3 }
export class DoorObj {
center: THREE.Vector3
totalW: number
panelW: number
open01 = 0
target = 0
panels: [THREE.Mesh, THREE.Mesh]
group = new THREE.Group()
constructor(center: THREE.Vector3, totalW: number) {
this.center = center
this.totalW = totalW
this.panelW = totalW / 2
const mat = new THREE.MeshStandardMaterial({ color: 0x5c6e60, roughness: 0.7, metalness: 0.3 })
const geo = new THREE.BoxGeometry(this.panelW, 3.2, 0.25)
const left = new THREE.Mesh(geo, mat)
const right = new THREE.Mesh(geo, mat)
left.castShadow = right.castShadow = true
this.panels = [left, right]
this.group.add(left, right)
// door frame header
const header = new THREE.Mesh(
new THREE.BoxGeometry(totalW + 0.6, WALL_H - 3.2, 0.6),
new THREE.MeshStandardMaterial({ color: 0x8f7f5c, roughness: 0.95 }),
)
header.position.set(center.x, 3.2 + (WALL_H - 3.2) / 2, center.z)
this.group.add(header)
this.layout()
}
private layout() {
const slide = this.open01 * (this.panelW + 0.3)
this.panels[0].position.set(this.center.x - this.panelW / 2 - slide, 1.6, this.center.z)
this.panels[1].position.set(this.center.x + this.panelW / 2 + slide, 1.6, this.center.z)
}
/** Returns 'open' | 'close' when the door starts moving (for audio). */
update(dt: number, positions: THREE.Vector3[]): 'open' | 'close' | null {
let near = false
for (const p of positions) {
const dx = p.x - this.center.x, dz = p.z - this.center.z
if (dx * dx + dz * dz < 4.2 * 4.2) { near = true; break }
}
const prevTarget = this.target
this.target = near ? 1 : 0
const before = this.open01
const k = 1 - Math.exp(-5 * dt)
this.open01 += (this.target - this.open01) * k
if (Math.abs(this.open01 - before) > 1e-5) this.layout()
if (prevTarget !== this.target) return this.target === 1 ? 'open' : 'close'
return null
}
colliders(): AABB[] {
if (this.open01 > 0.92) return []
const out: AABB[] = []
for (const p of this.panels) {
out.push({
min: new THREE.Vector3(p.position.x - this.panelW / 2, 0, this.center.z - 0.2),
max: new THREE.Vector3(p.position.x + this.panelW / 2, 3.2, this.center.z + 0.2),
})
}
return out
}
}
export interface HoldSpot { pos: THREE.Vector3; look: THREE.Vector3 }
function makeFloorTexture(): THREE.CanvasTexture {
const c = document.createElement('canvas')
c.width = c.height = 256
const g = c.getContext('2d')!
g.fillStyle = '#c9b183'
g.fillRect(0, 0, 256, 256)
for (let i = 0; i < 2600; i++) {
const v = Math.random()
g.fillStyle = v < 0.5 ? 'rgba(120,98,64,0.14)' : 'rgba(240,226,190,0.12)'
g.fillRect(Math.random() * 256, Math.random() * 256, 1 + Math.random() * 2.4, 1 + Math.random() * 2.4)
}
g.strokeStyle = 'rgba(90,74,50,0.20)'
g.lineWidth = 1
g.strokeRect(0.5, 0.5, 255, 255)
const tex = new THREE.CanvasTexture(c)
tex.wrapS = tex.wrapT = THREE.RepeatWrapping
tex.repeat.set(GRID, GRID)
tex.colorSpace = THREE.SRGBColorSpace
return tex
}
function makeLetterTexture(text: string, color: string): THREE.CanvasTexture {
const c = document.createElement('canvas')
c.width = c.height = 128
const g = c.getContext('2d')!
g.clearRect(0, 0, 128, 128)
g.font = 'bold 96px sans-serif'
g.textAlign = 'center'
g.textBaseline = 'middle'
g.fillStyle = color
g.fillText(text, 64, 68)
const tex = new THREE.CanvasTexture(c)
tex.colorSpace = THREE.SRGBColorSpace
return tex
}
export class GameMap {
grid = new Uint8Array(GRID * GRID) // Code values
crateCells = new Set<number>()
crates: AABB[] = []
doors: DoorObj[] = []
group = new THREE.Group()
spawns: Record<Team, { pos: THREE.Vector3; yaw: number }[]> = { CT: [], T: [] }
anchors: Record<string, THREE.Vector3> = {}
holdSpots: { A: HoldSpot[]; B: HoldSpot[]; mid: HoldSpot[] } = { A: [], B: [], mid: [] }
routes: { A: THREE.Vector3[][]; B: THREE.Vector3[][] } = { A: [], B: [] }
siteCells: { A: [number, number][]; B: [number, number][] } = { A: [], B: [] }
constructor() {
this.buildGrid()
this.buildVisuals()
this.buildMeta()
}
code(c: number, r: number): Code {
if (c < 0 || r < 0 || c >= GRID || r >= GRID) return Code.Wall
return this.grid[r * GRID + c] as Code
}
isWall(c: number, r: number): boolean { return this.code(c, r) === Code.Wall }
isWalkableCell(c: number, r: number): boolean {
return this.code(c, r) !== Code.Wall && !this.crateCells.has(r * GRID + c)
}
cellCenter(c: number, r: number): THREE.Vector3 {
return new THREE.Vector3((c - HALF + 0.5) * CELL, 0, (r - HALF + 0.5) * CELL)
}
worldToCell(x: number, z: number): [number, number] {
return [Math.floor(x / CELL + HALF), Math.floor(z / CELL + HALF)]
}
plantSiteAt(x: number, z: number): 'A' | 'B' | null {
const [c, r] = this.worldToCell(x, z)
const code = this.code(c, r)
if (code === Code.ASite) return 'A'
if (code === Code.BSite) return 'B'
return null
}
private buildGrid() {
for (const [x1, y1, x2, y2, code] of RECTS) {
for (let r = y1; r <= y2; r++) {
for (let c = x1; c <= x2; c++) this.grid[r * GRID + c] = code
}
}
// 中门: frame + door cells at row 16
this.grid[16 * GRID + 24] = Code.Wall
this.grid[16 * GRID + 29] = Code.Wall
for (let c = 25; c <= 28; c++) this.grid[16 * GRID + c] = Code.Door
// A大双门 at row 32
this.grid[32 * GRID + 44] = Code.Wall
this.grid[32 * GRID + 47] = Code.Wall
for (let c = 45; c <= 46; c++) this.grid[32 * GRID + c] = Code.Door
for (const [c, r, stack] of CRATES) {
this.crateCells.add(r * GRID + c)
const ctr = this.cellCenter(c, r)
const s = 1.05
this.crates.push({
min: new THREE.Vector3(ctr.x - s, 0, ctr.z - s),
max: new THREE.Vector3(ctr.x + s, 1.05 * stack, ctr.z + s),
})
}
}
private buildVisuals() {
// floor
const floorMat = new THREE.MeshStandardMaterial({ map: makeFloorTexture(), roughness: 1 })
const floor = new THREE.Mesh(new THREE.PlaneGeometry(GRID * CELL, GRID * CELL), floorMat)
floor.rotation.x = -Math.PI / 2
floor.receiveShadow = true
this.group.add(floor)
// walls: merge exposed wall cells into per-row runs
const exposed = (c: number, r: number): boolean => {
if (!this.isWall(c, r)) return false
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dc === 0 && dr === 0) continue
if (this.code(c + dc, r + dr) !== Code.Wall) return true
}
}
return false
}
const geos: THREE.BufferGeometry[] = []
const base = new THREE.Color('#c8ae7d')
for (let r = 0; r < GRID; r++) {
let c = 0
while (c < GRID) {
if (!exposed(c, r)) { c++; continue }
let end = c
while (end + 1 < GRID && exposed(end + 1, r)) end++
const w = (end - c + 1) * CELL
const g = new THREE.BoxGeometry(w, WALL_H, CELL)
const cx = ((c + end + 1) / 2 - HALF) * CELL
const cz = (r - HALF + 0.5) * CELL
g.translate(cx, WALL_H / 2, cz)
const col = base.clone().multiplyScalar(0.9 + Math.random() * 0.18)
const n = g.attributes.position.count
const colors = new Float32Array(n * 3)
for (let i = 0; i < n; i++) { colors[i * 3] = col.r; colors[i * 3 + 1] = col.g; colors[i * 3 + 2] = col.b }
g.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geos.push(g)
c = end + 1
}
}
const wallGeo = mergeGeometries(geos)!
const walls = new THREE.Mesh(wallGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.95 }))
walls.castShadow = true
walls.receiveShadow = true
this.group.add(walls)
// crates
const crateMat = new THREE.MeshStandardMaterial({ color: 0x9a7743, roughness: 0.9 })
const crateEdge = new THREE.MeshStandardMaterial({ color: 0x6e5430, roughness: 0.9 })
for (const [c, r, stack] of CRATES) {
const ctr = this.cellCenter(c, r)
for (let s = 0; s < stack; s++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(2.1, 1.02, 2.1), crateMat)
m.position.set(ctr.x, 0.525 + s * 1.05, ctr.z)
m.castShadow = m.receiveShadow = true
this.group.add(m)
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.16, 0.12, 2.16), crateEdge)
trim.position.set(ctr.x, 1.0 + s * 1.05, ctr.z)
this.group.add(trim)
}
}
// tunnel ceilings
const ceilMat = new THREE.MeshStandardMaterial({ color: 0xa08a5f, roughness: 1 })
for (const [x1, y1, x2, y2] of TUNNEL_RECTS) {
const w = (x2 - x1 + 1) * CELL, d = (y2 - y1 + 1) * CELL
const m = new THREE.Mesh(new THREE.BoxGeometry(w + CELL, 0.4, d + CELL), ceilMat)
m.position.set(((x1 + x2 + 1) / 2 - HALF) * CELL, 3.3, ((y1 + y2 + 1) / 2 - HALF) * CELL)
m.castShadow = true
this.group.add(m)
}
// site letters painted on the floor
const markers: [string, number, number, string][] = [
['A', 41, 5, 'rgba(214,120,50,0.85)'],
['B', 7, 7, 'rgba(214,120,50,0.85)'],
['MID', 26, 24, 'rgba(90,74,50,0.6)'],
]
for (const [text, c, r, color] of markers) {
const tex = makeLetterTexture(text, color)
const size = text.length > 1 ? 7 : 5
const m = new THREE.Mesh(
new THREE.PlaneGeometry(size, size),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false }),
)
const p = this.cellCenter(c, r)
m.rotation.x = -Math.PI / 2
m.position.set(p.x, 0.02, p.z)
this.group.add(m)
}
// doors
const midDoor = new DoorObj(this.cellCenter(26, 16).setX(((25 + 29) / 2 - HALF) * CELL), 4 * CELL)
const longDoor = new DoorObj(this.cellCenter(45, 32).setX(((45 + 47) / 2 - HALF) * CELL), 2 * CELL)
this.doors = [midDoor, longDoor]
for (const d of this.doors) this.group.add(d.group)
}
private buildMeta() {
const cc = (c: number, r: number) => this.cellCenter(c, r)
// spawns
const tCells: [number, number][] = [[25, 46], [27, 46], [23, 45], [29, 45], [26, 47]]
const ctCells: [number, number][] = [[27, 5], [29, 5], [25, 4], [31, 6], [28, 7]]
this.spawns.T = tCells.map(([c, r]) => ({ pos: cc(c, r), yaw: 0 }))
this.spawns.CT = ctCells.map(([c, r]) => ({ pos: cc(c, r), yaw: Math.PI }))
const an = this.anchors
an.aSite = cc(41, 5); an.bSite = cc(7, 7)
an.tSpawn = cc(26, 45); an.ctSpawn = cc(28, 6)
an.midLow = cc(26, 32); an.midDoorS = cc(26, 18); an.midTop = cc(26, 11)
an.catwalk = cc(33, 21); an.aShort = cc(36, 12)
an.outsideLong = cc(38, 36); an.longMid = cc(45, 26); an.longTop = cc(45, 11)
an.tunnelS = cc(18, 36); an.tunnelElbow = cc(8, 29); an.tunnelMid = cc(7, 20)
an.bDoors = cc(18, 11)
this.routes.A = [
[an.outsideLong, an.longMid, an.longTop],
[an.midLow, an.catwalk, an.aShort],
]
this.routes.B = [
[an.tunnelS, an.tunnelElbow, an.tunnelMid],
[an.midLow, an.midDoorS, an.midTop, an.bDoors],
]
this.holdSpots.A = [
{ pos: cc(38, 3), look: cc(45, 11) },
{ pos: cc(43, 8), look: cc(36, 13) },
{ pos: cc(45, 11), look: cc(45, 28) },
]
this.holdSpots.B = [
{ pos: cc(4, 4), look: cc(7, 15) },
{ pos: cc(10, 10), look: cc(15, 11) },
{ pos: cc(7, 14), look: cc(7, 22) },
]
this.holdSpots.mid = [
{ pos: cc(26, 12), look: cc(26, 20) },
{ pos: cc(18, 11), look: cc(25, 11) },
]
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const code = this.code(c, r)
if (code === Code.ASite && this.isWalkableCell(c, r)) this.siteCells.A.push([c, r])
if (code === Code.BSite && this.isWalkableCell(c, r)) this.siteCells.B.push([c, r])
}
}
}
randomSiteSpot(site: 'A' | 'B'): THREE.Vector3 {
const cells = this.siteCells[site]
const [c, r] = cells[Math.floor(Math.random() * cells.length)]
return this.cellCenter(c, r)
}
/** Random walkable cell center within `radius` (world units) of a point. */
randomSpotNear(p: THREE.Vector3, radius: number): THREE.Vector3 {
const [pc, pr] = this.worldToCell(p.x, p.z)
const cellRad = Math.ceil(radius / CELL)
for (let i = 0; i < 24; i++) {
const c = pc + Math.round((Math.random() * 2 - 1) * cellRad)
const r = pr + Math.round((Math.random() * 2 - 1) * cellRad)
if (this.isWalkableCell(c, r)) return this.cellCenter(c, r)
}
return p.clone()
}
drawMinimap(ctx: CanvasRenderingContext2D, px: number) {
ctx.clearRect(0, 0, GRID * px, GRID * px)
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
const code = this.code(c, r)
if (code === Code.Wall) continue
let fill = 'rgba(219,197,150,0.85)'
if (code === Code.ASite || code === Code.BSite) fill = 'rgba(226,160,96,0.9)'
if (code === Code.TSpawn) fill = 'rgba(216,190,132,0.9)'
if (code === Code.CTSpawn) fill = 'rgba(180,198,216,0.9)'
if (code === Code.Door) fill = 'rgba(140,170,140,0.9)'
ctx.fillStyle = fill
ctx.fillRect(c * px, r * px, px, px)
if (this.crateCells.has(r * GRID + c)) {
ctx.fillStyle = 'rgba(120,95,55,0.9)'
ctx.fillRect(c * px + 1, r * px + 1, px - 2, px - 2)
}
}
}
ctx.font = `bold ${px * 5}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'rgba(80,50,20,0.8)'
ctx.fillText('A', 41.5 * px, 6 * px)
ctx.fillText('B', 7.5 * px, 7.5 * px)
}
}
@@ -0,0 +1,155 @@
import * as THREE from 'three'
import { CELL, GRID, GameMap } from './map'
import type { PhysicsWorld } from './physics'
const HALF = GRID / 2
/** Grid A* with octile heuristic + string-pulling smoothing. */
export class Nav {
private map: GameMap
private physics: PhysicsWorld
private walkable: Uint8Array
constructor(map: GameMap, physics: PhysicsWorld) {
this.map = map
this.physics = physics
this.walkable = new Uint8Array(GRID * GRID)
for (let r = 0; r < GRID; r++) {
for (let c = 0; c < GRID; c++) {
this.walkable[r * GRID + c] = map.isWalkableCell(c, r) ? 1 : 0
}
}
}
private ok(c: number, r: number): boolean {
return c >= 0 && r >= 0 && c < GRID && r < GRID && this.walkable[r * GRID + c] === 1
}
private nearestWalkable(c: number, r: number): [number, number] | null {
if (this.ok(c, r)) return [c, r]
for (let rad = 1; rad <= 6; rad++) {
for (let dr = -rad; dr <= rad; dr++) {
for (let dc = -rad; dc <= rad; dc++) {
if (Math.max(Math.abs(dc), Math.abs(dr)) !== rad) continue
if (this.ok(c + dc, r + dr)) return [c + dc, r + dr]
}
}
}
return null
}
findPath(from: THREE.Vector3, to: THREE.Vector3): THREE.Vector3[] | null {
const s = this.nearestWalkable(...this.map.worldToCell(from.x, from.z))
const e = this.nearestWalkable(...this.map.worldToCell(to.x, to.z))
if (!s || !e) return null
const cells = this.aStar(s[0], s[1], e[0], e[1])
if (!cells) return null
const pts: THREE.Vector3[] = cells.map(i => this.map.cellCenter(i % GRID, Math.floor(i / GRID)))
pts.push(new THREE.Vector3(to.x, 0, to.z))
// string pulling
const smoothed: THREE.Vector3[] = []
let i = 0
const start = new THREE.Vector3(from.x, 0, from.z)
let cur = start
while (i < pts.length) {
let j = pts.length - 1
let found = i
for (; j > i; j--) {
if (this.physics.walkClear(cur, pts[j], 0.45)) { found = j; break }
}
cur = pts[found]
smoothed.push(cur)
i = found + 1
}
return smoothed
}
private aStar(sc: number, sr: number, ec: number, er: number): number[] | null {
const n = GRID * GRID
const g = new Float32Array(n).fill(Infinity)
const came = new Int32Array(n).fill(-1)
const closed = new Uint8Array(n)
const start = sr * GRID + sc
const end = er * GRID + ec
g[start] = 0
// binary heap of [f, idx]
const heapF: number[] = []
const heapI: number[] = []
const push = (f: number, idx: number) => {
heapF.push(f); heapI.push(idx)
let i = heapF.length - 1
while (i > 0) {
const p = (i - 1) >> 1
if (heapF[p] <= heapF[i]) break
;[heapF[p], heapF[i]] = [heapF[i], heapF[p]]
;[heapI[p], heapI[i]] = [heapI[i], heapI[p]]
i = p
}
}
const pop = (): number => {
const top = heapI[0]
const lf = heapF.pop()!, li = heapI.pop()!
if (heapF.length > 0) {
heapF[0] = lf; heapI[0] = li
let i = 0
for (;;) {
const l = i * 2 + 1, r = l + 1
let m = i
if (l < heapF.length && heapF[l] < heapF[m]) m = l
if (r < heapF.length && heapF[r] < heapF[m]) m = r
if (m === i) break
;[heapF[m], heapF[i]] = [heapF[i], heapF[m]]
;[heapI[m], heapI[i]] = [heapI[i], heapI[m]]
i = m
}
}
return top
}
const h = (idx: number) => {
const dc = Math.abs((idx % GRID) - ec)
const dr = Math.abs(Math.floor(idx / GRID) - er)
return Math.max(dc, dr) + 0.414 * Math.min(dc, dr)
}
push(h(start), start)
let expansions = 0
while (heapF.length > 0 && expansions < 8000) {
const cur = pop()
if (cur === end) {
const path: number[] = []
let c = end
while (c !== -1) { path.push(c); c = came[c] }
path.reverse()
return path
}
if (closed[cur]) continue
closed[cur] = 1
expansions++
const cc = cur % GRID, cr = Math.floor(cur / GRID)
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dc === 0 && dr === 0) continue
const nc = cc + dc, nr = cr + dr
if (!this.ok(nc, nr)) continue
// no corner cutting
if (dc !== 0 && dr !== 0 && (!this.ok(cc + dc, cr) || !this.ok(cc, cr + dr))) continue
const ni = nr * GRID + nc
if (closed[ni]) continue
const cost = dc !== 0 && dr !== 0 ? 1.414 : 1
const ng = g[cur] + cost
if (ng < g[ni]) {
g[ni] = ng
came[ni] = cur
push(ng + h(ni), ni)
}
}
}
}
return null
}
}
export { CELL }
@@ -0,0 +1,258 @@
import * as THREE from 'three'
import { CELL, GRID, WALL_H, GameMap, type AABB } from './map'
import type { BodyPart, SoldierBody } from './types'
import { GRAVITY } from './types'
const HALF = GRID / 2
const STEP = 0.35 // max ledge height walkable without jumping
export interface HitboxDef { part: BodyPart; min: [number, number, number]; max: [number, number, number] }
/** Local space, feet at origin, facing -Z. */
export const HITBOXES: HitboxDef[] = [
{ part: 'head', min: [-0.15, 1.48, -0.16], max: [0.15, 1.8, 0.16] },
{ part: 'chest', min: [-0.24, 1.12, -0.15], max: [0.24, 1.48, 0.15] },
{ part: 'stomach', min: [-0.22, 0.86, -0.14], max: [0.22, 1.12, 0.14] },
{ part: 'arm', min: [-0.44, 1.0, -0.15], max: [-0.24, 1.48, 0.15] },
{ part: 'arm', min: [0.24, 1.0, -0.15], max: [0.44, 1.48, 0.15] },
{ part: 'leg', min: [-0.22, 0, -0.14], max: [-0.02, 0.9, 0.14] },
{ part: 'leg', min: [0.02, 0, -0.14], max: [0.22, 0.9, 0.14] },
]
export interface RayHit {
t: number
point: THREE.Vector3
kind: 'wall' | 'crate' | 'door' | 'floor' | 'soldier'
soldier?: SoldierBody
part?: BodyPart
}
export interface MovingBody {
pos: THREE.Vector3 // feet
vel: THREE.Vector3
onGround: boolean
radius: number
height: number
}
function rayAABB(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, box: AABB): number | null {
let tmin = 0, tmax = Infinity
const o = [ox, oy, oz], d = [dx, dy, dz]
const mn = [box.min.x, box.min.y, box.min.z], mx = [box.max.x, box.max.y, box.max.z]
for (let i = 0; i < 3; i++) {
if (Math.abs(d[i]) < 1e-9) {
if (o[i] < mn[i] || o[i] > mx[i]) return null
} else {
let t1 = (mn[i] - o[i]) / d[i]
let t2 = (mx[i] - o[i]) / d[i]
if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp }
if (t1 > tmin) tmin = t1
if (t2 < tmax) tmax = t2
if (tmin > tmax) return null
}
}
return tmin
}
export class PhysicsWorld {
map: GameMap
constructor(map: GameMap) {
this.map = map
}
/** Is a circle at (x,z) with feet at y clear of walls/crates/doors? */
circleFree(x: number, z: number, r: number, feetY: number, ignoreDoors = false): boolean {
const cMin = Math.floor((x - r) / CELL + HALF), cMax = Math.floor((x + r) / CELL + HALF)
const rMin = Math.floor((z - r) / CELL + HALF), rMax = Math.floor((z + r) / CELL + HALF)
for (let cr = rMin; cr <= rMax; cr++) {
for (let cc = cMin; cc <= cMax; cc++) {
if (!this.map.isWall(cc, cr)) continue
if (this.circleHitsRect(x, z, r, (cc - HALF) * CELL, (cr - HALF) * CELL, (cc - HALF + 1) * CELL, (cr - HALF + 1) * CELL)) return false
}
}
for (const box of this.map.crates) {
if (box.max.y <= feetY + STEP) continue // can step onto / stand on it
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) return false
}
if (!ignoreDoors) {
for (const d of this.map.doors) {
for (const box of d.colliders()) {
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) return false
}
}
}
return true
}
private circleHitsRect(x: number, z: number, r: number, x1: number, z1: number, x2: number, z2: number): boolean {
const nx = Math.max(x1, Math.min(x, x2))
const nz = Math.max(z1, Math.min(z, z2))
const dx = x - nx, dz = z - nz
return dx * dx + dz * dz < r * r
}
/** Used by nav smoothing: walls + crates only (doors auto-open). */
walkClear(a: THREE.Vector3, b: THREE.Vector3, radius: number): boolean {
const dx = b.x - a.x, dz = b.z - a.z
const dist = Math.hypot(dx, dz)
const steps = Math.max(1, Math.ceil(dist / 0.35))
for (let i = 0; i <= steps; i++) {
const t = i / steps
if (!this.circleFree(a.x + dx * t, a.z + dz * t, radius, 0, true)) return false
}
return true
}
moveBody(body: MovingBody, wishWorld: THREE.Vector3, speed: number, jump: boolean, dt: number) {
const accel = body.onGround ? 22 : 4
const k = Math.min(1, accel * dt)
body.vel.x += (wishWorld.x * speed - body.vel.x) * k
body.vel.z += (wishWorld.z * speed - body.vel.z) * k
if (jump && body.onGround) {
body.vel.y = 7.0
body.onGround = false
}
// axis-separated horizontal collision
const nx = body.pos.x + body.vel.x * dt
if (this.circleFree(nx, body.pos.z, body.radius, body.pos.y)) body.pos.x = nx
else body.vel.x = 0
const nz = body.pos.z + body.vel.z * dt
if (this.circleFree(body.pos.x, nz, body.radius, body.pos.y)) body.pos.z = nz
else body.vel.z = 0
// vertical
const prevY = body.pos.y
body.vel.y -= GRAVITY * dt
body.pos.y += body.vel.y * dt
const ground = this.groundHeight(body.pos.x, body.pos.z, body.radius, prevY)
if (body.pos.y <= ground && body.vel.y <= 0) {
body.pos.y = ground
body.vel.y = 0
body.onGround = true
} else {
body.onGround = false
}
}
groundHeight(x: number, z: number, r: number, feetY: number): number {
let g = 0
for (const box of this.map.crates) {
if (box.max.y > feetY + STEP) continue
if (this.circleHitsRect(x, z, r, box.min.x, box.min.z, box.max.x, box.max.z)) {
if (box.max.y > g) g = box.max.y
}
}
return g
}
raycast(
origin: THREE.Vector3,
dir: THREE.Vector3,
maxDist: number,
soldiers?: SoldierBody[],
ignoreId?: number,
ignoreTeam?: string,
): RayHit | null {
let best: RayHit | null = null
const consider = (t: number, kind: RayHit['kind'], soldier?: SoldierBody, part?: BodyPart) => {
if (t < 0 || t > maxDist) return
if (best && t >= best.t) return
best = {
t, kind, soldier, part,
point: new THREE.Vector3(origin.x + dir.x * t, origin.y + dir.y * t, origin.z + dir.z * t),
}
}
// floor
if (dir.y < -1e-6) consider(-origin.y / dir.y, 'floor')
// walls via DDA
const wt = this.wallRay(origin, dir, maxDist)
if (wt !== null) consider(wt, 'wall')
// crates
for (const box of this.map.crates) {
const t = rayAABB(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, box)
if (t !== null) consider(t, 'crate')
}
// door panels
for (const d of this.map.doors) {
for (const box of d.colliders()) {
const t = rayAABB(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, box)
if (t !== null) consider(t, 'door')
}
}
// soldier hitboxes (ray transformed into each soldier's local yaw space)
if (soldiers) {
for (const s of soldiers) {
if (!s.alive || s.id === ignoreId) continue
if (ignoreTeam && s.team === ignoreTeam) continue
const cos = Math.cos(-s.yaw), sin = Math.sin(-s.yaw)
const rx = origin.x - s.pos.x, rz = origin.z - s.pos.z
const lox = rx * cos - rz * sin
const loz = rx * sin + rz * cos
const loy = origin.y - s.pos.y
const ldx = dir.x * cos - dir.z * sin
const ldz = dir.x * sin + dir.z * cos
for (const hb of HITBOXES) {
const t = rayAABB(lox, loy, loz, ldx, dir.y, ldz, {
min: new THREE.Vector3(hb.min[0], hb.min[1], hb.min[2]),
max: new THREE.Vector3(hb.max[0], hb.max[1], hb.max[2]),
})
if (t !== null) consider(t, 'soldier', s, hb.part)
}
}
}
return best
}
private wallRay(origin: THREE.Vector3, dir: THREE.Vector3, maxDist: number): number | null {
let cx = Math.floor(origin.x / CELL + HALF)
let cz = Math.floor(origin.z / CELL + HALF)
const stepX = dir.x > 0 ? 1 : -1
const stepZ = dir.z > 0 ? 1 : -1
const tDeltaX = Math.abs(dir.x) > 1e-9 ? CELL / Math.abs(dir.x) : Infinity
const tDeltaZ = Math.abs(dir.z) > 1e-9 ? CELL / Math.abs(dir.z) : Infinity
const nextGridX = (cx - HALF + (stepX > 0 ? 1 : 0)) * CELL
const nextGridZ = (cz - HALF + (stepZ > 0 ? 1 : 0)) * CELL
let tMaxX = Math.abs(dir.x) > 1e-9 ? (nextGridX - origin.x) / dir.x : Infinity
let tMaxZ = Math.abs(dir.z) > 1e-9 ? (nextGridZ - origin.z) / dir.z : Infinity
if (this.map.isWall(cx, cz)) return 0
let t = 0
for (let i = 0; i < GRID * 3; i++) {
if (tMaxX < tMaxZ) {
t = tMaxX
tMaxX += tDeltaX
cx += stepX
} else {
t = tMaxZ
tMaxZ += tDeltaZ
cz += stepZ
}
if (t > maxDist) return null
if (this.map.isWall(cx, cz)) {
const y = origin.y + dir.y * t
if (y >= 0 && y <= WALL_H) return t
}
}
return null
}
/** Line of sight between two points against static world only. */
hasLOS(a: THREE.Vector3, b: THREE.Vector3): boolean {
const dir = new THREE.Vector3().subVectors(b, a)
const dist = dir.length()
if (dist < 1e-4) return true
dir.multiplyScalar(1 / dist)
const hit = this.raycast(a, dir, dist * 0.99)
return hit === null
}
}
@@ -0,0 +1,116 @@
import * as THREE from 'three'
import { CharacterRig } from './character'
import { WEAPONS, WeaponState } from './weapons'
import {
BODY_HEIGHT, BODY_RADIUS, EYE_HEIGHT,
emptyControl, type Control, type Slot, type Team, type WeaponId,
} from './types'
import type { AIBrain } from './ai'
export class Soldier {
id: number
name: string
originalName: string
team: Team
isPlayer = false
pos = new THREE.Vector3()
vel = new THREE.Vector3()
yaw = 0
pitch = 0
onGround = true
radius = BODY_RADIUS
height = BODY_HEIGHT
hp = 100
armor = 0
alive = true
kills = 0
deaths = 0
weapons: Partial<Record<Slot, WeaponState>> = {}
slot: Slot = 'secondary'
scoped = false
switchCooldown = 0
hasBomb = false
channel: { kind: 'plant' | 'defuse'; t: number; total: number } | null = null
control: Control = emptyControl()
brain: AIBrain | null = null
rig: CharacterRig
recoilPitch = 0
recoilYaw = 0
stepAcc = 0
/** buffered semi-auto trigger: a click stays valid for a short window */
tapBuffer = 0
lastSeenBy: Record<Team, number> = { CT: -99, T: -99 }
wasReloading = false
constructor(id: number, name: string, team: Team) {
this.id = id
this.name = name
this.originalName = name
this.team = team
this.rig = new CharacterRig(team)
}
get weapon(): WeaponState {
return this.weapons[this.slot] ?? this.weapons.knife!
}
get eyePos(): THREE.Vector3 {
return new THREE.Vector3(this.pos.x, this.pos.y + EYE_HEIGHT, this.pos.z)
}
/** View direction including recoil offset (recoil affects both camera and bullets). */
viewDir(): THREE.Vector3 {
const yaw = this.yaw + this.recoilYaw
const pitch = THREE.MathUtils.clamp(this.pitch + this.recoilPitch, -1.55, 1.55)
const cp = Math.cos(pitch)
return new THREE.Vector3(-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp)
}
rightDir(): THREE.Vector3 {
return new THREE.Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw))
}
equip(primary: WeaponId | null, secondary: WeaponId, armor: number) {
this.weapons = { knife: new WeaponState(WEAPONS.knife) }
this.weapons.secondary = new WeaponState(WEAPONS[secondary])
if (primary) this.weapons.primary = new WeaponState(WEAPONS[primary])
this.slot = primary ? 'primary' : 'secondary'
this.armor = armor
this.scoped = false
this.rig.setWeapon(this.weapon.def.id)
}
respawn(pos: THREE.Vector3, yaw: number) {
this.pos.copy(pos)
this.vel.set(0, 0, 0)
this.yaw = yaw
this.pitch = 0
this.hp = 100
this.alive = true
this.onGround = true
this.channel = null
this.hasBomb = false
this.recoilPitch = 0
this.recoilYaw = 0
this.scoped = false
this.control = emptyControl()
this.rig.spawnReset()
this.rig.setBomb(false)
}
trySwitch(slot: Slot): boolean {
if (!this.alive || slot === this.slot || !this.weapons[slot]) return false
this.slot = slot
this.scoped = false
this.switchCooldown = 0.4
this.weapon.cooldown = Math.max(this.weapon.cooldown, 0.4)
this.rig.setWeapon(this.weapon.def.id)
return true
}
}
@@ -0,0 +1,53 @@
import type { HudState } from './types'
export function initialHud(): HudState {
return {
phase: 'menu',
locked: false,
auto: false,
round: 1,
pistolRound: true,
ctScore: 0,
tScore: 0,
timeLeft: 0,
bombTimeLeft: null,
bombPlanted: false,
freezeLeft: 0,
winner: null,
winReason: '',
matchWinner: null,
playerTeam: 'CT',
loadout: 'rifle',
me: {
name: '你', team: 'CT', hp: 100, armor: 0, alive: true,
weaponName: '', weaponId: 'knife', mag: 0, reserve: 0,
reloading: false, scoped: false, hasBomb: false, spreadPx: 4,
},
spectating: null,
channel: null,
centerHint: null,
killfeed: [],
hitmarkerAt: -99,
hitmarkerHead: false,
damageAt: -99,
}
}
/** Tiny external store bridging the game loop and React (useSyncExternalStore). */
export class HudStore {
state: HudState = initialHud()
private version = 0
private listeners = new Set<() => void>()
subscribe = (fn: () => void) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
getSnapshot = () => this.version
bump() {
this.version++
for (const fn of this.listeners) fn()
}
}
@@ -0,0 +1,106 @@
import * as THREE from 'three'
export type Team = 'CT' | 'T'
export type Slot = 'primary' | 'secondary' | 'knife'
export type BodyPart = 'head' | 'chest' | 'stomach' | 'arm' | 'leg'
export type WeaponId = 'ak47' | 'm4a4' | 'awp' | 'glock' | 'usp' | 'deagle' | 'knife'
export type Phase = 'menu' | 'freeze' | 'live' | 'over' | 'matchover'
export type Loadout = 'rifle' | 'awp'
/** Per-tick intent, produced by player input or an AI brain, consumed by physics/combat. */
export interface Control {
moveX: number // strafe, +right
moveZ: number // +forward
jump: boolean
walk: boolean
fire: boolean // held
fireTap: boolean // edge-triggered (semi-auto)
altTap: boolean // scope toggle
reload: boolean
use: boolean // E held: plant / defuse
slot: Slot | null
}
export function emptyControl(): Control {
return {
moveX: 0, moveZ: 0, jump: false, walk: false,
fire: false, fireTap: false, altTap: false,
reload: false, use: false, slot: null,
}
}
export interface KillEntry {
id: number
attacker: string
attackerTeam: Team
victim: string
victimTeam: Team
weapon: string
headshot: boolean
t: number
}
export interface HudState {
phase: Phase
locked: boolean
auto: boolean
round: number
pistolRound: boolean
ctScore: number
tScore: number
timeLeft: number
bombTimeLeft: number | null
bombPlanted: boolean
freezeLeft: number
winner: Team | null
winReason: string
matchWinner: Team | null
playerTeam: Team
loadout: Loadout
me: {
name: string
team: Team
hp: number
armor: number
alive: boolean
weaponName: string
weaponId: WeaponId
mag: number
reserve: number
reloading: boolean
scoped: boolean
hasBomb: boolean
spreadPx: number
}
spectating: { name: string; isSelf: boolean } | null
channel: { label: string; progress: number } | null
centerHint: string | null
killfeed: KillEntry[]
hitmarkerAt: number
hitmarkerHead: boolean
damageAt: number
}
/** Minimal body info physics needs for hitbox raycasts (avoids circular imports). */
export interface SoldierBody {
id: number
team: Team
alive: boolean
pos: THREE.Vector3
yaw: number
}
export const RUN_SPEED = 4.7
export const GRAVITY = 20
export const JUMP_VEL = 7.0
export const EYE_HEIGHT = 1.62
export const BODY_RADIUS = 0.4
export const BODY_HEIGHT = 1.8
export const PLANT_TIME = 3.2
export const DEFUSE_TIME = 7.0
export const BOMB_FUSE = 40
export const ROUND_TIME = 115
export const FREEZE_TIME = 4
export const ROUND_END_TIME = 5
export const WIN_SCORE = 13
@@ -0,0 +1,202 @@
import * as THREE from 'three'
import type { WeaponId } from './types'
function mat(color: number, metalness = 0.2): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness })
}
function box(w: number, h: number, d: number, m: THREE.Material): THREE.Mesh {
return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m)
}
/** First-person weapon models, attached to the camera. Procedural geometry only. */
export class ViewModel {
group = new THREE.Group()
private models = new Map<WeaponId, THREE.Group>()
private current: WeaponId = 'knife'
private kick = 0
private raise = 1
private slashT = 0
private reloadT = 0
private reloadDur = 0
private bobPhase = 0
private flash: THREE.Mesh
private flashT = 0
constructor(camera: THREE.Camera) {
this.group.position.set(0.26, -0.24, -0.5)
this.group.scale.setScalar(0.82)
camera.add(this.group)
const skin = mat(0xd8b094, 0)
for (const id of ['ak47', 'm4a4', 'awp', 'glock', 'usp', 'deagle', 'knife'] as WeaponId[]) {
const g = this.build(id)
// hands
const handL = box(0.05, 0.05, 0.07, skin)
const handR = box(0.05, 0.05, 0.07, skin)
if (id === 'knife') {
handR.position.set(0, -0.03, 0.1)
g.add(handR)
} else {
handL.position.set(-0.01, -0.04, -0.22)
handR.position.set(0.01, -0.05, 0.05)
g.add(handL, handR)
}
g.visible = false
this.group.add(g)
this.models.set(id, g)
}
this.flash = new THREE.Mesh(
new THREE.PlaneGeometry(0.16, 0.16),
new THREE.MeshBasicMaterial({ color: 0xffd080, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
)
this.flash.position.set(0, 0.02, -0.85)
this.group.add(this.flash)
this.setWeapon('knife', false)
}
private build(id: WeaponId): THREE.Group {
const g = new THREE.Group()
const dark = mat(0x35353a)
const darker = mat(0x232326)
switch (id) {
case 'ak47': {
const wood = mat(0x6f4c2e, 0)
const recv = box(0.055, 0.09, 0.42, dark); recv.position.z = -0.1
const guard = box(0.055, 0.07, 0.2, wood); guard.position.set(0, -0.005, -0.38)
const barrel = box(0.025, 0.025, 0.3, darker); barrel.position.z = -0.6
const sight = box(0.012, 0.05, 0.012, darker); sight.position.set(0, 0.06, -0.68)
const magM = box(0.04, 0.18, 0.1, mat(0x8a6a3a)); magM.position.set(0, -0.13, -0.02); magM.rotation.x = 0.45
const stock = box(0.045, 0.08, 0.2, wood); stock.position.set(0, -0.01, 0.2)
g.add(recv, guard, barrel, sight, magM, stock)
break
}
case 'm4a4': {
const recv = box(0.055, 0.09, 0.44, dark); recv.position.z = -0.12
const rail = box(0.03, 0.03, 0.3, darker); rail.position.set(0, 0.065, -0.2)
const barrel = box(0.024, 0.024, 0.32, darker); barrel.position.z = -0.62
const magM = box(0.04, 0.16, 0.08, darker); magM.position.set(0, -0.12, -0.06)
const stock = box(0.05, 0.075, 0.18, dark); stock.position.set(0, 0, 0.2)
const grip = box(0.03, 0.09, 0.045, darker); grip.position.set(0, -0.09, 0.08); grip.rotation.x = 0.3
g.add(recv, rail, barrel, magM, stock, grip)
break
}
case 'awp': {
const olive = mat(0x4c5f3b, 0)
const body = box(0.055, 0.095, 0.6, olive); body.position.z = -0.15
const barrel = box(0.022, 0.022, 0.5, darker); barrel.position.z = -0.78
const muzzleBrake = box(0.035, 0.035, 0.06, darker); muzzleBrake.position.z = -1.0
const scope = new THREE.Mesh(new THREE.CylinderGeometry(0.032, 0.032, 0.26, 10), darker)
scope.rotation.x = Math.PI / 2
scope.position.set(0, 0.085, -0.15)
const stock = box(0.05, 0.1, 0.22, olive); stock.position.set(0, -0.01, 0.2)
const bolt = box(0.015, 0.015, 0.08, mat(0xa8a8b0, 0.7)); bolt.position.set(0.045, 0.03, 0.02)
g.add(body, barrel, muzzleBrake, scope, stock, bolt)
break
}
case 'glock': {
const slide = box(0.04, 0.055, 0.22, dark); slide.position.z = -0.05
const frame = box(0.038, 0.05, 0.18, darker); frame.position.set(0, -0.045, -0.02)
const grip = box(0.036, 0.12, 0.055, darker); grip.position.set(0, -0.1, 0.05); grip.rotation.x = 0.25
g.add(slide, frame, grip)
break
}
case 'usp': {
const slide = box(0.04, 0.055, 0.26, mat(0x4a4a52, 0.5)); slide.position.z = -0.07
const supp = box(0.045, 0.045, 0.14, darker); supp.position.z = -0.27
const grip = box(0.038, 0.12, 0.055, darker); grip.position.set(0, -0.1, 0.05); grip.rotation.x = 0.22
g.add(slide, supp, grip)
break
}
case 'deagle': {
const silver = mat(0xb4b4bc, 0.8)
const slide = box(0.05, 0.07, 0.3, silver); slide.position.z = -0.08
const grip = box(0.04, 0.13, 0.06, darker); grip.position.set(0, -0.1, 0.06); grip.rotation.x = 0.25
g.add(slide, grip)
break
}
case 'knife': {
const blade = box(0.012, 0.04, 0.26, mat(0xd6dde4, 0.9)); blade.position.z = -0.16
const edge = box(0.006, 0.012, 0.24, mat(0xf2f6fa, 1)); edge.position.set(0, -0.026, -0.15)
const guardK = box(0.04, 0.015, 0.02, darker); guardK.position.z = -0.02
const handle = box(0.028, 0.045, 0.13, mat(0x2c2c30, 0)); handle.position.z = 0.06
g.add(blade, edge, guardK, handle)
g.rotation.z = 0.25
g.rotation.y = 0.28
break
}
}
return g
}
setWeapon(id: WeaponId, animate = true) {
if (id === this.current && animate) return
this.current = id
for (const [k, g] of this.models) g.visible = k === id
if (animate) this.raise = 0
this.reloadT = 0
this.reloadDur = 0
}
onFire(kickScale: number) {
if (this.current === 'knife') {
this.slashT = 0.3
return
}
this.kick = Math.min(1.6, this.kick + 0.5 + kickScale * 18)
this.flashT = 0.05
if (this.current !== 'awp') {
;(this.flash.material as THREE.MeshBasicMaterial).opacity = 0.9
}
}
onReload(duration: number) {
this.reloadDur = duration
this.reloadT = 0.0001
}
update(dt: number, speed: number, onGround: boolean, scoped: boolean) {
this.kick *= Math.exp(-13 * dt)
this.raise = Math.min(1, this.raise + dt / 0.28)
this.flashT -= dt
if (this.flashT <= 0) (this.flash.material as THREE.MeshBasicMaterial).opacity = 0
if (this.reloadDur > 0) {
this.reloadT += dt
if (this.reloadT >= this.reloadDur) { this.reloadT = 0; this.reloadDur = 0 }
}
this.group.visible = !scoped
const speedRatio = onGround ? Math.min(1, speed / 4.7) : 0.2
this.bobPhase += speed * dt * 1.9
let px = 0.26, py = -0.24, pz = -0.5
let rx = 0, ry = 0, rz = 0
py += Math.abs(Math.sin(this.bobPhase)) * 0.012 * speedRatio - 0.006 * speedRatio
px += Math.sin(this.bobPhase * 0.5) * 0.008 * speedRatio
pz += this.kick * 0.05
rx += this.kick * 0.06
py -= (1 - this.raise) * 0.3
rx -= (1 - this.raise) * 0.6
if (this.reloadDur > 0) {
const p = Math.sin(Math.PI * Math.min(1, this.reloadT / this.reloadDur))
py -= p * 0.09
rz += p * 0.5
rx -= p * 0.25
}
if (this.slashT > 0) {
this.slashT = Math.max(0, this.slashT - dt)
const p = 1 - this.slashT / 0.3 // 0 → 1 over the swing
const sweep = Math.sin(p * Math.PI)
// diagonal slash: wind up right, cut across to lower-left
px += 0.1 - p * 0.32
py += 0.06 - p * 0.2
pz -= sweep * 0.22
ry += 0.5 - p * 1.1
rz -= sweep * 0.9
rx -= sweep * 0.35
}
this.group.position.set(px, py, pz)
this.group.rotation.set(rx, ry, rz)
}
}
@@ -0,0 +1,141 @@
import type { BodyPart, Slot, WeaponId } from './types'
export interface WeaponDef {
id: WeaponId
name: string
slot: Slot
damage: number
auto: boolean
rpm: number
magSize: number
reserve: number
reloadTime: number
/** standing-still base inaccuracy, radians */
spreadBase: number
/** extra inaccuracy per shot fired (bloom) */
spreadPerShot: number
/** exponential bloom decay rate, 1/s */
spreadDecay: number
/** extra spread at full run speed */
spreadMove: number
/** camera kick per shot, radians */
kickPitch: number
kickYaw: number
moveMult: number
/** damage kept when target has armor (higher = better penetration) */
armorPen: number
sound: 'ak' | 'm4' | 'awp' | 'pistol' | 'glock' | 'deagle' | 'knife'
scopeFov?: number
spreadScoped?: number
range?: number
}
export const WEAPONS: Record<WeaponId, WeaponDef> = {
ak47: {
id: 'ak47', name: 'AK-47', slot: 'primary',
damage: 36, auto: true, rpm: 600, magSize: 30, reserve: 90, reloadTime: 2.4,
spreadBase: 0.006, spreadPerShot: 0.011, spreadDecay: 5.5, spreadMove: 0.045,
kickPitch: 0.019, kickYaw: 0.006, moveMult: 0.92, armorPen: 0.775, sound: 'ak',
},
m4a4: {
id: 'm4a4', name: 'M4A4', slot: 'primary',
damage: 33, auto: true, rpm: 666, magSize: 30, reserve: 90, reloadTime: 2.1,
spreadBase: 0.005, spreadPerShot: 0.007, spreadDecay: 6.5, spreadMove: 0.04,
kickPitch: 0.009, kickYaw: 0.004, moveMult: 0.94, armorPen: 0.7, sound: 'm4',
},
awp: {
id: 'awp', name: 'AWP', slot: 'primary',
damage: 115, auto: false, rpm: 41, magSize: 5, reserve: 30, reloadTime: 3.6,
spreadBase: 0.07, spreadPerShot: 0.01, spreadDecay: 3, spreadMove: 0.09,
kickPitch: 0.032, kickYaw: 0.004, moveMult: 0.84, armorPen: 0.975, sound: 'awp',
scopeFov: 20, spreadScoped: 0.0012,
},
glock: {
id: 'glock', name: 'Glock-18', slot: 'secondary',
damage: 26, auto: false, rpm: 400, magSize: 20, reserve: 120, reloadTime: 2.2,
spreadBase: 0.009, spreadPerShot: 0.012, spreadDecay: 7, spreadMove: 0.02,
kickPitch: 0.008, kickYaw: 0.003, moveMult: 1.0, armorPen: 0.47, sound: 'glock',
},
usp: {
id: 'usp', name: 'USP-S', slot: 'secondary',
damage: 33, auto: false, rpm: 352, magSize: 12, reserve: 24, reloadTime: 2.2,
spreadBase: 0.007, spreadPerShot: 0.011, spreadDecay: 7, spreadMove: 0.02,
kickPitch: 0.009, kickYaw: 0.003, moveMult: 1.0, armorPen: 0.505, sound: 'pistol',
},
deagle: {
id: 'deagle', name: 'Desert Eagle', slot: 'secondary',
damage: 53, auto: false, rpm: 267, magSize: 7, reserve: 35, reloadTime: 2.2,
spreadBase: 0.012, spreadPerShot: 0.042, spreadDecay: 4.5, spreadMove: 0.05,
kickPitch: 0.028, kickYaw: 0.008, moveMult: 0.96, armorPen: 0.8, sound: 'deagle',
},
knife: {
id: 'knife', name: '军刀', slot: 'knife',
damage: 45, auto: true, rpm: 120, magSize: 0, reserve: 0, reloadTime: 0,
spreadBase: 0, spreadPerShot: 0, spreadDecay: 1, spreadMove: 0,
kickPitch: 0.004, kickYaw: 0, moveMult: 1.05, armorPen: 0.85, sound: 'knife',
range: 2.2,
},
}
/** 头部伤害为身体(胸)的两倍,其余部位各不相同。 */
export const PART_MULT: Record<BodyPart, number> = {
head: 2.0,
chest: 1.0,
stomach: 1.25,
arm: 0.85,
leg: 0.7,
}
export class WeaponState {
def: WeaponDef
mag: number
reserve: number
cooldown = 0
bloom = 0
reloadLeft = 0
constructor(def: WeaponDef) {
this.def = def
this.mag = def.magSize
this.reserve = def.reserve
}
get reloading() { return this.reloadLeft > 0 }
get isKnife() { return this.def.id === 'knife' }
/** Returns true the tick a reload completes. */
update(dt: number): boolean {
if (this.cooldown > 0) this.cooldown -= dt
this.bloom *= Math.exp(-this.def.spreadDecay * dt)
if (this.reloadLeft > 0) {
this.reloadLeft -= dt
if (this.reloadLeft <= 0) {
this.reloadLeft = 0
const take = Math.min(this.def.magSize - this.mag, this.reserve)
this.mag += take
this.reserve -= take
this.bloom = 0
return true
}
}
return false
}
canFire(): boolean {
if (this.reloading || this.cooldown > 0) return false
return this.isKnife || this.mag > 0
}
onFire() {
if (!this.isKnife) this.mag--
this.cooldown = 60 / this.def.rpm
this.bloom = Math.min(this.bloom + this.def.spreadPerShot, 0.08)
}
startReload(): boolean {
if (this.isKnife || this.reloading) return false
if (this.mag >= this.def.magSize || this.reserve <= 0) return false
this.reloadLeft = this.def.reloadTime
return true
}
}
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client'
import App from './App'
import './ui/hud.css'
createRoot(document.getElementById('root')!).render(<App />)
@@ -0,0 +1,290 @@
import { useEffect, useRef, useSyncExternalStore } from 'react'
import type { Engine } from '../game/engine'
import { GRID } from '../game/map'
import type { HudState, Team } from '../game/types'
const MINIMAP_PX = 4 // px per cell
function fmtTime(t: number): string {
const s = Math.max(0, Math.ceil(t))
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
function Minimap({ engine }: { engine: Engine }) {
const bgRef = useRef<HTMLCanvasElement>(null)
const fgRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const bg = bgRef.current!
engine.map.drawMinimap(bg.getContext('2d')!, MINIMAP_PX)
let raf = 0
const draw = () => {
raf = requestAnimationFrame(draw)
const fg = fgRef.current
if (!fg) return
const ctx = fg.getContext('2d')!
const size = GRID * MINIMAP_PX
ctx.clearRect(0, 0, size, size)
const info = engine.minimapInfo()
const toPx = (x: number, z: number): [number, number] => [
(x / 2.5 + GRID / 2) * MINIMAP_PX,
(z / 2.5 + GRID / 2) * MINIMAP_PX,
]
// bomb
const bomb = info.bomb
let bombPos = bomb.plantedPos ?? bomb.droppedPos
if (!bombPos && bomb.carrierId !== null) {
const carrier = info.soldiers[bomb.carrierId]
if (carrier && (carrier.team === info.playerTeam || carrier.lastSeenBy[info.playerTeam] > info.time - 2)) {
bombPos = carrier.pos
}
}
if (bombPos && (bomb.plantedPos ? Math.sin(info.time * 10) > -0.4 : true)) {
const [bx, bz] = toPx(bombPos.x, bombPos.z)
ctx.fillStyle = '#ff7828'
ctx.fillRect(bx - 4, bz - 4, 8, 8)
ctx.fillStyle = '#000'
ctx.font = 'bold 7px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('C4', bx, bz + 2.5)
}
for (const s of info.soldiers) {
if (!s.alive) continue
const mine = s.team === info.playerTeam
const seen = s.lastSeenBy[info.playerTeam] > info.time - 1.6
if (!mine && !seen) continue
const [px, pz] = toPx(s.pos.x, s.pos.z)
if (s.id === info.viewedId) {
// white arrow with view direction
ctx.save()
ctx.translate(px, pz)
ctx.rotate(-s.yaw)
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(0, -6)
ctx.lineTo(4.4, 4.4)
ctx.lineTo(0, 2)
ctx.lineTo(-4.4, 4.4)
ctx.closePath()
ctx.fill()
ctx.restore()
} else {
ctx.fillStyle = mine ? '#57c46a' : '#e84c3d'
ctx.beginPath()
ctx.arc(px, pz, 3.4, 0, Math.PI * 2)
ctx.fill()
}
}
}
raf = requestAnimationFrame(draw)
return () => cancelAnimationFrame(raf)
}, [engine])
const size = GRID * MINIMAP_PX
return (
<div className="minimap">
<canvas ref={bgRef} width={size} height={size} />
<canvas ref={fgRef} width={size} height={size} />
</div>
)
}
function Crosshair({ st }: { st: HudState }) {
if (st.me.scoped) return null
if (!st.me.alive) return null
if (st.me.weaponId === 'awp') {
// unscoped AWP: big circle = inaccurate
return <div className="crosshair-circle" />
}
const gap = Math.min(40, st.me.spreadPx)
const len = 9
return (
<div className="crosshair">
<div className="ch-dot" />
<div className="ch-line" style={{ left: '50%', top: '50%', width: 2, height: len, transform: `translate(-50%, ${gap}px)` }} />
<div className="ch-line" style={{ left: '50%', top: '50%', width: 2, height: len, transform: `translate(-50%, ${-gap - len}px)` }} />
<div className="ch-line" style={{ left: '50%', top: '50%', width: len, height: 2, transform: `translate(${gap}px, -50%)` }} />
<div className="ch-line" style={{ left: '50%', top: '50%', width: len, height: 2, transform: `translate(${-gap - len}px, -50%)` }} />
</div>
)
}
function Scope() {
return (
<div className="scope">
<div className="scope-cutout" />
<div className="scope-hline" />
<div className="scope-vline" />
</div>
)
}
function TeamTag({ team }: { team: Team }) {
return <span className={`teamtag ${team === 'CT' ? 'ct' : 't'}`}>{team}</span>
}
export default function HUD({ engine }: { engine: Engine }) {
useSyncExternalStore(engine.hud.subscribe, engine.hud.getSnapshot)
const st = engine.hud.state
const now = engine.time
if (st.phase === 'menu') {
return (
<div className="overlay menu">
<h1>DUST&nbsp;II</h1>
<p className="subtitle">5v5 炸弹模式 · 三维第一人称 · 纯代码生成</p>
<div className="team-select">
<button className="team-card ct" onClick={() => { engine.start('CT'); engine.requestLock() }}>
<span className="team-name">加入 CT</span>
<span className="team-desc">反恐精英 · USP-S 起手<br />守卫 A / B 点,拆除 C4</span>
</button>
<button className="team-card t" onClick={() => { engine.start('T'); engine.requestLock() }}>
<span className="team-name">加入 T</span>
<span className="team-desc">恐怖分子 · Glock 起手<br />突入包点,安放 C4</span>
</button>
</div>
<button className="spectate-btn" onClick={() => engine.start('CT', true)}>AI 对战观赏模式</button>
<div className="controls-help">
<span>WASD 移动</span><span>空格 跳跃</span><span>Shift 静步</span>
<span>鼠标左键 开火</span><span>右键 AWP 开镜</span><span>R 换弹</span>
<span>1/2/3 切枪</span><span>E 安放 / 拆除</span><span>B 购买阶段换 AWP</span>
</div>
</div>
)
}
const dmgFlash = now - st.damageAt < 0.4
const hitAge = now - st.hitmarkerAt
return (
<div className="hud">
{st.me.scoped && <Scope />}
<Crosshair st={st} />
{hitAge < 0.25 && (
<div className={`hitmarker ${st.hitmarkerHead ? 'head' : ''}`}>
<span /><span /><span /><span />
</div>
)}
{dmgFlash && <div className="damage-flash" />}
{/* top bar */}
<div className="topbar">
<div className="score ct-score">{st.ctScore}</div>
<div className="timer">
{st.bombPlanted
? <span className="bomb-timer">💣 {Math.ceil(st.bombTimeLeft ?? 0)}</span>
: <span>{st.phase === 'freeze' ? fmtTime(st.freezeLeft) : fmtTime(st.timeLeft)}</span>}
<div className="round-label">回合 {st.round}{st.pistolRound ? ' · 手枪局' : ''}</div>
</div>
<div className="score t-score">{st.tScore}</div>
</div>
<Minimap engine={engine} />
{/* killfeed */}
<div className="killfeed">
{st.killfeed.map(k => (
<div className="kf-entry" key={k.id}>
<span className={k.attackerTeam === 'CT' ? 'kf-ct' : 'kf-t'}>{k.attacker}</span>
<span className="kf-weapon">{k.weapon}{k.headshot ? ' ◎' : ''}</span>
<span className={k.victimTeam === 'CT' ? 'kf-ct' : 'kf-t'}>{k.victim}</span>
</div>
))}
</div>
{/* bottom left: hp / armor */}
{st.me.alive && (
<div className="vitals">
<div className="vital">
<span className="vital-icon">+</span>
<span className="vital-num">{st.me.hp}</span>
<div className="vital-bar"><div style={{ width: `${st.me.hp}%` }} className="vital-fill hp" /></div>
</div>
<div className="vital">
<span className="vital-icon">🛡</span>
<span className="vital-num">{st.me.armor}</span>
<div className="vital-bar"><div style={{ width: `${st.me.armor}%` }} className="vital-fill armor" /></div>
</div>
{st.me.hasBomb && <div className="carrying-bomb">携带 C4</div>}
</div>
)}
{/* bottom right: ammo */}
{st.me.alive && (
<div className="ammo">
<div className="weapon-name">{st.me.weaponName}{st.me.reloading ? '(换弹中…)' : ''}</div>
{st.me.weaponId !== 'knife' && (
<div className="ammo-nums">
<span className="ammo-mag">{st.me.mag}</span>
<span className="ammo-sep">/</span>
<span className="ammo-reserve">{st.me.reserve}</span>
</div>
)}
</div>
)}
{/* center messages */}
<div className="center-stack">
{st.phase === 'freeze' && (
<div className="banner freeze">
<div className="banner-title">准备阶段 · {Math.ceil(st.freezeLeft)}s</div>
{!st.pistolRound && <div className="banner-sub">按 B 切换主武器:当前 {st.loadout === 'awp' ? 'AWP' : '步枪'}</div>}
{st.pistolRound && <div className="banner-sub">手枪局 — {st.playerTeam === 'T' ? 'Glock-18' : 'USP-S'},无护甲</div>}
</div>
)}
{st.channel && (
<div className="channel">
<div className="channel-label">{st.channel.label}</div>
<div className="channel-bar"><div style={{ width: `${st.channel.progress * 100}%` }} /></div>
</div>
)}
{st.centerHint && !st.channel && <div className="hint">{st.centerHint}</div>}
{st.bombPlanted && st.phase === 'live' && <div className="planted-note">C4 已安放</div>}
{st.phase === 'over' && st.winner && (
<div className={`banner winner ${st.winner === 'CT' ? 'ct' : 't'}`}>
<div className="banner-title">{st.winner} 获胜</div>
<div className="banner-sub">{st.winReason}</div>
</div>
)}
</div>
{/* spectate bar */}
{st.spectating && st.phase === 'live' && (
<div className="spectate-bar">
{st.spectating.isSelf
? <span>你已阵亡 — 正在寻找存活队友…</span>
: <>
<span>观战中:<b>{st.spectating.name}</b> <TeamTag team={st.me.team} /></span>
<span className="spec-keys">←/→ 或 左键 切换 · E 接管该队友</span>
</>}
</div>
)}
{/* pause overlay (pointer unlocked mid-game) */}
{!st.locked && !st.auto && st.phase !== 'matchover' && (
<div className="overlay pause" onClick={() => engine.requestLock()}>
<div className="pause-box">
<h2>已暂停 / 鼠标未锁定</h2>
<p>点击任意处继续(锁定鼠标)</p>
<button onClick={e => { e.stopPropagation(); engine.toMenu() }}>返回主菜单</button>
</div>
</div>
)}
{st.phase === 'matchover' && st.matchWinner && (
<div className="overlay">
<div className="pause-box">
<h2>比赛结束 — {st.matchWinner} {st.matchWinner === st.playerTeam ? '胜利 🏆' : '获胜'}</h2>
<p>CT {st.ctScore} : {st.tScore} T</p>
<button onClick={() => { engine.restartMatch(); engine.requestLock() }}>再来一局</button>
<button onClick={() => engine.toMenu()}>返回主菜单</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,114 @@
* { box-sizing: border-box; }
.app { position: relative; width: 100%; height: 100%; font-family: -apple-system, "PingFang SC", "Segoe UI", Roboto, sans-serif; user-select: none; }
.viewport { position: absolute; inset: 0; }
.viewport canvas { display: block; }
.hud { position: absolute; inset: 0; pointer-events: none; color: #eee; }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: auto; }
/* ---------- menu ---------- */
.menu { background: linear-gradient(180deg, rgba(10,14,20,0.55), rgba(10,14,20,0.82)); }
.menu h1 { font-size: 84px; margin: 0; letter-spacing: 22px; color: #f2e3c2; text-shadow: 0 4px 24px rgba(0,0,0,0.6); font-weight: 800; }
.menu .subtitle { color: #b8c4d0; letter-spacing: 4px; margin: 4px 0 42px; }
.team-select { display: flex; gap: 28px; }
.team-card { width: 250px; padding: 26px 22px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.18); cursor: pointer; text-align: left; transition: transform 0.12s, box-shadow 0.12s; color: #fff; }
.team-card:hover { transform: translateY(-4px); box-shadow: 0 12px 34px rgba(0,0,0,0.5); }
.team-card.ct { background: linear-gradient(160deg, #2c4a66, #1a2c3e); }
.team-card.t { background: linear-gradient(160deg, #7a5c30, #4a3820); }
.team-name { display: block; font-size: 26px; font-weight: 700; margin-bottom: 10px; }
.team-desc { display: block; font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.75); }
.spectate-btn { margin-top: 26px; background: none; border: 1px solid rgba(255,255,255,0.3); color: #b8c4d0; padding: 9px 26px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.spectate-btn:hover { border-color: #fff; color: #fff; }
.controls-help { margin-top: 40px; display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; max-width: 640px; color: #8a97a5; font-size: 12.5px; }
.controls-help span { background: rgba(255,255,255,0.07); padding: 5px 12px; border-radius: 4px; }
/* ---------- crosshair ---------- */
.crosshair { position: absolute; inset: 0; }
.ch-dot { position: absolute; left: 50%; top: 50%; width: 2px; height: 2px; background: #4dff9d; transform: translate(-50%, -50%); box-shadow: 0 0 2px #000; }
.ch-line { position: absolute; background: #4dff9d; box-shadow: 0 0 2px rgba(0,0,0,0.9); }
.crosshair-circle { position: absolute; left: 50%; top: 50%; width: 120px; height: 120px; border: 1.5px dashed rgba(255,255,255,0.5); border-radius: 50%; transform: translate(-50%, -50%); }
/* ---------- scope ---------- */
.scope { position: absolute; inset: 0; }
.scope-cutout { position: absolute; inset: 0; background: radial-gradient(circle at center, transparent 0, transparent 33vmin, rgba(0,0,0,0.985) 34vmin); }
.scope-hline { position: absolute; top: 50%; left: 0; right: 0; height: 1.5px; background: rgba(0,0,0,0.9); }
.scope-vline { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: rgba(0,0,0,0.9); }
/* ---------- hitmarker / damage ---------- */
.hitmarker span { position: absolute; left: 50%; top: 50%; width: 2.5px; height: 10px; background: #fff; }
.hitmarker.head span { background: #ff5544; }
.hitmarker span:nth-child(1) { transform: translate(-50%,-50%) rotate(45deg) translateY(-11px); }
.hitmarker span:nth-child(2) { transform: translate(-50%,-50%) rotate(135deg) translateY(-11px); }
.hitmarker span:nth-child(3) { transform: translate(-50%,-50%) rotate(225deg) translateY(-11px); }
.hitmarker span:nth-child(4) { transform: translate(-50%,-50%) rotate(315deg) translateY(-11px); }
.damage-flash { position: absolute; inset: 0; box-shadow: inset 0 0 140px 40px rgba(200,20,20,0.55); }
/* ---------- top bar ---------- */
.topbar { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); display: flex; align-items: stretch; gap: 2px; }
.score { min-width: 58px; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; padding: 4px 10px; }
.ct-score { background: rgba(58,110,165,0.85); border-radius: 6px 0 0 6px; }
.t-score { background: rgba(196,142,54,0.85); border-radius: 0 6px 6px 0; }
.timer { background: rgba(12,16,22,0.82); padding: 5px 18px; text-align: center; font-size: 21px; font-weight: 700; min-width: 96px; }
.timer .bomb-timer { color: #ff7828; }
.round-label { font-size: 10.5px; font-weight: 400; color: #93a1af; margin-top: 1px; }
/* ---------- minimap ---------- */
.minimap { position: absolute; top: 14px; left: 14px; background: rgba(10,13,18,0.72); border: 1px solid rgba(255,255,255,0.14); border-radius: 6px; padding: 4px; }
.minimap canvas { display: block; }
.minimap canvas + canvas { position: absolute; top: 4px; left: 4px; }
/* ---------- killfeed ---------- */
.killfeed { position: absolute; top: 14px; right: 14px; display: flex; flex-direction: column; gap: 5px; align-items: flex-end; }
.kf-entry { background: rgba(10,13,18,0.78); border-radius: 4px; padding: 5px 11px; font-size: 13px; font-weight: 600; display: flex; gap: 8px; }
.kf-ct { color: #6fa8dc; }
.kf-t { color: #e0aa4e; }
.kf-weapon { color: #cfd6dd; font-weight: 400; font-size: 12px; align-self: center; }
/* ---------- vitals ---------- */
.vitals { position: absolute; left: 18px; bottom: 18px; display: flex; gap: 22px; align-items: flex-end; }
.vital { background: rgba(10,13,18,0.72); border-radius: 6px; padding: 8px 14px; display: flex; align-items: center; gap: 9px; min-width: 130px; }
.vital-icon { font-size: 15px; color: #aab7c4; }
.vital-num { font-size: 26px; font-weight: 800; min-width: 44px; }
.vital-bar { flex: 1; height: 4px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; min-width: 40px; }
.vital-fill { height: 100%; border-radius: 2px; transition: width 0.15s; }
.vital-fill.hp { background: #57c46a; }
.vital-fill.armor { background: #6fa8dc; }
.carrying-bomb { background: rgba(180,80,20,0.85); padding: 8px 14px; border-radius: 6px; font-weight: 700; font-size: 14px; }
/* ---------- ammo ---------- */
.ammo { position: absolute; right: 18px; bottom: 18px; text-align: right; background: rgba(10,13,18,0.72); border-radius: 6px; padding: 10px 16px; }
.weapon-name { font-size: 13px; color: #aab7c4; letter-spacing: 1px; margin-bottom: 2px; }
.ammo-nums { font-weight: 800; }
.ammo-mag { font-size: 34px; }
.ammo-sep { font-size: 20px; color: #66727e; margin: 0 6px; }
.ammo-reserve { font-size: 20px; color: #93a1af; }
/* ---------- center stack ---------- */
.center-stack { position: absolute; left: 50%; top: 58%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 12px; }
.banner { text-align: center; background: rgba(10,13,18,0.8); border-radius: 8px; padding: 12px 34px; }
.banner-title { font-size: 24px; font-weight: 800; letter-spacing: 2px; }
.banner-sub { font-size: 13px; color: #aab7c4; margin-top: 4px; }
.banner.winner.ct .banner-title { color: #6fa8dc; }
.banner.winner.t .banner-title { color: #e0aa4e; }
.channel { width: 300px; background: rgba(10,13,18,0.8); border-radius: 8px; padding: 12px 18px; }
.channel-label { font-size: 14px; font-weight: 700; margin-bottom: 8px; text-align: center; }
.channel-bar { height: 8px; background: rgba(255,255,255,0.15); border-radius: 4px; overflow: hidden; }
.channel-bar div { height: 100%; background: #ff7828; }
.hint { background: rgba(10,13,18,0.8); padding: 8px 22px; border-radius: 6px; font-size: 15px; font-weight: 600; color: #ffd28a; }
.planted-note { color: #ff7828; font-weight: 700; font-size: 13px; letter-spacing: 2px; text-shadow: 0 1px 4px #000; }
/* ---------- spectate ---------- */
.spectate-bar { position: absolute; left: 50%; bottom: 90px; transform: translateX(-50%); background: rgba(10,13,18,0.85); border: 1px solid rgba(255,255,255,0.16); padding: 12px 26px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 5px; font-size: 15px; }
.spec-keys { font-size: 12px; color: #93a1af; }
.teamtag { font-size: 11px; font-weight: 800; padding: 1px 7px; border-radius: 3px; margin-left: 4px; }
.teamtag.ct { background: #3a6ea5; }
.teamtag.t { background: #c48e36; }
/* ---------- pause / matchover ---------- */
.pause { background: rgba(6,8,11,0.6); cursor: pointer; }
.pause-box { background: rgba(14,18,24,0.95); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; padding: 34px 48px; text-align: center; }
.pause-box h2 { margin: 0 0 10px; font-size: 22px; }
.pause-box p { color: #93a1af; margin: 0 0 20px; }
.pause-box button { display: block; width: 100%; margin-top: 10px; background: rgba(255,255,255,0.1); color: #eee; border: 1px solid rgba(255,255,255,0.25); padding: 10px 0; border-radius: 6px; cursor: pointer; font-size: 14px; }
.pause-box button:hover { background: rgba(255,255,255,0.2); }
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 5173 },
})
@@ -0,0 +1,2 @@
node_modules/
dist/
@@ -0,0 +1,64 @@
# Dust II Arena
A from-scratch **5v5 first-person shooter prototype** recreating the core areas of
Dust2, built with **React 18 + TypeScript + three.js**. No external assets: the map,
characters and weapons are procedural geometry, and every sound is synthesized with
the Web Audio API. The only third-party libraries are `three`, `react` and
`react-dom` — pathfinding, physics, the AI state machine, the render loop and the
round/economy logic are all hand-written.
## Run
```bash
bun install
bun run dev # http://localhost:5173 — click the arena to lock the mouse
```
Build a static bundle with `bun run build` (output in `dist/`). A prebuilt,
fully self-contained single file is also included as `dust2-arena.html`.
## Controls
| Action | Key |
| --- | --- |
| Move / Jump / Walk | `WASD` / `Space` / `Shift` |
| Fire / Scope (AWP) / Reload | `Mouse1` / `Mouse2` / `R` |
| Primary / Secondary / Knife | `1` / `2` / `3` (or wheel) |
| Swap primary ⇄ AWP | `B` |
| Plant / Defuse / Pick up C4 | hold `E` |
| (dead) Switch spectated teammate | `Mouse1` / `A` / `D` |
| (dead) Take control of teammate | `F` / `Space` |
## What it includes
- **Map** — T / CT spawns, A Long, A site, Mid (with passable doors), Catwalk,
B tunnels and B site, laid out on a connected tile grid with full wall + crate
collision.
- **Characters** — box-built humanoids (separate head, torso, arms, legs), CT blue
vs T tan, in a weapon-holding pose.
- **Weapons** — AK-47, M4A4, AWP (right-click scope), Glock / USP / Desert Eagle,
and a knife, each with distinct damage, recoil, spread, magazine and reload.
Hitscan with per-zone hitboxes (head = 2× body) and armor mitigation.
- **Rounds** — pistol-round opening, then rifle buys; C4 carry / drop / plant /
defuse and full round resolution (elimination, detonation, defuse, time).
- **AI** — 9 bots navigate with grid A*, spot enemies through line-of-sight, fight,
and push the plant / rotate to defuse.
- **Death spectating** — when you die you watch a living teammate in first person,
cycle between teammates, and can take control of one (it stops being AI-driven).
- **HUD** — dynamic crosshair, health / armor, ammo, killfeed, round + bomb timers,
and a live Dust2 minimap. Procedural audio for guns, reloads, footsteps, scope,
hits, C4 and kills.
## Layout
```
src/
main.tsx, App.tsx, styles.css
components/ GameCanvas, Hud, Minimap, Menu
engine/
grid.ts dust2.ts navmesh.ts collision.ts # map + spatial systems
weapons.ts combat.ts # weapon data + hitboxes
world.ts ai.ts player.ts input.ts # simulation + control
scene.ts effects.ts entities/ # three.js geometry
audio.ts store.ts constants.ts types.ts # sound, HUD bridge, config
```
@@ -0,0 +1,282 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "dust2-arena",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.185.1",
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.185.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vite-plugin-singlefile": "^2.0.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.185.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dust II Arena</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,27 @@
{
"name": "dust2-arena",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "5v5 procedural Dust2 first-person shooter prototype (React + TypeScript + three.js).",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:single": "tsc && vite build --config vite.single.config.ts",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.185.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.185.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vite-plugin-singlefile": "^2.0.3"
}
}
@@ -0,0 +1,10 @@
import { useState } from 'react';
import { Team } from './engine/types';
import { Menu } from './components/Menu';
import { GameCanvas } from './components/GameCanvas';
export function App() {
const [team, setTeam] = useState<Team | null>(null);
if (team === null) return <Menu onStart={setTeam} />;
return <GameCanvas team={team} />;
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
import { GameWorld } from '../engine/world';
import { Team } from '../engine/types';
import { Hud } from './Hud';
import { Minimap } from './Minimap';
export function GameCanvas({ team }: { team: Team }) {
const mountRef = useRef<HTMLDivElement>(null);
const miniRef = useRef<HTMLCanvasElement>(null);
const worldRef = useRef<GameWorld | null>(null);
const [locked, setLocked] = useState(false);
const [started, setStarted] = useState(false);
useEffect(() => {
const container = mountRef.current!;
const world = new GameWorld(container, team);
worldRef.current = world;
if (miniRef.current) world.setMinimapCanvas(miniRef.current);
let raf = 0;
let last = performance.now();
const loop = (t: number) => {
const dt = (t - last) / 1000;
last = t;
world.update(dt);
world.input.endFrame();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const onResize = () => world.resize();
window.addEventListener('resize', onResize);
const onLockChange = () => setLocked(document.pointerLockElement === world.renderer.domElement);
document.addEventListener('pointerlockchange', onLockChange);
const onClick = () => {
world.start();
setStarted(true);
world.input.requestLock();
};
container.addEventListener('click', onClick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
document.removeEventListener('pointerlockchange', onLockChange);
container.removeEventListener('click', onClick);
world.dispose();
worldRef.current = null;
};
}, [team]);
return (
<div className="game-root" ref={mountRef}>
<Hud />
<Minimap canvasRef={miniRef} />
{!locked && (
<div className="click-overlay">
<div className="click-inner">
<div className="click-title">{started ? 'Paused' : 'Ready'}</div>
<div className="click-hint">Click to {started ? 'resume' : 'lock mouse and start'}</div>
<div className="click-sub">Esc releases the mouse</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { useSyncExternalStore } from 'react';
import { hud } from '../engine/store';
import { Team } from '../engine/types';
import { WEAPONS } from '../engine/weapons';
function useHud() {
return useSyncExternalStore(hud.subscribe, hud.getSnapshot, hud.getSnapshot);
}
const T_COLOR = '#e0a24a';
const CT_COLOR = '#5aa9e6';
const teamColor = (t: Team) => (t === Team.T ? T_COLOR : CT_COLOR);
function fmt(t: number): string {
const s = Math.max(0, Math.ceil(t));
const m = Math.floor(s / 60);
const ss = s % 60;
return `${m}:${ss.toString().padStart(2, '0')}`;
}
function Crosshair({ spread, hit }: { spread: number; hit: boolean }) {
const gap = Math.max(3, Math.min(90, spread * 600));
const len = 7;
const thick = 2;
const col = hit ? '#ff5b5b' : '#e7fff0';
const line = (style: React.CSSProperties) => (
<span style={{ position: 'absolute', background: col, ...style }} />
);
return (
<div className="crosshair">
{line({ width: thick, height: len, left: -thick / 2, top: -gap - len })}
{line({ width: thick, height: len, left: -thick / 2, top: gap })}
{line({ width: len, height: thick, top: -thick / 2, left: -gap - len })}
{line({ width: len, height: thick, top: -thick / 2, left: gap })}
<span style={{ position: 'absolute', width: 2, height: 2, left: -1, top: -1, background: col }} />
</div>
);
}
export function Hud() {
const s = useHud();
return (
<div className="hud">
{/* top score bar */}
<div className="topbar">
<div className="team-block" style={{ color: T_COLOR }}>
<span className="team-tag">T</span>
<span className="team-alive">{'●'.repeat(s.aliveT)}<span className="dead-dots">{'○'.repeat(Math.max(0, 5 - s.aliveT))}</span></span>
</div>
<div className="score-center">
<span className="score" style={{ color: T_COLOR }}>{s.scoreT}</span>
<span className="score-time">{s.bombPlanted ? <b className="bomb-time">{fmt(s.bombTimer)}</b> : fmt(s.roundTime)}</span>
<span className="score" style={{ color: CT_COLOR }}>{s.scoreCT}</span>
</div>
<div className="team-block team-right" style={{ color: CT_COLOR }}>
<span className="team-alive"><span className="dead-dots">{'○'.repeat(Math.max(0, 5 - s.aliveCT))}</span>{'●'.repeat(s.aliveCT)}</span>
<span className="team-tag">CT</span>
</div>
</div>
{/* killfeed */}
<div className="killfeed">
{s.killfeed.map((k) => (
<div className="kill-row" key={k.id}>
<span style={{ color: teamColor(k.attackerTeam) }}>{k.attacker}</span>
<span className="kill-weapon">{WEAPONS[k.weapon]?.name ?? k.weapon}{k.headshot ? ' ×' : ''}</span>
<span style={{ color: teamColor(k.victimTeam) }}>{k.victim}</span>
</div>
))}
</div>
{/* center banner */}
{s.banner && <div className="banner">{s.banner}</div>}
{/* plant / defuse progress */}
{(s.planting || s.defusing) && (
<div className="progress-wrap">
<div className="progress-label">{s.planting ? 'PLANTING' : 'DEFUSING'}</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${Math.min(100, (s.planting ? s.plantProgress : s.defuseProgress) * 100)}%`,
background: s.planting ? T_COLOR : CT_COLOR,
}}
/>
</div>
</div>
)}
{/* scope overlay */}
{s.scoped && (
<div className="scope">
<div className="scope-circle" />
<div className="scope-line scope-h" />
<div className="scope-line scope-v" />
</div>
)}
{/* crosshair */}
{!s.scoped && (s.alive || s.spectating) && <Crosshair spread={s.spread} hit={false} />}
{/* dead: spectate bar, or eliminated when no teammate left */}
{!s.alive && s.spectating && (
<div className="spectate">
<div className="spectate-title">
SPECTATING <span className="spectate-name">{s.spectateName}</span>
</div>
<div className="spectate-keys">
<b>[F]</b> / <b>Space</b> take control&nbsp;&nbsp;·&nbsp;&nbsp;<b>Mouse1</b> / <b>A</b> / <b>D</b> switch player
</div>
</div>
)}
{!s.alive && !s.spectating && (
<div className="dead">
<div className="dead-title">ELIMINATED</div>
<div className="dead-sub">No teammates left — round ending</div>
</div>
)}
{/* hint */}
{s.hint && s.alive && <div className="hint">{s.hint}</div>}
{/* bomb carrier tag */}
{s.hasBomb && s.alive && <div className="bomb-tag">C4 — bring it to A or B</div>}
{/* bottom-left: health / armor */}
<div className="vitals">
<div className="vital">
<span className="vital-icon hp">+</span>
<span className="vital-num" style={{ color: s.health < 30 ? '#ff5b5b' : '#fff' }}>{s.health}</span>
</div>
<div className="vital">
<span className="vital-icon ar">{s.helmet ? 'A*' : 'A'}</span>
<span className="vital-num">{s.armor}</span>
</div>
</div>
{/* bottom-right: ammo + weapons */}
<div className="ammo-block">
<div className="ammo-weapon">{s.weaponName}</div>
<div className="ammo-nums">
{s.infiniteAmmo ? (
<span className="ammo-mag">∞</span>
) : (
<>
<span className="ammo-mag" style={{ color: s.reloading ? '#ffcf5b' : '#fff' }}>
{s.reloading ? 'R' : s.mag}
</span>
<span className="ammo-res">/ {s.reserve}</span>
</>
)}
</div>
<div className="weapon-slots">
{s.loadout.map((w) => (
<div key={w.id} className={`slot ${w.current ? 'slot-cur' : ''}`}>
<span className="slot-key">{w.slot === 'primary' ? '1' : w.slot === 'secondary' ? '2' : '3'}</span>
<span className="slot-name">{w.name}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { Team } from '../engine/types';
export function Menu({ onStart }: { onStart: (t: Team) => void }) {
const [team, setTeam] = useState<Team>(Team.T);
return (
<div className="menu">
<div className="menu-card">
<h1 className="menu-title">DUST II ARENA</h1>
<p className="menu-sub">5v5 procedural first-person prototype — pick a side</p>
<div className="side-select">
<button
className={`side side-t ${team === Team.T ? 'active' : ''}`}
onClick={() => setTeam(Team.T)}
>
<span className="side-name">Terrorists</span>
<span className="side-desc">Carry &amp; plant the C4 · attack A or B</span>
</button>
<button
className={`side side-ct ${team === Team.CT ? 'active' : ''}`}
onClick={() => setTeam(Team.CT)}
>
<span className="side-name">Counter-Terrorists</span>
<span className="side-desc">Hold the sites · defuse the C4</span>
</button>
</div>
<div className="controls">
<div className="ctrl-col">
<b>Move</b> WASD · <b>Jump</b> Space · <b>Walk</b> Shift
</div>
<div className="ctrl-col">
<b>Fire</b> Mouse1 · <b>Scope</b> Mouse2 (AWP) · <b>Reload</b> R
</div>
<div className="ctrl-col">
<b>Weapons</b> 1 / 2 / 3 · wheel · <b>Swap AWP</b> B
</div>
<div className="ctrl-col">
<b>Plant / Defuse / Pickup</b> hold E
</div>
<div className="ctrl-col">
<b>When dead</b> Mouse1 / A / D switch view · F take control
</div>
</div>
<button className="start-btn" onClick={() => onStart(team)}>
Enter Match
</button>
<p className="menu-foot">Round 1 is a pistol round. Click the arena to lock the mouse.</p>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { RefObject } from 'react';
export function Minimap({ canvasRef }: { canvasRef: RefObject<HTMLCanvasElement | null> }) {
return (
<div className="minimap">
<canvas ref={canvasRef as RefObject<HTMLCanvasElement>} width={200} height={200} />
<div className="minimap-label">DUST II</div>
</div>
);
}
@@ -0,0 +1,330 @@
// Bot brain: a compact state machine driving perception, navigation (grid A*),
// combat (face + strafe + burst fire with reaction delay + aim error) and the
// C4 objective (T push/plant, CT hold/defuse). It only sets an actor's intents
// and calls world combat helpers; physics + firing live in GameWorld.
import { AI } from './constants';
import { findPath } from './navmesh';
import { angleDelta, moveAngleToward, rand, yawFromDir } from './mathUtils';
import { BombState, Team } from './types';
import { WEAPONS } from './weapons';
import type { Actor, GameWorld } from './world';
export type BotMode = 'advance' | 'engage' | 'search' | 'hold' | 'plant' | 'defuse' | 'pickup';
export interface BotState {
mode: BotMode;
path: { x: number; z: number }[];
pathIdx: number;
repath: number;
goalX: number;
goalZ: number;
hasGoal: boolean;
target: number;
lastSeen: number;
lastSeenX: number;
lastSeenZ: number;
reactAt: number;
nextBurst: number;
burstUntil: number;
strafeDir: number;
strafeUntil: number;
wanderYaw: number;
targetSite?: 'A' | 'B';
defendSite?: 'A' | 'B';
}
export function initBotState(s?: BotState): BotState {
const base: BotState = {
mode: 'advance',
path: [],
pathIdx: 0,
repath: 0,
goalX: 0,
goalZ: 0,
hasGoal: false,
target: -1,
lastSeen: -99,
lastSeenX: 0,
lastSeenZ: 0,
reactAt: 0,
nextBurst: 0,
burstUntil: 0,
strafeDir: 1,
strafeUntil: 0,
wanderYaw: 0,
};
if (s) {
const keepT = s.targetSite;
const keepD = s.defendSite;
Object.assign(s, base);
s.targetSite = keepT;
s.defendSite = keepD;
return s;
}
return base;
}
function setGoal(world: GameWorld, a: Actor, gx: number, gz: number): void {
const bs = a.bs;
const moved = (gx - bs.goalX) ** 2 + (gz - bs.goalZ) ** 2 > 9;
bs.goalX = gx;
bs.goalZ = gz;
bs.hasGoal = true;
if (moved || !bs.path.length || bs.repath <= 0) {
const p = findPath(world.grid, a.x, a.z, gx, gz);
bs.path = p ?? [];
bs.pathIdx = 0;
bs.repath = AI.repathInterval;
}
}
// steer along the current path; returns false when the path is exhausted
function followPath(a: Actor): boolean {
const bs = a.bs;
if (bs.pathIdx >= bs.path.length) return false;
let wp = bs.path[bs.pathIdx];
let dx = wp.x - a.x;
let dz = wp.z - a.z;
let d = Math.hypot(dx, dz);
while (d < 1.1 && bs.pathIdx < bs.path.length - 1) {
bs.pathIdx++;
wp = bs.path[bs.pathIdx];
dx = wp.x - a.x;
dz = wp.z - a.z;
d = Math.hypot(dx, dz);
}
if (bs.pathIdx >= bs.path.length - 1 && d < 1.1) {
bs.pathIdx = bs.path.length;
return false;
}
if (d > 1e-3) {
a.moveX = dx / d;
a.moveZ = dz / d;
}
return true;
}
export function updateBot(world: GameWorld, a: Actor, dt: number, canMove: boolean): void {
const bs = a.bs;
bs.repath -= dt;
a.moveX = 0;
a.moveZ = 0;
a.wantRun = true;
a.action = 'none';
// --- perception ---
const seen = world.nearestVisibleEnemy(a, AI.fovDeg);
if (seen) {
if (bs.target !== seen.id) {
bs.target = seen.id;
bs.reactAt = world.time + AI.reactionTime + rand(0, 0.12);
}
bs.lastSeen = world.time;
bs.lastSeenX = seen.x;
bs.lastSeenZ = seen.z;
bs.mode = 'engage';
} else if (bs.mode === 'engage') {
bs.mode = world.time - bs.lastSeen < 3 ? 'search' : 'advance';
bs.target = -1;
}
if (!canMove) {
// during freeze, just face forward
return;
}
const bomb = world.bombInfo();
if (bs.mode === 'engage' && seen) {
combat(world, a, seen, dt);
return;
}
// --- objective / navigation ---
if (bs.mode === 'search') {
setGoal(world, a, bs.lastSeenX, bs.lastSeenZ);
if ((bs.lastSeenX - a.x) ** 2 + (bs.lastSeenZ - a.z) ** 2 < 4 || !followPathOrArrive(world, a)) {
bs.mode = 'advance';
}
faceMovement(a, dt);
return;
}
if (a.team === Team.T) {
objectiveT(world, a, bomb.state, bomb.x, bomb.z);
} else {
objectiveCT(world, a, bomb.state, bomb.x, bomb.z);
}
faceMovement(a, dt);
}
function followPathOrArrive(world: GameWorld, a: Actor): boolean {
const moving = followPath(a);
if (!moving) {
// recompute once toward goal in case we stalled
if (a.bs.hasGoal) {
const d2 = (a.bs.goalX - a.x) ** 2 + (a.bs.goalZ - a.z) ** 2;
if (d2 > 4 && a.bs.repath <= 0) setGoal(world, a, a.bs.goalX, a.bs.goalZ);
}
return false;
}
return true;
}
function objectiveT(world: GameWorld, a: Actor, bombState: BombState, bx: number, bz: number): void {
const bs = a.bs;
const site = world.dust2.sites[(bs.targetSite ?? 'A') as 'A' | 'B'];
if (bombState === BombState.Dropped) {
// nearest T without bomb goes for the pickup
if (isNearestTeammateTo(world, a, bx, bz)) {
setGoal(world, a, bx, bz);
followPathOrArrive(world, a);
world.pickupBomb(a);
return;
}
}
if (a.hasBomb && bombState === BombState.Carried) {
const inSite = world.isInSite(a) === bs.targetSite || world.isInSite(a) !== null;
if (inSite && a.grounded) {
// plant if no enemy currently threatening
a.action = 'plant';
a.moveX = a.moveZ = 0;
return;
}
setGoal(world, a, site.center.x, site.center.z);
followPathOrArrive(world, a);
return;
}
if (bombState === BombState.Planted) {
// defend near the bomb
const spot = spreadAround(a, bx, bz, 8);
setGoal(world, a, spot.x, spot.z);
followPathOrArrive(world, a);
return;
}
// support push toward the target site
const spot = spreadAround(a, site.center.x, site.center.z, 7);
setGoal(world, a, spot.x, spot.z);
followPathOrArrive(world, a);
}
function objectiveCT(world: GameWorld, a: Actor, bombState: BombState, bx: number, bz: number): void {
const bs = a.bs;
if (bombState === BombState.Planted) {
// rotate to defuse
const dist2 = (bx - a.x) ** 2 + (bz - a.z) ** 2;
if (dist2 < 2.3 * 2.3 && a.grounded) {
a.action = 'defuse';
a.moveX = a.moveZ = 0;
return;
}
setGoal(world, a, bx, bz);
followPathOrArrive(world, a);
return;
}
// hold the assigned site
const site = world.dust2.sites[(bs.defendSite ?? 'A') as 'A' | 'B'];
const d2 = (site.center.x - a.x) ** 2 + (site.center.z - a.z) ** 2;
if (d2 > 10 * 10) {
setGoal(world, a, site.center.x, site.center.z);
followPathOrArrive(world, a);
} else {
// patrol slowly around the site
if (world.time > a.bs.strafeUntil) {
a.bs.strafeUntil = world.time + rand(2, 4);
const ang = rand(0, Math.PI * 2);
setGoal(world, a, site.center.x + Math.cos(ang) * 6, site.center.z + Math.sin(ang) * 6);
}
a.wantRun = false;
followPathOrArrive(world, a);
}
}
function isNearestTeammateTo(world: GameWorld, a: Actor, x: number, z: number): boolean {
const myD = (a.x - x) ** 2 + (a.z - z) ** 2;
for (const t of world.actors) {
if (!t.alive || t.team !== a.team || t.id === a.id || t.isPlayer) continue;
const d = (t.x - x) ** 2 + (t.z - z) ** 2;
if (d < myD) return false;
}
return true;
}
function spreadAround(a: Actor, x: number, z: number, spread: number): { x: number; z: number } {
// deterministic per-bot offset so bots don't stack on one point
const ang = (a.id * 1.3) % (Math.PI * 2);
return { x: x + Math.cos(ang) * spread * 0.7, z: z + Math.sin(ang) * spread * 0.7 };
}
function faceMovement(a: Actor, dt: number): void {
if (Math.abs(a.moveX) + Math.abs(a.moveZ) > 1e-3) {
const desired = yawFromDir(a.moveX, a.moveZ);
a.yaw = moveAngleToward(a.yaw, desired, AI.aimSpeed * dt);
}
a.pitch += (0 - a.pitch) * Math.min(1, dt * 6);
}
function combat(world: GameWorld, a: Actor, target: Actor, dt: number): void {
const bs = a.bs;
const dx = target.x - a.x;
const dz = target.z - a.z;
const dist = Math.hypot(dx, dz);
// aim (with error)
const err = AI.aimError;
const desiredYaw = yawFromDir(dx, dz) + rand(-err, err);
const eyeY = a.y + a.eyeH;
const targetY = target.y + 1.15;
const desiredPitch = Math.atan2(targetY - eyeY, Math.max(0.5, dist)) + rand(-err, err);
a.yaw = moveAngleToward(a.yaw, desiredYaw, AI.aimSpeed * dt);
a.pitch += (desiredPitch - a.pitch) * Math.min(1, dt * 10);
// movement: strafe + keep an ideal range
if (world.time > bs.strafeUntil) {
bs.strafeUntil = world.time + rand(0.5, 1.3);
bs.strafeDir = Math.random() < 0.5 ? 1 : -1;
}
const perpX = -dz / (dist || 1);
const perpZ = dx / (dist || 1);
let mx = perpX * bs.strafeDir;
let mz = perpZ * bs.strafeDir;
const ideal = a.current === 'awp' ? 24 : 14;
if (dist > ideal + 6) {
mx += dx / dist;
mz += dz / dist;
} else if (dist < ideal - 6) {
mx -= dx / dist;
mz -= dz / dist;
}
const ml = Math.hypot(mx, mz) || 1;
a.moveX = mx / ml;
a.moveZ = mz / ml;
a.wantRun = false; // walk while fighting = more accurate
// fire control
const w = WEAPONS[a.current];
const ammo = a.ammo[a.current];
if (ammo && ammo.mag <= 0) {
world.startReload(a);
return;
}
const aligned = Math.abs(angleDelta(a.yaw, desiredYaw)) < 0.13;
const reacted = world.time >= bs.reactAt;
if (!reacted || !aligned) return;
if (!world.canSee(a, target, 200)) return;
if (w.automatic) {
if (world.time > bs.nextBurst) {
bs.burstUntil = world.time + rand(AI.burstMin, AI.burstMax);
bs.nextBurst = bs.burstUntil + rand(0.18, 0.45);
}
if (world.time <= bs.burstUntil) world.fireWeapon(a);
} else {
world.fireWeapon(a);
}
}
@@ -0,0 +1,233 @@
// Procedural sound via the Web Audio API (no audio files). World sounds are
// attenuated + panned relative to the player listener; the player's own weapon
// plays centered. Everything is short transients synthesized from noise + tones.
import { WeaponId } from './types';
export class AudioEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private noise: AudioBuffer | null = null;
private lx = 0;
private lz = 0;
private lyaw = 0;
muted = false;
init(): void {
if (this.ctx) {
if (this.ctx.state === 'suspended') void this.ctx.resume();
return;
}
const Ctor: typeof AudioContext =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ??
window.AudioContext;
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
// one second of white noise, reused everywhere
const len = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
this.noise = buf;
}
setListener(x: number, z: number, yaw: number): void {
this.lx = x;
this.lz = z;
this.lyaw = yaw;
}
private now(): number {
return this.ctx ? this.ctx.currentTime : 0;
}
// Spatial gain + stereo pan for a world position, or null if inaudible.
private spatial(x: number, z: number, maxDist: number): { gain: number; pan: number } | null {
const dx = x - this.lx;
const dz = z - this.lz;
const dist = Math.hypot(dx, dz);
if (dist > maxDist) return null;
const gain = Math.pow(1 - dist / maxDist, 1.6);
// rotate offset into listener frame; pan by the right-vector component
const c = Math.cos(this.lyaw);
const s = Math.sin(this.lyaw);
// right vector (matches -Z forward, YXZ): (cos, -sin)
const right = (dx * c - dz * -s) / (dist + 1e-3);
const pan = Math.max(-1, Math.min(1, right));
return { gain, pan };
}
private out(pan: number): AudioNode {
const ctx = this.ctx!;
if (typeof ctx.createStereoPanner === 'function') {
const p = ctx.createStereoPanner();
p.pan.value = pan;
p.connect(this.master!);
return p;
}
return this.master!;
}
private burst(
dur: number, type: BiquadFilterType, freq: number, q: number,
gain: number, pan: number, when = 0,
): void {
if (!this.ctx || !this.noise) return;
const ctx = this.ctx;
const t = this.now() + when;
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const filt = ctx.createBiquadFilter();
filt.type = type;
filt.frequency.value = freq;
filt.Q.value = q;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.002);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(filt);
filt.connect(g);
g.connect(this.out(pan));
src.start(t);
src.stop(t + dur + 0.02);
}
private tone(
freq: number, dur: number, type: OscillatorType, gain: number, pan: number,
when = 0, glideTo?: number,
): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = this.now() + when;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (glideTo !== undefined) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(g);
g.connect(this.out(pan));
osc.start(t);
osc.stop(t + dur + 0.02);
}
// --- public sound events ---
gunshot(weapon: WeaponId, x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 1, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 95);
if (!s) return;
sp = s;
}
const v = sp.gain;
switch (weapon) {
case 'ak47':
this.burst(0.16, 'bandpass', 1400, 0.7, 0.55 * v, sp.pan);
this.burst(0.09, 'lowpass', 500, 1, 0.5 * v, sp.pan);
this.tone(120, 0.1, 'sawtooth', 0.25 * v, sp.pan, 0, 60);
break;
case 'm4a4':
this.burst(0.11, 'bandpass', 2100, 0.9, 0.42 * v, sp.pan);
this.burst(0.06, 'lowpass', 700, 1, 0.35 * v, sp.pan);
this.tone(160, 0.07, 'square', 0.16 * v, sp.pan, 0, 90);
break;
case 'awp':
this.burst(0.35, 'lowpass', 900, 0.8, 0.7 * v, sp.pan);
this.burst(0.2, 'bandpass', 1600, 0.6, 0.5 * v, sp.pan);
this.tone(80, 0.3, 'sawtooth', 0.4 * v, sp.pan, 0, 40);
break;
case 'deagle':
this.burst(0.18, 'bandpass', 1100, 0.7, 0.55 * v, sp.pan);
this.tone(110, 0.12, 'square', 0.28 * v, sp.pan, 0, 55);
break;
case 'glock':
this.burst(0.08, 'bandpass', 1800, 1, 0.32 * v, sp.pan);
this.tone(220, 0.05, 'square', 0.12 * v, sp.pan, 0, 130);
break;
case 'usp':
this.burst(0.07, 'bandpass', 1500, 1.2, 0.26 * v, sp.pan);
this.tone(260, 0.04, 'sine', 0.1 * v, sp.pan, 0, 150);
break;
case 'knife':
this.burst(0.05, 'highpass', 3000, 0.6, 0.25 * v, sp.pan);
break;
}
}
reload(): void {
if (this.muted) return;
this.burst(0.05, 'bandpass', 900, 2, 0.25, 0, 0.0);
this.burst(0.05, 'bandpass', 1400, 2, 0.22, 0, 0.35);
this.tone(500, 0.03, 'square', 0.12, 0, 0.7);
}
footstep(x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 0.5, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 20);
if (!s) return;
sp = s;
}
this.burst(0.07, 'lowpass', 380, 1.2, 0.18 * sp.gain, sp.pan);
}
scope(on: boolean): void {
if (this.muted) return;
this.tone(on ? 700 : 500, 0.05, 'sine', 0.14, 0);
this.tone(on ? 900 : 400, 0.05, 'sine', 0.1, 0, 0.04);
}
hitmarker(): void {
if (this.muted) return;
this.tone(1400, 0.05, 'square', 0.16, 0);
}
kill(): void {
if (this.muted) return;
this.tone(880, 0.08, 'square', 0.2, 0);
this.tone(1320, 0.1, 'square', 0.2, 0, 0.08);
}
bombBeep(x?: number, z?: number): void {
if (this.muted) return;
let sp = { gain: 0.6, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 45);
if (!s) return;
sp = s;
}
this.tone(2000, 0.06, 'sine', 0.18 * sp.gain, sp.pan);
}
plantTick(): void {
if (this.muted) return;
this.tone(1200, 0.03, 'square', 0.12, 0);
}
defuseTick(): void {
if (this.muted) return;
this.burst(0.04, 'bandpass', 700, 3, 0.14, 0);
}
explode(x?: number, z?: number): void {
if (this.muted || !this.ctx) return;
let sp = { gain: 1, pan: 0 };
if (x !== undefined && z !== undefined) {
const s = this.spatial(x, z, 140);
if (!s) return;
sp = s;
}
const v = sp.gain;
this.burst(0.9, 'lowpass', 400, 0.7, 0.9 * v, sp.pan);
this.burst(0.5, 'bandpass', 900, 0.5, 0.6 * v, sp.pan);
this.tone(60, 0.8, 'sawtooth', 0.6 * v, sp.pan, 0, 30);
}
}
@@ -0,0 +1,153 @@
// Horizontal collision for a vertical-cylinder actor (radius r) against the wall
// grid and crate boxes, plus ground-height sampling for standing on crates.
import { Crate } from './dust2';
import { MapGrid } from './grid';
// Push a circle (cx,cz,r) out of an AABB if overlapping. Returns corrected center.
function pushOutAABB(
cx: number, cz: number, r: number,
minX: number, minZ: number, maxX: number, maxZ: number,
): { x: number; z: number } | null {
const closestX = cx < minX ? minX : cx > maxX ? maxX : cx;
const closestZ = cz < minZ ? minZ : cz > maxZ ? maxZ : cz;
const dx = cx - closestX;
const dz = cz - closestZ;
const d2 = dx * dx + dz * dz;
if (d2 > r * r) return null; // no overlap
if (d2 > 1e-8) {
const d = Math.sqrt(d2);
const push = r - d;
return { x: cx + (dx / d) * push, z: cz + (dz / d) * push };
}
// Center inside the box: push along the least-penetration axis.
const penL = cx - minX;
const penR = maxX - cx;
const penT = cz - minZ;
const penB = maxZ - cz;
const minPen = Math.min(penL, penR, penT, penB);
if (minPen === penL) return { x: minX - r, z: cz };
if (minPen === penR) return { x: maxX + r, z: cz };
if (minPen === penT) return { x: cx, z: minZ - r };
return { x: cx, z: maxZ + r };
}
// Resolve against wall cells overlapping the circle's neighborhood.
export function collideWalls(grid: MapGrid, x: number, z: number, r: number): { x: number; z: number } {
const cs = grid.cell;
let px = x;
let pz = z;
for (let iter = 0; iter < 3; iter++) {
const c0 = grid.cellOf(px - r, pz - r);
const c1 = grid.cellOf(px + r, pz + r);
let moved = false;
for (let cz = c0.cz; cz <= c1.cz; cz++) {
for (let cx = c0.cx; cx <= c1.cx; cx++) {
if (!grid.isWall(cx, cz)) continue;
const minX = grid.originX + cx * cs;
const minZ = grid.originZ + cz * cs;
const res = pushOutAABB(px, pz, r, minX, minZ, minX + cs, minZ + cs);
if (res) {
px = res.x;
pz = res.z;
moved = true;
}
}
}
if (!moved) break;
}
return { x: px, z: pz };
}
// Resolve against crates that the actor cannot stand on at its current feet Y.
export function collideCrates(
crates: Crate[], x: number, z: number, r: number, feetY: number,
): { x: number; z: number } {
let px = x;
let pz = z;
for (let iter = 0; iter < 2; iter++) {
let moved = false;
for (const cr of crates) {
if (cr.height <= feetY + 0.15) continue; // standing on / above it
const res = pushOutAABB(px, pz, r, cr.x - cr.hx, cr.z - cr.hz, cr.x + cr.hx, cr.z + cr.hz);
if (res) {
px = res.x;
pz = res.z;
moved = true;
}
}
if (!moved) break;
}
return { x: px, z: pz };
}
// Highest crate top under the actor's footprint, else 0 (floor).
export function groundHeightAt(crates: Crate[], x: number, z: number, r: number): number {
let h = 0;
for (const cr of crates) {
// Standing counts if the actor center is over the (slightly shrunk) top face.
const mx = cr.hx - r * 0.5;
const mz = cr.hz - r * 0.5;
if (
x >= cr.x - mx && x <= cr.x + mx &&
z >= cr.z - mz && z <= cr.z + mz
) {
if (cr.height > h) h = cr.height;
}
}
return h;
}
// 3D ray vs crate boxes: nearest hit distance within maxT, or maxT if none.
// Used for bullet clipping and line-of-sight through the (partial-height) crates.
export function rayCrateDistance(
crates: Crate[],
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
maxT: number,
): number {
let best = maxT;
for (const cr of crates) {
const t = rayBox3(
ox, oy, oz, dx, dy, dz,
cr.x - cr.hx, 0, cr.z - cr.hz,
cr.x + cr.hx, cr.height, cr.z + cr.hz,
);
if (t >= 0 && t < best) best = t;
}
return best;
}
function rayBox3(
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
minX: number, minY: number, minZ: number,
maxX: number, maxY: number, maxZ: number,
): number {
const o = [ox, oy, oz];
const d = [dx, dy, dz];
const mn = [minX, minY, minZ];
const mx = [maxX, maxY, maxZ];
let tmin = -Infinity;
let tmax = Infinity;
for (let a = 0; a < 3; a++) {
if (Math.abs(d[a]) < 1e-8) {
if (o[a] < mn[a] || o[a] > mx[a]) return -1;
} else {
const inv = 1 / d[a];
let t1 = (mn[a] - o[a]) * inv;
let t2 = (mx[a] - o[a]) * inv;
if (t1 > t2) {
const tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return -1;
}
}
if (tmax < 0) return -1;
return tmin >= 0 ? tmin : tmax;
}
@@ -0,0 +1,100 @@
// Per-zone hitbox raycast. Hitboxes live in the character's local frame (feet at
// y=0, facing -Z). A world-space ray is transformed into local space (undo yaw +
// translation) and slab-tested against each zone box; nearest entry wins.
import { HitZone } from './types';
interface LocalBox {
min: [number, number, number];
max: [number, number, number];
zone: HitZone;
}
// Ordered head->legs; overlaps resolved by nearest t at test time.
const HITBOXES: LocalBox[] = [
{ min: [-0.14, 1.5, -0.16], max: [0.14, 1.79, 0.16], zone: HitZone.Head },
{ min: [-0.27, 1.12, -0.18], max: [0.27, 1.5, 0.18], zone: HitZone.Chest },
{ min: [-0.23, 0.82, -0.17], max: [0.23, 1.12, 0.17], zone: HitZone.Stomach },
{ min: [0.26, 0.9, -0.14], max: [0.46, 1.5, 0.14], zone: HitZone.Arm },
{ min: [-0.46, 0.9, -0.14], max: [-0.26, 1.5, 0.14], zone: HitZone.Arm },
{ min: [-0.24, 0.0, -0.17], max: [0.24, 0.82, 0.17], zone: HitZone.Leg },
];
export const CHAR_HEIGHT = 1.79;
export const CHAR_BOUND_R = 0.55; // broadphase radius (XZ)
export interface CharHit {
zone: HitZone;
t: number;
}
// Slab intersection of a ray with an axis-aligned box; returns entry t or -1.
function rayBox(
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
min: [number, number, number], max: [number, number, number],
): number {
let tmin = -Infinity;
let tmax = Infinity;
const o = [ox, oy, oz];
const d = [dx, dy, dz];
for (let a = 0; a < 3; a++) {
if (Math.abs(d[a]) < 1e-8) {
if (o[a] < min[a] || o[a] > max[a]) return -1;
} else {
const inv = 1 / d[a];
let t1 = (min[a] - o[a]) * inv;
let t2 = (max[a] - o[a]) * inv;
if (t1 > t2) {
const tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return -1;
}
}
if (tmax < 0) return -1;
return tmin >= 0 ? tmin : tmax;
}
// px,py,pz = character feet position; yaw = facing. Ray in world space with unit
// dir. Returns nearest zone hit within maxT, or null.
export function raycastCharacter(
px: number, py: number, pz: number, yaw: number,
ox: number, oy: number, oz: number,
dx: number, dy: number, dz: number,
maxT: number,
): CharHit | null {
// Broadphase: distance from ray to the character's vertical axis.
const rx = ox - px;
const rz = oz - pz;
// closest approach param along ray in XZ
const proj = -(rx * dx + rz * dz);
const cxp = rx + dx * Math.max(0, proj);
const czp = rz + dz * Math.max(0, proj);
if (cxp * cxp + czp * czp > CHAR_BOUND_R * CHAR_BOUND_R && proj > 0) {
// may still clip if origin very close; fall through only when clearly outside
if (proj > CHAR_BOUND_R) return null;
}
const c = Math.cos(yaw);
const s = Math.sin(yaw);
const ry = oy - py;
const lox = c * rx - s * rz;
const loz = s * rx + c * rz;
const loy = ry;
const ldx = c * dx - s * dz;
const ldz = s * dx + c * dz;
const ldy = dy;
let best: CharHit | null = null;
for (const box of HITBOXES) {
const t = rayBox(lox, loy, loz, ldx, ldy, ldz, box.min, box.max);
if (t >= 0 && t <= maxT && (!best || t < best.t)) {
best = { zone: box.zone, t };
}
}
return best;
}
@@ -0,0 +1,59 @@
// Central gameplay tunables.
export const FIXED_DT = 1 / 60; // physics step
export const MAX_FRAME_DT = 1 / 20; // clamp big frame gaps (tab switches)
export const GRAVITY = 20; // m/s^2
export const PLAYER = {
radius: 0.42,
height: 1.8,
eyeHeight: 1.62,
jumpSpeed: 7.4, // apex ~1.37m (clears 1.2m crates)
runSpeed: 6.6,
walkSpeed: 3.3, // hold shift: slow + accurate
groundAccel: 65,
airAccel: 14,
friction: 10,
maxHealth: 100,
maxArmor: 100,
};
export const AI = {
fovDeg: 105, // vision cone half-handled in code
viewRange: 70,
reactionTime: 0.22, // seconds before a spotted enemy is engaged
aimError: 0.05, // radians of aim jitter (skill)
aimSpeed: 7, // rad/s turn toward target
burstMin: 0.3,
burstMax: 0.9,
repathInterval: 0.6,
};
export const ROUND = {
freezeTime: 3,
roundTime: 115, // seconds before CT win by time (no plant)
bombTimer: 40,
plantTime: 3.2,
defuseTime: 7,
overTime: 5, // seconds showing result before next round
bombDamageRadius: 14,
};
// Team colors (three.js hex). CT = navy/blue, T = tan/sand.
export const COLORS = {
ctPrimary: 0x2f5fa8,
ctSecondary: 0xbcd2f0,
ctSkin: 0xd7a98a,
tPrimary: 0xb08b4f,
tSecondary: 0x5a4526,
tSkin: 0xc99a76,
wall: 0xb8a488,
wallDark: 0x8f7f66,
floor: 0xa79878,
crate: 0x9c6b34,
crateEdge: 0x5c3d1c,
siteA: 0xd25b4a,
siteB: 0x4aa3d2,
bomb: 0x222222,
sky: 0x8fb3d9,
};
@@ -0,0 +1,122 @@
// Procedural Dust2 core layout, authored as carved rectangles on the tile grid.
// Orientation (top-down): T spawn south (+Z, bottom), CT spawn north (-Z, top),
// A site / A long east (+X, right), B site / tunnels west (-X, left), mid runs
// vertically through the center with the passable doors halfway.
import { MapGrid, Region } from './grid';
export interface Crate {
x: number;
z: number;
hx: number; // half extent X (m)
hz: number; // half extent Z (m)
height: number; // top Y (m); box spans y in [0, height]
}
export interface BombSite {
name: 'A' | 'B';
region: Region;
center: { x: number; z: number };
radius: number;
}
export interface SpawnPoint {
x: number;
z: number;
}
export interface Dust2 {
grid: MapGrid;
crates: Crate[];
sites: { A: BombSite; B: BombSite };
tSpawns: SpawnPoint[];
ctSpawns: SpawnPoint[];
doorCells: { cx: number; cz: number }[];
bounds: { minX: number; maxX: number; minZ: number; maxZ: number };
}
export const CELL = 2.2;
export const COLS = 60;
export const ROWS = 60;
export const WALL_HEIGHT = 4.2;
export function buildDust2(): Dust2 {
const g = new MapGrid(COLS, ROWS, CELL);
// --- rooms & corridors (inclusive cell coords) ---
g.carve(22, 3, 34, 9, Region.CTSpawn); // CT spawn (north)
g.carve(34, 5, 41, 9, Region.Connect); // A short (CT -> A)
g.carve(40, 6, 54, 18, Region.ASite); // A site (NE)
g.carve(28, 9, 30, 46, Region.Mid); // mid corridor
g.carve(31, 16, 41, 19, Region.Cat); // catwalk (mid -> A)
g.carve(49, 18, 53, 50, Region.Long); // A long (right side)
g.carve(42, 49, 53, 53, Region.Connect); // T -> long doors
g.carve(30, 50, 44, 57, Region.TSpawn); // T spawn (south)
g.carve(26, 44, 34, 50, Region.Connect); // T -> mid junction
g.carve(8, 44, 33, 48, Region.Tunnel); // upper tunnels (T -> B)
g.carve(8, 20, 13, 48, Region.Tunnel); // B tunnel (vertical)
g.carve(4, 14, 18, 26, Region.BSite); // B site (W)
g.carve(13, 20, 28, 24, Region.Connect); // mid -> B (lower/doors)
// Mid doors: a passable door prop sits across these cells.
g.tag(28, 26, 30, 28, Region.Door);
const doorCells = [
{ cx: 28, cz: 27 },
{ cx: 29, cz: 27 },
{ cx: 30, cz: 27 },
];
// --- crates (cover + plant references) ---
const crateCell = (cx: number, cz: number, hx: number, hz: number, height: number): Crate => {
const c = g.centerOf(cx, cz);
return { x: c.x, z: c.z, hx, hz, height };
};
const crates: Crate[] = [
// A site
crateCell(45, 13, 1.5, 1.5, 1.5),
crateCell(48, 11, 1.5, 1.5, 1.5),
crateCell(43, 9, 2.0, 1.1, 1.0),
// B site
crateCell(9, 21, 1.5, 1.5, 1.5),
crateCell(13, 24, 1.5, 1.5, 1.5),
crateCell(7, 17, 1.1, 1.1, 1.0),
// mid / long / catwalk cover
crateCell(29, 20, 1.3, 1.3, 1.2),
crateCell(51, 34, 1.8, 1.3, 1.2),
crateCell(36, 18, 1.3, 1.3, 1.2),
];
// --- bomb sites ---
const aCenter = g.centerOf(47, 12);
const bCenter = g.centerOf(11, 20);
const sites = {
A: { name: 'A' as const, region: Region.ASite, center: aCenter, radius: 16 },
B: { name: 'B' as const, region: Region.BSite, center: bCenter, radius: 15 },
};
// --- spawns ---
const spawnAt = (cx: number, cz: number): SpawnPoint => g.centerOf(cx, cz);
const tSpawns = [
spawnAt(32, 54),
spawnAt(35, 55),
spawnAt(38, 54),
spawnAt(41, 55),
spawnAt(43, 52),
];
const ctSpawns = [
spawnAt(24, 5),
spawnAt(27, 6),
spawnAt(30, 5),
spawnAt(32, 7),
spawnAt(25, 7),
];
const bounds = {
minX: g.originX,
maxX: g.originX + COLS * CELL,
minZ: g.originZ,
maxZ: g.originZ + ROWS * CELL,
};
return { grid: g, crates, sites, tSpawns, ctSpawns, doorCells, bounds };
}
@@ -0,0 +1,118 @@
// Pooled transient visuals: bullet tracers, muzzle flashes, and impact sparks /
// blood puffs. All are cheap emissive/additive meshes recycled from fixed pools.
import * as THREE from 'three';
interface Slot {
mesh: THREE.Mesh;
life: number;
max: number;
}
export class Effects {
private tracers: Slot[] = [];
private flashes: Slot[] = [];
private impacts: Slot[] = [];
private scene: THREE.Scene;
constructor(scene: THREE.Scene) {
this.scene = scene;
const tracerGeo = new THREE.BoxGeometry(0.035, 0.035, 1);
for (let i = 0; i < 24; i++) {
const mesh = new THREE.Mesh(
tracerGeo,
new THREE.MeshBasicMaterial({ color: 0xffe08a, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.tracers.push({ mesh, life: 0, max: 0.06 });
}
const flashGeo = new THREE.SphereGeometry(0.16, 6, 6);
for (let i = 0; i < 16; i++) {
const mesh = new THREE.Mesh(
flashGeo,
new THREE.MeshBasicMaterial({ color: 0xfff2b0, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.flashes.push({ mesh, life: 0, max: 0.05 });
}
const impactGeo = new THREE.SphereGeometry(0.09, 6, 6);
for (let i = 0; i < 32; i++) {
const mesh = new THREE.Mesh(
impactGeo,
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthWrite: false }),
);
mesh.visible = false;
scene.add(mesh);
this.impacts.push({ mesh, life: 0, max: 0.18 });
}
}
private take(pool: Slot[]): Slot | null {
for (const s of pool) if (s.life <= 0) return s;
return pool[0] ?? null;
}
tracer(fx: number, fy: number, fz: number, tx: number, ty: number, tz: number): void {
const s = this.take(this.tracers);
if (!s) return;
const dx = tx - fx, dy = ty - fy, dz = tz - fz;
const len = Math.hypot(dx, dy, dz) || 0.01;
s.mesh.position.set((fx + tx) / 2, (fy + ty) / 2, (fz + tz) / 2);
s.mesh.scale.set(1, 1, len);
s.mesh.lookAt(tx, ty, tz);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 0.9;
s.life = s.max;
}
flash(x: number, y: number, z: number): void {
const s = this.take(this.flashes);
if (!s) return;
s.mesh.position.set(x, y, z);
const sc = 0.7 + Math.random() * 0.6;
s.mesh.scale.setScalar(sc);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 1;
s.life = s.max;
}
impact(x: number, y: number, z: number, blood: boolean): void {
const s = this.take(this.impacts);
if (!s) return;
s.mesh.position.set(x, y, z);
s.mesh.scale.setScalar(blood ? 1.4 : 1);
(s.mesh.material as THREE.MeshBasicMaterial).color.setHex(blood ? 0x9c1c1c : 0xd9cdb0);
s.mesh.visible = true;
(s.mesh.material as THREE.MeshBasicMaterial).opacity = 1;
s.life = s.max;
}
update(dt: number): void {
for (const s of this.tracers) {
if (s.life <= 0) continue;
s.life -= dt;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, (s.life / s.max) * 0.9);
if (s.life <= 0) s.mesh.visible = false;
}
for (const s of this.flashes) {
if (s.life <= 0) continue;
s.life -= dt;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, s.life / s.max);
if (s.life <= 0) s.mesh.visible = false;
}
for (const s of this.impacts) {
if (s.life <= 0) continue;
s.life -= dt;
const k = s.life / s.max;
const mat = s.mesh.material as THREE.MeshBasicMaterial;
mat.opacity = Math.max(0, k);
s.mesh.scale.multiplyScalar(1 + dt * 2);
s.mesh.position.y += dt * 0.4;
if (s.life <= 0) s.mesh.visible = false;
}
}
}
@@ -0,0 +1,151 @@
// Blocky humanoid built entirely from boxes: separate head, torso, pelvis, two
// arms (posed forward gripping the weapon) and two legs with knees + feet. No
// capsules/cylinders. CT reads blue with a helmet; T reads tan with a balaclava.
import * as THREE from 'three';
import { COLORS } from '../constants';
import { Team } from '../types';
export interface CharacterRig {
root: THREE.Group;
legL: THREE.Group;
legR: THREE.Group;
armL: THREE.Group;
armR: THREE.Group;
head: THREE.Group;
weaponHolder: THREE.Group;
materials: THREE.Material[];
team: Team;
}
function box(
w: number, h: number, d: number, color: number, mats: THREE.Material[],
): THREE.Mesh {
const geo = new THREE.BoxGeometry(w, h, d);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.05, flatShading: true });
mats.push(mat);
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
return mesh;
}
export function buildCharacter(team: Team): CharacterRig {
const mats: THREE.Material[] = [];
const isCT = team === Team.CT;
const primary = isCT ? COLORS.ctPrimary : COLORS.tPrimary;
const secondary = isCT ? COLORS.ctSecondary : COLORS.tSecondary;
const skin = isCT ? COLORS.ctSkin : COLORS.tSkin;
const root = new THREE.Group();
// pelvis
const pelvis = box(0.34, 0.22, 0.22, secondary, mats);
pelvis.position.set(0, 0.94, 0);
root.add(pelvis);
// chest (torso) + vest overlay
const chest = box(0.44, 0.5, 0.26, primary, mats);
chest.position.set(0, 1.3, 0);
root.add(chest);
const vest = box(0.46, 0.34, 0.1, secondary, mats);
vest.position.set(0, 1.34, 0.12);
root.add(vest);
// shoulders
const shoulders = box(0.5, 0.14, 0.24, primary, mats);
shoulders.position.set(0, 1.5, 0);
root.add(shoulders);
// head group (neck pivot)
const head = new THREE.Group();
head.position.set(0, 1.57, 0);
root.add(head);
const neck = box(0.12, 0.08, 0.12, skin, mats);
neck.position.set(0, 0.0, 0);
head.add(neck);
const skull = box(0.24, 0.24, 0.24, isCT ? skin : COLORS.tSecondary, mats);
skull.position.set(0, 0.16, 0);
head.add(skull);
if (isCT) {
// helmet
const helmet = box(0.27, 0.13, 0.27, 0x24405f, mats);
helmet.position.set(0, 0.24, 0);
head.add(helmet);
// face strip
const face = box(0.2, 0.09, 0.02, 0x2a2a30, mats);
face.position.set(0, 0.14, 0.12);
head.add(face);
} else {
// balaclava eye strip (skin showing)
const eyes = box(0.2, 0.06, 0.02, skin, mats);
eyes.position.set(0, 0.18, 0.12);
head.add(eyes);
}
// helper to build a limb hanging from a pivot group
const makeLeg = (side: number): THREE.Group => {
const g = new THREE.Group();
g.position.set(0.12 * side, 0.9, 0);
const upper = box(0.17, 0.46, 0.19, primary, mats);
upper.position.set(0, -0.23, 0);
g.add(upper);
const lower = box(0.15, 0.44, 0.17, secondary, mats);
lower.position.set(0, -0.66, 0.01);
g.add(lower);
const foot = box(0.16, 0.1, 0.28, 0x2a2a2a, mats);
foot.position.set(0, -0.87, 0.06);
g.add(foot);
return g;
};
const legL = makeLeg(1);
const legR = makeLeg(-1);
root.add(legL, legR);
// arms posed forward to grip the weapon (pivot at shoulder)
const makeArm = (side: number): THREE.Group => {
const g = new THREE.Group();
g.position.set(0.26 * side, 1.46, 0);
const upper = box(0.13, 0.34, 0.14, primary, mats);
upper.position.set(0, -0.15, 0);
g.add(upper);
const lower = box(0.11, 0.3, 0.12, skin, mats);
lower.position.set(0, -0.42, 0);
g.add(lower);
const hand = box(0.1, 0.1, 0.12, 0x3a2f28, mats);
hand.position.set(0, -0.58, 0);
g.add(hand);
// rotate the whole arm forward + inward so hands meet at the gun
g.rotation.x = -1.35;
g.rotation.z = 0.28 * side;
return g;
};
const armL = makeArm(1);
const armR = makeArm(-1);
root.add(armL, armR);
// weapon attaches in front of the chest where the hands converge
const weaponHolder = new THREE.Group();
weaponHolder.position.set(0, 1.2, -0.36);
root.add(weaponHolder);
root.traverse((o) => {
o.castShadow = true;
});
return { root, legL, legR, armL, armR, head, weaponHolder, materials: mats, team };
}
// Walk cycle + idle bob; freezes into a fallen pose when dead.
export function animateCharacter(
rig: CharacterRig, speed: number, time: number, dead: boolean,
): void {
if (dead) return;
const stride = Math.min(1, speed / 6);
const freq = 8;
const swing = Math.sin(time * freq) * 0.6 * stride;
rig.legL.rotation.x = swing;
rig.legR.rotation.x = -swing;
// subtle arm counter-bob while keeping the aim pose
rig.armL.rotation.x = -1.35 + Math.sin(time * freq) * 0.05 * stride;
rig.armR.rotation.x = -1.35 - Math.sin(time * freq) * 0.05 * stride;
rig.root.position.y = Math.abs(Math.sin(time * freq)) * 0.04 * stride;
}
@@ -0,0 +1,132 @@
// Procedural weapon meshes. The same silhouette serves the third-person world
// model (parented to a character's hands) and the first-person viewmodel
// (parented to the camera). Barrels point down -Z; a `muzzle` empty marks the
// tip for flash + tracer spawning.
import * as THREE from 'three';
import { WeaponId } from '../types';
export interface WeaponMesh {
group: THREE.Group;
muzzle: THREE.Object3D;
}
function part(
g: THREE.Group, w: number, h: number, d: number, color: number,
x: number, y: number, z: number, rotX = 0, metal = 0.4, rough = 0.5,
): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(w, h, d),
new THREE.MeshStandardMaterial({ color, metalness: metal, roughness: rough }),
);
mesh.position.set(x, y, z);
mesh.rotation.x = rotX;
mesh.castShadow = true;
g.add(mesh);
return mesh;
}
const BLACK = 0x1c1d1f;
const STEEL = 0x9aa0a6;
const WOOD = 0x6b4a2a;
const TAN = 0x8a7a55;
function buildRifle(id: WeaponId): WeaponMesh {
const g = new THREE.Group();
const accent = id === 'ak47' ? WOOD : TAN;
part(g, 0.07, 0.11, 0.52, BLACK, 0, 0, -0.03); // receiver
part(g, 0.032, 0.032, 0.5, BLACK, 0, 0.03, -0.4, 0, 0.7, 0.35); // barrel
part(g, 0.06, 0.2, 0.1, accent, 0, -0.14, 0.0, 0.25); // magazine
part(g, 0.05, 0.1, 0.24, accent, 0, -0.01, 0.24); // stock
part(g, 0.045, 0.13, 0.055, BLACK, 0, -0.1, 0.09, 0.15); // grip
part(g, 0.02, 0.04, 0.12, BLACK, 0, 0.075, -0.18); // front sight/handguard top
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.03, -0.66);
g.add(muzzle);
return { group: g, muzzle };
}
function buildAwp(): WeaponMesh {
const g = new THREE.Group();
const green = 0x384231;
part(g, 0.07, 0.12, 0.72, green, 0, 0, 0.0);
part(g, 0.03, 0.03, 0.72, BLACK, 0, 0.02, -0.62, 0, 0.7, 0.35); // long barrel
part(g, 0.06, 0.13, 0.09, BLACK, 0, -0.11, -0.05, 0.1); // mag
part(g, 0.055, 0.11, 0.32, green, 0, -0.02, 0.36); // stock
part(g, 0.045, 0.14, 0.05, BLACK, 0, -0.1, 0.1, 0.1); // grip
// scope
part(g, 0.055, 0.055, 0.3, BLACK, 0, 0.12, -0.02, 0, 0.6, 0.3);
part(g, 0.02, 0.06, 0.02, BLACK, 0, 0.07, -0.14);
part(g, 0.02, 0.06, 0.02, BLACK, 0, 0.07, 0.1);
const lens = new THREE.Mesh(
new THREE.CircleGeometry(0.026, 12),
new THREE.MeshStandardMaterial({ color: 0x224466, metalness: 0.9, roughness: 0.1, emissive: 0x113355 }),
);
lens.position.set(0, 0.12, 0.14);
lens.rotation.y = Math.PI;
g.add(lens);
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.02, -0.98);
g.add(muzzle);
return { group: g, muzzle };
}
function buildPistol(id: WeaponId): WeaponMesh {
const g = new THREE.Group();
const scale = id === 'deagle' ? 1.28 : 1.0;
const bodyColor = id === 'deagle' ? 0xb8a44e : id === 'usp' ? 0x26282b : BLACK;
const s = (v: number) => v * scale;
part(g, s(0.05), s(0.07), s(0.24), bodyColor, 0, s(0.02), s(-0.03), 0, 0.6, 0.35); // slide
part(g, s(0.045), s(0.17), s(0.08), 0x222327, 0, s(-0.1), s(0.05), 0.28); // grip
part(g, s(0.028), s(0.028), s(0.06), BLACK, 0, s(0.02), s(-0.16)); // barrel tip
let muzZ = s(-0.18);
if (id === 'usp') {
part(g, s(0.05), s(0.05), s(0.16), 0x2a2c2f, 0, s(0.02), s(-0.26), 0, 0.5, 0.4); // suppressor
muzZ = s(-0.36);
}
const muzzle = new THREE.Object3D();
muzzle.position.set(0, s(0.02), muzZ);
g.add(muzzle);
return { group: g, muzzle };
}
function buildKnife(): WeaponMesh {
const g = new THREE.Group();
part(g, 0.028, 0.045, 0.14, 0x1a1a1a, 0, -0.02, 0.07, 0.05); // handle
part(g, 0.012, 0.06, 0.24, STEEL, 0, 0.03, -0.12, -0.03, 0.95, 0.15); // blade
const muzzle = new THREE.Object3D();
muzzle.position.set(0, 0.03, -0.24);
g.add(muzzle);
return { group: g, muzzle };
}
export function buildWeaponMesh(id: WeaponId): WeaponMesh {
switch (id) {
case 'ak47':
case 'm4a4':
return buildRifle(id);
case 'awp':
return buildAwp();
case 'glock':
case 'usp':
case 'deagle':
return buildPistol(id);
case 'knife':
return buildKnife();
}
}
// Two simple gripping hands for the first-person viewmodel.
export function addViewHands(g: THREE.Group): void {
const skin = 0xc99a76;
const mk = (x: number, y: number, z: number) => {
const m = new THREE.Mesh(
new THREE.BoxGeometry(0.09, 0.09, 0.12),
new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 }),
);
m.position.set(x, y, z);
g.add(m);
};
mk(0.02, -0.08, 0.08); // rear hand at grip
mk(0.0, -0.05, -0.16); // front hand on handguard
}
@@ -0,0 +1,233 @@
// Tile-grid map core. Pure TS (no three.js) so it can be unit-tested with bun.
// The world is a grid of square cells in the XZ plane; walls are full-height
// columns, floor cells are walkable. Line-of-sight and bullet clipping against
// walls use grid DDA traversal (Amanatides & Woo).
export enum Region {
None = 0,
TSpawn = 1,
CTSpawn = 2,
ASite = 3,
BSite = 4,
Mid = 5,
Long = 6,
Cat = 7,
Tunnel = 8,
Connect = 9,
Door = 10,
}
const REGION_CHAR: Record<number, string> = {
0: '#', // wall / void rendered as # in ascii dumps
1: 't',
2: 'c',
3: 'A',
4: 'B',
5: 'm',
6: 'L',
7: 'k',
8: 'u',
9: '+',
10: 'D',
};
export interface CellRef {
cx: number;
cz: number;
}
export class MapGrid {
readonly cols: number;
readonly rows: number;
readonly cell: number; // world size of one cell (meters)
readonly floor: Uint8Array; // 1 = walkable
readonly region: Uint8Array; // Region id per cell (only meaningful when floor)
readonly originX: number; // world X of cell (0,0) left edge
readonly originZ: number;
constructor(cols: number, rows: number, cell: number) {
this.cols = cols;
this.rows = rows;
this.cell = cell;
this.floor = new Uint8Array(cols * rows);
this.region = new Uint8Array(cols * rows);
// Center the grid on the world origin.
this.originX = -(cols * cell) / 2;
this.originZ = -(rows * cell) / 2;
}
idx(cx: number, cz: number): number {
return cz * this.cols + cx;
}
inBounds(cx: number, cz: number): boolean {
return cx >= 0 && cz >= 0 && cx < this.cols && cz < this.rows;
}
isFloor(cx: number, cz: number): boolean {
if (!this.inBounds(cx, cz)) return false;
return this.floor[this.idx(cx, cz)] === 1;
}
// Out-of-bounds counts as wall so the arena is sealed.
isWall(cx: number, cz: number): boolean {
return !this.isFloor(cx, cz);
}
regionAt(cx: number, cz: number): Region {
if (!this.inBounds(cx, cz)) return Region.None;
return this.region[this.idx(cx, cz)] as Region;
}
// Carve a filled rectangle of floor (inclusive cell coords) with a region tag.
carve(cx0: number, cz0: number, cx1: number, cz1: number, region: Region): void {
const x0 = Math.min(cx0, cx1);
const x1 = Math.max(cx0, cx1);
const z0 = Math.min(cz0, cz1);
const z1 = Math.max(cz0, cz1);
for (let cz = z0; cz <= z1; cz++) {
for (let cx = x0; cx <= x1; cx++) {
if (!this.inBounds(cx, cz)) continue;
const i = this.idx(cx, cz);
this.floor[i] = 1;
this.region[i] = region;
}
}
}
// Overwrite region on already-carved floor cells (does not create floor).
tag(cx0: number, cz0: number, cx1: number, cz1: number, region: Region): void {
const x0 = Math.min(cx0, cx1);
const x1 = Math.max(cx0, cx1);
const z0 = Math.min(cz0, cz1);
const z1 = Math.max(cz0, cz1);
for (let cz = z0; cz <= z1; cz++) {
for (let cx = x0; cx <= x1; cx++) {
if (!this.inBounds(cx, cz)) continue;
const i = this.idx(cx, cz);
if (this.floor[i]) this.region[i] = region;
}
}
}
// --- world <-> cell conversion ---
cellOf(x: number, z: number): CellRef {
return {
cx: Math.floor((x - this.originX) / this.cell),
cz: Math.floor((z - this.originZ) / this.cell),
};
}
centerOf(cx: number, cz: number): { x: number; z: number } {
return {
x: this.originX + (cx + 0.5) * this.cell,
z: this.originZ + (cz + 0.5) * this.cell,
};
}
isWallAtWorld(x: number, z: number): boolean {
const c = this.cellOf(x, z);
return this.isWall(c.cx, c.cz);
}
// DDA: distance (world units) along ray (ox,oz)+t*(dx,dz) to first wall cell.
// Returns maxDist if nothing hit within range. dir need not be normalized;
// dx,dz are treated as a unit direction here.
rayWallDistance(ox: number, oz: number, dx: number, dz: number, maxDist: number): number {
const cs = this.cell;
let cx = Math.floor((ox - this.originX) / cs);
let cz = Math.floor((oz - this.originZ) / cs);
if (this.isWall(cx, cz)) return 0;
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0;
const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
const tDeltaX = dx !== 0 ? Math.abs(cs / dx) : Infinity;
const tDeltaZ = dz !== 0 ? Math.abs(cs / dz) : Infinity;
// world coords of current cell edges
const cellMinX = this.originX + cx * cs;
const cellMinZ = this.originZ + cz * cs;
let tMaxX =
dx !== 0
? (stepX > 0 ? cellMinX + cs - ox : ox - cellMinX) / Math.abs(dx)
: Infinity;
let tMaxZ =
dz !== 0
? (stepZ > 0 ? cellMinZ + cs - oz : oz - cellMinZ) / Math.abs(dz)
: Infinity;
let guard = 0;
while (guard++ < 4096) {
let t: number;
if (tMaxX < tMaxZ) {
cx += stepX;
t = tMaxX;
tMaxX += tDeltaX;
} else {
cz += stepZ;
t = tMaxZ;
tMaxZ += tDeltaZ;
}
if (t > maxDist) return maxDist;
if (this.isWall(cx, cz)) return t;
}
return maxDist;
}
// True if a wall blocks the straight segment a->b in the XZ plane.
segmentBlocked(ax: number, az: number, bx: number, bz: number): boolean {
const dx = bx - ax;
const dz = bz - az;
const len = Math.hypot(dx, dz);
if (len < 1e-6) return false;
const d = this.rayWallDistance(ax, az, dx / len, dz / len, len);
return d < len - 1e-3;
}
// Breadth-first reachable set from a seed floor cell; used to verify the map
// is fully connected during authoring.
reachableCount(seedCx: number, seedCz: number): number {
const seen = new Uint8Array(this.cols * this.rows);
const stack: number[] = [];
if (!this.isFloor(seedCx, seedCz)) return 0;
stack.push(this.idx(seedCx, seedCz));
seen[this.idx(seedCx, seedCz)] = 1;
let count = 0;
while (stack.length) {
const i = stack.pop()!;
count++;
const cx = i % this.cols;
const cz = (i - cx) / this.cols;
const neigh = [
[cx + 1, cz],
[cx - 1, cz],
[cx, cz + 1],
[cx, cz - 1],
];
for (const [nx, nz] of neigh) {
if (!this.isFloor(nx, nz)) continue;
const ni = this.idx(nx, nz);
if (seen[ni]) continue;
seen[ni] = 1;
stack.push(ni);
}
}
return count;
}
totalFloor(): number {
let n = 0;
for (let i = 0; i < this.floor.length; i++) if (this.floor[i]) n++;
return n;
}
toAscii(): string {
const lines: string[] = [];
for (let cz = 0; cz < this.rows; cz++) {
let line = '';
for (let cx = 0; cx < this.cols; cx++) {
const i = this.idx(cx, cz);
line += this.floor[i] ? REGION_CHAR[this.region[i]] ?? '.' : '#';
}
lines.push(line);
}
return lines.join('\n');
}
}
@@ -0,0 +1,116 @@
// Keyboard + mouse + pointer-lock input. Movement/aim are polled each frame;
// discrete actions (weapon switch, reload, jump) use edge-triggered presses.
export class Input {
keys = new Set<string>();
private justPressed = new Set<string>();
mouseDX = 0;
mouseDY = 0;
left = false;
right = false;
leftJustPressed = false;
wheel = 0;
locked = false;
private el: HTMLElement;
private handlers: Array<[EventTarget, string, EventListenerOrEventListenerObject]> = [];
constructor(el: HTMLElement) {
this.el = el;
const gameKeys = new Set([
'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space', 'ShiftLeft', 'KeyR', 'KeyE', 'KeyG',
'KeyF', 'Digit1', 'Digit2', 'Digit3', 'Tab', 'KeyB',
]);
const onKeyDown = (e: KeyboardEvent) => {
if (gameKeys.has(e.code)) e.preventDefault();
if (!e.repeat && !this.keys.has(e.code)) this.justPressed.add(e.code);
this.keys.add(e.code);
};
const onKeyUp = (e: KeyboardEvent) => {
this.keys.delete(e.code);
};
const onMouseDown = (e: MouseEvent) => {
if (!this.locked) return;
if (e.button === 0) {
this.left = true;
this.leftJustPressed = true;
} else if (e.button === 2) {
this.right = true;
}
};
const onMouseUp = (e: MouseEvent) => {
if (e.button === 0) this.left = false;
else if (e.button === 2) this.right = false;
};
const onMouseMove = (e: MouseEvent) => {
if (!this.locked) return;
this.mouseDX += e.movementX;
this.mouseDY += e.movementY;
};
const onContext = (e: Event) => e.preventDefault();
const onWheel = (e: WheelEvent) => {
if (this.locked) {
e.preventDefault();
this.wheel += Math.sign(e.deltaY);
}
};
const onLockChange = () => {
this.locked = document.pointerLockElement === this.el;
if (!this.locked) {
this.keys.clear();
this.left = this.right = false;
}
};
this.add(window, 'keydown', onKeyDown as EventListener);
this.add(window, 'keyup', onKeyUp as EventListener);
this.add(window, 'mousedown', onMouseDown as EventListener);
this.add(window, 'mouseup', onMouseUp as EventListener);
this.add(window, 'mousemove', onMouseMove as EventListener);
this.add(el, 'contextmenu', onContext);
this.add(el, 'wheel', onWheel as EventListener, { passive: false } as AddEventListenerOptions);
this.add(document, 'pointerlockchange', onLockChange);
}
private add(
target: EventTarget, type: string, fn: EventListenerOrEventListenerObject,
opts?: AddEventListenerOptions,
): void {
target.addEventListener(type, fn, opts);
this.handlers.push([target, type, fn]);
}
requestLock(): void {
// Pointer lock can be unavailable (sandboxed iframe, unfocused document).
// Swallow both sync throws and promise rejections; keyboard control still
// works and the game keeps running in that degraded mode.
try {
const r = this.el.requestPointerLock?.() as unknown as Promise<void> | undefined;
if (r && typeof r.catch === 'function') r.catch(() => {});
} catch {
/* ignore */
}
}
down(code: string): boolean {
return this.keys.has(code);
}
pressed(code: string): boolean {
return this.justPressed.has(code);
}
// call at end of each frame after inputs consumed
endFrame(): void {
this.justPressed.clear();
this.leftJustPressed = false;
this.mouseDX = 0;
this.mouseDY = 0;
this.wheel = 0;
}
dispose(): void {
for (const [t, type, fn] of this.handlers) t.removeEventListener(type, fn);
this.handlers = [];
}
}
@@ -0,0 +1,56 @@
// Small math helpers. Yaw convention matches a three.js camera with
// rotation.order 'YXZ': at yaw = 0 the facing direction is -Z.
export function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function rand(lo: number, hi: number): number {
return lo + Math.random() * (hi - lo);
}
export function randInt(lo: number, hi: number): number {
return Math.floor(rand(lo, hi + 1));
}
export function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
// Facing direction for a yaw (XZ plane), matching camera base-forward -Z.
export function forwardFromYaw(yaw: number): { x: number; z: number } {
return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
}
// Inverse of forwardFromYaw.
export function yawFromDir(x: number, z: number): number {
return Math.atan2(-x, -z);
}
// Shortest signed angular difference a->b in (-PI, PI].
export function angleDelta(a: number, b: number): number {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return d;
}
export function moveAngleToward(from: number, to: number, maxStep: number): number {
const d = angleDelta(from, to);
if (Math.abs(d) <= maxStep) return to;
return from + Math.sign(d) * maxStep;
}
export function dist2(ax: number, az: number, bx: number, bz: number): number {
const dx = ax - bx;
const dz = az - bz;
return dx * dx + dz * dz;
}
export function len2(x: number, z: number): number {
return Math.hypot(x, z);
}
@@ -0,0 +1,163 @@
// A* pathfinding over the floor grid for AI navigation. 8-connected with no
// corner-cutting through walls; the resulting cell path is line-of-sight
// simplified so bots walk natural diagonals instead of hugging cell centers.
import { MapGrid } from './grid';
interface HeapItem {
i: number;
f: number;
}
class MinHeap {
private a: HeapItem[] = [];
get size(): number {
return this.a.length;
}
push(item: HeapItem): void {
const a = this.a;
a.push(item);
let c = a.length - 1;
while (c > 0) {
const p = (c - 1) >> 1;
if (a[p].f <= a[c].f) break;
[a[p], a[c]] = [a[c], a[p]];
c = p;
}
}
pop(): HeapItem {
const a = this.a;
const top = a[0];
const last = a.pop()!;
if (a.length) {
a[0] = last;
let p = 0;
const n = a.length;
for (;;) {
const l = p * 2 + 1;
const r = l + 1;
let s = p;
if (l < n && a[l].f < a[s].f) s = l;
if (r < n && a[r].f < a[s].f) s = r;
if (s === p) break;
[a[p], a[s]] = [a[s], a[p]];
p = s;
}
}
return top;
}
}
function nearestFloor(grid: MapGrid, cx: number, cz: number): { cx: number; cz: number } | null {
if (grid.isFloor(cx, cz)) return { cx, cz };
for (let r = 1; r <= 4; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.abs(dx) !== r && Math.abs(dz) !== r) continue;
if (grid.isFloor(cx + dx, cz + dz)) return { cx: cx + dx, cz: cz + dz };
}
}
}
return null;
}
const SQRT2 = Math.SQRT2;
export function findPath(
grid: MapGrid,
startX: number, startZ: number,
goalX: number, goalZ: number,
): { x: number; z: number }[] | null {
const s0 = grid.cellOf(startX, startZ);
const g0 = grid.cellOf(goalX, goalZ);
const start = nearestFloor(grid, s0.cx, s0.cz);
const goal = nearestFloor(grid, g0.cx, g0.cz);
if (!start || !goal) return null;
const cols = grid.cols;
const n = cols * grid.rows;
const startI = start.cz * cols + start.cx;
const goalI = goal.cz * cols + goal.cx;
if (startI === goalI) return [{ x: goalX, z: goalZ }];
const gScore = new Float32Array(n).fill(Infinity);
const came = new Int32Array(n).fill(-1);
const closed = new Uint8Array(n);
const open = new MinHeap();
gScore[startI] = 0;
open.push({ i: startI, f: 0 });
const gcx = goal.cx;
const gcz = goal.cz;
const heur = (cx: number, cz: number): number => {
const dx = Math.abs(cx - gcx);
const dz = Math.abs(cz - gcz);
return (dx + dz) + (SQRT2 - 2) * Math.min(dx, dz);
};
let guard = 0;
let found = false;
while (open.size && guard++ < 20000) {
const cur = open.pop();
const ci = cur.i;
if (closed[ci]) continue;
closed[ci] = 1;
if (ci === goalI) {
found = true;
break;
}
const cx = ci % cols;
const cz = (ci - cx) / cols;
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dz === 0) continue;
const nx = cx + dx;
const nz = cz + dz;
if (!grid.isFloor(nx, nz)) continue;
if (dx !== 0 && dz !== 0) {
// no corner cutting
if (grid.isWall(cx + dx, cz) || grid.isWall(cx, cz + dz)) continue;
}
const ni = nz * cols + nx;
if (closed[ni]) continue;
const step = dx !== 0 && dz !== 0 ? SQRT2 : 1;
const ng = gScore[ci] + step;
if (ng < gScore[ni]) {
gScore[ni] = ng;
came[ni] = ci;
open.push({ i: ni, f: ng + heur(nx, nz) });
}
}
}
}
if (!found) return null;
// reconstruct cell path
const cells: number[] = [];
let c = goalI;
while (c !== -1) {
cells.push(c);
c = came[c];
}
cells.reverse();
// to world points (cell centers), keep true goal as final point
const pts: { x: number; z: number }[] = cells.map((i) => {
const cx = i % cols;
const cz = (i - cx) / cols;
return grid.centerOf(cx, cz);
});
pts[pts.length - 1] = { x: goalX, z: goalZ };
// line-of-sight simplification (string pulling)
const out: { x: number; z: number }[] = [pts[0]];
let anchor = 0;
for (let i = 2; i < pts.length; i++) {
if (grid.segmentBlocked(pts[anchor].x, pts[anchor].z, pts[i].x, pts[i].z)) {
out.push(pts[i - 1]);
anchor = i - 1;
}
}
out.push(pts[pts.length - 1]);
return out;
}
@@ -0,0 +1,142 @@
// Player controller: turns raw input into the same intent fields the AI uses,
// plus first-person-only concerns (mouse look, scope hold, weapon switching,
// C4 plant/defuse/pickup and a buy-swap for trying the AWP).
import { PLAYER } from './constants';
import { forwardFromYaw, clamp } from './mathUtils';
import { BombState, Team, WeaponId, WeaponSlot } from './types';
import { WEAPONS } from './weapons';
import type { Actor, GameWorld } from './world';
const SENS = 0.0022;
const PITCH_LIMIT = 1.45;
function look(a: Actor, world: GameWorld): void {
const input = world.input;
if (!input.locked) return;
a.yaw -= input.mouseDX * SENS;
a.pitch -= input.mouseDY * SENS;
a.pitch = clamp(a.pitch, -PITCH_LIMIT, PITCH_LIMIT);
}
function equipSlot(world: GameWorld, a: Actor, slot: WeaponSlot): void {
const id = a.weapons.find((w) => WEAPONS[w].slot === slot);
if (id) world.equip(a, id);
}
function cycleWeapon(world: GameWorld, a: Actor, dir: number): void {
const idx = a.weapons.indexOf(a.current);
const next = (idx + (dir > 0 ? 1 : -1) + a.weapons.length) % a.weapons.length;
world.equip(a, a.weapons[next]);
}
function swapPrimary(world: GameWorld, a: Actor): void {
const rifle: WeaponId = a.team === Team.T ? 'ak47' : 'm4a4';
const primaryIdx = a.weapons.findIndex((w) => WEAPONS[w].slot === 'primary');
if (primaryIdx < 0) return;
const cur = a.weapons[primaryIdx];
const next: WeaponId = cur === 'awp' ? rifle : 'awp';
a.weapons[primaryIdx] = next;
const def = WEAPONS[next];
a.ammo[next] = { mag: def.magSize, reserve: def.reserve };
world.equip(a, next);
}
export function updatePlayer(world: GameWorld, a: Actor, dt: number, canMove: boolean): void {
const input = world.input;
if (!a.alive) {
updateSpectate(world);
return;
}
a.action = 'none';
look(a, world);
// movement intents
if (canMove) {
const fwd = forwardFromYaw(a.yaw);
const right = { x: -fwd.z, z: fwd.x };
let mx = 0, mz = 0;
if (input.down('KeyW')) { mx += fwd.x; mz += fwd.z; }
if (input.down('KeyS')) { mx -= fwd.x; mz -= fwd.z; }
if (input.down('KeyD')) { mx += right.x; mz += right.z; }
if (input.down('KeyA')) { mx -= right.x; mz -= right.z; }
a.moveX = mx;
a.moveZ = mz;
a.wantRun = !input.down('ShiftLeft');
if (input.down('Space')) a.wantJump = true;
} else {
a.moveX = a.moveZ = 0;
}
// weapon selection
if (input.pressed('Digit1')) equipSlot(world, a, 'primary');
if (input.pressed('Digit2')) equipSlot(world, a, 'secondary');
if (input.pressed('Digit3')) equipSlot(world, a, 'melee');
if (input.wheel !== 0) cycleWeapon(world, a, input.wheel);
if (input.pressed('KeyB')) swapPrimary(world, a);
if (input.pressed('KeyR')) world.startReload(a);
const w = WEAPONS[a.current];
// scope (hold right mouse on AWP)
if (w.scoped) {
const want = input.right;
if (want !== a.scoped) {
a.scoped = want;
world.audio.scope(want);
}
} else {
a.scoped = false;
}
// fire
if (w.automatic) {
if (input.left) world.fireWeapon(a);
} else if (input.leftJustPressed) {
world.fireWeapon(a);
}
// bomb interactions
handleBomb(world, a, canMove);
}
// While dead: cycle through living teammates' viewpoints and take control of one.
function updateSpectate(world: GameWorld): void {
const input = world.input;
world.ensureSpectate();
if (input.leftJustPressed || input.pressed('KeyD')) world.spectateCycle(1);
if (input.pressed('KeyA')) world.spectateCycle(-1);
if (input.pressed('KeyF') || input.pressed('Space')) world.takeOver();
}
function handleBomb(world: GameWorld, a: Actor, canMove: boolean): void {
const input = world.input;
const bomb = world.bombInfo();
let hint = '';
if (a.team === Team.T) {
if (a.hasBomb && world.isInSite(a) && a.grounded) {
hint = 'Hold [E] to plant the bomb';
if (input.down('KeyE')) a.action = 'plant';
} else if (bomb.state === BombState.Dropped) {
const d2 = (a.x - bomb.x) ** 2 + (a.z - bomb.z) ** 2;
if (d2 < 2.4 * 2.4) {
hint = 'Press [E] to pick up the bomb';
if (input.down('KeyE')) world.pickupBomb(a);
}
} else if (a.hasBomb) {
hint = 'Bring the bomb to site A or B';
}
} else {
if (bomb.state === BombState.Planted) {
const d2 = (a.x - bomb.x) ** 2 + (a.z - bomb.z) ** 2;
if (d2 < 2.3 * 2.3 && a.grounded) {
hint = 'Hold [E] to defuse';
if (input.down('KeyE')) a.action = 'defuse';
}
}
}
world.setHint(hint);
}
@@ -0,0 +1,142 @@
// Static world geometry: sky/fog, lights, floor, instanced walls (only cells
// bordering walkable space), crates, bomb-site markers and the passable mid
// doors. Returns the scene plus a few references the simulation needs.
import * as THREE from 'three';
import { COLORS } from './constants';
import { Dust2, WALL_HEIGHT } from './dust2';
import { Region } from './grid';
export interface SceneRefs {
scene: THREE.Scene;
sun: THREE.DirectionalLight;
}
function labelTexture(letter: string, color: string): THREE.CanvasTexture {
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, 128, 128);
ctx.fillStyle = color;
ctx.font = 'bold 110px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(letter, 64, 70);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 4;
return tex;
}
export function buildScene(d: Dust2): SceneRefs {
const scene = new THREE.Scene();
scene.background = new THREE.Color(COLORS.sky);
scene.fog = new THREE.Fog(COLORS.sky, 60, 170);
// lighting
const hemi = new THREE.HemisphereLight(0xdfeaf5, 0x6b5f47, 0.9);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.1);
sun.position.set(50, 90, 30);
scene.add(sun);
const amb = new THREE.AmbientLight(0xffffff, 0.18);
scene.add(amb);
const g = d.grid;
const { minX, maxX, minZ, maxZ } = d.bounds;
// floor
const floorGeo = new THREE.PlaneGeometry(maxX - minX, maxZ - minZ);
const floorMat = new THREE.MeshStandardMaterial({ color: COLORS.floor, roughness: 0.97, metalness: 0 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set((minX + maxX) / 2, 0, (minZ + maxZ) / 2);
scene.add(floor);
// walls: only cells that border a floor cell are visible
const borderCells: { cx: number; cz: number }[] = [];
for (let cz = 0; cz < g.rows; cz++) {
for (let cx = 0; cx < g.cols; cx++) {
if (!g.isWall(cx, cz)) continue;
if (
g.isFloor(cx + 1, cz) || g.isFloor(cx - 1, cz) ||
g.isFloor(cx, cz + 1) || g.isFloor(cx, cz - 1)
) {
borderCells.push({ cx, cz });
}
}
}
const wallGeo = new THREE.BoxGeometry(g.cell, WALL_HEIGHT, g.cell);
// Per-instance tint comes from setColorAt (instanceColor); no vertexColors flag
// (the box geometry has no color attribute, which would render black).
const wallMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95, metalness: 0.02, flatShading: true });
const walls = new THREE.InstancedMesh(wallGeo, wallMat, borderCells.length);
const m = new THREE.Matrix4();
const col = new THREE.Color();
const base = new THREE.Color(COLORS.wall);
const dark = new THREE.Color(COLORS.wallDark);
for (let i = 0; i < borderCells.length; i++) {
const { cx, cz } = borderCells[i];
const c = g.centerOf(cx, cz);
m.makeTranslation(c.x, WALL_HEIGHT / 2, c.z);
walls.setMatrixAt(i, m);
col.copy(base).lerp(dark, Math.random() * 0.6);
walls.setColorAt(i, col);
}
walls.instanceMatrix.needsUpdate = true;
if (walls.instanceColor) walls.instanceColor.needsUpdate = true;
scene.add(walls);
// crates
for (const cr of d.crates) {
const geo = new THREE.BoxGeometry(cr.hx * 2, cr.height, cr.hz * 2);
const mat = new THREE.MeshStandardMaterial({ color: COLORS.crate, roughness: 0.8, metalness: 0.05, flatShading: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(cr.x, cr.height / 2, cr.z);
scene.add(mesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(geo),
new THREE.LineBasicMaterial({ color: COLORS.crateEdge }),
);
edges.position.copy(mesh.position);
scene.add(edges);
}
// bomb-site markers
const marker = (x: number, z: number, letter: string, color: number, hex: string) => {
const ring = new THREE.Mesh(
new THREE.RingGeometry(5.2, 6, 40),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5, side: THREE.DoubleSide }),
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.05, z);
scene.add(ring);
const lbl = new THREE.Mesh(
new THREE.PlaneGeometry(4, 4),
new THREE.MeshBasicMaterial({ map: labelTexture(letter, hex), transparent: true, opacity: 0.7 }),
);
lbl.rotation.x = -Math.PI / 2;
lbl.position.set(x, 0.06, z);
scene.add(lbl);
};
marker(d.sites.A.center.x, d.sites.A.center.z, 'A', COLORS.siteA, '#e0745f');
marker(d.sites.B.center.x, d.sites.B.center.z, 'B', COLORS.siteB, '#5fb4e0');
// passable mid doors (visual only, no collision)
for (const dc of d.doorCells) {
const c = g.centerOf(dc.cx, dc.cz);
const door = new THREE.Mesh(
new THREE.BoxGeometry(g.cell * 0.96, 2.6, 0.12),
new THREE.MeshStandardMaterial({ color: 0x7a6a4a, roughness: 0.7, metalness: 0.1, transparent: true, opacity: 0.92 }),
);
door.position.set(c.x, 1.3, c.z);
scene.add(door);
const frame = new THREE.Mesh(
new THREE.BoxGeometry(g.cell * 0.98, 2.9, 0.16),
new THREE.MeshStandardMaterial({ color: 0x4a3f2c, roughness: 0.8 }),
);
frame.position.set(c.x, 1.45, c.z);
scene.add(frame);
}
return { scene, sun };
}
@@ -0,0 +1,107 @@
// Minimal external store bridging the imperative engine to React HUD via
// useSyncExternalStore. The engine mutates through set(); the snapshot identity
// changes on each flush so React re-renders.
import { KillEvent, RoundPhase, Team } from './types';
export interface HudState {
started: boolean;
playerTeam: Team;
phase: RoundPhase;
roundNum: number;
scoreT: number;
scoreCT: number;
roundTime: number;
aliveT: number;
aliveCT: number;
alive: boolean;
spectating: boolean;
spectateName: string;
health: number;
armor: number;
helmet: boolean;
weaponName: string;
weaponId: string;
mag: number;
reserve: number;
infiniteAmmo: boolean;
reloading: boolean;
scoped: boolean;
spread: number; // radians, drives crosshair gap
hasBomb: boolean;
bombPlanted: boolean;
bombTimer: number;
defusing: boolean;
defuseProgress: number;
planting: boolean;
plantProgress: number;
hint: string;
banner: string;
killfeed: KillEvent[];
loadout: { id: string; name: string; slot: string; mag: number; reserve: number; current: boolean }[];
}
const initial: HudState = {
started: false,
playerTeam: Team.T,
phase: RoundPhase.Warmup,
roundNum: 0,
scoreT: 0,
scoreCT: 0,
roundTime: 0,
aliveT: 5,
aliveCT: 5,
alive: true,
spectating: false,
spectateName: '',
health: 100,
armor: 0,
helmet: false,
weaponName: '',
weaponId: '',
mag: 0,
reserve: 0,
infiniteAmmo: false,
reloading: false,
scoped: false,
spread: 0.02,
hasBomb: false,
bombPlanted: false,
bombTimer: 0,
defusing: false,
defuseProgress: 0,
planting: false,
plantProgress: 0,
hint: '',
banner: '',
killfeed: [],
loadout: [],
};
class HudStore {
private state: HudState = initial;
private listeners = new Set<() => void>();
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getSnapshot = (): HudState => this.state;
set(partial: Partial<HudState>): void {
this.state = { ...this.state, ...partial };
for (const fn of this.listeners) fn();
}
reset(): void {
this.set({ ...initial });
}
}
export const hud = new HudStore();
@@ -0,0 +1,51 @@
// Shared enums / interfaces used across engine + UI.
export enum Team {
T = 0,
CT = 1,
}
export enum HitZone {
Head = 0,
Chest = 1,
Stomach = 2,
Arm = 3,
Leg = 4,
}
export type WeaponSlot = 'primary' | 'secondary' | 'melee';
export type WeaponId =
| 'ak47'
| 'm4a4'
| 'awp'
| 'glock'
| 'usp'
| 'deagle'
| 'knife';
export enum RoundPhase {
Warmup = 0,
Freeze = 1,
Live = 2,
Over = 3,
}
export enum BombState {
Carried = 0,
Dropped = 1,
Planted = 2,
Defused = 3,
Exploded = 4,
}
export interface KillEvent {
id: number;
attacker: string;
victim: string;
attackerTeam: Team;
victimTeam: Team;
weapon: WeaponId;
headshot: boolean;
time: number;
}
@@ -0,0 +1,118 @@
// Weapon data table + per-zone damage. Extensible: add an entry to WEAPONS and
// a matching viewmodel/worldmodel builder in entities/weaponModels.ts.
import { HitZone, WeaponId, WeaponSlot } from './types';
export interface WeaponDef {
id: WeaponId;
name: string;
slot: WeaponSlot;
damage: number; // base, to chest, no armor
armorPen: number; // fraction of damage kept vs armor (0..1)
fireInterval: number; // seconds between shots
automatic: boolean;
magSize: number;
reserve: number;
reloadTime: number;
range: number; // effective hitscan range (m)
// recoil / accuracy (radians)
recoilPitch: number; // upward kick per shot
recoilYaw: number; // max horizontal kick per shot (± random)
spreadPerShot: number; // inaccuracy added per shot
baseSpread: number; // standing inaccuracy
maxSpread: number;
recoverRate: number; // spread recovered per second
moveSpeedMul: number; // movement multiplier while held
scoped?: boolean; // AWP: right-click 3D zoom + scope mask
scopedFov?: number;
isKnife?: boolean;
}
// Head is 2x body per the spec; other zones scale off chest.
export const ZONE_MULT: Record<HitZone, number> = {
[HitZone.Head]: 2.0,
[HitZone.Chest]: 1.0,
[HitZone.Stomach]: 1.25,
[HitZone.Arm]: 0.75,
[HitZone.Leg]: 0.75,
};
export const WEAPONS: Record<WeaponId, WeaponDef> = {
ak47: {
id: 'ak47', name: 'AK-47', slot: 'primary',
damage: 36, armorPen: 0.775, fireInterval: 0.1, automatic: true,
magSize: 30, reserve: 90, reloadTime: 2.4, range: 90,
recoilPitch: 0.019, recoilYaw: 0.011, spreadPerShot: 0.013,
baseSpread: 0.006, maxSpread: 0.14, recoverRate: 0.5, moveSpeedMul: 0.92,
},
m4a4: {
id: 'm4a4', name: 'M4A4', slot: 'primary',
damage: 33, armorPen: 0.7, fireInterval: 0.09, automatic: true,
magSize: 30, reserve: 90, reloadTime: 3.1, range: 90,
recoilPitch: 0.011, recoilYaw: 0.006, spreadPerShot: 0.008,
baseSpread: 0.005, maxSpread: 0.1, recoverRate: 0.55, moveSpeedMul: 0.94,
},
awp: {
id: 'awp', name: 'AWP', slot: 'primary',
damage: 115, armorPen: 0.975, fireInterval: 1.5, automatic: false,
magSize: 10, reserve: 30, reloadTime: 3.7, range: 130,
recoilPitch: 0.05, recoilYaw: 0.02, spreadPerShot: 0.2,
baseSpread: 0.06, maxSpread: 0.3, recoverRate: 0.8, moveSpeedMul: 0.84,
scoped: true, scopedFov: 25,
},
glock: {
id: 'glock', name: 'Glock-18', slot: 'secondary',
damage: 26, armorPen: 0.47, fireInterval: 0.15, automatic: false,
magSize: 20, reserve: 120, reloadTime: 2.2, range: 60,
recoilPitch: 0.012, recoilYaw: 0.008, spreadPerShot: 0.011,
baseSpread: 0.008, maxSpread: 0.11, recoverRate: 0.7, moveSpeedMul: 1.0,
},
usp: {
id: 'usp', name: 'USP-S', slot: 'secondary',
damage: 33, armorPen: 0.505, fireInterval: 0.17, automatic: false,
magSize: 12, reserve: 24, reloadTime: 2.2, range: 60,
recoilPitch: 0.012, recoilYaw: 0.006, spreadPerShot: 0.01,
baseSpread: 0.006, maxSpread: 0.1, recoverRate: 0.7, moveSpeedMul: 1.0,
},
deagle: {
id: 'deagle', name: 'Desert Eagle', slot: 'secondary',
damage: 55, armorPen: 0.93, fireInterval: 0.225, automatic: false,
magSize: 7, reserve: 35, reloadTime: 2.2, range: 75,
recoilPitch: 0.032, recoilYaw: 0.012, spreadPerShot: 0.02,
baseSpread: 0.009, maxSpread: 0.14, recoverRate: 0.6, moveSpeedMul: 0.96,
},
knife: {
id: 'knife', name: 'Knife', slot: 'melee',
damage: 55, armorPen: 0.85, fireInterval: 0.4, automatic: false,
magSize: Infinity, reserve: Infinity, reloadTime: 0, range: 2.4,
recoilPitch: 0, recoilYaw: 0, spreadPerShot: 0,
baseSpread: 0, maxSpread: 0, recoverRate: 1, moveSpeedMul: 1.05,
isKnife: true,
},
};
export interface DamageResult {
health: number;
armor: number;
applied: number;
}
// Apply zone + armor model. Head protected only with a helmet; legs never armored.
export function computeDamage(
weapon: WeaponDef,
zone: HitZone,
health: number,
armor: number,
helmet: boolean,
): DamageResult {
let dmg = weapon.damage * ZONE_MULT[zone];
let armorLeft = armor;
const protectable = zone !== HitZone.Leg && (zone !== HitZone.Head || helmet);
if (armorLeft > 0 && protectable) {
const preArmor = dmg;
dmg *= weapon.armorPen;
armorLeft = Math.max(0, armorLeft - Math.round((preArmor - dmg) * 0.5 + dmg * 0.5));
}
const applied = Math.min(health, dmg);
return { health: Math.max(0, health - dmg), armor: armorLeft, applied };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
// No StrictMode: the game owns an imperative three.js loop with manual setup /
// teardown, and double-invoked effects would spin up two render loops.
createRoot(document.getElementById('root')!).render(<App />);
@@ -0,0 +1,336 @@
:root {
--t: #e0a24a;
--ct: #5aa9e6;
--panel: rgba(12, 15, 20, 0.62);
--panel-solid: #0d1015;
--ink: #eaf0f2;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
background: #05070a;
color: var(--ink);
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
user-select: none;
-webkit-user-select: none;
}
/* ---------- menu ---------- */
.menu {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background:
radial-gradient(1200px 700px at 50% -10%, #1b2740 0%, #0a0e16 55%, #05070a 100%);
}
.menu-card {
width: min(680px, 92vw);
padding: 40px 40px 30px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(10, 14, 20, 0.7);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
text-align: center;
}
.menu-title {
font-size: 44px;
letter-spacing: 10px;
font-weight: 800;
background: linear-gradient(90deg, var(--t), #fff 55%, var(--ct));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.menu-sub { margin-top: 8px; color: #93a0ab; font-size: 13px; letter-spacing: 1px; }
.side-select { display: flex; gap: 14px; margin: 26px 0 18px; }
.side {
flex: 1;
padding: 18px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--ink);
cursor: pointer;
text-align: left;
transition: transform 0.1s, border-color 0.15s, background 0.15s;
}
.side:hover { transform: translateY(-2px); }
.side-name { display: block; font-size: 17px; font-weight: 700; }
.side-desc { display: block; margin-top: 6px; font-size: 12px; color: #93a0ab; }
.side-t.active { border-color: var(--t); background: rgba(224, 162, 74, 0.14); }
.side-ct.active { border-color: var(--ct); background: rgba(90, 169, 230, 0.14); }
.side-t .side-name { color: var(--t); }
.side-ct .side-name { color: var(--ct); }
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
margin: 18px 4px;
font-size: 12px;
color: #aab4bd;
text-align: left;
}
.controls b { color: #fff; font-weight: 700; }
.start-btn {
margin-top: 10px;
padding: 14px 40px;
font-size: 16px;
font-weight: 800;
letter-spacing: 3px;
border: none;
border-radius: 10px;
color: #08111e;
background: linear-gradient(90deg, #7fd0ff, #cfeaff);
cursor: pointer;
font-family: inherit;
}
.start-btn:hover { filter: brightness(1.08); }
.menu-foot { margin-top: 14px; font-size: 11px; color: #6a757f; }
/* ---------- game ---------- */
.game-root { position: fixed; inset: 0; cursor: none; }
.game-root canvas { position: absolute; inset: 0; display: block; z-index: 1; }
.hud {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
/* top bar */
.topbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 16px;
padding: 6px 14px;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.team-block { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.team-right { flex-direction: row; }
.team-tag { font-size: 16px; letter-spacing: 1px; }
.team-alive { font-size: 10px; letter-spacing: 1px; }
.dead-dots { opacity: 0.35; }
.score-center { display: flex; align-items: center; gap: 14px; }
.score { font-size: 26px; font-weight: 800; min-width: 26px; text-align: center; }
.score-time { font-size: 15px; color: #cfd8de; min-width: 46px; text-align: center; }
.bomb-time { color: #ff4b3e; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0.35; } }
/* killfeed */
.killfeed {
position: absolute;
top: 12px;
right: 14px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.kill-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 9px;
background: var(--panel);
border-radius: 5px;
font-size: 12px;
font-weight: 700;
}
.kill-weapon { color: #cbd3da; font-weight: 500; }
/* banner */
.banner {
position: absolute;
top: 20%;
left: 50%;
transform: translateX(-50%);
font-size: 30px;
font-weight: 800;
letter-spacing: 3px;
color: #fff;
padding: 8px 26px;
background: rgba(8, 11, 16, 0.5);
border-radius: 10px;
white-space: nowrap;
}
/* progress bar */
.progress-wrap {
position: absolute;
top: 62%;
left: 50%;
transform: translateX(-50%);
width: 280px;
text-align: center;
}
.progress-label { font-size: 13px; letter-spacing: 3px; margin-bottom: 6px; }
.progress-bar {
height: 10px;
background: rgba(255, 255, 255, 0.14);
border-radius: 6px;
overflow: hidden;
}
.progress-fill { height: 100%; transition: width 0.05s linear; }
/* crosshair */
.crosshair { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
/* scope */
.scope { position: absolute; inset: 0; display: grid; place-items: center; }
.scope-circle {
width: min(86vh, 86vw);
height: min(86vh, 86vw);
border-radius: 50%;
box-shadow: 0 0 0 100vmax #000, inset 0 0 0 2px rgba(0, 0, 0, 0.9);
}
.scope-line { position: absolute; background: rgba(0, 0, 0, 0.85); }
.scope-h { width: 100%; height: 1px; }
.scope-v { height: 100%; width: 1px; }
/* dead */
.dead {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.dead-title { font-size: 34px; font-weight: 800; letter-spacing: 6px; color: #ff5b5b; }
.dead-sub { margin-top: 8px; font-size: 13px; color: #b9c2c9; }
/* spectate bar (after death) */
.spectate {
position: absolute;
bottom: 15%;
left: 50%;
transform: translateX(-50%);
text-align: center;
padding: 10px 22px;
background: rgba(8, 11, 16, 0.62);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
white-space: nowrap;
}
.spectate-title { font-size: 16px; letter-spacing: 3px; color: #e7edf1; }
.spectate-name { color: #39d353; font-weight: 800; }
.spectate-keys { margin-top: 6px; font-size: 12px; color: #aab4bd; }
.spectate-keys b { color: #ffcf5b; }
/* hint */
.hint {
position: absolute;
bottom: 27%;
left: 50%;
transform: translateX(-50%);
padding: 6px 16px;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 14px;
letter-spacing: 1px;
}
.bomb-tag {
position: absolute;
bottom: 33%;
left: 50%;
transform: translateX(-50%);
color: var(--t);
font-size: 12px;
letter-spacing: 2px;
}
/* vitals */
.vitals {
position: absolute;
left: 22px;
bottom: 20px;
display: flex;
gap: 22px;
padding: 10px 16px;
background: var(--panel);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.vital { display: flex; align-items: center; gap: 8px; }
.vital-icon { font-size: 15px; font-weight: 800; }
.vital-icon.hp { color: #4fd06a; }
.vital-icon.ar { color: var(--ct); }
.vital-num { font-size: 30px; font-weight: 800; min-width: 44px; }
/* ammo */
.ammo-block {
position: absolute;
right: 22px;
bottom: 20px;
text-align: right;
padding: 10px 16px;
background: var(--panel);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.ammo-weapon { font-size: 13px; letter-spacing: 2px; color: #cbd3da; margin-bottom: 2px; }
.ammo-nums { display: flex; align-items: baseline; justify-content: flex-end; gap: 8px; }
.ammo-mag { font-size: 34px; font-weight: 800; }
.ammo-res { font-size: 16px; color: #9aa4ac; }
.weapon-slots { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; align-items: flex-end; }
.slot { display: flex; gap: 8px; font-size: 12px; opacity: 0.55; }
.slot-cur { opacity: 1; color: #fff; }
.slot-key { color: #ffcf5b; font-weight: 700; }
.slot-name { min-width: 96px; text-align: right; }
/* minimap */
.minimap {
position: absolute;
left: 18px;
top: 14px;
z-index: 6;
width: 200px;
height: 200px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(6, 9, 13, 0.72);
overflow: hidden;
pointer-events: none;
}
.minimap canvas { width: 100%; height: 100%; display: block; }
.minimap-label {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 10px;
letter-spacing: 3px;
color: rgba(255, 255, 255, 0.4);
}
/* click / pause overlay */
.click-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(4, 6, 10, 0.55);
backdrop-filter: blur(2px);
pointer-events: auto;
cursor: pointer;
}
.click-inner { text-align: center; }
.click-title { font-size: 30px; font-weight: 800; letter-spacing: 6px; }
.click-hint { margin-top: 10px; font-size: 15px; color: #cfd8de; }
.click-sub { margin-top: 6px; font-size: 12px; color: #7c8790; }
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// base: './' keeps asset URLs relative so the built dist/ also works when opened
// straight from the filesystem.
export default defineConfig({
base: './',
plugins: [react()],
});
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
// `bun run build:single` — inlines all JS/CSS into one self-contained HTML
// (dist/index.html), which is what ships as dust2-arena.html.
export default defineConfig({
base: './',
plugins: [react(), viteSingleFile()],
});
+22
View File
@@ -28,6 +28,10 @@ const isBilingual = (v: unknown): boolean => {
const modelIds = new Set<string>() const modelIds = new Set<string>()
const artifactDirs = new Map<string, string>() const artifactDirs = new Map<string, string>()
const declaredArtifacts = new Set<string>() const declaredArtifacts = new Set<string>()
// React cases declare a whole project directory (built to a single file at
// deploy time), not one artifact file — track those so the loose-file scan
// below skips their subtree.
const declaredDirs = new Set<string>()
for (const f of readdirSync(modelsDir).filter((f) => f.endsWith('.json'))) { for (const f of readdirSync(modelsDir).filter((f) => f.endsWith('.json'))) {
const id = f.replace(/\.json$/, '') const id = f.replace(/\.json$/, '')
modelIds.add(id) modelIds.add(id)
@@ -92,6 +96,23 @@ for (const f of readdirSync(modelsDir).filter((f) => f.endsWith('.json'))) {
if (run?.contributor !== undefined && (typeof run.contributor !== 'string' || !run.contributor.trim())) { if (run?.contributor !== undefined && (typeof run.contributor !== 'string' || !run.contributor.trim())) {
errors.push(`models/${f}: ${where}.contributor must be a non-empty GitHub username`) errors.push(`models/${f}: ${where}.contributor must be a non-empty GitHub username`)
} }
if (caseKind.get(caseId) === 'react') {
// React case: the artifact is a full project directory, not a file.
const dirName = (typeof run?.file === 'string' && run.file) || caseId
if (seenFiles.has(dirName)) {
errors.push(`models/${f}: ${where} resolves to the same project dir "${dirName}" as another variant`)
}
seenFiles.add(dirName)
const relDir = `${artifactDir}/${dirName}`
declaredDirs.add(relDir)
const dirPath = join(outputsDir, artifactDir, dirName)
if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
errors.push(`models/${f}: ${where} declares a React project but directory missing: outputs/${relDir}/`)
} else if (!existsSync(join(dirPath, 'package.json'))) {
errors.push(`models/${f}: ${where}: outputs/${relDir}/ must contain a package.json (a full project)`)
}
return
}
const file = (typeof run?.file === 'string' && run.file) || `${caseId}.${caseKind.get(caseId)}` const file = (typeof run?.file === 'string' && run.file) || `${caseId}.${caseKind.get(caseId)}`
if (seenFiles.has(file)) { if (seenFiles.has(file)) {
errors.push(`models/${f}: ${where} resolves to the same artifact "${file}" as another variant — extra variants must set a distinct "file"`) errors.push(`models/${f}: ${where} resolves to the same artifact "${file}" as another variant — extra variants must set a distinct "file"`)
@@ -118,6 +139,7 @@ function walkOutputs(dir: string, prefix = '') {
if (entry.name.startsWith('.')) continue if (entry.name.startsWith('.')) continue
const rel = prefix ? `${prefix}/${entry.name}` : entry.name const rel = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (declaredDirs.has(rel)) continue // React project dir — declared wholesale
walkOutputs(join(dir, entry.name), rel) walkOutputs(join(dir, entry.name), rel)
} else if (!declaredArtifacts.has(rel)) { } else if (!declaredArtifacts.has(rel)) {
errors.push(`outputs/${rel} is not declared by any run in models/*.json`) errors.push(`outputs/${rel} is not declared by any run in models/*.json`)