diff --git a/AGENTS.md b/AGENTS.md index 62eea02..ea229d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//.` (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/.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.` 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.` 写成**数组**,别新建 -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 `` 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//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 `
`. +- 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 diff --git a/cases.json b/cases.json index b1a27a1..c91a6db 100644 --- a/cases.json +++ b/cases.json @@ -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": "制作一个逼真的、生动的、在海边骑着自行车的鹈鹕 ", "en": "Create a realistic, lively 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" + ] } ] diff --git a/models/claude-fable-5-cc-high.json b/models/claude-fable-5-cc-high.json new file mode 100644 index 0000000..da27512 --- /dev/null +++ b/models/claude-fable-5-cc-high.json @@ -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" + } + } +} diff --git a/models/claude-opus-4-8-cc.json b/models/claude-opus-4-8-cc.json index 0228454..86c02f8 100644 --- a/models/claude-opus-4-8-cc.json +++ b/models/claude-opus-4-8-cc.json @@ -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" } } } diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/bun.lock b/outputs/claude-fable-5/claude-code-high/cs-dust2/bun.lock new file mode 100644 index 0000000..b17a12e --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/bun.lock @@ -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=="], + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/index.html b/outputs/claude-fable-5/claude-code-high/cs-dust2/index.html new file mode 100644 index 0000000..b5482db --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/index.html @@ -0,0 +1,15 @@ + + + + + + DUST II — 5v5 模拟器 + + + +
+ + + diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/package.json b/outputs/claude-fable-5/claude-code-high/cs-dust2/package.json new file mode 100644 index 0000000..3029155 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/package.json @@ -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" + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/App.tsx b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/App.tsx new file mode 100644 index 0000000..dbc0a29 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/App.tsx @@ -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(null) + const [engine, setEngine] = useState(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 ( +
+
+ {engine && } +
+ ) +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/ai.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/ai.ts new file mode 100644 index 0000000..290d4e7 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/ai.ts @@ -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 + } + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/audio.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/audio.ts new file mode 100644 index 0000000..f6fbb9c --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/audio.ts @@ -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 }) + } + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/character.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/character.ts new file mode 100644 index 0000000..b08526f --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/character.ts @@ -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 + 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() + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/effects.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/effects.ts new file mode 100644 index 0000000..aa8537c --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/effects.ts @@ -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) + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/engine.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/engine.ts new file mode 100644 index 0000000..28640fa --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/engine.ts @@ -0,0 +1,1106 @@ +import * as THREE from 'three' +import { AIBrain, type AIWorld } from './ai' +import { AudioMan } from './audio' +import { Effects } from './effects' +import { GameMap } from './map' +import { Nav } from './nav' +import { PhysicsWorld } from './physics' +import { Soldier } from './soldier' +import { HudStore } from './store' +import { ViewModel } from './viewmodel' +import { PART_MULT, WEAPONS } from './weapons' +import { + BOMB_FUSE, DEFUSE_TIME, FREEZE_TIME, PLANT_TIME, + ROUND_END_TIME, ROUND_TIME, RUN_SPEED, WIN_SCORE, + type BodyPart, type Loadout, type Phase, type Slot, type Team, type WeaponId, +} from './types' + +const CT_NAMES = ['Rock', 'Duke', 'Nova', 'Ice', 'Sarge'] +const T_NAMES = ['Viper', 'Wolf', 'Ghost', 'Blade', 'Fox'] +const FIXED_DT = 1 / 64 + +interface BombState { + carrierId: number | null + droppedPos: THREE.Vector3 | null + plantedPos: THREE.Vector3 | null + plantedAt: number + exploded: boolean +} + +export class Engine { + // three + private renderer: THREE.WebGLRenderer + private scene = new THREE.Scene() + private camera = new THREE.PerspectiveCamera(75, 1, 0.05, 400) + private container: HTMLElement + + // world + map: GameMap + private physics: PhysicsWorld + private nav: Nav + private effects: Effects + audio = new AudioMan() + private viewmodel: ViewModel + private bombMesh: THREE.Group + + // game state + soldiers: Soldier[] = [] + playerTeam: Team = 'CT' + private playerId = 0 + private controlledId = 0 + viewedId = 0 + private autoMode = false + phase: Phase = 'menu' + private round = 1 + private ctScore = 0 + private tScore = 0 + private phaseEndsAt = 0 + private roundEndsAt = 0 + private winner: Team | null = null + private winReason = '' + private matchWinner: Team | null = null + bomb: BombState = { carrierId: null, droppedPos: null, plantedPos: null, plantedAt: 0, exploded: false } + private nextBeepAt = 0 + private nextDefTickAt = 0 + playerLoadout: Loadout = 'rifle' + + // timing + time = 0 + private accumulator = 0 + private lastFrame = 0 + private rafId = 0 + private hudSyncAt = 0 + private seenScanAt = 0 + private disposed = false + + // input + private keys = new Set() + private mouseFire = false + private fireTapQueued = false + private altTapQueued = false + private jumpQueued = false + private slotQueued: Slot | null = null + private deathAt = 0 + pointerLocked = false + + hud = new HudStore() + private killfeedId = 0 + + constructor(container: HTMLElement) { + this.container = container + this.renderer = new THREE.WebGLRenderer({ antialias: true }) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + this.renderer.shadowMap.enabled = true + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap + this.renderer.toneMapping = THREE.ACESFilmicToneMapping + this.renderer.toneMappingExposure = 1.15 + this.renderer.setClearColor(0xb9d2e8) + container.appendChild(this.renderer.domElement) + + this.scene.fog = new THREE.Fog(0xc4d6e6, 70, 240) + const hemi = new THREE.HemisphereLight(0xcfe4f5, 0x8a7a55, 1.5) + const sun = new THREE.DirectionalLight(0xfff2dd, 2.2) + sun.position.set(60, 90, 30) + sun.castShadow = true + sun.shadow.mapSize.set(2048, 2048) + sun.shadow.camera.left = -90 + sun.shadow.camera.right = 90 + sun.shadow.camera.top = 90 + sun.shadow.camera.bottom = -90 + sun.shadow.camera.far = 260 + sun.shadow.bias = -0.0006 + this.scene.add(hemi, sun, sun.target) + + this.map = new GameMap() + this.scene.add(this.map.group) + this.physics = new PhysicsWorld(this.map) + this.nav = new Nav(this.map, this.physics) + this.effects = new Effects(this.scene) + + this.camera.rotation.order = 'YXZ' + this.scene.add(this.camera) + this.viewmodel = new ViewModel(this.camera) + + this.bombMesh = this.buildBombMesh() + this.bombMesh.visible = false + this.scene.add(this.bombMesh) + + this.bindEvents() + this.resize() + this.lastFrame = performance.now() + const loop = (t: number) => { + if (this.disposed) return + this.rafId = requestAnimationFrame(loop) + const dt = Math.min(0.05, (t - this.lastFrame) / 1000) + this.lastFrame = t + this.update(dt) + } + this.rafId = requestAnimationFrame(loop) + } + + private buildBombMesh(): THREE.Group { + const g = new THREE.Group() + const body = new THREE.Mesh( + new THREE.BoxGeometry(0.42, 0.16, 0.3), + new THREE.MeshStandardMaterial({ color: 0x8a7455, roughness: 0.8 }), + ) + body.position.y = 0.08 + body.castShadow = true + const panel = new THREE.Mesh( + new THREE.BoxGeometry(0.2, 0.04, 0.12), + new THREE.MeshStandardMaterial({ color: 0x1a1f18 }), + ) + panel.position.set(0, 0.18, 0) + const led = new THREE.Mesh( + new THREE.BoxGeometry(0.05, 0.03, 0.05), + new THREE.MeshBasicMaterial({ color: 0xff2222 }), + ) + led.position.set(0.12, 0.19, 0) + led.name = 'led' + g.add(body, panel, led) + return g + } + + // ---------------------------------------------------------------- lifecycle + + start(team: Team, auto = false) { + this.audio.resume() + this.playerTeam = team + this.autoMode = auto + this.ctScore = 0 + this.tScore = 0 + this.matchWinner = null + + for (const s of this.soldiers) s.rig.dispose(this.scene) + this.soldiers = [] + let id = 0 + for (const t of ['CT', 'T'] as Team[]) { + const names = t === 'CT' ? CT_NAMES : T_NAMES + for (let i = 0; i < 5; i++) { + const isPlayer = !auto && t === team && i === 0 + const s = new Soldier(id, isPlayer ? '你' : names[i], t) + s.isPlayer = isPlayer + if (!isPlayer) s.brain = new AIBrain(s) + if (isPlayer) this.playerId = id + this.scene.add(s.rig.root) + this.soldiers.push(s) + id++ + } + } + if (auto) this.playerId = 0 + this.controlledId = this.playerId + this.viewedId = this.playerId + this.startRound(1) + } + + toMenu() { + this.phase = 'menu' + for (const s of this.soldiers) s.rig.dispose(this.scene) + this.soldiers = [] + this.bombMesh.visible = false + document.exitPointerLock?.() + this.syncHud() + } + + restartMatch() { + this.start(this.playerTeam, this.autoMode) + } + + requestLock() { + this.renderer.domElement.requestPointerLock() + } + + dispose() { + this.disposed = true + cancelAnimationFrame(this.rafId) + this.unbindEvents() + this.renderer.dispose() + this.renderer.domElement.remove() + } + + // ------------------------------------------------------------------ rounds + + private startRound(n: number) { + this.round = n + this.phase = 'freeze' + this.phaseEndsAt = this.time + FREEZE_TIME + this.winner = null + this.winReason = '' + this.bomb = { carrierId: null, droppedPos: null, plantedPos: null, plantedAt: 0, exploded: false } + this.bombMesh.visible = false + this.controlledId = this.playerId + this.viewedId = this.playerId + + const pistol = n === 1 + // restore bots that were taken over in a previous round + for (const s of this.soldiers) { + if (s.id !== this.playerId) { + s.isPlayer = false + s.name = s.originalName + if (!s.brain) s.brain = new AIBrain(s) + } else { + s.isPlayer = !this.autoMode + } + } + const spawnIdx: Record = { CT: 0, T: 0 } + // one AI per team may carry an AWP on gun rounds + const awpBot: Record = { CT: -1, T: -1 } + if (!pistol) { + for (const t of ['CT', 'T'] as Team[]) { + if (Math.random() < 0.45) { + const bots = this.soldiers.filter(s => s.team === t && !s.isPlayer) + awpBot[t] = bots[Math.floor(Math.random() * bots.length)].id + } + } + } + + for (const s of this.soldiers) { + const spawn = this.map.spawns[s.team][spawnIdx[s.team]++ % 5] + s.respawn(spawn.pos.clone(), spawn.yaw) + const secondary: WeaponId = pistol ? (s.team === 'T' ? 'glock' : 'usp') : 'deagle' + let primary: WeaponId | null = null + if (!pistol) { + primary = s.team === 'T' ? 'ak47' : 'm4a4' + if (s.id === awpBot[s.team]) primary = 'awp' + if (s.id === this.playerId && !this.autoMode && this.playerLoadout === 'awp') primary = 'awp' + } + s.equip(primary, secondary, pistol ? 0 : 100) + s.brain?.reset() + } + + // hand C4 to a random T + const ts = this.soldiers.filter(s => s.team === 'T') + const carrier = ts[Math.floor(Math.random() * ts.length)] + carrier.hasBomb = true + carrier.rig.setBomb(true) + this.bomb.carrierId = carrier.id + + this.viewmodel.setWeapon(this.controlled?.weapon.def.id ?? 'knife', false) + this.audio.roundStart() + if (this.autoMode) console.log(`[round ${n}] start — CT ${this.ctScore} : ${this.tScore} T`) + this.syncHud() + } + + private goLive() { + this.phase = 'live' + this.roundEndsAt = this.time + ROUND_TIME + this.assignMissions() + } + + private assignMissions() { + const site: 'A' | 'B' = Math.random() < 0.55 ? 'A' : 'B' + const routes = this.map.routes[site] + const sitePos = this.map.anchors[site === 'A' ? 'aSite' : 'bSite'] + + const ts = this.soldiers.filter(s => s.team === 'T' && s.brain) + const carrierRoute = Math.floor(Math.random() * routes.length) + ts.forEach((s, i) => { + const route = routes[(carrierRoute + (i % 2 === 0 ? 0 : 1)) % routes.length] + const isCarrier = s.hasBomb + const final = isCarrier ? this.map.randomSiteSpot(site) : this.map.randomSpotNear(sitePos, 9) + s.brain!.setMission(route, final, this.map.anchors.ctSpawn, isCarrier ? 'plant' : 'none') + }) + + const cts = this.soldiers.filter(s => s.team === 'CT' && s.brain) + const spots = [ + ...this.map.holdSpots.A.slice(0, 2).map(h => ({ ...h })), + ...this.map.holdSpots.B.slice(0, 2).map(h => ({ ...h })), + ...this.map.holdSpots.mid.slice(0, 1).map(h => ({ ...h })), + ] + cts.forEach((s, i) => { + const spot = spots[i % spots.length] + s.brain!.setMission([], spot.pos, spot.look, 'none') + }) + } + + private winRound(team: Team, reason: string) { + if (this.phase !== 'live') return + this.phase = 'over' + this.phaseEndsAt = this.time + ROUND_END_TIME + this.winner = team + this.winReason = reason + if (team === 'CT') this.ctScore++ + else this.tScore++ + this.audio.roundEnd(this.autoMode ? true : team === this.playerTeam) + if (this.autoMode) console.log(`[round ${this.round}] winner=${team} reason=${reason} — CT ${this.ctScore} : ${this.tScore} T`) + this.syncHud() + } + + private checkElimination() { + if (this.phase !== 'live') return + const ctAlive = this.soldiers.some(s => s.team === 'CT' && s.alive) + const tAlive = this.soldiers.some(s => s.team === 'T' && s.alive) + if (!ctAlive) this.winRound('T', '歼灭敌方') + else if (!tAlive && !this.bomb.plantedPos) this.winRound('CT', '歼灭敌方') + // T 全灭但已下包 → 等待 C4 结算 + } + + // ------------------------------------------------------------------- input + + private onKeyDown = (e: KeyboardEvent) => { + if (e.repeat) return + this.keys.add(e.code) + if (e.code === 'Space') { this.jumpQueued = true; e.preventDefault() } + if (e.code === 'Digit1') this.slotQueued = 'primary' + if (e.code === 'Digit2') this.slotQueued = 'secondary' + if (e.code === 'Digit3') this.slotQueued = 'knife' + if (e.code === 'KeyR') { /* handled per tick via keys */ } + if (e.code === 'KeyB' && this.phase === 'freeze' && this.round > 1) { + this.playerLoadout = this.playerLoadout === 'rifle' ? 'awp' : 'rifle' + const p = this.soldiers[this.playerId] + if (p && !this.autoMode) { + const primary: WeaponId = this.playerLoadout === 'awp' ? 'awp' : (p.team === 'T' ? 'ak47' : 'm4a4') + p.equip(primary, 'deagle', p.armor) + this.viewmodel.setWeapon(p.weapon.def.id) + } + this.syncHud() + } + // spectate controls + const me = this.soldiers[this.controlledId] + if (me && !me.alive && this.phase === 'live') { + if (e.code === 'ArrowLeft') this.cycleSpectate(-1) + if (e.code === 'ArrowRight') this.cycleSpectate(1) + if (e.code === 'KeyE' || e.code === 'Enter') this.takeOver() + } + } + + private onKeyUp = (e: KeyboardEvent) => { + this.keys.delete(e.code) + } + + private onMouseMove = (e: MouseEvent) => { + if (!this.pointerLocked) return + const s = this.controlled + if (!s || !s.alive || !s.isPlayer) return + const sens = 0.0022 * (s.scoped ? 0.35 : 1) + s.yaw -= e.movementX * sens + s.pitch = THREE.MathUtils.clamp(s.pitch - e.movementY * sens, -1.55, 1.55) + } + + private onMouseDown = (e: MouseEvent) => { + if (this.phase === 'menu' || this.phase === 'matchover') return + if (!this.pointerLocked) return + const me = this.soldiers[this.controlledId] + if (me && !me.alive && this.phase === 'live') { + if (e.button === 0) this.cycleSpectate(1) + return + } + if (e.button === 0) { this.mouseFire = true; this.fireTapQueued = true } + if (e.button === 2) this.altTapQueued = true + } + + private onMouseUp = (e: MouseEvent) => { + if (e.button === 0) this.mouseFire = false + } + + private onLockChange = () => { + this.pointerLocked = document.pointerLockElement === this.renderer.domElement + if (!this.pointerLocked) this.mouseFire = false + this.syncHud() + } + + private onContext = (e: Event) => e.preventDefault() + private onResize = () => this.resize() + + private bindEvents() { + window.addEventListener('keydown', this.onKeyDown) + window.addEventListener('keyup', this.onKeyUp) + window.addEventListener('mousemove', this.onMouseMove) + window.addEventListener('mousedown', this.onMouseDown) + window.addEventListener('mouseup', this.onMouseUp) + window.addEventListener('resize', this.onResize) + document.addEventListener('pointerlockchange', this.onLockChange) + this.renderer.domElement.addEventListener('contextmenu', this.onContext) + } + + private unbindEvents() { + window.removeEventListener('keydown', this.onKeyDown) + window.removeEventListener('keyup', this.onKeyUp) + window.removeEventListener('mousemove', this.onMouseMove) + window.removeEventListener('mousedown', this.onMouseDown) + window.removeEventListener('mouseup', this.onMouseUp) + window.removeEventListener('resize', this.onResize) + document.removeEventListener('pointerlockchange', this.onLockChange) + this.renderer.domElement.removeEventListener('contextmenu', this.onContext) + } + + private resize() { + const w = this.container.clientWidth || window.innerWidth + const h = this.container.clientHeight || window.innerHeight + this.renderer.setSize(w, h) + this.camera.aspect = w / h + this.camera.updateProjectionMatrix() + } + + get controlled(): Soldier | null { + return this.soldiers[this.controlledId] ?? null + } + + get viewed(): Soldier | null { + return this.soldiers[this.viewedId] ?? null + } + + private cycleSpectate(dir: number) { + const mates = this.soldiers.filter(s => s.team === this.playerTeam && s.alive) + if (mates.length === 0) return + const curIdx = mates.findIndex(s => s.id === this.viewedId) + const next = mates[(curIdx + dir + mates.length) % mates.length] + this.viewedId = next.id + this.viewmodel.setWeapon(next.weapon.def.id, false) + this.syncHud() + } + + private takeOver() { + let target = this.soldiers[this.viewedId] + // still on the death cam (or viewing a corpse): jump to a live teammate first + if (!target || !target.alive || target.id === this.controlledId) { + this.cycleSpectate(1) + target = this.soldiers[this.viewedId] + } + if (!target || !target.alive || target.team !== this.playerTeam || target.isPlayer) return + target.brain = null + target.isPlayer = true + target.name = `你(${target.originalName})` + this.controlledId = target.id + this.viewmodel.setWeapon(target.weapon.def.id, false) + this.syncHud() + } + + // ----------------------------------------------------------------- updates + + private update(dt: number) { + this.time += dt + + if (this.phase === 'menu') { + const t = this.time * 0.06 + this.camera.position.set(Math.cos(t) * 75, 52, Math.sin(t) * 75) + this.camera.lookAt(0, 0, 0) + this.viewmodel.group.visible = false + this.effects.update(dt) + this.renderer.render(this.scene, this.camera) + if (this.time > this.hudSyncAt) { this.hudSyncAt = this.time + 0.25; this.syncHud() } + return + } + + // phase transitions + if (this.phase === 'freeze' && this.time >= this.phaseEndsAt) this.goLive() + if (this.phase === 'over' && this.time >= this.phaseEndsAt) { + if (this.ctScore >= WIN_SCORE || this.tScore >= WIN_SCORE) { + this.phase = 'matchover' + this.matchWinner = this.ctScore >= WIN_SCORE ? 'CT' : 'T' + document.exitPointerLock?.() + } else { + this.startRound(this.round + 1) + } + this.syncHud() + } + if (this.phase === 'live' && !this.bomb.plantedPos && this.time >= this.roundEndsAt) { + this.winRound('CT', '时间耗尽') + } + + const canAct = this.phase === 'live' + + // player input → control + const me = this.controlled + if (me && me.isPlayer && me.alive) { + const c = me.control + c.moveX = (this.keys.has('KeyD') ? 1 : 0) - (this.keys.has('KeyA') ? 1 : 0) + c.moveZ = (this.keys.has('KeyW') ? 1 : 0) - (this.keys.has('KeyS') ? 1 : 0) + c.walk = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight') + // edge-triggered inputs stay latched until a fixed step consumes them — + // on high-refresh displays a frame may run zero fixed steps, and a plain + // per-frame assignment would silently eat clicks + c.jump = c.jump || this.jumpQueued + c.fire = this.mouseFire && this.pointerLocked + c.fireTap = c.fireTap || this.fireTapQueued + c.altTap = c.altTap || this.altTapQueued + c.reload = this.keys.has('KeyR') + c.use = this.keys.has('KeyE') + c.slot = this.slotQueued ?? c.slot + if (!canAct) { + c.moveX = 0; c.moveZ = 0; c.jump = false + c.fire = false; c.fireTap = false; c.use = false + } + } + this.jumpQueued = false + this.fireTapQueued = false + this.altTapQueued = false + this.slotQueued = null + + // AI + if (canAct) { + const aiWorld: AIWorld = { + map: this.map, nav: this.nav, physics: this.physics, + soldiers: this.soldiers, time: this.time, + bombPlantedPos: this.bomb.plantedPos, + bombDroppedPos: this.bomb.droppedPos, + } + for (const s of this.soldiers) s.brain?.update(dt, aiWorld) + } else { + for (const s of this.soldiers) { + if (s.brain) { s.control.moveX = 0; s.control.moveZ = 0; s.control.fire = false; s.control.fireTap = false; s.control.use = false } + } + } + + // fixed-step simulation + this.accumulator = Math.min(this.accumulator + dt, 0.1) + while (this.accumulator >= FIXED_DT) { + this.accumulator -= FIXED_DT + this.fixedStep(FIXED_DT) + } + + // doors + const alivePos = this.soldiers.filter(s => s.alive).map(s => s.pos) + for (const d of this.map.doors) { + const ev = d.update(dt, alivePos) + if (ev) this.audio.doorSlide(d.center) + } + + // bomb & round timing + this.updateBomb() + if (canAct) this.updateSpectate() + + // team vision stamps (minimap) + if (this.time >= this.seenScanAt) { + this.seenScanAt = this.time + 0.15 + this.scanVision() + } + + // visuals + for (const s of this.soldiers) { + const speed = Math.hypot(s.vel.x, s.vel.z) + s.rig.update(dt, s.pos, s.yaw, s.pitch, speed, s.onGround) + s.rig.root.visible = s.id !== this.viewedId && s.rig.root.visible + if (s.id !== this.viewedId && s.alive) s.rig.root.visible = true + if (!s.alive) { /* keep rig's own fade logic */ } + } + const v = this.viewed + if (v) { + // camera at viewed soldier's eye + const shake = this.effects.shake + this.camera.position.copy(v.eyePos) + this.camera.rotation.y = v.yaw + v.recoilYaw + (Math.random() - 0.5) * shake * 0.05 + this.camera.rotation.x = THREE.MathUtils.clamp(v.pitch + v.recoilPitch, -1.55, 1.55) + (Math.random() - 0.5) * shake * 0.05 + this.camera.rotation.z = (Math.random() - 0.5) * shake * 0.03 + if (!v.alive) { + this.camera.position.y = Math.max(0.4, this.camera.position.y - 1.1) + this.camera.rotation.z += 0.35 + } + const targetFov = v.scoped && v.weapon.def.scopeFov ? v.weapon.def.scopeFov : 75 + if (Math.abs(this.camera.fov - targetFov) > 0.5) { + this.camera.fov += (targetFov - this.camera.fov) * Math.min(1, 18 * dt) + this.camera.updateProjectionMatrix() + } + const speed = Math.hypot(v.vel.x, v.vel.z) + this.viewmodel.update(dt, speed, v.onGround, v.scoped || !v.alive) + this.audio.setListener(v.eyePos, v.rightDir()) + } + + this.effects.update(dt) + this.renderer.render(this.scene, this.camera) + + if (this.time >= this.hudSyncAt) { + this.hudSyncAt = this.time + 0.05 + this.syncHud() + } + } + + private fixedStep(dt: number) { + for (const s of this.soldiers) { + if (!s.alive) continue + const c = s.control + + // weapon switching + if (c.slot) { + if (s.trySwitch(c.slot)) { + if (s.id === this.viewedId) { this.viewmodel.setWeapon(s.weapon.def.id); this.audio.drawWeapon() } + } + c.slot = null + } + + // weapon state + const wpn = s.weapon + wpn.update(dt) + if (c.reload && wpn.startReload()) { + s.scoped = false + this.audio.reload(s.id === this.viewedId ? null : s.pos, wpn.def.reloadTime) + if (s.id === this.viewedId) this.viewmodel.onReload(wpn.def.reloadTime) + } + + // scope toggle + if (c.altTap && wpn.def.scopeFov && !wpn.reloading) { + s.scoped = !s.scoped + if (s.id === this.viewedId) this.audio.scope(s.scoped) + } + if (!wpn.def.scopeFov) s.scoped = false + + // channels (plant / defuse) + this.updateChannel(s, dt) + + // movement (channeling locks you in place) + const chanLock = s.channel !== null + let speed = RUN_SPEED * wpn.def.moveMult + if (c.walk) speed *= 0.52 + if (s.scoped) speed *= 0.55 + const fwd = new THREE.Vector3(-Math.sin(s.yaw), 0, -Math.cos(s.yaw)) + const right = s.rightDir() + const wish = new THREE.Vector3() + if (!chanLock) { + wish.addScaledVector(fwd, c.moveZ).addScaledVector(right, c.moveX) + if (wish.lengthSq() > 1) wish.normalize() + } + this.physics.moveBody(s, wish, speed, c.jump && !chanLock, dt) + c.jump = false + + // recoil recovery + s.recoilPitch *= Math.exp(-6 * dt) + s.recoilYaw *= Math.exp(-6 * dt) + + // footsteps + const hSpeed = Math.hypot(s.vel.x, s.vel.z) + if (s.onGround && hSpeed > 1) { + s.stepAcc += hSpeed * dt + if (s.stepAcc > 2.55) { + s.stepAcc = 0 + const vol = c.walk ? 0.25 : 1 + this.audio.footstep(s.id === this.viewedId ? null : s.pos, s.id === this.viewedId ? vol * 0.4 : vol) + } + } + + // firing (taps are buffered so clicks during weapon cooldown aren't dropped) + if (c.fireTap) s.tapBuffer = 0.22 + if (!chanLock && this.phase === 'live') { + const wantsFire = wpn.def.auto ? (c.fire || s.tapBuffer > 0) : s.tapBuffer > 0 + if (wantsFire && wpn.canFire() && s.switchCooldown <= 0) { + this.performShot(s) + s.tapBuffer = 0 + } + } + if (s.tapBuffer > 0) s.tapBuffer -= dt + c.fireTap = false + c.altTap = false + if (s.switchCooldown > 0) s.switchCooldown -= dt + + // bomb pickup + if (s.team === 'T' && this.bomb.droppedPos && s.alive) { + if (s.pos.distanceTo(this.bomb.droppedPos) < 1.3) { + this.bomb.droppedPos = null + this.bomb.carrierId = s.id + s.hasBomb = true + s.rig.setBomb(true) + this.bombMesh.visible = false + this.reassignPlantDuty(s) + } + } + } + + // soldier-vs-soldier separation + const alive = this.soldiers.filter(s => s.alive) + for (let i = 0; i < alive.length; i++) { + for (let j = i + 1; j < alive.length; j++) { + const a = alive[i], b = alive[j] + const dx = b.pos.x - a.pos.x, dz = b.pos.z - a.pos.z + const d2 = dx * dx + dz * dz + if (d2 > 0.0001 && d2 < 0.72 * 0.72) { + const d = Math.sqrt(d2) + const push = (0.72 - d) / 2 + const nx = dx / d, nz = dz / d + if (this.physics.circleFree(a.pos.x - nx * push, a.pos.z - nz * push, a.radius, a.pos.y)) { + a.pos.x -= nx * push; a.pos.z -= nz * push + } + if (this.physics.circleFree(b.pos.x + nx * push, b.pos.z + nz * push, b.radius, b.pos.y)) { + b.pos.x += nx * push; b.pos.z += nz * push + } + } + } + } + } + + private updateChannel(s: Soldier, dt: number) { + const wantPlant = s.control.use && s.team === 'T' && s.hasBomb && !this.bomb.plantedPos + && s.onGround && this.map.plantSiteAt(s.pos.x, s.pos.z) !== null && this.phase === 'live' + const wantDefuse = s.control.use && s.team === 'CT' && this.bomb.plantedPos !== null && this.phase === 'live' + && Math.hypot(this.bomb.plantedPos.x - s.pos.x, this.bomb.plantedPos.z - s.pos.z) < 1.5 + + if (wantPlant) { + if (!s.channel || s.channel.kind !== 'plant') s.channel = { kind: 'plant', t: 0, total: PLANT_TIME } + s.channel.t += dt + if (Math.floor(s.channel.t * 4) !== Math.floor((s.channel.t - dt) * 4)) this.audio.plantTick(s.id === this.viewedId ? null : s.pos) + if (s.channel.t >= s.channel.total) { + s.channel = null + this.plantBomb(s) + } + } else if (wantDefuse) { + if (!s.channel || s.channel.kind !== 'defuse') s.channel = { kind: 'defuse', t: 0, total: DEFUSE_TIME } + s.channel.t += dt + if (this.time >= this.nextDefTickAt) { + this.nextDefTickAt = this.time + 0.5 + this.audio.defuseTick(s.id === this.viewedId ? null : s.pos) + } + if (s.channel.t >= s.channel.total) { + s.channel = null + this.audio.bombDefused() + this.winRound('CT', '炸弹已拆除') + } + } else { + s.channel = null + } + } + + // ------------------------------------------------------------------ combat + + private performShot(shooter: Soldier) { + const wpn = shooter.weapon + const def = wpn.def + + if (def.id === 'knife') { + wpn.onFire() + const isViewed = shooter.id === this.viewedId + this.audio.shot('knife', isViewed ? null : shooter.pos) + if (isViewed) this.viewmodel.onFire(0) + const dir = shooter.viewDir() + const hit = this.physics.raycast(shooter.eyePos, dir, def.range ?? 2.2, this.soldiers, shooter.id, shooter.team) + if (hit?.kind === 'soldier' && hit.soldier) { + this.audio.knifeHit(isViewed ? null : hit.point) + this.applyDamage(hit.soldier as Soldier, def.damage, shooter, hit.part ?? 'chest', def) + this.effects.bloodBurst(hit.point) + } else if (hit) { + // scraped a wall / crate + this.audio.knifeHit(isViewed ? null : hit.point) + this.effects.impact(hit.point) + } + return + } + + wpn.onFire() + + // spread + let spread = shooter.scoped && def.spreadScoped !== undefined ? def.spreadScoped : def.spreadBase + spread += wpn.bloom - def.spreadPerShot // bloom before this shot's own bump + const hSpeed = Math.hypot(shooter.vel.x, shooter.vel.z) + spread += (hSpeed / RUN_SPEED) * def.spreadMove + if (!shooter.onGround) spread += 0.05 + + const dir = shooter.viewDir() + const right = shooter.rightDir() + const up = new THREE.Vector3().crossVectors(right, dir).normalize() + const a = Math.random() * Math.PI * 2 + const r = Math.sqrt(Math.random()) * spread + dir.addScaledVector(right, Math.cos(a) * r).addScaledVector(up, Math.sin(a) * r).normalize() + + // recoil + shooter.recoilPitch += def.kickPitch * (0.85 + Math.random() * 0.3) + shooter.recoilYaw += (Math.random() - 0.5) * def.kickYaw * 2 + + const isViewed = shooter.id === this.viewedId + this.audio.shot(def.sound, isViewed ? null : shooter.pos) + this.notifyHeard(shooter) + + // muzzle position for tracer + const eye = shooter.eyePos + const muzzle = eye.clone().addScaledVector(dir, 0.7).addScaledVector(right, isViewed ? 0.13 : 0.2).addScaledVector(up, -0.1) + this.effects.muzzle(muzzle) + + if (isViewed) { + this.viewmodel.onFire(def.kickPitch) + } + + const hit = this.physics.raycast(eye, dir, 300, this.soldiers, shooter.id, shooter.team) + const end = hit ? hit.point : eye.clone().addScaledVector(dir, 300) + if (def.id !== 'usp') this.effects.tracer(muzzle, end) + + if (hit) { + if (hit.kind === 'soldier' && hit.soldier) { + this.effects.bloodBurst(hit.point) + this.applyDamage(hit.soldier as Soldier, def.damage, shooter, hit.part ?? 'chest', def) + } else { + this.effects.impact(hit.point) + } + } + } + + private notifyHeard(shooter: Soldier) { + for (const s of this.soldiers) { + if (!s.alive || s.team === shooter.team || !s.brain) continue + if (s.pos.distanceTo(shooter.pos) < 38) s.brain.heard(shooter.pos, this.time) + } + } + + private applyDamage(target: Soldier, baseDmg: number, attacker: Soldier, part: BodyPart, def: { armorPen: number; name: string }) { + if (!target.alive) return + let dmg = baseDmg * PART_MULT[part] + if (target.armor > 0 && part !== 'leg') { + const kept = dmg * def.armorPen + const absorbed = (dmg - kept) * 0.5 + target.armor = Math.max(0, target.armor - absorbed) + dmg = kept + } + dmg = Math.round(dmg) + target.hp -= dmg + + const headshot = part === 'head' + if (attacker.id === this.controlledId) { + this.hud.state.hitmarkerAt = this.time + this.hud.state.hitmarkerHead = headshot + this.audio.hit(headshot) + } + if (target.id === this.controlledId) { + this.hud.state.damageAt = this.time + this.audio.damage() + } + + if (target.hp <= 0) { + this.kill(target, attacker, def.name, headshot) + } + } + + private kill(victim: Soldier, attacker: Soldier, weaponName: string, headshot: boolean) { + victim.alive = false + victim.hp = 0 + victim.deaths++ + attacker.kills++ + victim.channel = null + victim.scoped = false + victim.rig.die() + this.audio.death(victim.pos) + if (attacker.id === this.controlledId) this.audio.kill() + + // drop bomb + if (victim.hasBomb) { + victim.hasBomb = false + victim.rig.setBomb(false) + this.bomb.carrierId = null + this.bomb.droppedPos = victim.pos.clone() + this.bombMesh.visible = true + this.bombMesh.position.copy(victim.pos) + this.bombMesh.rotation.set(0, Math.random() * 6, 0) + // send nearest T to pick it up + const ts = this.soldiers.filter(s => s.team === 'T' && s.alive && s.brain) + ts.sort((a, b) => a.pos.distanceTo(victim.pos) - b.pos.distanceTo(victim.pos)) + if (ts[0]) ts[0].brain!.setMission([], victim.pos.clone(), null, 'pickup') + } + + this.hud.state.killfeed.push({ + id: this.killfeedId++, + attacker: attacker.name, attackerTeam: attacker.team, + victim: victim.name, victimTeam: victim.team, + weapon: weaponName, headshot, t: this.time, + }) + if (this.hud.state.killfeed.length > 6) this.hud.state.killfeed.shift() + if (this.autoMode) console.log(`[kill] ${attacker.name} → ${victim.name} (${weaponName}${headshot ? ', HS' : ''})`) + + // a teammate of the victim may investigate + const mates = this.soldiers.filter(s => s.team === victim.team && s.alive && s.brain && !s.brain.target) + if (mates.length && Math.random() < 0.5) { + const m = mates[Math.floor(Math.random() * mates.length)] + m.brain!.heard(victim.pos, this.time) + } + + if (victim.id === this.controlledId) this.deathAt = this.time + this.checkElimination() + this.syncHud() + } + + // -------------------------------------------------------------------- bomb + + private plantBomb(planter: Soldier) { + planter.hasBomb = false + planter.rig.setBomb(false) + this.bomb.carrierId = null + this.bomb.plantedPos = planter.pos.clone() + this.bomb.plantedAt = this.time + this.bombMesh.visible = true + this.bombMesh.position.copy(this.bomb.plantedPos) + this.bombMesh.rotation.set(0, Math.random() * 6, 0) + this.nextBeepAt = this.time + this.audio.bombPlanted() + if (this.autoMode) console.log(`[bomb] planted at ${this.map.plantSiteAt(this.bomb.plantedPos.x, this.bomb.plantedPos.z)}`) + + // T defend spots, CT retake + defuse duty + for (const s of this.soldiers) { + if (!s.alive || !s.brain) continue + if (s.team === 'T') { + s.brain.setMission([], this.map.randomSpotNear(this.bomb.plantedPos, 9), this.bomb.plantedPos, 'none') + } + } + this.assignDefuser() + this.syncHud() + } + + private assignDefuser() { + if (!this.bomb.plantedPos) return + const cts = this.soldiers.filter(s => s.team === 'CT' && s.alive && s.brain) + cts.sort((a, b) => a.pos.distanceTo(this.bomb.plantedPos!) - b.pos.distanceTo(this.bomb.plantedPos!)) + cts.forEach((s, i) => { + if (i === 0) s.brain!.setMission([], this.bomb.plantedPos!.clone(), null, 'defuse') + else s.brain!.setMission([], this.map.randomSpotNear(this.bomb.plantedPos!, 8), this.bomb.plantedPos, 'none') + }) + } + + private reassignPlantDuty(carrier: Soldier) { + if (!carrier.brain) return + const site: 'A' | 'B' = Math.random() < 0.5 ? 'A' : 'B' + const near = this.map.plantSiteAt(carrier.pos.x, carrier.pos.z) + const chosen = near ?? site + carrier.brain.setMission([], this.map.randomSiteSpot(chosen), null, 'plant') + } + + private updateBomb() { + if (this.bomb.plantedPos && !this.bomb.exploded) { + const led = this.bombMesh.getObjectByName('led') as THREE.Mesh | undefined + const remaining = this.bomb.plantedAt + BOMB_FUSE - this.time + if (led) { + const blink = Math.sin(this.time * (remaining < 8 ? 30 : 8)) > 0 + ;(led.material as THREE.MeshBasicMaterial).color.setHex(blink ? 0xff2222 : 0x441111) + } + if (this.time >= this.nextBeepAt && this.phase === 'live') { + const frac = Math.max(0, remaining / BOMB_FUSE) + this.nextBeepAt = this.time + 0.12 + 0.9 * Math.pow(frac, 1.4) + this.audio.bombBeep(this.bomb.plantedPos, remaining < 10) + } + if (remaining <= 0 && this.phase === 'live') { + this.explodeBomb() + } + // keep a defuser assigned if the previous one died + if (this.phase === 'live' && !this.soldiers.some(s => s.team === 'CT' && s.alive && s.brain?.mission?.duty === 'defuse')) { + this.assignDefuser() + } + } + } + + private explodeBomb() { + if (!this.bomb.plantedPos) return + this.bomb.exploded = true + this.bombMesh.visible = false + this.effects.explosion(this.bomb.plantedPos) + this.audio.explosion(this.bomb.plantedPos) + for (const s of this.soldiers) { + if (!s.alive) continue + const d = s.pos.distanceTo(this.bomb.plantedPos) + if (d < 20) { + let dmg = 550 * (1 - d / 20) + if (!this.physics.hasLOS(this.bomb.plantedPos.clone().add(new THREE.Vector3(0, 1, 0)), s.eyePos)) dmg *= 0.4 + s.hp -= dmg + if (s.hp <= 0) { + s.alive = false + s.deaths++ + s.rig.die() + if (s.id === this.controlledId) this.deathAt = this.time + } + } + } + this.winRound('T', 'C4 爆炸') + } + + // ---------------------------------------------------------------- spectate + + private updateSpectate() { + const me = this.soldiers[this.controlledId] + if (!me || me.alive) return + if (this.viewedId === this.controlledId && this.time - this.deathAt > 0.8) { + // auto-switch to an alive teammate + const mate = this.soldiers.find(s => s.team === this.playerTeam && s.alive) + if (mate) { + this.viewedId = mate.id + this.viewmodel.setWeapon(mate.weapon.def.id, false) + } + } + const v = this.viewed + if (v && !v.alive && v.id !== this.controlledId) { + const mate = this.soldiers.find(s => s.team === this.playerTeam && s.alive) + if (mate) { + this.viewedId = mate.id + this.viewmodel.setWeapon(mate.weapon.def.id, false) + } else { + this.viewedId = this.controlledId + } + } + } + + private scanVision() { + for (const team of ['CT', 'T'] as Team[]) { + const observers = this.soldiers.filter(s => s.team === team && s.alive) + for (const enemy of this.soldiers) { + if (enemy.team === team || !enemy.alive) continue + for (const o of observers) { + const dx = enemy.pos.x - o.pos.x, dz = enemy.pos.z - o.pos.z + const dist = Math.hypot(dx, dz) + if (dist > 70) continue + const dirYaw = Math.atan2(-dx, -dz) + let dyaw = (dirYaw - o.yaw) % (Math.PI * 2) + if (dyaw > Math.PI) dyaw -= Math.PI * 2 + if (dyaw < -Math.PI) dyaw += Math.PI * 2 + if (Math.abs(dyaw) > 1.1 && dist > 3) continue + if (!this.physics.hasLOS(o.eyePos, new THREE.Vector3(enemy.pos.x, enemy.pos.y + 1.3, enemy.pos.z))) continue + enemy.lastSeenBy[team] = this.time + break + } + } + } + } + + // ------------------------------------------------------------------- hud + + private syncHud() { + const st = this.hud.state + st.phase = this.phase + st.locked = this.pointerLocked + st.auto = this.autoMode + st.round = this.round + st.pistolRound = this.round === 1 + st.ctScore = this.ctScore + st.tScore = this.tScore + st.playerTeam = this.playerTeam + st.loadout = this.playerLoadout + st.winner = this.winner + st.winReason = this.winReason + st.matchWinner = this.matchWinner + st.freezeLeft = this.phase === 'freeze' ? Math.max(0, this.phaseEndsAt - this.time) : 0 + st.timeLeft = this.phase === 'live' ? Math.max(0, this.roundEndsAt - this.time) : ROUND_TIME + st.bombPlanted = this.bomb.plantedPos !== null && !this.bomb.exploded + st.bombTimeLeft = st.bombPlanted ? Math.max(0, this.bomb.plantedAt + BOMB_FUSE - this.time) : null + + const me = this.soldiers[this.controlledId] + const v = this.viewed ?? me + if (v) { + const wpn = v.weapon + st.me = { + name: v.name, team: v.team, hp: Math.max(0, Math.ceil(v.hp)), armor: Math.ceil(v.armor), + alive: v.alive, weaponName: wpn.def.name, weaponId: wpn.def.id, + mag: wpn.mag, reserve: wpn.reserve, reloading: wpn.reloading, + scoped: v.scoped, hasBomb: v.hasBomb, + spreadPx: 5 + (wpn.bloom + wpn.def.spreadBase) * 900 + Math.hypot(v.vel.x, v.vel.z) * 1.4, + } + st.channel = v.channel ? { + label: v.channel.kind === 'plant' ? '正在安放 C4…' : '正在拆除 C4…', + progress: v.channel.t / v.channel.total, + } : null + + // center hint + st.centerHint = null + if (v.alive && this.phase === 'live' && !v.channel) { + if (v.team === 'T' && v.hasBomb && this.map.plantSiteAt(v.pos.x, v.pos.z)) st.centerHint = '按住 E 安放 C4' + else if (v.team === 'CT' && this.bomb.plantedPos && Math.hypot(this.bomb.plantedPos.x - v.pos.x, this.bomb.plantedPos.z - v.pos.z) < 3) st.centerHint = '按住 E 拆除 C4' + } + } + st.spectating = me && v && v.id !== me.id + ? { name: v.name, isSelf: false } + : (me && !me.alive ? { name: me.name, isSelf: true } : null) + + // prune old killfeed + st.killfeed = st.killfeed.filter(k => this.time - k.t < 6.5) + this.hud.bump() + } + + // Minimap data access (imperative canvas drawing from React) + minimapInfo() { + return { + map: this.map, + soldiers: this.soldiers, + viewedId: this.viewedId, + playerTeam: this.playerTeam, + time: this.time, + bomb: this.bomb, + } + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/map.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/map.ts new file mode 100644 index 0000000..d00216a --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/map.ts @@ -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() + crates: AABB[] = [] + doors: DoorObj[] = [] + group = new THREE.Group() + spawns: Record = { CT: [], T: [] } + anchors: Record = {} + 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) + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/nav.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/nav.ts new file mode 100644 index 0000000..9ab14f2 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/nav.ts @@ -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 } diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/physics.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/physics.ts new file mode 100644 index 0000000..cbfe584 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/physics.ts @@ -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 + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/soldier.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/soldier.ts new file mode 100644 index 0000000..ab3fd73 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/soldier.ts @@ -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> = {} + 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 = { 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 + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/store.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/store.ts new file mode 100644 index 0000000..2c56e47 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/store.ts @@ -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() + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/types.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/types.ts new file mode 100644 index 0000000..dda84f3 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/types.ts @@ -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 diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/viewmodel.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/viewmodel.ts new file mode 100644 index 0000000..b3c72c7 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/viewmodel.ts @@ -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() + 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) + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/weapons.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/weapons.ts new file mode 100644 index 0000000..d71e5fa --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/game/weapons.ts @@ -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 = { + 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 = { + 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 + } +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/main.tsx b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/main.tsx new file mode 100644 index 0000000..da1742b --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client' +import App from './App' +import './ui/hud.css' + +createRoot(document.getElementById('root')!).render() diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/HUD.tsx b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/HUD.tsx new file mode 100644 index 0000000..0f0fe15 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/HUD.tsx @@ -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(null) + const fgRef = useRef(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 ( +
+ + +
+ ) +} + +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
+ } + const gap = Math.min(40, st.me.spreadPx) + const len = 9 + return ( +
+
+
+
+
+
+
+ ) +} + +function Scope() { + return ( +
+
+
+
+
+ ) +} + +function TeamTag({ team }: { team: Team }) { + return {team} +} + +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 ( +
+

DUST II

+

5v5 炸弹模式 · 三维第一人称 · 纯代码生成

+
+ + +
+ +
+ WASD 移动空格 跳跃Shift 静步 + 鼠标左键 开火右键 AWP 开镜R 换弹 + 1/2/3 切枪E 安放 / 拆除B 购买阶段换 AWP +
+
+ ) + } + + const dmgFlash = now - st.damageAt < 0.4 + const hitAge = now - st.hitmarkerAt + + return ( +
+ {st.me.scoped && } + + + {hitAge < 0.25 && ( +
+ +
+ )} + {dmgFlash &&
} + + {/* top bar */} +
+
{st.ctScore}
+
+ {st.bombPlanted + ? 💣 {Math.ceil(st.bombTimeLeft ?? 0)} + : {st.phase === 'freeze' ? fmtTime(st.freezeLeft) : fmtTime(st.timeLeft)}} +
回合 {st.round}{st.pistolRound ? ' · 手枪局' : ''}
+
+
{st.tScore}
+
+ + + + {/* killfeed */} +
+ {st.killfeed.map(k => ( +
+ {k.attacker} + {k.weapon}{k.headshot ? ' ◎' : ''} + {k.victim} +
+ ))} +
+ + {/* bottom left: hp / armor */} + {st.me.alive && ( +
+
+ + + {st.me.hp} +
+
+
+ 🛡 + {st.me.armor} +
+
+ {st.me.hasBomb &&
携带 C4
} +
+ )} + + {/* bottom right: ammo */} + {st.me.alive && ( +
+
{st.me.weaponName}{st.me.reloading ? '(换弹中…)' : ''}
+ {st.me.weaponId !== 'knife' && ( +
+ {st.me.mag} + / + {st.me.reserve} +
+ )} +
+ )} + + {/* center messages */} +
+ {st.phase === 'freeze' && ( +
+
准备阶段 · {Math.ceil(st.freezeLeft)}s
+ {!st.pistolRound &&
按 B 切换主武器:当前 {st.loadout === 'awp' ? 'AWP' : '步枪'}
} + {st.pistolRound &&
手枪局 — {st.playerTeam === 'T' ? 'Glock-18' : 'USP-S'},无护甲
} +
+ )} + {st.channel && ( +
+
{st.channel.label}
+
+
+ )} + {st.centerHint && !st.channel &&
{st.centerHint}
} + {st.bombPlanted && st.phase === 'live' &&
C4 已安放
} + {st.phase === 'over' && st.winner && ( +
+
{st.winner} 获胜
+
{st.winReason}
+
+ )} +
+ + {/* spectate bar */} + {st.spectating && st.phase === 'live' && ( +
+ {st.spectating.isSelf + ? 你已阵亡 — 正在寻找存活队友… + : <> + 观战中:{st.spectating.name} + ←/→ 或 左键 切换 · E 接管该队友 + } +
+ )} + + {/* pause overlay (pointer unlocked mid-game) */} + {!st.locked && !st.auto && st.phase !== 'matchover' && ( +
engine.requestLock()}> +
+

已暂停 / 鼠标未锁定

+

点击任意处继续(锁定鼠标)

+ +
+
+ )} + + {st.phase === 'matchover' && st.matchWinner && ( +
+
+

比赛结束 — {st.matchWinner} {st.matchWinner === st.playerTeam ? '胜利 🏆' : '获胜'}

+

CT {st.ctScore} : {st.tScore} T

+ + +
+
+ )} +
+ ) +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/hud.css b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/hud.css new file mode 100644 index 0000000..c03f732 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/src/ui/hud.css @@ -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); } diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/tsconfig.json b/outputs/claude-fable-5/claude-code-high/cs-dust2/tsconfig.json new file mode 100644 index 0000000..b6800d3 --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/tsconfig.json @@ -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"] +} diff --git a/outputs/claude-fable-5/claude-code-high/cs-dust2/vite.config.ts b/outputs/claude-fable-5/claude-code-high/cs-dust2/vite.config.ts new file mode 100644 index 0000000..9d1138f --- /dev/null +++ b/outputs/claude-fable-5/claude-code-high/cs-dust2/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { port: 5173 }, +}) diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/.gitignore b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/README.md b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/README.md new file mode 100644 index 0000000..6d3a6b2 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/README.md @@ -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 +``` diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/bun.lock b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/bun.lock new file mode 100644 index 0000000..a207fe2 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/bun.lock @@ -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=="], + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/dust2-arena.html b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/dust2-arena.html new file mode 100644 index 0000000..326fcf2 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/dust2-arena.html @@ -0,0 +1,4168 @@ + + + + + + Dust II Arena + + + + +
+ + diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/index.html b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/index.html new file mode 100644 index 0000000..9588dd9 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/index.html @@ -0,0 +1,12 @@ + + + + + + Dust II Arena + + +
+ + + diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/package.json b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/package.json new file mode 100644 index 0000000..d99812c --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/package.json @@ -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" + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/App.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/App.tsx new file mode 100644 index 0000000..4cf0128 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/App.tsx @@ -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(null); + if (team === null) return ; + return ; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/GameCanvas.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/GameCanvas.tsx new file mode 100644 index 0000000..740fd67 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/GameCanvas.tsx @@ -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(null); + const miniRef = useRef(null); + const worldRef = useRef(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 ( +
+ + + {!locked && ( +
+
+
{started ? 'Paused' : 'Ready'}
+
Click to {started ? 'resume' : 'lock mouse and start'}
+
Esc releases the mouse
+
+
+ )} +
+ ); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Hud.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Hud.tsx new file mode 100644 index 0000000..16ea9b0 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Hud.tsx @@ -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) => ( + + ); + return ( +
+ {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 })} + +
+ ); +} + +export function Hud() { + const s = useHud(); + + return ( +
+ {/* top score bar */} +
+
+ T + {'●'.repeat(s.aliveT)}{'○'.repeat(Math.max(0, 5 - s.aliveT))} +
+
+ {s.scoreT} + {s.bombPlanted ? {fmt(s.bombTimer)} : fmt(s.roundTime)} + {s.scoreCT} +
+
+ {'○'.repeat(Math.max(0, 5 - s.aliveCT))}{'●'.repeat(s.aliveCT)} + CT +
+
+ + {/* killfeed */} +
+ {s.killfeed.map((k) => ( +
+ {k.attacker} + {WEAPONS[k.weapon]?.name ?? k.weapon}{k.headshot ? ' ×' : ''} + {k.victim} +
+ ))} +
+ + {/* center banner */} + {s.banner &&
{s.banner}
} + + {/* plant / defuse progress */} + {(s.planting || s.defusing) && ( +
+
{s.planting ? 'PLANTING' : 'DEFUSING'}
+
+
+
+
+ )} + + {/* scope overlay */} + {s.scoped && ( +
+
+
+
+
+ )} + + {/* crosshair */} + {!s.scoped && (s.alive || s.spectating) && } + + {/* dead: spectate bar, or eliminated when no teammate left */} + {!s.alive && s.spectating && ( +
+
+ SPECTATING {s.spectateName} +
+
+ [F] / Space take control  ·  Mouse1 / A / D switch player +
+
+ )} + {!s.alive && !s.spectating && ( +
+
ELIMINATED
+
No teammates left — round ending
+
+ )} + + {/* hint */} + {s.hint && s.alive &&
{s.hint}
} + + {/* bomb carrier tag */} + {s.hasBomb && s.alive &&
C4 — bring it to A or B
} + + {/* bottom-left: health / armor */} +
+
+ + + {s.health} +
+
+ {s.helmet ? 'A*' : 'A'} + {s.armor} +
+
+ + {/* bottom-right: ammo + weapons */} +
+
{s.weaponName}
+
+ {s.infiniteAmmo ? ( + ∞ + ) : ( + <> + + {s.reloading ? 'R' : s.mag} + + / {s.reserve} + + )} +
+
+ {s.loadout.map((w) => ( +
+ {w.slot === 'primary' ? '1' : w.slot === 'secondary' ? '2' : '3'} + {w.name} +
+ ))} +
+
+
+ ); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Menu.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Menu.tsx new file mode 100644 index 0000000..09269fa --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Menu.tsx @@ -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.T); + return ( +
+
+

DUST II ARENA

+

5v5 procedural first-person prototype — pick a side

+ +
+ + +
+ +
+
+ Move WASD · Jump Space · Walk Shift +
+
+ Fire Mouse1 · Scope Mouse2 (AWP) · Reload R +
+
+ Weapons 1 / 2 / 3 · wheel · Swap AWP B +
+
+ Plant / Defuse / Pickup hold E +
+
+ When dead Mouse1 / A / D switch view · F take control +
+
+ + +

Round 1 is a pistol round. Click the arena to lock the mouse.

+
+
+ ); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Minimap.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Minimap.tsx new file mode 100644 index 0000000..0e192ed --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/components/Minimap.tsx @@ -0,0 +1,10 @@ +import { RefObject } from 'react'; + +export function Minimap({ canvasRef }: { canvasRef: RefObject }) { + return ( +
+ } width={200} height={200} /> +
DUST II
+
+ ); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/ai.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/ai.ts new file mode 100644 index 0000000..f805d17 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/ai.ts @@ -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); + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/audio.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/audio.ts new file mode 100644 index 0000000..f769a4c --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/audio.ts @@ -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); + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/collision.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/collision.ts new file mode 100644 index 0000000..dad29dd --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/collision.ts @@ -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; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/combat.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/combat.ts new file mode 100644 index 0000000..6b2a741 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/combat.ts @@ -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; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/constants.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/constants.ts new file mode 100644 index 0000000..4cdc535 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/constants.ts @@ -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, +}; diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/dust2.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/dust2.ts new file mode 100644 index 0000000..b1df4f3 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/dust2.ts @@ -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 }; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/effects.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/effects.ts new file mode 100644 index 0000000..75bdbc3 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/effects.ts @@ -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; + } + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/characterModel.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/characterModel.ts new file mode 100644 index 0000000..9e3b497 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/characterModel.ts @@ -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; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/weaponModels.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/weaponModels.ts new file mode 100644 index 0000000..0d13219 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/entities/weaponModels.ts @@ -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 +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/grid.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/grid.ts new file mode 100644 index 0000000..6ddc148 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/grid.ts @@ -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 = { + 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'); + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/input.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/input.ts new file mode 100644 index 0000000..0a28391 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/input.ts @@ -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(); + private justPressed = new Set(); + 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 | 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 = []; + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/mathUtils.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/mathUtils.ts new file mode 100644 index 0000000..3fed07f --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/mathUtils.ts @@ -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(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); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/navmesh.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/navmesh.ts new file mode 100644 index 0000000..44fc926 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/navmesh.ts @@ -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; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/player.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/player.ts new file mode 100644 index 0000000..b5acbcf --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/player.ts @@ -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); +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/scene.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/scene.ts new file mode 100644 index 0000000..ec4127c --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/scene.ts @@ -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 }; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/store.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/store.ts new file mode 100644 index 0000000..45d11f7 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/store.ts @@ -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): void { + this.state = { ...this.state, ...partial }; + for (const fn of this.listeners) fn(); + } + + reset(): void { + this.set({ ...initial }); + } +} + +export const hud = new HudStore(); diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/types.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/types.ts new file mode 100644 index 0000000..4128418 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/types.ts @@ -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; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/weapons.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/weapons.ts new file mode 100644 index 0000000..813a135 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/weapons.ts @@ -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.Head]: 2.0, + [HitZone.Chest]: 1.0, + [HitZone.Stomach]: 1.25, + [HitZone.Arm]: 0.75, + [HitZone.Leg]: 0.75, +}; + +export const WEAPONS: Record = { + 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 }; +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/world.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/world.ts new file mode 100644 index 0000000..47f9901 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/engine/world.ts @@ -0,0 +1,1275 @@ +// GameWorld: the simulation + rendering orchestrator. Owns the scene, camera, +// first-person viewmodel, all 10 actors (1 player + 9 bots), the round/bomb +// state machine, hitscan combat, effects, minimap and HUD sync. The React layer +// only mounts this and reads the HUD store. + +import * as THREE from 'three'; +import { AudioEngine } from './audio'; +import { + collideCrates, collideWalls, groundHeightAt, rayCrateDistance, +} from './collision'; +import { CHAR_HEIGHT, raycastCharacter } from './combat'; +import { AI, COLORS, FIXED_DT, GRAVITY, MAX_FRAME_DT, PLAYER, ROUND } from './constants'; +import { buildDust2, Crate, Dust2 } from './dust2'; +import { Effects } from './effects'; +import { Region } from './grid'; +import { Input } from './input'; +import { buildScene } from './scene'; +import { hud } from './store'; +import { BombState, HitZone, KillEvent, RoundPhase, Team, WeaponId } from './types'; +import { WEAPONS } from './weapons'; +import { animateCharacter, buildCharacter, CharacterRig } from './entities/characterModel'; +import { buildWeaponMesh } from './entities/weaponModels'; +import { clamp } from './mathUtils'; +import { initBotState, updateBot, type BotState } from './ai'; +import { updatePlayer } from './player'; + +export interface Ammo { + mag: number; + reserve: number; +} + +export type Action = 'none' | 'plant' | 'defuse' | 'pickup'; + +export interface Actor { + id: number; + name: string; + team: Team; + isPlayer: boolean; + alive: boolean; + deadAt: number; + + x: number; y: number; z: number; + yaw: number; pitch: number; + vx: number; vy: number; vz: number; + grounded: boolean; + eyeH: number; + + // per-frame intents set by the controller + moveX: number; moveZ: number; + wantJump: boolean; + wantRun: boolean; + action: Action; + + health: number; armor: number; helmet: boolean; + weapons: WeaponId[]; + current: WeaponId; + ammo: Record; + nextFire: number; + reloadEnd: number; + spread: number; + kickPitch: number; + kickYaw: number; + scoped: boolean; + + hasBomb: boolean; + + rig: CharacterRig | null; + worldWeapon: THREE.Group | null; + worldMuzzle: THREE.Object3D | null; + blob: THREE.Mesh | null; + footDist: number; + animTime: number; + + bs: BotState; + kills: number; + deaths: number; +} + +interface Bomb { + state: BombState; + x: number; y: number; z: number; + site: 'A' | 'B' | null; + timer: number; + plantProgress: number; + defuseProgress: number; + beepTimer: number; + mesh: THREE.Group; + light: THREE.Mesh; +} + +const SENS = 0.0022; +const PITCH_LIMIT = 1.45; + +export class GameWorld { + readonly dust2: Dust2; + readonly grid; + readonly crates: Crate[]; + readonly scene: THREE.Scene; + readonly camera: THREE.PerspectiveCamera; + readonly renderer: THREE.WebGLRenderer; + readonly effects: Effects; + readonly audio = new AudioEngine(); + readonly input: Input; + readonly actors: Actor[] = []; + player!: Actor; + playerTeam: Team; + + private container: HTMLElement; + private viewmodel: THREE.Group; + private vmMuzzle: THREE.Object3D | null = null; + private vmRecoil = 0; + private sun: THREE.DirectionalLight; + private bomb: Bomb; + private baseFov = 78; + + time = 0; + phase: RoundPhase = RoundPhase.Warmup; + roundNum = 0; + scoreT = 0; + scoreCT = 0; + private freezeTimer = 0; + private roundTimer = 0; + private overTimer = 0; + private banner = ''; + private hint = ''; + private killfeed: KillEvent[] = []; + private killId = 0; + private tSite: 'A' | 'B' = 'A'; + private hudAccum = 0; + private visibleEnemyIds = new Set(); + + private minimapCanvas: HTMLCanvasElement | null = null; + private minimapStatic: HTMLCanvasElement | null = null; + private started = false; + private everLocked = false; + private spectateTarget: Actor | null = null; + + constructor(container: HTMLElement, playerTeam: Team) { + this.container = container; + this.playerTeam = playerTeam; + this.dust2 = buildDust2(); + this.grid = this.dust2.grid; + this.crates = this.dust2.crates; + + const refs = buildScene(this.dust2); + this.scene = refs.scene; + this.sun = refs.sun; + + this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); + this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); + this.renderer.setSize(container.clientWidth, container.clientHeight); + container.appendChild(this.renderer.domElement); + this.input = new Input(this.renderer.domElement); + + this.camera = new THREE.PerspectiveCamera(this.baseFov, container.clientWidth / container.clientHeight, 0.04, 220); + this.camera.rotation.order = 'YXZ'; + this.scene.add(this.camera); + + this.viewmodel = new THREE.Group(); + this.camera.add(this.viewmodel); + + this.effects = new Effects(this.scene); + + // bomb entity + const bg = new THREE.Group(); + const body = new THREE.Mesh( + new THREE.BoxGeometry(0.4, 0.28, 0.3), + new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.6, metalness: 0.3 }), + ); + body.position.y = 0.14; + bg.add(body); + const light = new THREE.Mesh( + new THREE.SphereGeometry(0.05, 8, 8), + new THREE.MeshBasicMaterial({ color: 0xff3020 }), + ); + light.position.set(0, 0.32, 0); + bg.add(light); + bg.visible = false; + this.scene.add(bg); + this.bomb = { + state: BombState.Carried, x: 0, y: 0, z: 0, site: null, timer: ROUND.bombTimer, + plantProgress: 0, defuseProgress: 0, beepTimer: 0, mesh: bg, light, + }; + + this.createActors(); + this.startRound(); // place everyone for the pre-start view + hud.set({ playerTeam }); + } + + // ---- setup ---- + + setMinimapCanvas(c: HTMLCanvasElement): void { + this.minimapCanvas = c; + this.buildMinimapStatic(); + } + + private createActors(): void { + const mkAmmoEmpty = (): Record => ({}); + for (let i = 0; i < 10; i++) { + const team: Team = i < 5 ? this.playerTeam : (this.playerTeam === Team.T ? Team.CT : Team.T); + const isPlayer = i === 0; + const teamLetter = team === Team.T ? 'T' : 'CT'; + const a: Actor = { + id: i, + // Every actor has a fixed callsign; the human is shown by the minimap + // arrow + first-person view, and control can move between callsigns. + name: `${teamLetter}-${(i % 5) + 1}`, + team, + isPlayer, + alive: true, + deadAt: 0, + x: 0, y: 0, z: 0, + yaw: 0, pitch: 0, + vx: 0, vy: 0, vz: 0, + grounded: true, + eyeH: isPlayer ? PLAYER.eyeHeight : 1.55, + moveX: 0, moveZ: 0, wantJump: false, wantRun: true, action: 'none', + health: 100, armor: 0, helmet: false, + weapons: [], current: 'knife', ammo: mkAmmoEmpty(), + nextFire: 0, reloadEnd: 0, spread: 0, kickPitch: 0, kickYaw: 0, scoped: false, + hasBomb: false, + rig: null, worldWeapon: null, worldMuzzle: null, blob: null, + footDist: 0, animTime: Math.random() * 10, + bs: initBotState(), + kills: 0, deaths: 0, + }; + // Every actor gets a body so control can be handed to any of them; the + // actor currently being viewed has its rig hidden for the first person. + const rig = buildCharacter(team); + this.scene.add(rig.root); + a.rig = rig; + // blob shadow + const blob = new THREE.Mesh( + new THREE.CircleGeometry(0.5, 16), + new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28, depthWrite: false }), + ); + blob.rotation.x = -Math.PI / 2; + this.scene.add(blob); + a.blob = blob; + this.actors.push(a); + if (isPlayer) this.player = a; + } + } + + start(): void { + if (this.started) return; + this.started = true; + this.audio.init(); + // give a fresh freeze once the player actually starts + this.phase = RoundPhase.Freeze; + this.freezeTimer = ROUND.freezeTime; + } + + private loadoutFor(team: Team, pistol: boolean, index: number): { weapons: WeaponId[]; current: WeaponId; armor: number; helmet: boolean } { + if (pistol) { + const sec: WeaponId = team === Team.T ? 'glock' : 'usp'; + return { weapons: [sec, 'knife'], current: sec, armor: 100, helmet: false }; + } + const rifle: WeaponId = team === Team.T ? 'ak47' : 'm4a4'; + const sec: WeaponId = team === Team.T ? 'glock' : 'usp'; + // one designated AWPer per team + const primary: WeaponId = index === 1 ? 'awp' : rifle; + return { weapons: [primary, sec, 'knife'], current: primary, armor: 100, helmet: true }; + } + + private startRound(): void { + this.roundNum++; + const pistol = this.roundNum === 1; + this.phase = RoundPhase.Freeze; + this.freezeTimer = ROUND.freezeTime; + this.roundTimer = ROUND.roundTime; + this.banner = pistol ? 'Pistol Round' : `Round ${this.roundNum}`; + this.hint = ''; + this.visibleEnemyIds.clear(); + + // bomb reset + this.bomb.state = BombState.Carried; + this.bomb.site = null; + this.bomb.timer = ROUND.bombTimer; + this.bomb.plantProgress = 0; + this.bomb.defuseProgress = 0; + this.bomb.mesh.visible = false; + + // T target site + CT defense split for this round + this.tSite = Math.random() < 0.5 ? 'A' : 'B'; + + const tSpawns = this.dust2.tSpawns; + const ctSpawns = this.dust2.ctSpawns; + let ti = 0; + let ci = 0; + const ts: Actor[] = []; + for (const a of this.actors) { + const spawn = a.team === Team.T ? tSpawns[ti++ % tSpawns.length] : ctSpawns[ci++ % ctSpawns.length]; + const teamIndex = a.team === Team.T ? ti - 1 : ci - 1; + this.resetActor(a, spawn.x, spawn.z, pistol, teamIndex); + if (a.team === Team.T) ts.push(a); + } + + // assign bomb carrier: player if T, else first T bot + const carrier = this.playerTeam === Team.T ? this.player : ts.find((a) => !a.isPlayer) ?? ts[0]; + if (carrier) { + carrier.hasBomb = true; + } + + // CT defense assignment: split across sites + let dc = 0; + for (const a of this.actors) { + if (a.team === Team.CT && !a.isPlayer) { + a.bs.defendSite = dc++ % 2 === 0 ? 'A' : 'B'; + } + if (a.team === Team.T && !a.isPlayer) { + a.bs.targetSite = this.tSite; + } + } + + this.syncHudFull(); + } + + private resetActor(a: Actor, x: number, z: number, pistol: boolean, teamIndex: number): void { + a.alive = true; + a.health = PLAYER.maxHealth; + const lo = this.loadoutFor(a.team, pistol, teamIndex); + a.armor = lo.armor; + a.helmet = lo.helmet; + a.weapons = lo.weapons.slice(); + a.ammo = {}; + for (const w of a.weapons) { + const def = WEAPONS[w]; + a.ammo[w] = { mag: def.magSize === Infinity ? Infinity : def.magSize, reserve: def.reserve === Infinity ? Infinity : def.reserve }; + } + a.x = x; + a.y = 0; + a.z = z; + a.vx = a.vy = a.vz = 0; + a.grounded = true; + a.yaw = a.team === Team.T ? 0 : Math.PI; + a.pitch = 0; + a.kickPitch = a.kickYaw = 0; + a.spread = 0; + a.scoped = false; + a.hasBomb = false; + a.nextFire = 0; + a.reloadEnd = 0; + a.action = 'none'; + a.moveX = a.moveZ = 0; + a.footDist = 0; + initBotState(a.bs); + if (a.rig) { + a.rig.root.visible = true; + a.rig.root.position.set(a.x, a.y, a.z); + a.rig.root.rotation.set(0, a.yaw, 0); + } + this.equip(a, lo.current); + } + + // ---- weapon equip / viewmodel ---- + + equip(a: Actor, id: WeaponId): void { + if (!a.weapons.includes(id)) return; + a.current = id; + a.scoped = false; + if (a.isPlayer) { + this.rebuildViewmodel(id); + } else if (a.rig) { + if (a.worldWeapon) a.rig.weaponHolder.remove(a.worldWeapon); + const wm = buildWeaponMesh(id); + a.rig.weaponHolder.add(wm.group); + a.worldWeapon = wm.group; + a.worldMuzzle = wm.muzzle; + } + } + + private rebuildViewmodel(id: WeaponId): void { + this.viewmodel.clear(); + const wm = buildWeaponMesh(id); + // hands + const skin = 0xc99a76; + const handGeo = new THREE.BoxGeometry(0.09, 0.09, 0.12); + const handMat = new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 }); + const h1 = new THREE.Mesh(handGeo, handMat); + h1.position.set(0.0, -0.09, 0.06); + const h2 = new THREE.Mesh(handGeo, handMat); + h2.position.set(0.0, -0.06, -0.18); + wm.group.add(h1, h2); + wm.group.position.set(0.22, -0.2, -0.48); + wm.group.rotation.y = 0.05; + wm.group.scale.setScalar(0.7); + this.viewmodel.add(wm.group); + this.vmMuzzle = wm.muzzle; + } + + // ---- main loop ---- + + update(rawDt: number): void { + const dt = clamp(rawDt, 0, MAX_FRAME_DT); + this.time += dt; + + // Pause the simulation when the pointer is unlocked (Esc), but only once the + // player has actually locked at least once — so if pointer lock is blocked + // (e.g. a restrictive iframe) the game degrades to keyboard control instead + // of freezing forever. + if (this.input.locked) this.everLocked = true; + const active = this.started && (this.input.locked || !this.everLocked); + if (!active) { + this.updateActorsVisual(0); + this.updateCamera(dt); + this.drawMinimap(); + this.syncHud(); + this.renderer.render(this.scene, this.camera); + return; + } + + // phase transitions + if (this.phase === RoundPhase.Freeze) { + this.freezeTimer -= dt; + if (this.freezeTimer <= 0) { + this.phase = RoundPhase.Live; + this.banner = ''; + } + } else if (this.phase === RoundPhase.Over) { + this.overTimer -= dt; + if (this.overTimer <= 0) this.startRound(); + } + + const live = this.phase === RoundPhase.Live; + const canMove = live; + + // compute team vision (for minimap + AI shortcuts) + this.computeVisibility(); + + // controllers + this.hint = ''; + updatePlayer(this, this.player, dt, canMove); + for (const a of this.actors) { + if (a.isPlayer || !a.alive) continue; + updateBot(this, a, dt, canMove); + } + + // reloads finishing + for (const a of this.actors) { + if (a.alive && a.reloadEnd > 0 && this.time >= a.reloadEnd) this.finishReload(a); + } + + // physics + for (const a of this.actors) { + if (!a.alive) continue; + this.integrateActor(a, canMove ? dt : 0, dt); + } + if (canMove) this.separateActors(); + + // decay recoil/spread + for (const a of this.actors) { + const rec = Math.max(0, 1 - dt * 9); + a.kickPitch *= rec; + a.kickYaw *= rec; + const w = WEAPONS[a.current]; + a.spread = Math.max(0, a.spread - w.recoverRate * dt); + } + + if (live) { + if (this.bomb.state !== BombState.Planted) this.roundTimer -= dt; + this.updateBomb(dt); + this.checkRoundEnd(); + } + + // presentation + this.updateActorsVisual(dt); + this.updateCamera(dt); + this.effects.update(dt); + const listener = this.viewActor(); + this.audio.setListener(listener.x, listener.z, listener.yaw); + + this.drawMinimap(); + + this.hudAccum += dt; + if (this.hudAccum >= 0.06) { + this.hudAccum = 0; + this.syncHud(); + } + + this.renderer.render(this.scene, this.camera); + } + + // ---- physics ---- + + private integrateActor(a: Actor, moveDt: number, physDt: number): void { + const w = WEAPONS[a.current]; + // vertical + if (a.wantJump && a.grounded && moveDt > 0) { + a.vy = PLAYER.jumpSpeed; + a.grounded = false; + } + a.wantJump = false; + a.vy -= GRAVITY * physDt; + a.y += a.vy * physDt; + const ground = groundHeightAt(this.crates, a.x, a.z, PLAYER.radius); + if (a.y <= ground) { + a.y = ground; + a.vy = 0; + a.grounded = true; + } else { + a.grounded = false; + } + + // horizontal + if (moveDt > 0) { + const scopedMul = a.scoped ? 0.5 : 1; + let wishLen = Math.hypot(a.moveX, a.moveZ); + let wx = 0, wz = 0; + if (wishLen > 1e-4) { + wx = a.moveX / wishLen; + wz = a.moveZ / wishLen; + } + const maxSpeed = (a.wantRun ? PLAYER.runSpeed : PLAYER.walkSpeed) * w.moveSpeedMul * scopedMul; + const speed = Math.hypot(a.vx, a.vz); + if (a.grounded) { + // friction + if (speed > 0) { + const drop = speed * PLAYER.friction * moveDt; + const ns = Math.max(0, speed - drop) / speed; + a.vx *= ns; + a.vz *= ns; + } + } + const accel = a.grounded ? PLAYER.groundAccel : PLAYER.airAccel; + if (wishLen > 1e-4) { + const curSpeedInWish = a.vx * wx + a.vz * wz; + const add = clamp(maxSpeed - curSpeedInWish, 0, accel * moveDt); + a.vx += wx * add; + a.vz += wz * add; + } + const oldX = a.x, oldZ = a.z; + let nx = a.x + a.vx * moveDt; + let nz = a.z + a.vz * moveDt; + let r = collideWalls(this.grid, nx, nz, PLAYER.radius); + r = collideCrates(this.crates, r.x, r.z, PLAYER.radius, a.y); + a.x = r.x; + a.z = r.z; + // rebuild velocity from actual movement to avoid wall sticking + a.vx = (a.x - oldX) / moveDt; + a.vz = (a.z - oldZ) / moveDt; + } + } + + private separateActors(): void { + const minD = PLAYER.radius * 2; + for (let i = 0; i < this.actors.length; i++) { + const a = this.actors[i]; + if (!a.alive) continue; + for (let j = i + 1; j < this.actors.length; j++) { + const b = this.actors[j]; + if (!b.alive) continue; + const dx = b.x - a.x; + const dz = b.z - a.z; + const d2 = dx * dx + dz * dz; + if (d2 < minD * minD && d2 > 1e-5) { + const d = Math.sqrt(d2); + const push = (minD - d) / 2; + const nx = dx / d, nz = dz / d; + if (!a.isPlayer) { a.x -= nx * push; a.z -= nz * push; } + if (!b.isPlayer) { b.x += nx * push; b.z += nz * push; } + // if one is player, push the bot fully + if (a.isPlayer) { b.x += nx * push * 2; b.z += nz * push * 2; } + if (b.isPlayer) { a.x -= nx * push * 2; a.z -= nz * push * 2; } + } + } + } + } + + // ---- combat ---- + + eyePos(a: Actor): { x: number; y: number; z: number } { + return { x: a.x, y: a.y + a.eyeH, z: a.z }; + } + + aimDir(a: Actor): { x: number; y: number; z: number } { + const yaw = a.yaw + a.kickYaw; + const pitch = clamp(a.pitch + a.kickPitch, -PITCH_LIMIT, PITCH_LIMIT); + const cp = Math.cos(pitch); + return { x: -Math.sin(yaw) * cp, y: Math.sin(pitch), z: -Math.cos(yaw) * cp }; + } + + private coneDir(dx: number, dy: number, dz: number, spread: number): { x: number; y: number; z: number } { + if (spread <= 1e-5) return { x: dx, y: dy, z: dz }; + const a = spread * Math.sqrt(Math.random()); + const th = Math.random() * Math.PI * 2; + // tangent basis + let ux = 0, uy = 1, uz = 0; + if (Math.abs(dy) > 0.9) { ux = 1; uy = 0; } + let t1x = uy * dz - uz * dy; + let t1y = uz * dx - ux * dz; + let t1z = ux * dy - uy * dx; + const t1l = Math.hypot(t1x, t1y, t1z) || 1; + t1x /= t1l; t1y /= t1l; t1z /= t1l; + const t2x = dy * t1z - dz * t1y; + const t2y = dz * t1x - dx * t1z; + const t2z = dx * t1y - dy * t1x; + const sa = Math.sin(a), ca = Math.cos(a); + const ox = Math.cos(th) * sa, oy = Math.sin(th) * sa; + return { + x: dx * ca + (t1x * ox + t2x * oy), + y: dy * ca + (t1y * ox + t2y * oy), + z: dz * ca + (t1z * ox + t2z * oy), + }; + } + + // movement/air inaccuracy contribution + private moveInaccuracy(a: Actor): number { + const w = WEAPONS[a.current]; + const speed = Math.hypot(a.vx, a.vz); + let inacc = (speed / PLAYER.runSpeed) * 0.03; + if (!a.grounded) inacc += 0.08; + if (a.scoped) inacc *= 3; + return inacc * (w.scoped && !a.scoped ? 1.5 : 1); + } + + fireWeapon(a: Actor): boolean { + const w = WEAPONS[a.current]; + if (this.time < a.nextFire || a.reloadEnd > 0) return false; + if (w.isKnife) return this.melee(a); + const ammo = a.ammo[a.current]; + if (ammo.mag <= 0) { + // auto reload attempt + if (a.isPlayer) this.startReload(a); + return false; + } + a.nextFire = this.time + w.fireInterval; + ammo.mag = ammo.mag === Infinity ? Infinity : ammo.mag - 1; + + const eye = this.eyePos(a); + const base = this.aimDir(a); + const spread = w.baseSpread + a.spread + this.moveInaccuracy(a); + const dir = this.coneDir(base.x, base.y, base.z, a.scoped ? spread * 0.15 : spread); + const dl = Math.hypot(dir.x, dir.y, dir.z) || 1; + const ndx = dir.x / dl, ndy = dir.y / dl, ndz = dir.z / dl; + + const range = w.range; + // geometry distances. rayWallDistance parametrizes the XZ ray by the same + // t as the (unit) 3D ray, so its return value is already the 3D distance. + let wall3d = this.grid.rayWallDistance(eye.x, eye.z, ndx, ndz, range); + // floor + if (ndy < -1e-4) { + const tf = -eye.y / ndy; + if (tf < wall3d) wall3d = tf; + } + const crate3d = rayCrateDistance(this.crates, eye.x, eye.y, eye.z, ndx, ndy, ndz, wall3d); + let nearest = Math.min(wall3d, crate3d); + let hitActor: Actor | null = null; + let hitZone: HitZone = HitZone.Chest; + + for (const t of this.actors) { + if (!t.alive || t.team === a.team || t.id === a.id) continue; + const r = raycastCharacter(t.x, t.y, t.z, t.yaw, eye.x, eye.y, eye.z, ndx, ndy, ndz, nearest); + if (r && r.t < nearest) { + nearest = r.t; + hitActor = t; + hitZone = r.zone; + } + } + + // muzzle world position for tracer/flash + const muzzle = a.isPlayer ? this.vmMuzzle : a.worldMuzzle; + const mp = new THREE.Vector3(); + if (muzzle) muzzle.getWorldPosition(mp); + else mp.set(eye.x + ndx * 0.4, eye.y - 0.1, eye.z + ndz * 0.4); + const hx = eye.x + ndx * nearest; + const hy = eye.y + ndy * nearest; + const hz = eye.z + ndz * nearest; + this.effects.tracer(mp.x, mp.y, mp.z, hx, hy, hz); + this.effects.flash(mp.x, mp.y, mp.z); + this.audio.gunshot(a.current, a.isPlayer ? undefined : a.x, a.isPlayer ? undefined : a.z); + + if (hitActor) { + this.effects.impact(hx, hy, hz, true); + this.damageActor(hitActor, a, a.current, hitZone, hx, hy, hz); + } else if (nearest < range) { + this.effects.impact(hx, hy, hz, false); + } + + // recoil + a.spread = Math.min(w.maxSpread, a.spread + w.spreadPerShot); + if (a.isPlayer) { + a.kickPitch += w.recoilPitch; + a.kickYaw += (Math.random() * 2 - 1) * w.recoilYaw; + this.vmRecoil = Math.min(0.16, this.vmRecoil + (w.scoped ? 0.14 : 0.05)); + } + return true; + } + + private melee(a: Actor): boolean { + const w = WEAPONS.knife; + a.nextFire = this.time + w.fireInterval; + const eye = this.eyePos(a); + const d = this.aimDir(a); + this.audio.gunshot('knife', a.isPlayer ? undefined : a.x, a.isPlayer ? undefined : a.z); + let nearest = w.range; + let hitActor: Actor | null = null; + let zone: HitZone = HitZone.Chest; + for (const t of this.actors) { + if (!t.alive || t.team === a.team || t.id === a.id) continue; + const r = raycastCharacter(t.x, t.y, t.z, t.yaw, eye.x, eye.y, eye.z, d.x, d.y, d.z, nearest); + if (r && r.t < nearest) { nearest = r.t; hitActor = t; zone = r.zone; } + } + if (hitActor) { + const hx = eye.x + d.x * nearest, hy = eye.y + d.y * nearest, hz = eye.z + d.z * nearest; + this.effects.impact(hx, hy, hz, true); + this.damageActor(hitActor, a, 'knife', zone, hx, hy, hz); + return true; + } + return false; + } + + startReload(a: Actor): void { + const w = WEAPONS[a.current]; + if (w.isKnife || a.reloadEnd > 0) return; + const ammo = a.ammo[a.current]; + if (ammo.mag >= w.magSize || ammo.reserve <= 0) return; + a.reloadEnd = this.time + w.reloadTime; + a.scoped = false; + if (a.isPlayer) this.audio.reload(); + } + + private finishReload(a: Actor): void { + const w = WEAPONS[a.current]; + const ammo = a.ammo[a.current]; + const need = w.magSize - ammo.mag; + const take = Math.min(need, ammo.reserve); + ammo.mag += take; + ammo.reserve -= take; + a.reloadEnd = 0; + } + + private damageActor(t: Actor, from: Actor, weapon: WeaponId, zone: HitZone, hx: number, hy: number, hz: number): void { + if (!t.alive) return; + const w = WEAPONS[weapon]; + let dmg = w.damage * ({ [HitZone.Head]: 2, [HitZone.Chest]: 1, [HitZone.Stomach]: 1.25, [HitZone.Arm]: 0.75, [HitZone.Leg]: 0.75 }[zone]); + const protectable = zone !== HitZone.Leg && (zone !== HitZone.Head || t.helmet); + if (t.armor > 0 && protectable) { + const pre = dmg; + dmg *= w.armorPen; + t.armor = Math.max(0, t.armor - Math.round((pre - dmg) * 0.5 + dmg * 0.5)); + } + t.health -= dmg; + if (from.isPlayer) this.audio.hitmarker(); + if (t.health <= 0) { + this.onDeath(t, from, weapon, zone === HitZone.Head); + } + } + + private onDeath(t: Actor, from: Actor, weapon: WeaponId, headshot: boolean): void { + t.alive = false; + t.health = 0; + t.deadAt = this.time; + t.deaths++; + if (from.id !== t.id) from.kills++; + if (t.hasBomb) this.dropBomb(t); + t.hasBomb = false; + t.scoped = false; + if (t.rig) { + // fallen pose + t.rig.root.rotation.z = Math.PI / 2; + t.rig.root.position.y = 0.35; + } + this.killId++; + this.killfeed.unshift({ + id: this.killId, + attacker: from.name, + victim: t.name, + attackerTeam: from.team, + victimTeam: t.team, + weapon, + headshot, + time: this.time, + }); + if (this.killfeed.length > 6) this.killfeed.pop(); + if (from.isPlayer) this.audio.kill(); + if (t.isPlayer) this.banner = 'You were eliminated'; + } + + // ---- bomb ---- + + private inSite(a: Actor): 'A' | 'B' | null { + const c = this.grid.cellOf(a.x, a.z); + const reg = this.grid.regionAt(c.cx, c.cz); + if (reg === Region.ASite) return 'A'; + if (reg === Region.BSite) return 'B'; + return null; + } + + isInSite(a: Actor): 'A' | 'B' | null { + return this.inSite(a); + } + + private dropBomb(carrier: Actor): void { + this.bomb.state = BombState.Dropped; + this.bomb.x = carrier.x; + this.bomb.y = 0; + this.bomb.z = carrier.z; + this.bomb.mesh.position.set(carrier.x, 0, carrier.z); + this.bomb.mesh.visible = true; + } + + pickupBomb(a: Actor): void { + if (this.bomb.state !== BombState.Dropped || a.team !== Team.T) return; + const dx = a.x - this.bomb.x, dz = a.z - this.bomb.z; + if (dx * dx + dz * dz > 2.2 * 2.2) return; + this.bomb.state = BombState.Carried; + a.hasBomb = true; + this.bomb.mesh.visible = false; + } + + bombInfo() { + return this.bomb; + } + + private updateBomb(dt: number): void { + const b = this.bomb; + if (b.state === BombState.Carried) { + const carrier = this.actors.find((a) => a.hasBomb && a.alive); + // planting + const planter = this.actors.find((a) => a.alive && a.hasBomb && a.action === 'plant' && a.grounded && this.inSite(a) !== null); + if (planter) { + b.plantProgress += dt; + if (planter.isPlayer) this.audio.plantTick(); + if (b.plantProgress >= ROUND.plantTime) { + b.state = BombState.Planted; + b.site = this.inSite(planter); + b.x = planter.x; b.y = 0; b.z = planter.z; + b.timer = ROUND.bombTimer; + b.mesh.position.set(b.x, 0, b.z); + b.mesh.visible = true; + planter.hasBomb = false; + this.banner = `Bomb planted at ${b.site}`; + this.audio.plantTick(); + } + } else { + b.plantProgress = Math.max(0, b.plantProgress - dt * 2); + } + if (carrier) { b.x = carrier.x; b.z = carrier.z; } + } else if (b.state === BombState.Planted) { + b.timer -= dt; + // beeping accelerates near the end + b.beepTimer -= dt; + const interval = clamp(b.timer / ROUND.bombTimer, 0.08, 1) * 1.1; + if (b.beepTimer <= 0) { + b.beepTimer = interval; + this.audio.bombBeep(b.x, b.z); + } + const pulse = 0.5 + 0.5 * Math.sin(this.time * (12 - 10 * (1 - b.timer / ROUND.bombTimer))); + (this.bomb.light.material as THREE.MeshBasicMaterial).color.setRGB(0.6 + pulse * 0.4, 0.1, 0.05); + // defusing + const defuser = this.actors.find((a) => { + if (!a.alive || a.team !== Team.CT || a.action !== 'defuse' || !a.grounded) return false; + const dx = a.x - b.x, dz = a.z - b.z; + return dx * dx + dz * dz < 2.4 * 2.4; + }); + if (defuser) { + b.defuseProgress += dt; + if (defuser.isPlayer && Math.floor(b.defuseProgress * 4) !== Math.floor((b.defuseProgress - dt) * 4)) { + this.audio.defuseTick(); + } + if (b.defuseProgress >= ROUND.defuseTime) { + b.state = BombState.Defused; + this.endRound(Team.CT, 'Bomb defused'); + } + } else { + b.defuseProgress = Math.max(0, b.defuseProgress - dt * 1.5); + } + if (b.timer <= 0) { + this.explodeBomb(); + } + } + } + + private explodeBomb(): void { + const b = this.bomb; + b.state = BombState.Exploded; + this.audio.explode(b.x, b.z); + this.effects.impact(b.x, 0.5, b.z, false); + // radius damage + for (const a of this.actors) { + if (!a.alive) continue; + const dx = a.x - b.x, dz = a.z - b.z; + const d = Math.hypot(dx, dz); + if (d < ROUND.bombDamageRadius) { + const dmg = 200 * (1 - d / ROUND.bombDamageRadius); + a.health -= dmg; + if (a.health <= 0) { + a.alive = false; + a.deadAt = this.time; + if (a.rig) { a.rig.root.rotation.z = Math.PI / 2; a.rig.root.position.y = 0.35; } + } + } + } + this.endRound(Team.T, 'Bomb detonated'); + } + + // ---- round resolution ---- + + private countAlive(team: Team): number { + let n = 0; + for (const a of this.actors) if (a.team === team && a.alive) n++; + return n; + } + + private checkRoundEnd(): void { + const tAlive = this.countAlive(Team.T); + const ctAlive = this.countAlive(Team.CT); + const planted = this.bomb.state === BombState.Planted; + + if (planted) { + if (ctAlive === 0) { + this.endRound(Team.T, 'Terrorists win'); + } + // if all T dead but planted, CTs must still defuse (handled by timer/defuse) + } else { + if (tAlive === 0) { + this.endRound(Team.CT, 'Counter-Terrorists win'); + } else if (ctAlive === 0) { + this.endRound(Team.T, 'Terrorists win'); + } else if (this.roundTimer <= 0) { + this.endRound(Team.CT, 'Time — Counter-Terrorists win'); + } + } + } + + private endRound(winner: Team, reason: string): void { + if (this.phase === RoundPhase.Over) return; + this.phase = RoundPhase.Over; + this.overTimer = ROUND.overTime; + if (winner === Team.T) this.scoreT++; + else this.scoreCT++; + this.banner = reason; + this.syncHudFull(); + } + + // ---- visibility ---- + + canSee(from: Actor, to: Actor, fovDeg = 360): boolean { + if (!to.alive) return false; + const ex = from.x, ez = from.z; + const ey = from.y + from.eyeH; + const tx = to.x, tz = to.z; + const ty = to.y + 1.2; + const dx = tx - ex, dz = tz - ez; + const dist = Math.hypot(dx, dz); + if (dist > AI.viewRange) return false; + if (fovDeg < 360) { + const fwd = this.aimDir(from); + const fl = Math.hypot(fwd.x, fwd.z) || 1; + const dot = (dx * fwd.x + dz * fwd.z) / (dist * fl + 1e-4); + const cosHalf = Math.cos((fovDeg * Math.PI) / 180 / 2); + if (dot < cosHalf) return false; + } + if (this.grid.segmentBlocked(ex, ez, tx, tz)) return false; + // crate occlusion (3D) + const dy = ty - ey; + const len = Math.hypot(dx, dy, dz) || 1; + const cd = rayCrateDistance(this.crates, ex, ey, ez, dx / len, dy / len, dz / len, len); + if (cd < len - 0.2) return false; + return true; + } + + nearestVisibleEnemy(a: Actor, fovDeg = 360): Actor | null { + let best: Actor | null = null; + let bestD = Infinity; + for (const t of this.actors) { + if (!t.alive || t.team === a.team) continue; + if (!this.canSee(a, t, fovDeg)) continue; + const d = (t.x - a.x) ** 2 + (t.z - a.z) ** 2; + if (d < bestD) { bestD = d; best = t; } + } + return best; + } + + private computeVisibility(): void { + this.visibleEnemyIds.clear(); + const myTeam = this.playerTeam; + for (const viewer of this.actors) { + if (!viewer.alive || viewer.team !== myTeam) continue; + for (const e of this.actors) { + if (!e.alive || e.team === myTeam) continue; + if (this.visibleEnemyIds.has(e.id)) continue; + const fov = viewer.isPlayer ? 110 : AI.fovDeg; + if (this.canSee(viewer, e, fov)) this.visibleEnemyIds.add(e.id); + } + } + } + + enemiesVisibleToTeam(): Set { + return this.visibleEnemyIds; + } + + // ---- hints (from player controller) ---- + setHint(h: string): void { + this.hint = h; + } + + // ---- spectate / take over (after death) ---- + + // The actor whose eyes the camera looks through: the controlled player while + // alive, otherwise the spectated teammate. + private viewActor(): Actor { + if (this.player.alive) return this.player; + if (this.spectateTarget && this.spectateTarget.alive && this.spectateTarget.team === this.player.team) { + return this.spectateTarget; + } + return this.player; + } + + private aliveTeammates(): Actor[] { + return this.actors.filter((a) => a.alive && a.team === this.player.team && a !== this.player); + } + + // Keep a valid spectate target (called each dead frame). + ensureSpectate(): void { + if (this.spectateTarget && this.spectateTarget.alive && this.spectateTarget.team === this.player.team) return; + this.spectateTarget = this.aliveTeammates()[0] ?? null; + } + + spectateCycle(dir: number): void { + const mates = this.aliveTeammates(); + if (!mates.length) { + this.spectateTarget = null; + return; + } + const cur = this.spectateTarget ? mates.indexOf(this.spectateTarget) : -1; + const next = (((cur + (dir >= 0 ? 1 : -1)) % mates.length) + mates.length) % mates.length; + this.spectateTarget = mates[next]; + } + + // Assume control of the spectated teammate: it stops being AI-driven and + // becomes the player-controlled actor. + takeOver(): boolean { + const t = this.spectateTarget; + if (!t || !t.alive || t.team !== this.player.team) return false; + this.player.isPlayer = false; + t.isPlayer = true; + t.eyeH = PLAYER.eyeHeight; + t.moveX = t.moveZ = 0; + t.wantJump = false; + t.scoped = false; + t.kickPitch = t.kickYaw = 0; + this.player = t; + this.spectateTarget = null; + this.equip(t, t.current); // build the first-person viewmodel for the new actor + return true; + } + + // ---- visuals ---- + + private updateActorsVisual(dt: number): void { + const view = this.viewActor(); + for (const a of this.actors) { + if (a.blob) { + a.blob.visible = a.alive && a !== view; + a.blob.position.set(a.x, a.y + 0.02, a.z); + } + if (!a.rig) continue; + if (!a.alive) { + // corpse in its fallen pose (hidden only if we are looking from it) + a.rig.root.visible = a !== view; + a.rig.root.position.set(a.x, a.rig.root.position.y, a.z); + continue; + } + // hide the body we are looking through (first person) + a.rig.root.visible = a !== view; + a.rig.root.position.set(a.x, a.y, a.z); + a.rig.root.rotation.set(0, a.yaw, 0); + const speed = Math.hypot(a.vx, a.vz); + a.animTime += dt; + animateCharacter(a.rig, speed, a.animTime, false); + a.rig.root.position.y = a.y + a.rig.root.position.y; // add bob (animateCharacter set root.y to bob) + } + // footsteps + for (const a of this.actors) { + if (!a.alive || !a.grounded) continue; + const speed = Math.hypot(a.vx, a.vz); + if (speed > 1.5) { + a.footDist += speed * dt; + if (a.footDist > 2.4) { + a.footDist = 0; + if (a.isPlayer) this.audio.footstep(); + else this.audio.footstep(a.x, a.z); + } + } + } + } + + private updateCamera(dt: number): void { + const p = this.viewActor(); // player while alive, spectated teammate while dead + const controlling = this.player.alive; + const eye = p.eyeH; + // slight view bob + const speed = Math.hypot(p.vx, p.vz); + const bob = p.grounded && speed > 1 ? Math.sin(this.time * 10) * 0.03 * (speed / PLAYER.runSpeed) : 0; + this.camera.position.set(p.x, p.y + eye + bob, p.z); + this.camera.rotation.set( + clamp(p.pitch + p.kickPitch, -PITCH_LIMIT, PITCH_LIMIT), + p.yaw + p.kickYaw, + 0, + ); + + // fov for scope (only the controlled player scopes) + const wantFov = controlling && this.player.scoped ? (WEAPONS[this.player.current].scopedFov ?? 30) : this.baseFov; + this.camera.fov += (wantFov - this.camera.fov) * Math.min(1, dt * 12); + this.camera.updateProjectionMatrix(); + + // viewmodel recoil + reload pose (hidden while spectating) + this.vmRecoil = Math.max(0, this.vmRecoil - dt * 0.9); + const rl = this.player.reloadEnd > 0 ? 1 : 0; + this.viewmodel.visible = controlling && !this.player.scoped; + const child = this.viewmodel.children[0] as THREE.Group | undefined; + if (child && controlling) { + const bobX = Math.sin(this.time * 10) * 0.006 * (speed / PLAYER.runSpeed); + const bobY = Math.abs(Math.cos(this.time * 10)) * 0.006 * (speed / PLAYER.runSpeed); + child.position.set(0.22 + bobX, -0.2 + bobY - rl * 0.12, -0.48 + this.vmRecoil); + child.rotation.set(this.vmRecoil * 1.4 - rl * 0.5, 0.05, 0); + } + } + + // ---- minimap ---- + + private buildMinimapStatic(): void { + const g = this.grid; + const c = document.createElement('canvas'); + c.width = g.cols; + c.height = g.rows; + const ctx = c.getContext('2d')!; + ctx.fillStyle = 'rgba(10,12,16,0)'; + ctx.clearRect(0, 0, g.cols, g.rows); + for (let cz = 0; cz < g.rows; cz++) { + for (let cx = 0; cx < g.cols; cx++) { + if (g.isFloor(cx, cz)) { + const reg = g.regionAt(cx, cz); + ctx.fillStyle = reg === Region.ASite ? '#5c3330' : reg === Region.BSite ? '#2d4757' : '#3b3730'; + ctx.fillRect(cx, cz, 1, 1); + } + } + } + this.minimapStatic = c; + } + + private drawMinimap(): void { + const canvas = this.minimapCanvas; + if (!canvas || !this.minimapStatic) return; + const ctx = canvas.getContext('2d')!; + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(this.minimapStatic, 0, 0, W, H); + + const { minX, maxX, minZ, maxZ } = this.dust2.bounds; + const toPx = (x: number, z: number): [number, number] => [ + ((x - minX) / (maxX - minX)) * W, + ((z - minZ) / (maxZ - minZ)) * H, + ]; + + // bomb + const b = this.bomb; + if (b.state === BombState.Planted || b.state === BombState.Dropped) { + const [bx, by] = toPx(b.x, b.z); + ctx.fillStyle = b.state === BombState.Planted ? '#ff3b30' : '#ffcc00'; + ctx.beginPath(); + ctx.arc(bx, by, 3.5, 0, Math.PI * 2); + ctx.fill(); + } + + const vis = this.visibleEnemyIds; + for (const a of this.actors) { + if (!a.alive) continue; + const [px, py] = toPx(a.x, a.z); + if (a.isPlayer) { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(-a.yaw); + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.moveTo(0, -5); + ctx.lineTo(3.5, 4); + ctx.lineTo(-3.5, 4); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } else if (a.team === this.playerTeam) { + ctx.fillStyle = '#39d353'; + ctx.beginPath(); + ctx.arc(px, py, 2.6, 0, Math.PI * 2); + ctx.fill(); + } else if (vis.has(a.id)) { + ctx.fillStyle = '#ff453a'; + ctx.beginPath(); + ctx.arc(px, py, 2.8, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + // ---- HUD ---- + + private syncHud(): void { + // When dead and spectating a teammate, mirror their vitals/weapon. + const view = this.viewActor(); + const controlling = this.player.alive; + const spectating = !controlling && view.alive && view !== this.player; + const p = spectating ? view : this.player; + const w = WEAPONS[p.current]; + const ammo = p.ammo[p.current]; + const b = this.bomb; + hud.set({ + phase: this.phase, + roundNum: this.roundNum, + scoreT: this.scoreT, + scoreCT: this.scoreCT, + roundTime: Math.max(0, this.roundTimer), + aliveT: this.countAlive(Team.T), + aliveCT: this.countAlive(Team.CT), + alive: this.player.alive, + spectating, + spectateName: spectating ? view.name : '', + health: Math.max(0, Math.ceil(p.health)), + armor: Math.max(0, Math.ceil(p.armor)), + helmet: p.helmet, + weaponName: w.name, + weaponId: p.current, + mag: ammo ? ammo.mag : 0, + reserve: ammo ? ammo.reserve : 0, + infiniteAmmo: w.isKnife, + reloading: p.reloadEnd > 0, + scoped: p.scoped, + spread: w.baseSpread + p.spread + this.moveInaccuracy(p), + hasBomb: p.hasBomb, + bombPlanted: b.state === BombState.Planted, + bombTimer: Math.max(0, b.timer), + defusing: b.defuseProgress > 0.05 && b.state === BombState.Planted, + defuseProgress: b.defuseProgress / ROUND.defuseTime, + planting: b.plantProgress > 0.05 && b.state === BombState.Carried, + plantProgress: b.plantProgress / ROUND.plantTime, + hint: this.hint, + banner: this.banner || (this.phase === RoundPhase.Freeze ? `Round ${this.roundNum}` : ''), + killfeed: this.killfeed.slice(), + loadout: p.weapons.map((id) => ({ + id, + name: WEAPONS[id].name, + slot: WEAPONS[id].slot, + mag: p.ammo[id]?.mag ?? 0, + reserve: p.ammo[id]?.reserve ?? 0, + current: id === p.current, + })), + }); + } + + private syncHudFull(): void { + this.syncHud(); + } + + // ---- lifecycle ---- + + resize(): void { + const w = this.container.clientWidth; + const h = this.container.clientHeight; + this.renderer.setSize(w, h); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + } + + dispose(): void { + this.input.dispose(); + this.renderer.dispose(); + this.audio.muted = true; + if (this.renderer.domElement.parentElement === this.container) { + this.container.removeChild(this.renderer.domElement); + } + } +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/main.tsx b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/main.tsx new file mode 100644 index 0000000..97f78c7 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/main.tsx @@ -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(); diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/styles.css b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/styles.css new file mode 100644 index 0000000..9b8eb84 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/src/styles.css @@ -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; } diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/tsconfig.json b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/tsconfig.json new file mode 100644 index 0000000..3c52be6 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/tsconfig.json @@ -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"] +} diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/vite.config.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/vite.config.ts new file mode 100644 index 0000000..7feb8a7 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/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()], +}); diff --git a/outputs/claude-opus-4-8/claude-code-max/dust2-arena/vite.single.config.ts b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/vite.single.config.ts new file mode 100644 index 0000000..f38f259 --- /dev/null +++ b/outputs/claude-opus-4-8/claude-code-max/dust2-arena/vite.single.config.ts @@ -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()], +}); diff --git a/scripts/validate-data.ts b/scripts/validate-data.ts index 8fb1de3..0b6f376 100644 --- a/scripts/validate-data.ts +++ b/scripts/validate-data.ts @@ -28,6 +28,10 @@ const isBilingual = (v: unknown): boolean => { const modelIds = new Set() const artifactDirs = new Map() const declaredArtifacts = new Set() +// 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() 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`)