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:
1 parent
c4cc293748
commit
cbfce3b7cc
62 files changed
+13583
-43
No files matched your search
@@ -1,18 +1,15 @@
|
||||
# AGENTS.md
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
公开**数据仓库**:社区贡献的「一次生成」LLM 测评产物。你提交某模型在某个 case 上的
|
||||
原始输出,站点会原样渲染。这里只有**数据**,没有站点代码。
|
||||
|
||||
## A contribution is exactly two files / 一次贡献就两个文件
|
||||
## A contribution is exactly two files
|
||||
|
||||
1. **The artifact** — the raw model output, dropped at
|
||||
`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
|
||||
generated from `models/*.json` and synced automatically after merge. Hand
|
||||
edits are overwritten. (**不要改 README**,注册表是自动生成并在合并后自动同步的。)
|
||||
- **Do not edit `cases.json`.** Cases are maintainer-owned.(case 由维护者管理。)
|
||||
edits are overwritten.
|
||||
- **Do not edit `cases.json`.** Cases are maintainer-owned.
|
||||
|
||||
## `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
|
||||
(`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:
|
||||
`reasonix-reasonix-max`. Good: `deepseek-v4-flash-reasonix`.
|
||||
(文件名即条目 id,是稳定投票主键——按**模型**命名,别按 harness 命名。)
|
||||
- `artifactDir` may be one or two dash-case segments
|
||||
(`deepseek-v4-flash/reasonix-max`); set it when several entries share a model
|
||||
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**
|
||||
`models/*.json` file, not an extra variant inside an existing one. "Codex",
|
||||
"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
|
||||
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
|
||||
different people can add takes to the same model entry.
|
||||
|
||||
(同一 **model × harness × effort** 想留多张,就把 `runs.<case>` 写成**数组**,别新建
|
||||
JSON——这是唯一能把多份产物塞进一个条目的情形。第一版可省略 `file`,其余每版都要有
|
||||
各自不同、描述性的 dash-case `file` 名;**别用 `index.html`**(会被当成目录默认页)。
|
||||
每版各自带 note,可各自署名 contributor。)
|
||||
|
||||
### The `note` is required and bilingual / note 必须双语
|
||||
### The `note` is required and bilingual
|
||||
|
||||
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
|
||||
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
|
||||
skills, plugins, MCP servers or custom system prompts beyond what it bundles.
|
||||
These shift the output as much as swapping the harness does, so such a run is
|
||||
not comparable to others under the same harness and **will not be accepted**.
|
||||
Disclosing it in `note` does not fix this: the leaderboard identity is only
|
||||
model × harness × effort, so a note gives the modified setup no separate
|
||||
identity — it would still be mixed into an Agent row it is not comparable with.
|
||||
(用 harness 的**默认形态**:不要额外加载 harness 自带以外的 skill / 插件 /
|
||||
MCP / 自定义系统提示——它们对产出的影响不亚于换 harness,这类 run 与同一
|
||||
harness 下其它条目不可比,**不予收录**。在 `note` 里写明也没用:榜单身份只看
|
||||
模型 × harness × effort,note 不另立身份,它仍会混进本不该可比的那一行。)
|
||||
Run the model with the harness's default model and effort, but otherwise
|
||||
**stripped down**: **disable every skill and every plugin — including the ones
|
||||
the harness ships with and would auto-invoke** — and add no MCP servers and no
|
||||
custom system prompt. A skill or plugin (a built-in `design`, `gsap`, game, or
|
||||
diagram helper, an added toolset, etc.) rewrites the output far more than the
|
||||
model itself does, so a run with any of them active measures the tooling, not the
|
||||
model × harness × effort. It is not comparable to other entries and **will not be
|
||||
accepted**. Disclosing it in `note` does not fix this: leaderboard identity is
|
||||
only model × harness × effort, so a modified setup gets no separate identity — it
|
||||
would just be mixed into a row it is not comparable with.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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,
|
||||
not as required PR feedback.
|
||||
|
||||
## Before you open the PR / 提交前
|
||||
## Before you open the PR
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
- `outputs/` files are served verbatim in a sandboxed iframe. Keep them
|
||||
|
||||
+52
-6
@@ -3,7 +3,10 @@
|
||||
"id": "mythos-craft",
|
||||
"index": "01",
|
||||
"kind": "html",
|
||||
"title": { "zh": "神话境界", "en": "Mythos Craft" },
|
||||
"title": {
|
||||
"zh": "神话境界",
|
||||
"en": "Mythos Craft"
|
||||
},
|
||||
"tagline": {
|
||||
"zh": "单个 HTML 文件,一次生成一款《我的世界》式的可玩 3D 体素世界。",
|
||||
"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. 要有一个内置的、极其丰富的神话幻想中的建筑和地形等,由你自行设计。",
|
||||
"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",
|
||||
"index": "02",
|
||||
"kind": "svg",
|
||||
"title": { "zh": "鹈鹕海边骑行", "en": "Pelican by the Sea" },
|
||||
"title": {
|
||||
"zh": "鹈鹕海边骑行",
|
||||
"en": "Pelican by the Sea"
|
||||
},
|
||||
"tagline": {
|
||||
"zh": "经典 SVG 测评题:让模型用代码「画」出一只骑自行车的鹈鹕。",
|
||||
"en": "The classic SVG eval: make the model \"draw\" a pelican riding a bicycle, in code."
|
||||
@@ -27,13 +38,20 @@
|
||||
"zh": "制作一个逼真的、生动的、在海边骑着自行车的鹈鹕 <svg>",
|
||||
"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",
|
||||
"index": "03",
|
||||
"kind": "html",
|
||||
"title": { "zh": "镂空机械表", "en": "Skeleton Watch" },
|
||||
"title": {
|
||||
"zh": "镂空机械表",
|
||||
"en": "Skeleton Watch"
|
||||
},
|
||||
"tagline": {
|
||||
"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."
|
||||
@@ -42,6 +60,34 @@
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
},
|
||||
"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 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 · <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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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;
|
||||
}
|
||||
+132
@@ -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()],
|
||||
});
|
||||
@@ -28,6 +28,10 @@ const isBilingual = (v: unknown): boolean => {
|
||||
const modelIds = new Set<string>()
|
||||
const artifactDirs = new Map<string, 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'))) {
|
||||
const id = f.replace(/\.json$/, '')
|
||||
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())) {
|
||||
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)}`
|
||||
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"`)
|
||||
@@ -118,6 +139,7 @@ function walkOutputs(dir: string, prefix = '') {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
if (declaredDirs.has(rel)) continue // React project dir — declared wholesale
|
||||
walkOutputs(join(dir, entry.name), rel)
|
||||
} else if (!declaredArtifacts.has(rel)) {
|
||||
errors.push(`outputs/${rel} is not declared by any run in models/*.json`)
|
||||
|
||||
Reference in new issue
Block a user