diff --git a/models/claude-opus-5-5-cc-max.json b/models/claude-opus-5-5-cc-max.json
new file mode 100644
index 0000000..c74daf1
--- /dev/null
+++ b/models/claude-opus-5-5-cc-max.json
@@ -0,0 +1,38 @@
+{
+ "label": "Claude Opus 5.5",
+ "vendor": "Anthropic",
+ "harness": "Claude Code",
+ "effort": "Max",
+ "artifactDir": "claude-opus-5-5/claude-code-max",
+ "order": 8.3,
+ "runs": {
+ "pelican-cycling": {
+ "note": {
+ "zh": "在本地通过 Claude Code CLI 以 claude-opus-5-5(max)一次生成,未经修改,耗时 46 分 1 秒。空 workspace,关闭全部 skill(含 claude.ai 同步与内置 skill)、插件(含内置插件)与 MCP/连接器,禁用 WebFetch/WebSearch,无自定义系统提示。模型输出文件名为 pelican-bicycle.svg,此处按仓库规范无内容改动地重命名为 pelican-cycling.svg。",
+ "en": "One-shot by claude-opus-5-5 (max) in Claude Code CLI, unmodified, 46 minutes 1 second. Empty workspace, all skills (including claude.ai-synced and bundled ones), plugins (including built-in ones) and MCP servers/connectors disabled, WebFetch/WebSearch denied, no custom system prompt. The model wrote pelican-bicycle.svg; it was renamed to pelican-cycling.svg per repo convention with no content changes."
+ },
+ "contributor": "HearTFireJ"
+ },
+ "skeleton-watch": {
+ "note": {
+ "zh": "在本地通过 Claude Code CLI 以 claude-opus-5-5(max)一次生成,未经修改,耗时 1 小时 30 分 23 秒。空 workspace,关闭全部 skill(含 claude.ai 同步与内置 skill)、插件(含内置插件)与 MCP/连接器,禁用 WebFetch/WebSearch,无自定义系统提示。披露:shell 未断网,模型在运行中用 curl 从 jsDelivr 获取了 three.js 的版本列表及 0.169.0/0.170.0 的源码(three.module.js 与部分 examples/jsm 模块)用于核对 API,未访问其他网站。运行中触发过一次 Claude Code 内置的输出上限自动续写,全程无人工输入。",
+ "en": "One-shot by claude-opus-5-5 (max) in Claude Code CLI, unmodified, 1 hour 30 minutes 23 seconds. Empty workspace, all skills (including claude.ai-synced and bundled ones), plugins (including built-in ones) and MCP servers/connectors disabled, WebFetch/WebSearch denied, no custom system prompt. Disclosure: the shell was not network-isolated; during the run the model used curl to fetch the three.js version list and the 0.169.0/0.170.0 sources (three.module.js and some examples/jsm modules) from jsDelivr to check APIs, and accessed no other sites. Claude Code's built-in output-limit auto-continue fired once; there was no human input during the run."
+ },
+ "contributor": "HearTFireJ"
+ },
+ "mythos-craft": {
+ "note": {
+ "zh": "在本地通过 Claude Code CLI 以 claude-opus-5-5(max)一次生成,未经修改,耗时 1 小时 43 分 9 秒。空 workspace,关闭全部 skill(含 claude.ai 同步与内置 skill)、插件(含内置插件)与 MCP/连接器,禁用 WebFetch/WebSearch,无自定义系统提示;运行中未联网(仅在本机启动静态服务器做无头浏览器测试),全程无人工输入。模型输出文件名为 index.html,此处按仓库规范无内容改动地重命名为 mythos-craft.html。",
+ "en": "One-shot by claude-opus-5-5 (max) in Claude Code CLI, unmodified, 1 hour 43 minutes 9 seconds. Empty workspace, all skills (including claude.ai-synced and bundled ones), plugins (including built-in ones) and MCP servers/connectors disabled, WebFetch/WebSearch denied, no custom system prompt; no network access during the run (only a local static server for headless-browser testing), no human input. The model wrote index.html; it was renamed to mythos-craft.html per repo convention with no content changes."
+ },
+ "contributor": "HearTFireJ"
+ },
+ "cs-dust2": {
+ "note": {
+ "zh": "在本地通过 Claude Code CLI 以 claude-opus-5-5(max)一次生成,未经修改,耗时 1 小时 37 分 23 秒。空 workspace,关闭全部 skill(含 claude.ai 同步与内置 skill)、插件(含内置插件)与 MCP/连接器,禁用 WebFetch/WebSearch,无自定义系统提示,全程无人工输入。按提示词允许,模型用 npm 查询版本并安装了项目依赖(react、react-dom、three 及 Vite/TypeScript 工具链),另在其临时目录中安装了 puppeteer-core,调用本机 Edge 做无头浏览器测试;未访问其他网站。运行结束时未保留 dist/,此处提交其完整源码工程(不含 node_modules,未改动任何文件)。",
+ "en": "One-shot by claude-opus-5-5 (max) in Claude Code CLI, unmodified, 1 hour 37 minutes 23 seconds. Empty workspace, all skills (including claude.ai-synced and bundled ones), plugins (including built-in ones) and MCP servers/connectors disabled, WebFetch/WebSearch denied, no custom system prompt, no human input. As the prompt allows, the model used npm to query versions and install the project dependencies (react, react-dom, three and the Vite/TypeScript toolchain), and installed puppeteer-core in its scratch directory to drive the local Edge for headless-browser testing; no other sites were accessed. No dist/ was kept at the end of the run; the full source project is submitted (without node_modules, no files changed)."
+ },
+ "contributor": "HearTFireJ"
+ }
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/.gitignore b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/.gitignore
new file mode 100644
index 0000000..3c25e1e
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.log
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/README.md b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/README.md
new file mode 100644
index 0000000..70af72d
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/README.md
@@ -0,0 +1,140 @@
+# Dust2 · 5v5 浏览器 FPS 原型
+
+用 **React 18 + TypeScript + three.js** 从零实现的 3D 第一人称射击原型:在程序化生成的 Dust2 核心区域里进行 5v5 爆破模式对战(1 名玩家 + 4 名 AI 队友 vs 5 名 AI 敌人)。
+
+- **零外部资产**:地图、角色、武器、贴图全部由代码生成(几何体 + Canvas 程序化纹理),所有音效由 Web Audio API 实时合成。
+- **零游戏引擎依赖**:运行时依赖只有 `three` / `react` / `react-dom`。碰撞、寻路、AI 状态机、固定步长模拟、渲染插值全部自研。
+
+## 快速开始
+
+```bash
+npm install
+npm run dev # 打开 http://localhost:5173
+```
+
+其他脚本:
+
+| 命令 | 说明 |
+| --- | --- |
+| `npm run build` | 类型检查 + 生产构建(输出到 `dist/`) |
+| `npm run preview` | 预览生产构建 |
+| `npm run typecheck` | 仅做 TypeScript 类型检查 |
+| `npm run sim -- [seed] [easy\|normal\|hard] [胜局数] [--verbose]` | **无渲染的整场 10 BOT 对战模拟**(规则 + AI 的集成/压力测试,约 200 倍实时速度) |
+
+进入游戏后点击画面锁定鼠标;`Esc` 暂停(可调灵敏度/音量、退出到主菜单)。
+
+## 操作
+
+| 按键 | 功能 |
+| --- | --- |
+| `W A S D` / 鼠标 | 移动 / 视角(Pointer Lock) |
+| `空格` | 跳跃(需重新按下,无自动连跳) |
+| `Shift` | 静步(无脚步声) |
+| `C` | 下蹲(不绑定 Ctrl,避免 Ctrl+W 关闭标签页) |
+| 鼠标左键 | 开火 / 刀轻击 |
+| 鼠标右键 | AWP 开镜(两级)/ 刀重击 |
+| `R` | 换弹 |
+| `1` `2` `3` `5`(`4`)、`Q`、滚轮 | 主武器 / 副武器 / 刀 / C4、上一把武器、循环切换 |
+| `E` | 安放炸弹(在 A/B 区按住)· 拆除炸弹(CT 在炸弹旁按住)· 交换地上的武器 · **观战时接管队友 BOT** |
+| `G` | 丢弃当前武器 / C4 |
+| `B` | 购买菜单(冻结时间及回合开始 20 秒内,数字键购买) |
+| `Tab` | 计分板 |
+| 死亡后 左/右键、`←` `→` | 切换观察的存活队友 |
+
+## 需求实现对照
+
+### 1. Dust2 地图(`src/game/world/MapData.ts`)
+- 以米为单位、按经典雷达方位(T 南 / CT 北 / B 西北 / A 东北)搭建,约 108 × 104 m,跑图时间接近原作(如 CT→A 约 4 s、T→A 大约 18 s)。
+- 完整包含:**T 出生点、CT 出生点、A 大(含 A 大门、A 坑、A 斜坡)、A 点(含 A 平台/“鹅点”、车、箱子)、中路、中门(两扇门体:一扇关闭、一扇开启,留出可穿过的门缝)、猫道 + A 小、B 洞(上/下 B 洞)、B 点(含 B 门、B 窗、后平台、车)**,并按原作走位连通:
+ - T → A 门外 → A 大门 → A 大 → A 斜坡 → A 点
+ - T → 中路 → 猫道楼梯 → 猫道 → A 小 → A 点
+ - T → 中路 → 中门 → CT 中路 → B 门 → B 点
+ - T → B 洞外 → 上 B 洞 → B 点;中路 ↔ 下 B 洞 ↔ 上 B 洞
+ - CT 出生点 ↔ CT 斜坡 ↔ A 点;CT 出生点 ↔ 中门区域 ↔ B 门
+- 地面以“地板矩形(平地/坡道/楼梯)”描述,地图边界内其余部分自动栅格化为实心建筑并贪心合并成碰撞盒,**保证所有区域都被墙体封闭**。墙、箱子、门板、车、桶全部有碰撞(玩家与 AI 共用同一套碰撞)。
+
+### 2. 角色(`src/game/gfx/CharacterModel.ts`)
+- 独立的头、躯干(腹部/胸部)、骨盆、双臂(上臂/前臂/手)、双腿(大腿/小腿/脚),层级骨骼 + 双骨 IK:双手实时贴合所持武器的握把与护木。
+- CT:深蓝制服 + 防弹背心 + 头盔护目镜;T:卡其外套 + 黑色头套 + 红色头巾/臂章——一眼可辨。
+- 默认持枪姿态,上半身随俯仰瞄准,武器随朝向移动;行走步态、下蹲、换弹动作、死亡倒地。
+- 每个肢体网格同时就是命中盒(在网格局部空间做射线-OBB 检测),命中判定与所见完全一致。
+
+### 3. 武器系统(`src/game/weapons/WeaponDefs.ts`、`src/game/sim/Combat.ts`)
+- 数据驱动:伤害、护甲穿透、射程衰减、射速、全/半自动、弹匣/备弹、换弹/切枪时间、移速、散布模型(站立/蹲/移动/空中/每发扩散/恢复)、后坐力弹道表、瞄准镜参数。新增武器只需添加一条定义 + 一个程序化模型。
+- 主武器 / 副武器 / 近战 / C4 槽位切换,均有第一人称手持模型(独立渲染通道,不穿墙)与第三人称模型。
+
+| 武器 | 基础伤害(胸) | 爆头 | 射速 | 弹匣 | 特点 |
+| --- | --- | --- | --- | --- | --- |
+| AK-47 | 50 | 100 | 600 RPM | 30 | 高伤害,剧烈后坐:准星大幅扩散 + 视角明显上扬 |
+| M4A4 | 38 | 76 | 667 RPM | 30 | 中等伤害,后坐力约为 AK 一半,射速更高 |
+| AWP | 125 | 250 | 41 RPM | 10 | 单发致命;右键开镜(3D 视野 30°/9° 拉近 + 2D 瞄准镜遮罩),开枪后自动退镜、拉栓后恢复 |
+| Glock-18 | 24 | 48 | 半自动 | 20 | T 默认手枪 |
+| USP-S | 32 | 64 | 半自动 | 12 | CT 默认手枪(消音) |
+| Desert Eagle | 46 | 92 | 半自动 | 7 | 伤害介于手枪与步枪之间,弹匣小、后坐大 |
+| 刀 | 34 / 65 | ×2 | — | — | 轻击/重击,背刺加成 |
+
+- **Hitbox 分区伤害倍率**(`HITGROUP_MULTIPLIER`):头 ×2.0、胸 ×1.0(身体基准)、腹 ×1.25、手臂 ×0.8、腿 ×0.65 —— **爆头恰为身体伤害的两倍**。
+- **护甲**:头盔保护头部,防弹衣保护胸/腹/手臂,腿部不受保护;受保护部位只有 `armorPen` 比例的伤害进入生命值,其余损耗护甲(CS 规则)。
+
+### 4. 手枪局
+- 默认第 1 回合为手枪局:全员只有默认手枪(T: Glock-18 / CT: USP-S)+ 刀,无主武器,护甲按“手枪局护甲”配置(主菜单可选:无护甲 / 防弹衣无头盔),购买菜单只允许手枪。之后的回合为长枪局(全甲、CT 拆弹器、主武器;每回合每队有一定概率由一名 BOT 改用 AWP)。
+
+### 5. C4 与回合胜负(`src/game/sim/World.ts`)
+- 回合开始随机交给一名 T;持包者阵亡后炸弹掉落,T 走近自动拾取(BOT 会主动去捡)。
+- 持包者在 A/B 安放区按住 `E`(或切到 C4 按住左键)3.2 s 安放,期间不能移动;安放后 40 s 倒计时,蜂鸣频率逐渐加快。
+- CT 在炸弹旁按住 `E` 拆除:无拆弹器 10 s,有拆弹器 5 s;松开或离开即中断。
+- 结算:T 消灭全部 CT 或 C4 爆炸 → T 胜;CT 消灭全部 T(且未安放)、拆弹成功或时间耗尽 → CT 胜。爆炸按距离造成伤害。
+- 玩家阵亡后:约 2.5 s 第三人称死亡镜头 → 自动切到存活队友第一人称视角(含其武器模型),可切换观察对象,按 `E` **接管该队友 BOT** 继续作战;下一回合恢复为自己的角色。
+
+### 6. 5v5 AI(`src/game/ai/`)
+- **导航**:0.5 m 栅格 NavGrid,按玩家碰撞体(半径 + 半格)膨胀后可走,A*(八方向、靠墙代价)+ 拉直路径;行进中持续校验直线可达性,偏离后就地重新规划;队友避让、离网回正与卡住脱困。
+- **感知**:视野角(按难度 100°–130°)+ 视线射线(头/胸/腿三点,墙体遮挡即不可见),听觉(枪声/脚步)与受击方向;全队共享敌人情报(同时驱动小地图“被发现的敌人”)。
+- **单兵状态机**(`BotBrain`):交战(反应延迟 → 受转速限制的瞄准 + 递减的瞄准误差 + 后坐补偿 → 按武器与距离点射/连射/AWP 开镜单发 → 急停反向键/左右横移)> 安放/拆弹 > 任务执行(沿路线推进、驻守与巡逻、拾包、搜索)> 观察(预瞄最后目击点、响应声音和队友情报)。
+- **团队战术**(`TeamAI`):T 随机选 A/B,按真实路线分组(A 大 / 猫道 / 中路摸中门 / B 洞),在集结点会合后统一进攻,持包者安放,安放后各自卡点防守;包掉落派最近队友回收;回合后期强攻。CT 按多套默认站位分守 A/中/B 并巡逻,根据情报回防,下包后回防并指派拆弹手。
+- 难度:简单 / 普通 / 困难(反应、转速、准度、爆头倾向、压枪、横移)。
+
+### 7. 操作与 HUD(`src/ui/`)
+- 动态十字准星(随实际散布实时扩散)、命中标记(爆头/击杀区分)、受击方向指示与红色暗角。
+- 生命值 / 护甲(头盔、拆弹器、C4 标签),动态弹药面板(开火递减、换弹恢复、弹匣子弹条),武器槽位。
+- 右上角实时击杀播报(武器 + 爆头标记,与玩家相关的高亮)。
+- 顶部比分、回合计时(下包后变为炸弹图标)、双方存活指示;回合横幅、事件提示、安放/拆除进度条、计分板、购买菜单、暂停与比赛结束界面。
+- **小地图**:常驻显示 Dust2 俯视轮廓(由关卡栅格预渲染,含区域名称与 A/B 标记),实时标出自己(箭头 + 视野锥)、队友、当前被己方看见的敌人、阵亡队友位置与 C4(携带/掉落/已安放闪烁)。下方显示当前所在区域名。
+
+### 8. 声音(`src/game/audio/AudioEngine.ts`)
+全部由 Web Audio 节点图实时合成(噪声 + 滤波 + 扫频振荡器 + 包络 + 合成卷积混响),并带 3D 声像、距离低通与遮挡衰减:
+- 开火:AK(低沉有力)、M4(更清脆)、AWP(巨响 + 长尾)、Glock(短促)、USP-S(消音“噗”声)、沙鹰(厚重)各自不同的配方。
+- 换弹(按武器换弹时长排程的卸弹匣/上弹匣/拉栓声)、切枪、空仓、脚步(3D)、落地、AWP 开镜咔哒声。
+- 命中反馈(身体“噗” / 爆头金属“叮”)、击杀提示音、受伤闷响。
+- C4:安放时的键盘按键音、已安放提示、逐渐加速的蜂鸣、拆除声、拆除成功、爆炸(低频轰鸣 + 滚雷尾音)、回合胜负提示。
+
+## 架构
+
+```
+src/
+├─ main.tsx / App.tsx React 入口:菜单 ↔ 对局,设置持久化
+├─ ui/ React HUD 与界面(只读引擎状态)
+│ ├─ GameView.tsx 挂载/销毁引擎(生命周期由 React 管理)
+│ ├─ Hud.tsx, Overlays.tsx 各 HUD 面板、购买/计分板/暂停/结束
+│ ├─ Crosshair.tsx, Minimap.tsx 每帧绘制的高频组件(直接读 FastHud,不触发 React 渲染)
+│ └─ radar.ts 小地图底图预渲染
+└─ game/
+ ├─ core/ 数学、随机数、类型、常量、类型安全事件总线
+ ├─ world/ Collision(AABB/斜坡楔体、网格加速、角色移动与射线)、Level(栅格化建图)、NavGrid(A*)、MapData(Dust2)
+ ├─ sim/ World(权威游戏状态 + 固定步长 tick)、Actor、Movement(Source 风格移动)、Combat(武器/命中/伤害)、Events、headless
+ ├─ ai/ BotBrain(单兵状态机)、TeamAI(团队战术)
+ ├─ weapons/ 武器数据定义
+ ├─ gfx/ Renderer(场景/天空/阳光阴影/插值/双通道渲染)、MapMeshes、CharacterModel、WeaponModels、ViewModel、Effects、Textures、GeoBuilder
+ ├─ audio/ AudioEngine(程序化音效)
+ └─ engine/ GameEngine(主循环与系统编排)、Input、Store(外部状态仓库)、HudState
+```
+
+### 三条更新链路的同步
+1. **物理/规则帧(固定 64 Hz)**:`GameEngine` 用累加器驱动 `World.tick(1/64)`(每帧最多追赶 8 步)。所有规则、AI、碰撞、命中、回合与 C4 都只在这里推进,是唯一的权威状态。
+2. **渲染循环(requestAnimationFrame)**:每帧先把鼠标增量直接作用到玩家视角(零额外延迟),再按 `alpha = 累加余量 / 步长` 在上一 tick 与当前 tick 之间插值所有角色位置/朝向;世界通道渲染后清深度,再渲染第一人称武器通道。
+3. **React 状态**:引擎每 50 ms 生成一次不可变 HUD 快照写入 `Store`,组件通过 `useSyncExternalStore` + 选择器订阅各自切片,只有变化的切片重渲染;准星、命中标记、受击方向、小地图等每帧变化的数据走可变的 `FastHud` / 直接读取,在各自的 rAF 中绘制,不经过 React。
+4. 模拟层通过 **EventBus** 广播(开火、命中、击杀、换弹、脚步、C4…),音频、特效、HUD、AI 听觉各自订阅,模拟层不依赖任何表现层 —— 因此 `npm run sim` 可在 Node 中无 DOM 运行整场比赛。
+
+## 已知的原型取舍
+- 无经济系统(购买免费,仅受回合类型/时间限制)、无投掷物、无穿墙射击、不切换攻守。
+- 半开的 A 大门门板碰撞由一串小 AABB 近似(视觉为旋转的门板)。
+- 角色是方块风格的程序化模型,非蒙皮网格。
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/index.html b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/index.html
new file mode 100644
index 0000000..589dc1e
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Dust2 · 5v5 FPS 原型
+
+
+
+
+
+
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package-lock.json b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package-lock.json
new file mode 100644
index 0000000..7e3f790
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package-lock.json
@@ -0,0 +1,1932 @@
+{
+ "name": "dust2-5v5-fps",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dust2-5v5-fps",
+ "version": "1.0.0",
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^4.7.0",
+ "typescript": "~5.9.3",
+ "vite": "^6.4.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.6.tgz",
+ "integrity": "sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.6.tgz",
+ "integrity": "sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.6.tgz",
+ "integrity": "sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.6.tgz",
+ "integrity": "sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.6.tgz",
+ "integrity": "sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.6.tgz",
+ "integrity": "sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.6.tgz",
+ "integrity": "sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.6.tgz",
+ "integrity": "sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.6.tgz",
+ "integrity": "sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.6.tgz",
+ "integrity": "sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.6.tgz",
+ "integrity": "sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.6.tgz",
+ "integrity": "sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.6.tgz",
+ "integrity": "sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.6.tgz",
+ "integrity": "sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.6.tgz",
+ "integrity": "sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.6.tgz",
+ "integrity": "sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.6.tgz",
+ "integrity": "sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.6.tgz",
+ "integrity": "sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.6.tgz",
+ "integrity": "sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.6.tgz",
+ "integrity": "sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.6.tgz",
+ "integrity": "sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.6.tgz",
+ "integrity": "sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.6.tgz",
+ "integrity": "sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.6.tgz",
+ "integrity": "sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.6.tgz",
+ "integrity": "sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.186.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
+ "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.3",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.26",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz",
+ "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001814",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
+ "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.443",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.443.tgz",
+ "integrity": "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.6",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.6.tgz",
+ "integrity": "sha512-w4+GKyTBqshj84F1GF3s2zxAPFZPEmNkCbQtqilsYS7GsksmX6VoV7UggfZg8sUHCCOkBe7UtQp6fZwbH311FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.6",
+ "@rollup/rollup-android-arm64": "4.63.6",
+ "@rollup/rollup-darwin-arm64": "4.63.6",
+ "@rollup/rollup-darwin-x64": "4.63.6",
+ "@rollup/rollup-freebsd-arm64": "4.63.6",
+ "@rollup/rollup-freebsd-x64": "4.63.6",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.6",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.6",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.6",
+ "@rollup/rollup-linux-arm64-musl": "4.63.6",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.6",
+ "@rollup/rollup-linux-loong64-musl": "4.63.6",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.6",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.6",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.6",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.6",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.6",
+ "@rollup/rollup-linux-x64-gnu": "4.63.6",
+ "@rollup/rollup-linux-x64-musl": "4.63.6",
+ "@rollup/rollup-openbsd-x64": "4.63.6",
+ "@rollup/rollup-openharmony-arm64": "4.63.6",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.6",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.6",
+ "@rollup/rollup-win32-x64-gnu": "4.63.6",
+ "@rollup/rollup-win32-x64-msvc": "4.63.6",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package.json b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package.json
new file mode 100644
index 0000000..d4ff218
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "dust2-5v5-fps",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Browser 3D FPS prototype: Dust2 core areas, 5v5 bots, C4 rounds (React + TypeScript + three.js, fully procedural assets)",
+ "type": "module",
+ "scripts": {
+ "dev": "node node_modules/vite/bin/vite.js",
+ "build": "node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build",
+ "preview": "node node_modules/vite/bin/vite.js preview",
+ "typecheck": "node node_modules/typescript/bin/tsc --noEmit",
+ "sim": "node scripts/simulate.mjs"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.31",
+ "@types/react-dom": "^18.3.7",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^4.7.0",
+ "typescript": "~5.9.3",
+ "vite": "^6.4.3"
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/scripts/simulate.mjs b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/scripts/simulate.mjs
new file mode 100644
index 0000000..fe7bb3f
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/scripts/simulate.mjs
@@ -0,0 +1,28 @@
+// Headless match simulation: `npm run sim -- [seed] [difficulty] [roundsToWin] [--verbose]`
+// Uses Vite's SSR module loader to execute the TypeScript simulation directly in Node,
+// proving the game rules + AI run without any rendering/DOM (and doubling as a soak test).
+import { createServer } from 'vite';
+
+const args = process.argv.slice(2);
+const verbose = args.includes('--verbose');
+const pos = args.filter((a) => !a.startsWith('--'));
+const seed = Number(pos[0] ?? 1);
+const difficulty = pos[1] ?? 'normal';
+const roundsToWin = Number(pos[2] ?? 8);
+
+const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
+try {
+ const { runSimulation } = await server.ssrLoadModule('/src/game/sim/headless.ts');
+ console.log(`Simulating 5v5 bots on de_dust2 (seed ${seed}, ${difficulty}, first to ${roundsToWin})...`);
+ const r = runSimulation({ seed, difficulty, roundsToWin, verbose });
+ console.log('\n==== match report ====');
+ console.log(`winner : ${r.winner ?? 'none (time cap)'} score CT ${r.score.CT} : ${r.score.T} T`);
+ console.log(`rounds : ${r.rounds.length} (${r.rounds.map((x) => `${x.winner}:${x.reason}`).join(', ')})`);
+ console.log(`kills : ${r.kills} (headshots ${r.headshots})`);
+ console.log(`bomb : ${r.plants} planted, ${r.defuses} defused, ${r.explosions} exploded`);
+ console.log(`simulated : ${r.simulatedSeconds.toFixed(0)} s game time in ${(r.wallMs / 1000).toFixed(2)} s wall time (${(r.simulatedSeconds / (r.wallMs / 1000)).toFixed(0)}x realtime)`);
+ console.log(`worst bot stall: ${r.worstStall.toFixed(2)} s`);
+ if (!r.winner) process.exitCode = 1;
+} finally {
+ await server.close();
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/App.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/App.tsx
new file mode 100644
index 0000000..596c87e
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/App.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useMemo, useState } from 'react';
+import { AudioEngine } from './game/audio/AudioEngine';
+import { DEFAULT_SETTINGS, type MatchSettings } from './game/core/types';
+import { GameView } from './ui/GameView';
+import { MainMenu } from './ui/MainMenu';
+
+const STORAGE_KEY = 'dust2-fps-settings-v1';
+
+function loadSettings(): MatchSettings {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial) };
+ } catch {
+ // storage unavailable (private mode): fall back to defaults
+ }
+ return { ...DEFAULT_SETTINGS };
+}
+
+function saveSettings(s: MatchSettings): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
+ } catch {
+ // ignore
+ }
+}
+
+export default function App() {
+ const [settings, setSettings] = useState(loadSettings);
+ const [screen, setScreen] = useState<'menu' | 'game'>('menu');
+ const [session, setSession] = useState(0);
+ const audio = useMemo(() => new AudioEngine(), []);
+
+ const start = useCallback(
+ (s: MatchSettings) => {
+ saveSettings(s);
+ setSettings(s);
+ audio.init(); // user gesture: unlock Web Audio
+ audio.setVolume(s.volume);
+ audio.uiClick();
+ setSession((n) => n + 1);
+ setScreen('game');
+ },
+ [audio],
+ );
+
+ const exit = useCallback((s?: MatchSettings) => {
+ if (s) {
+ saveSettings(s);
+ setSettings(s);
+ }
+ if (document.pointerLockElement) document.exitPointerLock();
+ setScreen('menu');
+ }, []);
+
+ return screen === 'menu' ? (
+
+ ) : (
+
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/BotBrain.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/BotBrain.ts
new file mode 100644
index 0000000..6aa1192
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/BotBrain.ts
@@ -0,0 +1,735 @@
+import * as THREE from 'three';
+import { angleDelta, clamp, DEG, dist2D, pitchFromDir, wrapAngle, yawFromDir } from '../core/math';
+import type { Difficulty, SiteId } from '../core/types';
+import { clearInput, type Actor } from '../sim/Actor';
+import { bestSlot } from '../sim/Combat';
+import type { World } from '../sim/World';
+import type { NavPoint } from '../world/NavGrid';
+
+export interface BotSkill {
+ /** Reaction delay range (s) before the first shot at a newly seen enemy. */
+ reaction: [number, number];
+ /** Max view turn rate (deg/s). */
+ turnRate: number;
+ /** Exponential view tracking rate (1/s). */
+ trackRate: number;
+ /** Initial aim error (deg, 1 sigma). */
+ aimError: number;
+ errDecay: number;
+ headshot: number;
+ recoilComp: number;
+ fov: number;
+ strafe: number;
+}
+
+export const SKILLS: Record = {
+ easy: { reaction: [0.55, 0.85], turnRate: 260, trackRate: 7, aimError: 4.2, errDecay: 1.8, headshot: 0.1, recoilComp: 0.3, fov: 100, strafe: 0.15 },
+ normal: { reaction: [0.33, 0.52], turnRate: 420, trackRate: 11, aimError: 2.7, errDecay: 2.8, headshot: 0.24, recoilComp: 0.6, fov: 120, strafe: 0.45 },
+ hard: { reaction: [0.2, 0.32], turnRate: 640, trackRate: 16, aimError: 1.6, errDecay: 4.2, headshot: 0.4, recoilComp: 0.85, fov: 130, strafe: 0.75 },
+};
+
+export type TaskKind = 'idle' | 'route' | 'hold' | 'plant' | 'pickup' | 'defuse' | 'hunt';
+
+/** High-level order given by TeamAI; BotBrain turns it into movement/look/actions. */
+export interface BotTask {
+ kind: TaskKind;
+ points?: NavPoint[];
+ index?: number;
+ /** Route index at which to wait until the group is released. */
+ holdAt?: number;
+ released?: boolean;
+ group?: string;
+ dest?: NavPoint;
+ look?: NavPoint;
+ patrol?: NavPoint[];
+ nextPatrol?: number;
+ site?: SiteId;
+ then?: BotTask;
+}
+
+const _eye = new THREE.Vector3();
+const _p = new THREE.Vector3();
+
+/**
+ * Per-bot state machine. Layers, highest priority first:
+ * 1. Engage: visible enemy -> reaction delay -> aim (turn-rate limited, decaying error, recoil
+ * compensation) -> weapon-specific fire control (bursts / taps / scoped shots) -> counter-strafe.
+ * 2. Objective actions (plant / defuse) when safe.
+ * 3. Task execution: path following on the nav grid with smoothing, avoidance and stuck recovery.
+ * 4. Awareness: pre-aim last known enemy positions, react to sounds and damage.
+ */
+export class BotBrain {
+ readonly skill: BotSkill;
+ readonly fovHalf: number;
+ task: BotTask = { kind: 'idle' };
+
+ // navigation
+ private path: NavPoint[] = [];
+ private pathIdx = 0;
+ private pathGoal: NavPoint | null = null;
+ private nextRepath = 0;
+ private nextLineCheck = 0;
+ private pathFailures = 0;
+ private progressPos = new THREE.Vector3();
+ private progressTime = 0;
+ private unstuckUntil = 0;
+ private unstuckDir = 0;
+ private stuckCount = 0;
+
+ // combat
+ target: Actor | null = null;
+ private reactionAt = 0;
+ private lastSeenTime = -100;
+ private readonly lastSeenPos = new THREE.Vector3();
+ private aimHead = false;
+ private errYaw = 0;
+ private errPitch = 0;
+ private burstLeft = 0;
+ private burstPauseUntil = 0;
+ private nextTapAt = 0;
+ private seenShotTime = -100;
+ private strafeDir = 0;
+ private strafeUntil = 0;
+ private scopeRequestedAt = -10;
+
+ // awareness
+ private readonly alertPos = new THREE.Vector3();
+ private alertUntil = -1;
+ private scanPhase = 0;
+
+ constructor(
+ private readonly world: World,
+ readonly actor: Actor,
+ difficulty: Difficulty,
+ ) {
+ this.skill = SKILLS[difficulty];
+ this.fovHalf = (this.skill.fov / 2) * DEG;
+ this.scanPhase = world.rng.range(0, 10);
+ }
+
+ resetForRound(): void {
+ this.task = { kind: 'idle' };
+ this.path.length = 0;
+ this.pathGoal = null;
+ this.target = null;
+ this.lastSeenTime = -100;
+ this.alertUntil = -1;
+ this.burstLeft = 0;
+ this.burstPauseUntil = 0;
+ this.stuckCount = 0;
+ this.unstuckUntil = 0;
+ this.progressTime = this.world.time;
+ this.progressPos.copy(this.actor.pos);
+ this.seenShotTime = -100;
+ }
+
+ setTask(task: BotTask): void {
+ this.task = task;
+ this.path.length = 0;
+ this.pathGoal = null;
+ this.pathFailures = 0;
+ }
+
+ get hasRecentContact(): boolean {
+ return this.world.time - this.lastSeenTime < 3;
+ }
+
+ // ================================================================== external stimuli
+
+ hear(source: Actor, pos: THREE.Vector3): void {
+ if (this.target && this.world.sees(this.actor, this.target)) return;
+ // close noises are always noticed, distant ones only sometimes
+ const near = source.pos.distanceToSquared(this.actor.pos) < 15 * 15;
+ if (near || this.world.rng.chance(0.75)) {
+ this.alertPos.copy(pos);
+ this.alertPos.y += 1.2;
+ this.alertUntil = this.world.time + 2.2;
+ }
+ }
+
+ onDamaged(attacker: Actor): void {
+ if (attacker.team === this.actor.team) return;
+ if (!this.target || !this.world.sees(this.actor, this.target)) {
+ this.alertPos.set(attacker.pos.x, attacker.pos.y + 1.4, attacker.pos.z);
+ this.alertUntil = this.world.time + 2.5;
+ }
+ }
+
+ // ================================================================== main update
+
+ update(dt: number): void {
+ const a = this.actor;
+ const w = this.world;
+ const inp = a.input;
+ clearInput(inp);
+ if (!a.alive) return;
+ const now = w.time;
+
+ if (w.phase === 'freeze') {
+ this.lookAround(dt, now);
+ return;
+ }
+
+ this.updateTarget(now);
+ this.manageWeapon(now);
+
+ const tgt = this.target;
+ if (tgt && tgt.alive && w.sees(a, tgt)) {
+ if (a.defusing && this.mustKeepDefusing()) {
+ inp.use = true;
+ this.aimAtActor(tgt, dt, false);
+ return;
+ }
+ this.combat(tgt, dt, now);
+ return;
+ }
+
+ // keep planting / defusing when no enemy is in sight
+ this.executeTask(dt, now);
+ }
+
+ // ================================================================== perception
+
+ private updateTarget(now: number): void {
+ const w = this.world;
+ const a = this.actor;
+ let best: Actor | null = null;
+ let bestD = Infinity;
+ for (const e of w.actors) {
+ if (!e.alive || e.team === a.team || !w.sees(a, e)) continue;
+ const d = e.pos.distanceToSquared(a.pos);
+ // stick to the current target while it stays visible
+ const bias = e === this.target ? 0.6 : 1;
+ if (d * bias < bestD) {
+ bestD = d * bias;
+ best = e;
+ }
+ }
+ if (best) {
+ if (best !== this.target || now - this.lastSeenTime > 1.2) {
+ const recentlySameTarget = best === this.target && now - this.lastSeenTime < 2.5;
+ const r = w.rng.range(this.skill.reaction[0], this.skill.reaction[1]);
+ // peripheral targets take longer to react to
+ a.eyePos(_eye);
+ const yawTo = yawFromDir(best.pos.x - _eye.x, best.pos.z - _eye.z);
+ const off = Math.abs(angleDelta(a.yaw, yawTo));
+ this.reactionAt = now + r * (recentlySameTarget ? 0.35 : 1) * (1 + off * 0.35);
+ const errScale = this.skill.aimError * DEG * (1 + off * 0.6);
+ this.errYaw = w.rng.gaussian() * errScale;
+ this.errPitch = w.rng.gaussian() * errScale * 0.6;
+ this.aimHead = w.rng.chance(this.skill.headshot);
+ this.burstLeft = 0;
+ }
+ this.target = best;
+ this.lastSeenTime = now;
+ this.lastSeenPos.copy(best.pos);
+ } else if (this.target && (now - this.lastSeenTime > 4 || !this.target.alive)) {
+ this.target = null;
+ }
+ }
+
+ private mustKeepDefusing(): boolean {
+ const w = this.world;
+ const a = this.actor;
+ const need = (a.hasKit ? 5 : 10) - a.defuseProgress;
+ return w.bomb.timeLeft < need + 2.5 || a.defuseProgress > need * 0.6;
+ }
+
+ // ================================================================== combat
+
+ private aimPoint(t: Actor, out: THREE.Vector3): THREE.Vector3 {
+ if (this.aimHead) return t.model.headWorld(out);
+ return out.set(t.pos.x, t.pos.y + 1.22 - t.crouchAmt * 0.35, t.pos.z);
+ }
+
+ /** Turns towards the target; returns angular error (rad) to the true aim point. */
+ private aimAtActor(t: Actor, dt: number, compensate: boolean): number {
+ const a = this.actor;
+ a.eyePos(_eye);
+ this.aimPoint(t, _p);
+ const dx = _p.x - _eye.x;
+ const dy = _p.y - _eye.y;
+ const dz = _p.z - _eye.z;
+ let yaw = yawFromDir(dx, dz);
+ let pitch = pitchFromDir(dx, dy, dz);
+ if (compensate) {
+ pitch -= a.recoilPitch * DEG * this.skill.recoilComp;
+ yaw += a.recoilYaw * DEG * this.skill.recoilComp;
+ }
+ const decay = Math.exp(-this.skill.errDecay * dt);
+ this.errYaw *= decay;
+ this.errPitch *= decay;
+ // tracking a strafing target re-introduces some error
+ const lateral = Math.abs(t.vel.x * Math.cos(yaw) - t.vel.z * Math.sin(yaw));
+ this.errYaw += (this.world.rng.next() - 0.5) * lateral * 0.0025;
+ this.turnTowards(yaw + this.errYaw, pitch + this.errPitch, dt, this.skill.turnRate, this.skill.trackRate);
+ const ey = angleDelta(a.yaw, yaw);
+ const ep = pitch - a.pitch;
+ return Math.hypot(ey, ep);
+ }
+
+ private combat(t: Actor, dt: number, now: number): void {
+ const a = this.actor;
+ const inp = a.input;
+ const w = a.weapon;
+ if (!w) return;
+ const def = w.def;
+ const dist = a.pos.distanceTo(t.pos);
+
+ // detect a shot fired in the previous tick (bursts)
+ if (a.lastShotTime > this.seenShotTime) {
+ this.seenShotTime = a.lastShotTime;
+ if (this.burstLeft > 0) {
+ this.burstLeft--;
+ if (this.burstLeft === 0) {
+ this.burstPauseUntil = now + (dist > 28 ? 0.42 : dist > 14 ? 0.28 : 0.16) * this.world.rng.range(0.8, 1.3);
+ }
+ }
+ }
+
+ const angErr = this.aimAtActor(t, dt, true);
+ const radius = this.aimHead ? 0.12 : 0.22;
+ const tol = Math.atan2(radius, Math.max(1, dist)) * 1.25 + 0.35 * DEG;
+ const ready = now >= this.reactionAt && now >= a.drawEnd && !a.reloading && w.ammo > 0;
+ let shooting = false;
+
+ if (def.kind === 'knife') {
+ // close in and slash
+ this.steerDirect(t.pos.x, t.pos.z);
+ if (dist < 1.7 && ready) {
+ inp.fire = true;
+ inp.firePressed = true;
+ }
+ return;
+ }
+
+ if (def.kind === 'sniper') {
+ if (a.scopeLevel === 0 && !a.reloading && now >= a.drawEnd && now - this.scopeRequestedAt > 0.4 && a.rezoomAt === 0) {
+ inp.altPressed = true;
+ this.scopeRequestedAt = now;
+ }
+ const still = Math.hypot(a.vel.x, a.vel.z) < 1.2;
+ if (ready && a.scopeLevel > 0 && now - this.scopeRequestedAt > 0.18 && angErr < tol * 0.9 && still) {
+ inp.fire = true;
+ inp.firePressed = true;
+ shooting = true;
+ }
+ } else if (def.kind === 'pistol') {
+ if (ready && angErr < tol * 1.3 && now >= this.nextTapAt) {
+ inp.fire = true;
+ inp.firePressed = true;
+ shooting = true;
+ this.nextTapAt = now + def.fireInterval + this.world.rng.range(0.04, dist > 15 ? 0.32 : 0.16);
+ }
+ } else {
+ // rifles: bursts scaled by distance
+ if (ready && now >= this.burstPauseUntil && angErr < Math.max(tol * 1.4, 1.1 * DEG)) {
+ if (this.burstLeft <= 0) {
+ const r = this.world.rng;
+ this.burstLeft = dist > 28 ? r.int(1, 3) : dist > 14 ? r.int(3, 5) : r.int(6, 12);
+ inp.firePressed = true;
+ }
+ inp.fire = true;
+ shooting = true;
+ }
+ }
+
+ // ---- combat movement: stop to shoot (counter-strafe), strafe between bursts
+ if (a.planting || a.defusing) return;
+ const speed = Math.hypot(a.vel.x, a.vel.z);
+ if (shooting || (now < this.reactionAt && dist > 10)) {
+ if (speed > 0.6) this.counterStrafe();
+ } else if (this.skill.strafe > 0 && def.kind !== 'sniper') {
+ if (now >= this.strafeUntil) {
+ const r = this.world.rng;
+ this.strafeDir = r.chance(this.skill.strafe) ? (r.chance(0.5) ? 1 : -1) : 0;
+ this.strafeUntil = now + r.range(0.25, 0.6);
+ }
+ inp.right = this.strafeDir;
+ inp.walk = dist > 25;
+ }
+ }
+
+ private counterStrafe(): void {
+ const a = this.actor;
+ const sy = Math.sin(a.yaw);
+ const cy = Math.cos(a.yaw);
+ const vf = a.vel.x * -sy + a.vel.z * -cy;
+ const vr = a.vel.x * cy + a.vel.z * -sy;
+ a.input.forward = Math.abs(vf) > 0.5 ? -Math.sign(vf) : 0;
+ a.input.right = Math.abs(vr) > 0.5 ? -Math.sign(vr) : 0;
+ }
+
+ private manageWeapon(now: number): void {
+ const a = this.actor;
+ const inp = a.input;
+ const w = a.weapon;
+ const inCombat = this.target !== null && now - this.lastSeenTime < 1.5;
+ if (a.planting || a.defusing) return;
+ if (!w) {
+ inp.slot = bestSlot(a);
+ return;
+ }
+ if (w.def.kind === 'c4' || (w.def.kind === 'knife' && (a.weapons[0] || a.weapons[1]))) {
+ inp.slot = bestSlot(a);
+ return;
+ }
+ if (w.def.magSize > 0 && w.ammo === 0) {
+ if (inCombat && a.slot === 0 && a.weapons[1] && a.weapons[1].ammo > 0) inp.slot = 1;
+ else if (w.reserve > 0) inp.reload = true;
+ else inp.slot = a.slot === 0 && a.weapons[1] ? 1 : 2;
+ return;
+ }
+ if (!inCombat) {
+ if (a.slot !== 0 && a.weapons[0] && (a.weapons[0].ammo > 0 || a.weapons[0].reserve > 0)) {
+ inp.slot = 0;
+ return;
+ }
+ if (w.def.magSize > 0 && w.ammo < w.def.magSize * 0.45 && w.reserve > 0 && now - this.lastSeenTime > 2.5) inp.reload = true;
+ // un-scope to travel at full speed
+ if (a.scopeLevel > 0 && now - this.lastSeenTime > 3 && this.task.kind !== 'hold') {
+ a.scopeLevel = 0;
+ a.rezoomAt = 0;
+ }
+ }
+ }
+
+ // ================================================================== tasks
+
+ private executeTask(dt: number, now: number): void {
+ const a = this.actor;
+ const inp = a.input;
+ const w = this.world;
+ const task = this.task;
+ let moving = false;
+ let lookTarget: NavPoint | null = null;
+
+ switch (task.kind) {
+ case 'route': {
+ const pts = task.points ?? [];
+ let i = task.index ?? 0;
+ if (i >= pts.length) {
+ this.setTask(task.then ?? { kind: 'idle' });
+ return this.executeTask(dt, now);
+ }
+ const waiting = task.holdAt !== undefined && i === task.holdAt && !task.released;
+ const p = pts[i];
+ if (this.moveTo(p, waiting ? 1.6 : 2.2, now)) {
+ if (waiting) {
+ lookTarget = pts[Math.min(i + 1, pts.length - 1)];
+ } else {
+ i++;
+ task.index = i;
+ }
+ } else moving = true;
+ if (moving) lookTarget = this.pathLookPoint();
+ break;
+ }
+ case 'hold': {
+ const dest = task.dest!;
+ if (this.moveTo(dest, 0.7, now)) {
+ lookTarget = task.look ?? null;
+ if (task.patrol && task.patrol.length > 1) {
+ if (task.nextPatrol === undefined) task.nextPatrol = now + w.rng.range(6, 14);
+ if (now >= task.nextPatrol && !this.hasRecentContact) {
+ const next = w.rng.pick(task.patrol);
+ task.dest = next;
+ task.nextPatrol = now + w.rng.range(7, 16);
+ }
+ }
+ } else {
+ moving = true;
+ lookTarget = this.pathLookPoint();
+ }
+ break;
+ }
+ case 'plant': {
+ if (!a.hasBomb) {
+ this.setTask({ kind: 'idle' });
+ break;
+ }
+ const dest = task.dest!;
+ const inSite = w.level.bombsiteAt(a.pos.x, a.pos.z);
+ if (dist2D(a.pos.x, a.pos.z, dest.x, dest.z) > 1.2 || !inSite) {
+ this.moveTo(dest, 0.6, now);
+ moving = true;
+ lookTarget = this.pathLookPoint();
+ } else {
+ inp.use = true;
+ this.lookAt(a.pos.x + Math.sin(-a.yaw) * 2, a.pos.y + 0.2, a.pos.z - Math.cos(a.yaw) * 2, dt, 240);
+ return;
+ }
+ break;
+ }
+ case 'pickup': {
+ const b = w.bomb;
+ if (b.state !== 'dropped') {
+ this.setTask({ kind: 'idle' });
+ break;
+ }
+ this.moveTo({ x: b.pos.x, z: b.pos.z }, 0.3, now);
+ moving = true;
+ lookTarget = this.pathLookPoint();
+ break;
+ }
+ case 'defuse': {
+ const b = w.bomb;
+ if (b.state !== 'planted') {
+ this.setTask({ kind: 'idle' });
+ break;
+ }
+ if (!w.inBombReach(a)) {
+ this.moveTo({ x: b.pos.x, z: b.pos.z }, 0.5, now);
+ moving = true;
+ lookTarget = this.pathLookPoint();
+ } else {
+ // only start when no threat is known nearby, or time is running out
+ const threat = this.hasRecentContact && now - this.lastSeenTime < 1.2;
+ if (!threat || a.defusing) {
+ inp.use = true;
+ this.lookAt(b.pos.x, b.pos.y + 0.1, b.pos.z, dt, 300);
+ return;
+ }
+ lookTarget = { x: this.lastSeenPos.x, z: this.lastSeenPos.z };
+ }
+ break;
+ }
+ case 'hunt': {
+ if (this.moveTo(task.dest!, 1.5, now)) {
+ this.setTask({ kind: 'idle' });
+ } else {
+ moving = true;
+ lookTarget = this.pathLookPoint();
+ }
+ break;
+ }
+ case 'idle':
+ default:
+ break;
+ }
+
+ // ---- look behaviour
+ const recentLoss = this.target && now - this.lastSeenTime < 2.5;
+ if (recentLoss) {
+ // pre-aim where the enemy disappeared
+ this.lookAt(this.lastSeenPos.x, this.lastSeenPos.y + 1.4, this.lastSeenPos.z, dt, this.skill.turnRate * 0.7);
+ } else if (now < this.alertUntil) {
+ this.lookAt(this.alertPos.x, this.alertPos.y, this.alertPos.z, dt, this.skill.turnRate * 0.6);
+ } else if (!moving && this.intelLook(dt)) {
+ // looking at a teammate-reported enemy
+ } else if (lookTarget) {
+ const gy = w.nav.heightAt(lookTarget.x, lookTarget.z) + 1.55;
+ const scan = moving ? 0 : Math.sin(now * 0.7 + this.scanPhase) * 0.25;
+ const yawTo = yawFromDir(lookTarget.x - a.pos.x, lookTarget.z - a.pos.z) + scan;
+ const d = Math.max(1, dist2D(a.pos.x, a.pos.z, lookTarget.x, lookTarget.z));
+ a.eyePos(_eye);
+ const pitch = Math.atan2(gy - _eye.y, d);
+ this.turnTowards(yawTo, clamp(pitch, -0.6, 0.6), dt, moving ? 300 : 160, moving ? 6 : 4);
+ } else {
+ this.lookAround(dt, now);
+ }
+ }
+
+ /** Look towards a fresh, nearby enemy position reported by the team (radar awareness). */
+ private intelLook(dt: number): boolean {
+ const a = this.actor;
+ const intel = this.world.intel[a.team];
+ let best: { x: number; y: number; z: number } | null = null;
+ let bestD = 30 * 30;
+ for (const e of intel.values()) {
+ if (this.world.time - e.time > 3) continue;
+ const d = (e.x - a.pos.x) ** 2 + (e.z - a.pos.z) ** 2;
+ if (d < bestD) {
+ bestD = d;
+ best = e;
+ }
+ }
+ if (!best) return false;
+ this.lookAt(best.x, best.y + 1.4, best.z, dt, 200);
+ return true;
+ }
+
+ private lookAround(dt: number, now: number): void {
+ const a = this.actor;
+ const target = a.yaw + Math.sin(now * 0.5 + this.scanPhase) * 0.004;
+ this.turnTowards(target, 0, dt, 60, 2);
+ }
+
+ private lookAt(x: number, y: number, z: number, dt: number, rate: number): void {
+ const a = this.actor;
+ a.eyePos(_eye);
+ const dx = x - _eye.x;
+ const dz = z - _eye.z;
+ if (dx * dx + dz * dz < 0.04) return;
+ const yaw = yawFromDir(dx, dz);
+ const pitch = pitchFromDir(dx, y - _eye.y, dz);
+ this.turnTowards(yaw, pitch, dt, rate, 8);
+ }
+
+ private turnTowards(yaw: number, pitch: number, dt: number, maxRateDeg: number, track: number): void {
+ const a = this.actor;
+ const k = 1 - Math.exp(-track * dt);
+ let sy = angleDelta(a.yaw, yaw) * k;
+ let sp = (pitch - a.pitch) * k;
+ const maxStep = maxRateDeg * DEG * dt;
+ const mag = Math.hypot(sy, sp);
+ if (mag > maxStep) {
+ sy *= maxStep / mag;
+ sp *= maxStep / mag;
+ }
+ a.yaw = wrapAngle(a.yaw + sy);
+ a.pitch = clamp(a.pitch + sp, -1.4, 1.4);
+ }
+
+ // ================================================================== locomotion
+
+ private pathLookPoint(): NavPoint | null {
+ if (this.pathIdx < this.path.length) {
+ const p = this.path[Math.min(this.pathIdx + 1, this.path.length - 1)];
+ const q = this.path[this.pathIdx];
+ const a = this.actor;
+ // look ahead to the following corner once close to the current one
+ return dist2D(a.pos.x, a.pos.z, q.x, q.z) < 3 ? p : q;
+ }
+ return this.pathGoal;
+ }
+
+ /** Follow a nav path to `goal`; true once within `arrive` meters. */
+ moveTo(goal: NavPoint, arrive: number, now: number): boolean {
+ const a = this.actor;
+ const w = this.world;
+ const d = dist2D(a.pos.x, a.pos.z, goal.x, goal.z);
+ if (d <= arrive) {
+ this.progressTime = now;
+ this.progressPos.copy(a.pos);
+ return true;
+ }
+ const goalChanged = !this.pathGoal || dist2D(this.pathGoal.x, this.pathGoal.z, goal.x, goal.z) > 0.75;
+ if ((goalChanged || this.pathIdx >= this.path.length) && now >= this.nextRepath) {
+ const ok = w.nav.findPath(a.pos.x, a.pos.z, goal.x, goal.z, this.path, a.pos.y);
+ this.pathIdx = 0;
+ this.pathGoal = { x: goal.x, z: goal.z };
+ this.nextRepath = now + 0.3;
+ if (!ok) {
+ this.pathFailures++;
+ this.path.length = 0;
+ this.nextRepath = now + 1;
+ return false;
+ }
+ this.pathFailures = 0;
+ }
+ if (this.pathIdx >= this.path.length) return false;
+
+ let tp = this.path[this.pathIdx];
+ while (this.pathIdx < this.path.length - 1 && dist2D(a.pos.x, a.pos.z, tp.x, tp.z) < 0.65) {
+ this.pathIdx++;
+ tp = this.path[this.pathIdx];
+ }
+ // shortcut: skip a corner when the next one is already in a straight walkable line
+ if (this.pathIdx < this.path.length - 1) {
+ const nx = this.path[this.pathIdx + 1];
+ if (dist2D(a.pos.x, a.pos.z, tp.x, tp.z) < 2 && w.nav.lineWalkable(a.pos.x, a.pos.z, nx.x, nx.z)) {
+ this.pathIdx++;
+ tp = nx;
+ }
+ }
+ // drifted off the smoothed line (avoidance, pushes, inertia)? re-plan from where we are
+ if (now >= this.nextLineCheck) {
+ this.nextLineCheck = now + 0.2;
+ if (w.nav.isWalkable(a.pos.x, a.pos.z) && !w.nav.lineWalkable(a.pos.x, a.pos.z, tp.x, tp.z) && now >= this.nextRepath) {
+ if (w.nav.findPath(a.pos.x, a.pos.z, goal.x, goal.z, this.path, a.pos.y)) {
+ this.pathIdx = 0;
+ tp = this.path[0];
+ }
+ this.nextRepath = now + 0.3;
+ }
+ }
+
+ this.steerDirect(tp.x, tp.z);
+ if (now < this.unstuckUntil) {
+ // wiggle sideways (and hop if repeatedly stuck) on top of the recovery steering
+ a.input.right += this.unstuckDir * 0.8;
+ a.input.jump = this.stuckCount >= 3;
+ }
+ this.checkStuck(now);
+ return false;
+ }
+
+ /**
+ * Converts a world-space move goal into forward/right input. Adds teammate avoidance (only
+ * if it keeps us on walkable ground) and pulls the bot back onto the nav grid whenever it has
+ * been pushed into an inflated (too-close-to-geometry) cell.
+ */
+ private steerDirect(x: number, z: number): void {
+ const a = this.actor;
+ const nav = this.world.nav;
+ let dx = x - a.pos.x;
+ let dz = z - a.pos.z;
+ const len = Math.hypot(dx, dz);
+ if (len < 1e-3) return;
+ dx /= len;
+ dz /= len;
+ let ax = 0;
+ let az = 0;
+ for (const o of this.world.actors) {
+ if (o === a || !o.alive) continue;
+ const ox = a.pos.x - o.pos.x;
+ const oz = a.pos.z - o.pos.z;
+ const d2 = ox * ox + oz * oz;
+ if (d2 > 1.6 * 1.6 || d2 < 1e-6 || Math.abs(o.pos.y - a.pos.y) > 1.5) continue;
+ const d = Math.sqrt(d2);
+ if (-(ox * dx + oz * dz) / d < -0.2) continue;
+ const s = (1.6 - d) / 1.6;
+ const side = dx * oz - dz * ox > 0 ? 1 : -1;
+ ax += -dz * side * s * 0.9 + (ox / d) * s * 0.4;
+ az += dx * side * s * 0.9 + (oz / d) * s * 0.4;
+ }
+ let fx = dx + ax;
+ let fz = dz + az;
+ let fl = Math.hypot(fx, fz) || 1;
+ fx /= fl;
+ fz /= fl;
+ if ((ax !== 0 || az !== 0) && !nav.isWalkable(a.pos.x + fx * 0.7, a.pos.z + fz * 0.7)) {
+ fx = dx;
+ fz = dz;
+ }
+ if (!nav.isWalkable(a.pos.x, a.pos.z)) {
+ const idx = nav.nearestWalkableIndex(a.pos.x, a.pos.z, 1.6, a.pos.y);
+ if (idx >= 0) {
+ const rx = nav.cellCenterX(idx) - a.pos.x;
+ const rz = nav.cellCenterZ(idx) - a.pos.z;
+ const rl = Math.hypot(rx, rz);
+ if (rl > 0.05) {
+ fx = fx * 0.35 + rx / rl;
+ fz = fz * 0.35 + rz / rl;
+ fl = Math.hypot(fx, fz) || 1;
+ fx /= fl;
+ fz /= fl;
+ }
+ }
+ }
+ const sy = Math.sin(a.yaw);
+ const cy = Math.cos(a.yaw);
+ a.input.forward = fx * -sy + fz * -cy;
+ a.input.right = fx * cy + fz * -sy;
+ }
+
+ private checkStuck(now: number): void {
+ const a = this.actor;
+ if (now - this.progressTime < 0.9) return;
+ const moved = dist2D(a.pos.x, a.pos.z, this.progressPos.x, this.progressPos.z);
+ if (moved < 0.35 && !a.rooted) {
+ this.stuckCount++;
+ this.unstuckUntil = now + 0.45;
+ this.unstuckDir = this.world.rng.chance(0.5) ? 1 : -1;
+ this.path.length = 0;
+ this.pathGoal = null;
+ this.nextRepath = now;
+ if (this.stuckCount > 6 && this.pathIdx < this.path.length) this.pathIdx++;
+ } else if (moved > 1) {
+ this.stuckCount = 0;
+ }
+ this.progressTime = now;
+ this.progressPos.copy(a.pos);
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/TeamAI.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/TeamAI.ts
new file mode 100644
index 0000000..3887345
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/ai/TeamAI.ts
@@ -0,0 +1,316 @@
+import type { SiteId, Team } from '../core/types';
+import type { Actor } from '../sim/Actor';
+import type { World } from '../sim/World';
+import type { HoldSpot } from '../world/MapData';
+import type { NavPoint } from '../world/NavGrid';
+import type { BotTask } from './BotBrain';
+
+const A_ZONES = new Set(['A 大', 'A 门外', 'A 大门', 'A 坑', 'A 斜坡', 'A 点', '猫道', 'A 小']);
+const B_ZONES = new Set(['B 洞', 'B 洞外', '下 B 洞', 'B 点', 'B 门']);
+
+/** Where attackers enter each site from — retake guards face these. */
+const SITE_THREATS: Record = {
+ A: [
+ [93, 31],
+ [78, 28],
+ [74, 13],
+ ],
+ B: [
+ [20, 31.5],
+ [33, 14.5],
+ ],
+};
+
+/** Route index where the group regroups before executing onto the site. */
+const STAGE_POINT: Record = {
+ long: 'longCorner',
+ short: 'catTop',
+ tunnels: 'upperTunnels',
+ midToB: 'upperTunnels',
+ midDoors: 'midDoorsT',
+};
+
+const CT_PLANS: string[][] = [
+ ['aLong', 'bSite', 'mid', 'aShort', 'bDoors'],
+ ['aGoose', 'bCar', 'midBox', 'aCt', 'bPlat'],
+ ['aLong', 'bPlat', 'mid', 'aShort', 'bSite'],
+ ['aShort', 'bDoors', 'mid', 'aLong', 'bCar'],
+];
+
+/**
+ * Team-level tactics ("commander"). T: picks a site, splits bots across real Dust2 routes
+ * (long / catwalk / mid / tunnels), regroups at staging points, executes, plants, then holds
+ * post-plant angles; recovers dropped bombs; rushes late. CT: default setup across A / mid / B,
+ * patrols holds, rotates on intel, retakes and defuses after a plant.
+ */
+export class TeamAI {
+ private nextThink = 0;
+ site: SiteId = 'A';
+ private released = false;
+ private planted = false;
+ private lateRush = false;
+ private rotatedTo: SiteId | null = null;
+ private readonly roles = new Map();
+
+ constructor(
+ private readonly world: World,
+ readonly team: Team,
+ ) {}
+
+ private bots(): Actor[] {
+ return this.world.actors.filter((a) => a.team === this.team && a.alive && a.isBot && a.brain);
+ }
+
+ private jitter(p: NavPoint, r: number): NavPoint {
+ const rng = this.world.rng;
+ const q = { x: p.x + rng.range(-r, r), z: p.z + rng.range(-r, r) };
+ return this.world.nav.nearestWalkable(q.x, q.z, 3) ?? p;
+ }
+
+ private holdTask(h: HoldSpot, patrolRadius = 2.5): BotTask {
+ const dest = this.world.nav.nearestWalkable(h.pos[0], h.pos[1], 3) ?? { x: h.pos[0], z: h.pos[1] };
+ const patrol: NavPoint[] = [dest];
+ for (let i = 0; i < 2; i++) patrol.push(this.jitter(dest, patrolRadius));
+ return { kind: 'hold', dest, look: { x: h.look[0], z: h.look[1] }, patrol };
+ }
+
+ onRoundStart(): void {
+ this.released = false;
+ this.planted = false;
+ this.lateRush = false;
+ this.rotatedTo = null;
+ this.roles.clear();
+ this.nextThink = 0;
+ if (this.team === 'T') this.planT();
+ else this.planCT();
+ }
+
+ update(): void {
+ const w = this.world;
+ if (w.phase !== 'live' || w.time < this.nextThink) return;
+ this.nextThink = w.time + 0.25;
+ if (this.team === 'T') this.thinkT();
+ else this.thinkCT();
+ }
+
+ // ================================================================== T side
+
+ private planT(): void {
+ const w = this.world;
+ const r = w.rng;
+ this.site = r.chance(0.5) ? 'A' : 'B';
+ const carrier = w.bomb.carrier;
+ const bots = r.shuffle(this.bots()).sort((a, b) => (a === carrier ? -1 : b === carrier ? 1 : 0));
+ let routes: string[];
+ if (this.site === 'A') {
+ routes = r.chance(0.55) ? ['long', 'long', 'short', 'long', 'midToA'] : ['long', 'short', 'long', 'short', 'midToA'];
+ } else {
+ routes = r.chance(0.6) ? ['tunnels', 'tunnels', 'tunnels', 'midDoors', 'tunnels'] : ['tunnels', 'tunnels', 'midToB', 'midDoors', 'tunnels'];
+ }
+ bots.forEach((bot, i) => this.assignRoute(bot, routes[i % routes.length]));
+ }
+
+ private assignRoute(bot: Actor, routeName: string): void {
+ const level = this.world.level;
+ const names = level.def.routes[routeName];
+ const points = names.map((n) => this.jitter(level.point(n), 0.9));
+ const stageName = STAGE_POINT[routeName];
+ const holdAt = stageName ? names.indexOf(stageName) : undefined;
+ let then: BotTask;
+ if (routeName === 'midToA') {
+ // mid lurker: hold mid doors, watching CT mid
+ then = this.holdTask({ id: 'lurk', pos: [47.5, 30], look: [48, 18], area: 'mid' }, 1.5);
+ } else {
+ then = this.siteTask(bot);
+ }
+ bot.brain!.setTask({
+ kind: 'route',
+ points,
+ index: 1,
+ holdAt: holdAt !== undefined && holdAt >= 0 ? holdAt : undefined,
+ released: false,
+ group: routeName === 'midToA' ? 'lurk' : 'main',
+ then,
+ });
+ }
+
+ private plantTask(): BotTask {
+ const s = this.world.level.site(this.site);
+ return { kind: 'plant', site: this.site, dest: this.jitter({ x: s.plant[0], z: s.plant[1] }, 1.0) };
+ }
+
+ private siteTask(bot: Actor): BotTask {
+ if (bot.hasBomb) return this.plantTask();
+ const spots = this.world.level.def.postPlant[this.site];
+ const idx = bot.id % spots.length;
+ return this.holdTask(spots[idx], 2);
+ }
+
+ private thinkT(): void {
+ const w = this.world;
+ const b = w.bomb;
+ const bots = this.bots();
+ if (!bots.length) return;
+
+ if (b.state === 'planted') {
+ if (!this.planted) {
+ this.planted = true;
+ this.site = b.site ?? this.site;
+ const spots = w.rng.shuffle([...w.level.def.postPlant[this.site]]);
+ bots.forEach((bot, i) => {
+ if (bot.brain!.task.kind !== 'hold' || i < spots.length) bot.brain!.setTask(this.holdTask(spots[i % spots.length], 2));
+ });
+ }
+ return;
+ }
+
+ // recover a dropped bomb with the closest bot
+ if (b.state === 'dropped' && !bots.some((x) => x.brain!.task.kind === 'pickup')) {
+ let best: Actor | null = null;
+ let bestD = Infinity;
+ for (const bot of bots) {
+ const d = bot.pos.distanceToSquared(b.pos);
+ if (d < bestD) {
+ bestD = d;
+ best = bot;
+ }
+ }
+ best?.brain!.setTask({ kind: 'pickup' });
+ }
+
+ // the carrier always ends up planting
+ if (b.state === 'carried' && b.carrier?.isBot && b.carrier.brain) {
+ const t = b.carrier.brain.task;
+ if (t.kind === 'route') {
+ if (t.then?.kind !== 'plant') t.then = this.plantTask();
+ } else if (t.kind !== 'plant') {
+ b.carrier.brain.setTask(this.plantTask());
+ }
+ }
+
+ // regroup at staging, then execute together
+ if (!this.released) {
+ const main = bots.filter((x) => x.brain!.task.kind === 'route' && x.brain!.task.group === 'main');
+ const waiting = main.filter((x) => {
+ const t = x.brain!.task;
+ if (t.holdAt === undefined || t.index !== t.holdAt) return false;
+ const p = t.points![t.holdAt];
+ return Math.hypot(x.pos.x - p.x, x.pos.z - p.z) < 2.6;
+ });
+ const enough = waiting.length >= Math.min(2, main.length) && main.length > 0;
+ if (enough || w.roundElapsed > 38 || main.length === 0) this.releaseAll();
+ }
+
+ if (!this.lateRush && w.roundTimeLeft < 32) {
+ this.lateRush = true;
+ this.releaseAll();
+ for (const bot of bots) {
+ const t = bot.brain!.task;
+ if (t.kind === 'hold' && !bot.hasBomb) bot.brain!.setTask(this.siteTask(bot));
+ }
+ }
+
+ for (const bot of bots) {
+ if (bot.brain!.task.kind === 'idle') bot.brain!.setTask(this.siteTask(bot));
+ }
+ }
+
+ private releaseAll(): void {
+ this.released = true;
+ for (const bot of this.bots()) {
+ const t = bot.brain!.task;
+ if (t.kind === 'route') t.released = true;
+ }
+ }
+
+ // ================================================================== CT side
+
+ private planCT(): void {
+ const w = this.world;
+ const plan = w.rng.pick(CT_PLANS);
+ const bots = w.rng.shuffle(this.bots());
+ bots.forEach((bot, i) => this.assignHold(bot, plan[i % plan.length]));
+ }
+
+ private assignHold(bot: Actor, holdId: string): void {
+ const h = this.world.level.def.ctHolds.find((x) => x.id === holdId)!;
+ bot.brain!.setTask(this.holdTask(h, 2.2));
+ this.roles.set(bot.id, h.area);
+ }
+
+ private thinkCT(): void {
+ const w = this.world;
+ const b = w.bomb;
+ const bots = this.bots();
+ if (!bots.length) return;
+
+ if (b.state === 'planted') {
+ if (!this.planted) {
+ this.planted = true;
+ this.assignRetake(bots);
+ }
+ if (!bots.some((x) => x.brain!.task.kind === 'defuse')) {
+ const closest = bots.slice().sort((p, q) => p.pos.distanceToSquared(b.pos) - q.pos.distanceToSquared(b.pos))[0];
+ closest.brain!.setTask({ kind: 'defuse' });
+ }
+ return;
+ }
+
+ // rotate on fresh intel
+ let nA = 0;
+ let nB = 0;
+ for (const e of w.intel.CT.values()) {
+ if (w.time - e.time > 5) continue;
+ const zone = w.level.zoneAt(e.x, e.z);
+ if (A_ZONES.has(zone)) nA++;
+ else if (B_ZONES.has(zone)) nB++;
+ }
+ if (nA >= 2 && nA > nB && this.rotatedTo !== 'A') this.rotate('A', bots);
+ else if (nB >= 2 && nB > nA && this.rotatedTo !== 'B') this.rotate('B', bots);
+
+ for (const bot of bots) {
+ if (bot.brain!.task.kind === 'idle') {
+ const area = this.roles.get(bot.id) ?? 'mid';
+ const holds = w.level.def.ctHolds.filter((h) => h.area === area);
+ if (holds.length) bot.brain!.setTask(this.holdTask(w.rng.pick(holds), 2.2));
+ }
+ }
+ }
+
+ private rotate(site: SiteId, bots: Actor[]): void {
+ const w = this.world;
+ this.rotatedTo = site;
+ const holds = w.level.def.ctHolds.filter((h) => h.area === site);
+ const target = w.level.site(site);
+ const movers = bots
+ .filter((x) => this.roles.get(x.id) !== site && !x.brain!.hasRecentContact)
+ .sort(
+ (p, q) =>
+ (p.pos.x - target.plant[0]) ** 2 + (p.pos.z - target.plant[1]) ** 2 - ((q.pos.x - target.plant[0]) ** 2 + (q.pos.z - target.plant[1]) ** 2),
+ )
+ .slice(0, 2);
+ movers.forEach((bot, i) => {
+ const h = holds[(i + 2) % holds.length];
+ bot.brain!.setTask(this.holdTask(h, 2));
+ this.roles.set(bot.id, site);
+ });
+ }
+
+ private assignRetake(bots: Actor[]): void {
+ const w = this.world;
+ const b = w.bomb;
+ const site = b.site ?? 'A';
+ const threats = SITE_THREATS[site];
+ const sorted = bots.slice().sort((p, q) => p.pos.distanceToSquared(b.pos) - q.pos.distanceToSquared(b.pos));
+ sorted.forEach((bot, i) => {
+ if (i === 0) {
+ bot.brain!.setTask({ kind: 'defuse' });
+ return;
+ }
+ const ang = (i / Math.max(1, sorted.length - 1)) * Math.PI * 2;
+ const p = w.nav.nearestWalkable(b.pos.x + Math.cos(ang) * 4.5, b.pos.z + Math.sin(ang) * 4.5, 4) ?? { x: b.pos.x, z: b.pos.z };
+ const look = threats[i % threats.length];
+ bot.brain!.setTask({ kind: 'hold', dest: p, look: { x: look[0], z: look[1] }, patrol: [p] });
+ });
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/audio/AudioEngine.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/audio/AudioEngine.ts
new file mode 100644
index 0000000..8a77083
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/audio/AudioEngine.ts
@@ -0,0 +1,549 @@
+import type * as THREE from 'three';
+import type { SoundProfile, WeaponKind } from '../weapons/WeaponDefs';
+
+export interface SoundPos {
+ x: number;
+ y: number;
+ z: number;
+}
+
+interface Emit {
+ /** Connect sound sources here. */
+ input: GainNode;
+ t: number;
+}
+
+interface ShotRecipe {
+ gain: number;
+ thump: [number, number, number, number]; // f0, f1, sweep, decay
+ thumpGain: number;
+ bodyLP: number;
+ bodyDecay: number;
+ bodyGain: number;
+ crackF: number;
+ crackDecay: number;
+ crackGain: number;
+ tail: number;
+ tailGain: number;
+ reverb: number;
+}
+
+const SHOTS: Record, ShotRecipe> = {
+ ak: { gain: 1, thump: [150, 46, 0.07, 0.16], thumpGain: 1.0, bodyLP: 1700, bodyDecay: 0.2, bodyGain: 0.9, crackF: 2400, crackDecay: 0.05, crackGain: 0.55, tail: 0.65, tailGain: 0.35, reverb: 0.5 },
+ m4: { gain: 0.85, thump: [190, 70, 0.05, 0.11], thumpGain: 0.75, bodyLP: 2700, bodyDecay: 0.14, bodyGain: 0.8, crackF: 3600, crackDecay: 0.04, crackGain: 0.65, tail: 0.45, tailGain: 0.25, reverb: 0.4 },
+ awp: { gain: 1.25, thump: [95, 28, 0.12, 0.35], thumpGain: 1.3, bodyLP: 1300, bodyDecay: 0.42, bodyGain: 1.0, crackF: 1900, crackDecay: 0.09, crackGain: 0.7, tail: 1.6, tailGain: 0.55, reverb: 0.9 },
+ glock: { gain: 0.62, thump: [280, 120, 0.03, 0.06], thumpGain: 0.55, bodyLP: 3200, bodyDecay: 0.07, bodyGain: 0.7, crackF: 4200, crackDecay: 0.028, crackGain: 0.6, tail: 0.25, tailGain: 0.15, reverb: 0.3 },
+ deagle: { gain: 1.1, thump: [125, 38, 0.08, 0.22], thumpGain: 1.15, bodyLP: 2000, bodyDecay: 0.28, bodyGain: 0.95, crackF: 2800, crackDecay: 0.06, crackGain: 0.6, tail: 0.85, tailGain: 0.4, reverb: 0.6 },
+};
+
+/**
+ * All game audio synthesised with the Web Audio API — no sample files. Sounds are short node
+ * graphs (noise bursts through filters, swept oscillators, envelopes) routed through optional
+ * 3D panners, distance/occlusion low-pass filtering and a shared convolution reverb.
+ */
+export class AudioEngine {
+ private ctx: AudioContext | null = null;
+ private master!: GainNode;
+ private sfx!: GainNode;
+ private reverbIn!: GainNode;
+ private noiseBuf!: AudioBuffer;
+ private brownBuf!: AudioBuffer;
+ private volume = 0.7;
+ private listener = { x: 0, y: 0, z: 0 };
+ private readonly loops = new Map void }>();
+ /** Occlusion test supplied by the engine (true = clear line of sight). */
+ occlusion: ((from: SoundPos, to: SoundPos) => boolean) | null = null;
+
+ get ready(): boolean {
+ return this.ctx !== null && this.ctx.state === 'running';
+ }
+
+ /** Must be called from a user gesture. */
+ init(): void {
+ if (this.ctx) {
+ if (this.ctx.state === 'suspended') void this.ctx.resume();
+ return;
+ }
+ const Ctor: typeof AudioContext | undefined =
+ window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (!Ctor) return;
+ const ctx = new Ctor();
+ this.ctx = ctx;
+ const comp = ctx.createDynamicsCompressor();
+ comp.threshold.value = -14;
+ comp.knee.value = 12;
+ comp.ratio.value = 5;
+ comp.attack.value = 0.003;
+ comp.release.value = 0.2;
+ this.master = ctx.createGain();
+ this.master.gain.value = this.volume;
+ this.master.connect(comp);
+ comp.connect(ctx.destination);
+ this.sfx = ctx.createGain();
+ this.sfx.connect(this.master);
+
+ // synthetic impulse response: exponentially decaying stereo noise (outdoor slap-back + tail)
+ const len = Math.floor(ctx.sampleRate * 1.8);
+ const ir = ctx.createBuffer(2, len, ctx.sampleRate);
+ for (let ch = 0; ch < 2; ch++) {
+ const d = ir.getChannelData(ch);
+ for (let i = 0; i < len; i++) {
+ const t = i / ctx.sampleRate;
+ const early = t > 0.045 && t < 0.07 ? 0.5 : 0;
+ d[i] = (Math.random() * 2 - 1) * (Math.exp(-t * 3.2) * 0.6 + early * Math.exp(-(t - 0.045) * 40));
+ }
+ }
+ const conv = ctx.createConvolver();
+ conv.buffer = ir;
+ const revOut = ctx.createGain();
+ revOut.gain.value = 0.35;
+ this.reverbIn = ctx.createGain();
+ this.reverbIn.connect(conv);
+ conv.connect(revOut);
+ revOut.connect(this.master);
+
+ const n = ctx.sampleRate * 2;
+ this.noiseBuf = ctx.createBuffer(1, n, ctx.sampleRate);
+ const nd = this.noiseBuf.getChannelData(0);
+ for (let i = 0; i < n; i++) nd[i] = Math.random() * 2 - 1;
+ this.brownBuf = ctx.createBuffer(1, n, ctx.sampleRate);
+ const bd = this.brownBuf.getChannelData(0);
+ let last = 0;
+ for (let i = 0; i < n; i++) {
+ last = (last + 0.02 * (Math.random() * 2 - 1)) / 1.02;
+ bd[i] = last * 3.5;
+ }
+ }
+
+ setVolume(v: number): void {
+ this.volume = v;
+ if (this.ctx) this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.05);
+ }
+
+ suspend(): void {
+ void this.ctx?.suspend();
+ }
+
+ resume(): void {
+ void this.ctx?.resume();
+ }
+
+ updateListener(pos: THREE.Vector3, forward: THREE.Vector3): void {
+ this.listener.x = pos.x;
+ this.listener.y = pos.y;
+ this.listener.z = pos.z;
+ const ctx = this.ctx;
+ if (!ctx) return;
+ const l = ctx.listener;
+ if (l.positionX) {
+ const t = ctx.currentTime;
+ l.positionX.setValueAtTime(pos.x, t);
+ l.positionY.setValueAtTime(pos.y, t);
+ l.positionZ.setValueAtTime(pos.z, t);
+ l.forwardX.setValueAtTime(forward.x, t);
+ l.forwardY.setValueAtTime(forward.y, t);
+ l.forwardZ.setValueAtTime(forward.z, t);
+ l.upX.setValueAtTime(0, t);
+ l.upY.setValueAtTime(1, t);
+ l.upZ.setValueAtTime(0, t);
+ } else {
+ l.setPosition(pos.x, pos.y, pos.z);
+ l.setOrientation(forward.x, forward.y, forward.z, 0, 1, 0);
+ }
+ }
+
+ // ================================================================== routing helpers
+
+ /**
+ * Create an output chain. `pos === null` => non-spatial (the local player's own sounds).
+ * Spatial sounds get a panner plus distance/occlusion low-pass.
+ */
+ private emit(pos: SoundPos | null, gain: number, reverb: number, maxDist = 140): Emit | null {
+ const ctx = this.ctx;
+ if (!ctx || ctx.state !== 'running') return null;
+ const t = ctx.currentTime;
+ const input = ctx.createGain();
+ input.gain.value = gain;
+ let tail: AudioNode = input;
+ if (pos) {
+ const dx = pos.x - this.listener.x;
+ const dy = pos.y - this.listener.y;
+ const dz = pos.z - this.listener.z;
+ const dist = Math.hypot(dx, dy, dz);
+ if (dist > maxDist) return null;
+ const occluded = this.occlusion ? !this.occlusion(pos, this.listener) : false;
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.value = Math.max(700, 18000 * Math.exp(-dist / 45)) * (occluded ? 0.25 : 1);
+ lp.Q.value = 0.5;
+ tail.connect(lp);
+ const pan = ctx.createPanner();
+ pan.panningModel = 'equalpower';
+ pan.distanceModel = 'inverse';
+ pan.refDistance = 4;
+ pan.maxDistance = 400;
+ pan.rolloffFactor = 1.1;
+ if (pan.positionX) {
+ pan.positionX.value = pos.x;
+ pan.positionY.value = pos.y;
+ pan.positionZ.value = pos.z;
+ } else pan.setPosition(pos.x, pos.y, pos.z);
+ lp.connect(pan);
+ const occ = ctx.createGain();
+ occ.gain.value = occluded ? 0.55 : 1;
+ pan.connect(occ);
+ tail = occ;
+ reverb *= 1 + Math.min(1.5, dist / 30);
+ }
+ tail.connect(this.sfx);
+ if (reverb > 0) {
+ const send = ctx.createGain();
+ send.gain.value = reverb;
+ tail.connect(send);
+ send.connect(this.reverbIn);
+ }
+ return { input, t };
+ }
+
+ private noise(out: AudioNode, t: number, dur: number, brown = false): AudioBufferSourceNode {
+ const ctx = this.ctx!;
+ const src = ctx.createBufferSource();
+ src.buffer = brown ? this.brownBuf : this.noiseBuf;
+ const off = Math.random() * 1.5;
+ src.connect(out);
+ src.start(t, off, dur + 0.05);
+ return src;
+ }
+
+ private env(t: number, attack: number, peak: number, decay: number, curve: 'exp' | 'lin' = 'exp'): GainNode {
+ const g = this.ctx!.createGain();
+ g.gain.setValueAtTime(0.0001, t);
+ g.gain.linearRampToValueAtTime(peak, t + attack);
+ if (curve === 'exp') g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
+ else g.gain.linearRampToValueAtTime(0, t + attack + decay);
+ return g;
+ }
+
+ private filter(type: BiquadFilterType, freq: number, q = 0.7): BiquadFilterNode {
+ const f = this.ctx!.createBiquadFilter();
+ f.type = type;
+ f.frequency.value = freq;
+ f.Q.value = q;
+ return f;
+ }
+
+ private filteredNoise(e: Emit, t: number, type: BiquadFilterType, freq: number, q: number, attack: number, peak: number, decay: number, brown = false): void {
+ const g = this.env(t, attack, peak, decay);
+ const f = this.filter(type, freq, q);
+ this.noise(f, t, attack + decay, brown);
+ f.connect(g);
+ g.connect(e.input);
+ }
+
+ private tone(e: Emit, t: number, type: OscillatorType, f0: number, f1: number, sweep: number, attack: number, peak: number, decay: number): void {
+ const ctx = this.ctx!;
+ const o = ctx.createOscillator();
+ o.type = type;
+ o.frequency.setValueAtTime(f0, t);
+ if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + sweep);
+ const g = this.env(t, attack, peak, decay);
+ o.connect(g);
+ g.connect(e.input);
+ o.start(t);
+ o.stop(t + attack + decay + 0.05);
+ }
+
+ // ================================================================== weapons
+
+ gunshot(profile: SoundProfile, pos: SoundPos | null): void {
+ if (profile === 'none' || profile === 'knife') return;
+ if (profile === 'usp') {
+ // suppressed: soft "thwip" + slide clack, little reverb
+ const e = this.emit(pos, pos ? 0.9 : 0.55, 0.12, 50);
+ if (!e) return;
+ const t = e.t;
+ this.filteredNoise(e, t, 'bandpass', 1300, 1.6, 0.002, 0.9, 0.07);
+ this.filteredNoise(e, t, 'highpass', 5000, 0.7, 0.001, 0.25, 0.03);
+ this.tone(e, t, 'sine', 420, 160, 0.04, 0.002, 0.35, 0.05);
+ this.filteredNoise(e, t + 0.035, 'bandpass', 2600, 4, 0.001, 0.35, 0.025);
+ return;
+ }
+ const r = SHOTS[profile];
+ const e = this.emit(pos, r.gain * (pos ? 1.6 : 0.85), r.reverb, 260);
+ if (!e) return;
+ const t = e.t;
+ const jitter = 0.94 + Math.random() * 0.12;
+ this.tone(e, t, 'sine', r.thump[0] * jitter, r.thump[1], r.thump[2], 0.002, r.thumpGain, r.thump[3]);
+ this.tone(e, t, 'triangle', r.thump[0] * 2 * jitter, r.thump[1] * 1.5, r.thump[2], 0.001, r.thumpGain * 0.35, r.thump[3] * 0.6);
+ this.filteredNoise(e, t, 'lowpass', r.bodyLP * jitter, 0.8, 0.0015, r.bodyGain, r.bodyDecay);
+ this.filteredNoise(e, t, 'bandpass', r.crackF * jitter, 0.9, 0.0008, r.crackGain, r.crackDecay);
+ this.filteredNoise(e, t + 0.01, 'lowpass', 700, 0.6, 0.02, r.tailGain, r.tail, true);
+ // mechanical action
+ if (!pos) this.filteredNoise(e, t + 0.03, 'bandpass', 3200, 5, 0.001, 0.12, 0.03);
+ }
+
+ knife(pos: SoundPos | null, hit: 'none' | 'wall' | 'flesh'): void {
+ const e = this.emit(pos, 0.6, 0.05, 30);
+ if (!e) return;
+ const t = e.t;
+ const g = this.env(t, 0.03, 0.6, 0.18);
+ const f = this.filter('bandpass', 700, 1.4);
+ f.frequency.setValueAtTime(700, t);
+ f.frequency.exponentialRampToValueAtTime(3200, t + 0.16);
+ this.noise(f, t, 0.25);
+ f.connect(g);
+ g.connect(e.input);
+ if (hit === 'wall') {
+ this.filteredNoise(e, t + 0.08, 'bandpass', 3800, 6, 0.001, 0.6, 0.12);
+ this.tone(e, t + 0.08, 'triangle', 2600, 2400, 0.1, 0.001, 0.2, 0.15);
+ } else if (hit === 'flesh') {
+ this.filteredNoise(e, t + 0.07, 'lowpass', 500, 1, 0.002, 1.0, 0.1);
+ this.tone(e, t + 0.07, 'sine', 140, 70, 0.06, 0.002, 0.6, 0.08);
+ }
+ }
+
+ /** Reload foley timed to the weapon's reload duration. */
+ reload(kind: WeaponKind, duration: number, pos: SoundPos | null): void {
+ const e = this.emit(pos, pos ? 0.7 : 0.55, 0.05, 25);
+ if (!e) return;
+ const t = e.t;
+ const at = (f: number) => t + duration * f;
+ const click = (time: number, freq: number, gain: number, dur = 0.03) => {
+ this.filteredNoise(e, time, 'bandpass', freq, 5, 0.001, gain, dur);
+ this.tone(e, time, 'square', freq * 0.5, freq * 0.45, dur, 0.001, gain * 0.12, dur);
+ };
+ if (kind === 'sniper') {
+ click(at(0.12), 1800, 0.8); // bolt up
+ this.filteredNoise(e, at(0.15), 'bandpass', 2400, 2, 0.01, 0.4, 0.12); // bolt back
+ click(at(0.35), 900, 0.9, 0.05); // mag out
+ click(at(0.62), 1300, 1.0, 0.04); // mag in
+ click(at(0.66), 1900, 0.6);
+ this.filteredNoise(e, at(0.82), 'bandpass', 2600, 2, 0.01, 0.4, 0.1); // bolt forward
+ click(at(0.86), 2100, 0.9);
+ } else if (kind === 'pistol') {
+ click(at(0.16), 1100, 0.8, 0.04); // mag release
+ click(at(0.5), 1500, 1.0, 0.035); // mag in
+ click(at(0.54), 2200, 0.5);
+ this.filteredNoise(e, at(0.78), 'bandpass', 2800, 2.5, 0.005, 0.5, 0.06); // slide
+ click(at(0.82), 2400, 0.8);
+ } else {
+ click(at(0.15), 900, 0.7, 0.05); // mag release
+ this.filteredNoise(e, at(0.2), 'lowpass', 900, 1, 0.005, 0.5, 0.15); // mag slides out
+ click(at(0.52), 1300, 1.0, 0.04); // new mag seats
+ click(at(0.56), 1800, 0.7);
+ this.filteredNoise(e, at(0.74), 'bandpass', 2500, 2, 0.01, 0.45, 0.09); // charging handle back
+ click(at(0.8), 2200, 0.9, 0.035); // forward
+ }
+ }
+
+ draw(kind: WeaponKind, pos: SoundPos | null): void {
+ const e = this.emit(pos, 0.4, 0.02, 15);
+ if (!e) return;
+ const t = e.t;
+ if (kind === 'knife') {
+ this.filteredNoise(e, t, 'highpass', 5000, 1, 0.02, 0.35, 0.18);
+ this.tone(e, t + 0.05, 'triangle', 3400, 3000, 0.2, 0.002, 0.08, 0.25);
+ } else {
+ this.filteredNoise(e, t + 0.05, 'bandpass', 2000, 3, 0.002, 0.6, 0.04);
+ this.filteredNoise(e, t + 0.16, 'bandpass', 2800, 4, 0.001, 0.5, 0.03);
+ }
+ }
+
+ dryFire(): void {
+ const e = this.emit(null, 0.5, 0);
+ if (!e) return;
+ this.filteredNoise(e, e.t, 'bandpass', 3000, 6, 0.001, 0.9, 0.025);
+ }
+
+ scope(level: number): void {
+ const e = this.emit(null, 0.45, 0.02);
+ if (!e) return;
+ const t = e.t;
+ this.filteredNoise(e, t, 'bandpass', level === 2 ? 4200 : level === 1 ? 3200 : 2400, 5, 0.001, 0.8, 0.03);
+ const g = this.env(t, 0.01, 0.25, 0.09);
+ const f = this.filter('bandpass', 1500, 2);
+ f.frequency.setValueAtTime(level ? 1200 : 3000, t);
+ f.frequency.exponentialRampToValueAtTime(level ? 3200 : 1200, t + 0.09);
+ this.noise(f, t, 0.12);
+ f.connect(g);
+ g.connect(e.input);
+ }
+
+ // ================================================================== movement
+
+ footstep(pos: SoundPos | null, loud = 1): void {
+ const e = this.emit(pos, (pos ? 1.1 : 0.32) * loud, 0.04, 35);
+ if (!e) return;
+ const t = e.t;
+ const p = 0.85 + Math.random() * 0.3;
+ this.filteredNoise(e, t, 'lowpass', 420 * p, 1.2, 0.004, 1, 0.09);
+ this.tone(e, t, 'sine', 95 * p, 60, 0.05, 0.002, 0.5, 0.07);
+ this.filteredNoise(e, t + 0.012, 'highpass', 3500 * p, 0.7, 0.002, 0.18, 0.05); // grit
+ }
+
+ land(pos: SoundPos | null, speed: number): void {
+ const e = this.emit(pos, Math.min(1.4, speed / 6) * (pos ? 1 : 0.5), 0.05, 30);
+ if (!e) return;
+ this.filteredNoise(e, e.t, 'lowpass', 300, 1, 0.003, 1, 0.13);
+ this.tone(e, e.t, 'sine', 80, 45, 0.08, 0.002, 0.7, 0.12);
+ }
+
+ // ================================================================== feedback
+
+ hitMarker(headshot: boolean): void {
+ const e = this.emit(null, headshot ? 0.55 : 0.4, headshot ? 0.15 : 0);
+ if (!e) return;
+ const t = e.t;
+ if (headshot) {
+ // helmet "dink"
+ this.tone(e, t, 'sine', 2350, 2300, 0.2, 0.001, 0.7, 0.32);
+ this.tone(e, t, 'sine', 3720, 3650, 0.2, 0.001, 0.45, 0.22);
+ this.tone(e, t, 'sine', 5600, 5500, 0.1, 0.001, 0.2, 0.12);
+ this.filteredNoise(e, t, 'highpass', 6000, 0.7, 0.001, 0.25, 0.04);
+ } else {
+ this.tone(e, t, 'sine', 240, 120, 0.05, 0.001, 0.8, 0.07);
+ this.filteredNoise(e, t, 'bandpass', 1800, 3, 0.001, 0.4, 0.03);
+ }
+ }
+
+ hurt(): void {
+ const e = this.emit(null, 0.7, 0);
+ if (!e) return;
+ this.filteredNoise(e, e.t, 'lowpass', 260, 1, 0.004, 1, 0.16);
+ this.tone(e, e.t, 'sine', 95, 50, 0.12, 0.003, 0.8, 0.15);
+ }
+
+ killConfirm(headshot: boolean): void {
+ const e = this.emit(null, 0.35, 0.25);
+ if (!e) return;
+ const t = e.t;
+ this.tone(e, t, 'triangle', 880, 880, 0.01, 0.004, 0.6, 0.12);
+ this.tone(e, t + 0.07, 'triangle', headshot ? 1760 : 1320, headshot ? 1760 : 1320, 0.01, 0.004, 0.6, 0.22);
+ this.tone(e, t + 0.07, 'sine', headshot ? 2640 : 1980, headshot ? 2640 : 1980, 0.01, 0.004, 0.18, 0.2);
+ }
+
+ // ================================================================== bomb
+
+ c4Beep(pos: SoundPos, urgency: number): void {
+ const e = this.emit(pos, 0.9, 0.2, 90);
+ if (!e) return;
+ const t = e.t;
+ const g = this.env(t, 0.003, 0.5, 0.09, 'lin');
+ const o = this.ctx!.createOscillator();
+ o.type = 'square';
+ o.frequency.value = 1950 + urgency * 300;
+ const f = this.filter('bandpass', 2100, 3);
+ o.connect(f);
+ f.connect(g);
+ g.connect(e.input);
+ o.start(t);
+ o.stop(t + 0.12);
+ }
+
+ /** Keypad presses while arming; returns a cancel handle. */
+ plantSequence(pos: SoundPos | null, duration: number): void {
+ this.stopLoop('plant');
+ const e = this.emit(pos, pos ? 0.8 : 0.5, 0.05, 40);
+ if (!e) return;
+ const t = e.t;
+ const keys = [697, 770, 852, 941];
+ const cols = [1209, 1336, 1477];
+ const n = 7;
+ for (let i = 0; i < n; i++) {
+ const tt = t + 0.25 + (i * (duration - 0.6)) / n;
+ const f1 = keys[Math.floor(Math.random() * keys.length)];
+ const f2 = cols[Math.floor(Math.random() * cols.length)];
+ this.tone(e, tt, 'sine', f1, f1, 0.01, 0.003, 0.28, 0.1);
+ this.tone(e, tt, 'sine', f2, f2, 0.01, 0.003, 0.28, 0.1);
+ this.filteredNoise(e, tt - 0.02, 'bandpass', 2500, 5, 0.001, 0.3, 0.02);
+ }
+ this.loops.set('plant', { stop: () => e.input.gain.setTargetAtTime(0, this.ctx!.currentTime, 0.01) });
+ }
+
+ bombPlanted(pos: SoundPos | null): void {
+ this.stopLoop('plant');
+ const e = this.emit(pos, 0.7, 0.2, 120);
+ if (!e) return;
+ const t = e.t;
+ this.tone(e, t, 'square', 1200, 1200, 0.01, 0.005, 0.25, 0.12);
+ this.tone(e, t + 0.15, 'square', 1600, 1600, 0.01, 0.005, 0.25, 0.12);
+ this.tone(e, t + 0.3, 'square', 2000, 2000, 0.01, 0.005, 0.25, 0.25);
+ }
+
+ defuseLoop(pos: SoundPos | null, duration: number): void {
+ this.stopLoop('defuse');
+ const e = this.emit(pos, pos ? 0.8 : 0.5, 0.05, 40);
+ if (!e) return;
+ const t = e.t;
+ const n = Math.floor(duration / 0.3);
+ for (let i = 0; i < n; i++) {
+ const tt = t + 0.1 + i * 0.3 + Math.random() * 0.05;
+ this.filteredNoise(e, tt, 'bandpass', 1800 + Math.random() * 1500, 6, 0.001, 0.5, 0.03);
+ if (i % 4 === 0) this.filteredNoise(e, tt + 0.1, 'highpass', 4000, 0.7, 0.01, 0.15, 0.08);
+ }
+ this.loops.set('defuse', { stop: () => e.input.gain.setTargetAtTime(0, this.ctx!.currentTime, 0.01) });
+ }
+
+ stopLoop(id: string): void {
+ const l = this.loops.get(id);
+ if (l) {
+ l.stop();
+ this.loops.delete(id);
+ }
+ }
+
+ bombDefused(): void {
+ this.stopLoop('defuse');
+ const e = this.emit(null, 0.45, 0.2);
+ if (!e) return;
+ const t = e.t;
+ [880, 1109, 1319, 1760].forEach((f, i) => this.tone(e, t + i * 0.08, 'triangle', f, f, 0.01, 0.005, 0.5, 0.25));
+ }
+
+ explosion(pos: SoundPos): void {
+ const e = this.emit(pos, 3.2, 1.2, 600);
+ if (!e) return;
+ const t = e.t;
+ const g = this.env(t, 0.005, 1.4, 2.6);
+ const f = this.filter('lowpass', 4000, 0.8);
+ f.frequency.setValueAtTime(4500, t);
+ f.frequency.exponentialRampToValueAtTime(160, t + 1.8);
+ this.noise(f, t, 2.8);
+ f.connect(g);
+ g.connect(e.input);
+ this.tone(e, t, 'sine', 70, 24, 0.9, 0.004, 1.6, 1.4);
+ this.filteredNoise(e, t + 0.05, 'lowpass', 140, 0.8, 0.1, 1.2, 3.5, true);
+ this.filteredNoise(e, t, 'bandpass', 2400, 0.8, 0.001, 0.8, 0.12);
+ }
+
+ // ================================================================== misc
+
+ pickup(): void {
+ const e = this.emit(null, 0.4, 0);
+ if (!e) return;
+ this.filteredNoise(e, e.t, 'bandpass', 1600, 4, 0.002, 0.7, 0.04);
+ this.filteredNoise(e, e.t + 0.07, 'bandpass', 2400, 5, 0.002, 0.5, 0.03);
+ }
+
+ roundJingle(win: boolean): void {
+ const e = this.emit(null, 0.22, 0.4);
+ if (!e) return;
+ const t = e.t;
+ const notes = win ? [523, 659, 784, 1047] : [659, 523, 440, 330];
+ notes.forEach((f, i) => {
+ this.tone(e, t + i * 0.14, 'triangle', f, f, 0.01, 0.01, 0.6, 0.35);
+ this.tone(e, t + i * 0.14, 'sine', f / 2, f / 2, 0.01, 0.01, 0.3, 0.4);
+ });
+ }
+
+ uiClick(): void {
+ const e = this.emit(null, 0.25, 0);
+ if (!e) return;
+ this.tone(e, e.t, 'sine', 1500, 1200, 0.03, 0.001, 0.4, 0.04);
+ }
+
+ dispose(): void {
+ void this.ctx?.close();
+ this.ctx = null;
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/EventBus.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/EventBus.ts
new file mode 100644
index 0000000..ccb471a
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/EventBus.ts
@@ -0,0 +1,29 @@
+type Handler = (payload: T) => void;
+
+/**
+ * Strongly typed synchronous event bus. The simulation emits gameplay events; audio, effects,
+ * HUD and AI subscribe without the simulation knowing about any of them.
+ */
+export class EventBus {
+ private handlers = new Map>>();
+
+ on(type: K, fn: Handler): () => void {
+ let set = this.handlers.get(type);
+ if (!set) {
+ set = new Set();
+ this.handlers.set(type, set);
+ }
+ set.add(fn as Handler);
+ return () => set!.delete(fn as Handler);
+ }
+
+ emit(type: K, payload: Events[K]): void {
+ const set = this.handlers.get(type);
+ if (!set) return;
+ for (const fn of set) (fn as Handler)(payload);
+ }
+
+ clear(): void {
+ this.handlers.clear();
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/Random.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/Random.ts
new file mode 100644
index 0000000..5cdda9b
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/Random.ts
@@ -0,0 +1,55 @@
+/** Small seedable PRNG (mulberry32) so simulations can be replayed deterministically. */
+export class Random {
+ private state: number;
+
+ constructor(seed: number = (Date.now() ^ 0x9e3779b9) >>> 0) {
+ this.state = seed >>> 0 || 1;
+ }
+
+ seed(seed: number): void {
+ this.state = seed >>> 0 || 1;
+ }
+
+ next(): number {
+ let t = (this.state = (this.state + 0x6d2b79f5) >>> 0);
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ }
+
+ range(min: number, max: number): number {
+ return min + (max - min) * this.next();
+ }
+
+ /** Inclusive integer range. */
+ int(min: number, max: number): number {
+ return Math.floor(this.range(min, max + 1));
+ }
+
+ chance(p: number): boolean {
+ return this.next() < p;
+ }
+
+ pick(arr: readonly T[]): T {
+ return arr[Math.floor(this.next() * arr.length) % arr.length];
+ }
+
+ shuffle(arr: T[]): T[] {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(this.next() * (i + 1));
+ const tmp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = tmp;
+ }
+ return arr;
+ }
+
+ /** Standard normal sample (Box-Muller). */
+ gaussian(): number {
+ const u = Math.max(1e-9, this.next());
+ const v = this.next();
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
+ }
+}
+
+export const rng = new Random();
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/config.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/config.ts
new file mode 100644
index 0000000..9639686
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/config.ts
@@ -0,0 +1,40 @@
+/** Fixed simulation rate (CS-style 64 tick). Rendering interpolates between ticks. */
+export const TICK_RATE = 64;
+export const TICK_DT = 1 / TICK_RATE;
+
+/** Player hull & movement tuning (meters / seconds). Values follow Source-engine proportions. */
+export const PHYS = {
+ gravity: 20.3,
+ jumpSpeed: 6.75,
+ stepHeight: 0.45,
+ airStepHeight: 0.22,
+ snapDown: 0.55,
+ radius: 0.4,
+ height: 1.8,
+ crouchHeight: 1.36,
+ eyeHeight: 1.64,
+ crouchEyeHeight: 1.2,
+ friction: 5.2,
+ stopSpeed: 1.9,
+ accelerate: 5.5,
+ airAccelerate: 12,
+ airWishCap: 0.8,
+ walkFactor: 0.52,
+ crouchFactor: 0.34,
+ /** Speeds below this fraction of max speed count as "standing still" for accuracy. */
+ accurateSpeedFraction: 0.34,
+} as const;
+
+export const RULES = {
+ freezeTime: 5,
+ roundTime: 115,
+ postRoundTime: 6,
+ bombTimer: 40,
+ plantTime: 3.2,
+ defuseTime: 10,
+ defuseKitTime: 5,
+ bombMaxDamage: 500,
+ bombSigma: 12,
+ useDistance: 1.7,
+ pickupDistance: 1.4,
+} as const;
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/math.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/math.ts
new file mode 100644
index 0000000..cc37509
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/math.ts
@@ -0,0 +1,84 @@
+import * as THREE from 'three';
+
+export const DEG = Math.PI / 180;
+export const TAU = Math.PI * 2;
+
+export function clamp(v: number, min: number, max: number): number {
+ return v < min ? min : v > max ? max : v;
+}
+
+export function lerp(a: number, b: number, t: number): number {
+ return a + (b - a) * t;
+}
+
+/** Frame-rate independent exponential smoothing towards a target. */
+export function damp(current: number, target: number, lambda: number, dt: number): number {
+ return lerp(current, target, 1 - Math.exp(-lambda * dt));
+}
+
+export function moveTowards(current: number, target: number, maxDelta: number): number {
+ if (Math.abs(target - current) <= maxDelta) return target;
+ return current + Math.sign(target - current) * maxDelta;
+}
+
+export function smoothstep(e0: number, e1: number, x: number): number {
+ const t = clamp((x - e0) / (e1 - e0), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+
+/** Wrap an angle to [-PI, PI). */
+export function wrapAngle(a: number): number {
+ a = (a + Math.PI) % TAU;
+ if (a < 0) a += TAU;
+ return a - Math.PI;
+}
+
+/** Signed shortest delta from `from` to `to`. */
+export function angleDelta(from: number, to: number): number {
+ return wrapAngle(to - from);
+}
+
+export function lerpAngle(a: number, b: number, t: number): number {
+ return a + angleDelta(a, b) * t;
+}
+
+/**
+ * Engine convention: yaw = 0 looks towards -Z (north), positive yaw turns left (towards -X),
+ * pitch > 0 looks up. Identical to a three.js camera with rotation order 'YXZ'.
+ */
+export function dirFromAngles(yaw: number, pitch: number, out = new THREE.Vector3()): THREE.Vector3 {
+ const cp = Math.cos(pitch);
+ return out.set(-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp);
+}
+
+export function yawFromDir(dx: number, dz: number): number {
+ return Math.atan2(-dx, -dz);
+}
+
+export function pitchFromDir(dx: number, dy: number, dz: number): number {
+ return Math.atan2(dy, Math.hypot(dx, dz));
+}
+
+export function dist2D(ax: number, az: number, bx: number, bz: number): number {
+ return Math.hypot(ax - bx, az - bz);
+}
+
+/** Deterministic hash -> [0,1) for procedural variation. */
+export function hash01(...n: number[]): number {
+ let h = 2166136261;
+ for (const v of n) {
+ h ^= Math.floor(v * 1000) | 0;
+ h = Math.imul(h, 16777619);
+ }
+ h ^= h >>> 13;
+ h = Math.imul(h, 0x5bd1e995);
+ h ^= h >>> 15;
+ return (h >>> 0) / 4294967296;
+}
+
+export function formatClock(seconds: number): string {
+ const s = Math.max(0, Math.ceil(seconds));
+ const m = Math.floor(s / 60);
+ const r = s % 60;
+ return `${m}:${r.toString().padStart(2, '0')}`;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/types.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/types.ts
new file mode 100644
index 0000000..cfd9c75
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/core/types.ts
@@ -0,0 +1,74 @@
+export type Team = 'CT' | 'T';
+export type SiteId = 'A' | 'B';
+export type Difficulty = 'easy' | 'normal' | 'hard';
+export type HitGroup = 'head' | 'chest' | 'stomach' | 'arm' | 'leg';
+export type SurfaceMaterial = 'stone' | 'sand' | 'wood' | 'metal' | 'plaster' | 'flesh';
+
+export const otherTeam = (t: Team): Team => (t === 'CT' ? 'T' : 'CT');
+
+export const TEAM_NAME: Record = {
+ CT: '反恐精英',
+ T: '恐怖分子',
+};
+
+export const HITGROUP_NAME: Record = {
+ head: '头部',
+ chest: '胸部',
+ stomach: '腹部',
+ arm: '手臂',
+ leg: '腿部',
+};
+
+/**
+ * Damage multipliers per hitbox zone. Chest is the reference "body" damage;
+ * a headshot always deals exactly twice the body damage.
+ */
+export const HITGROUP_MULTIPLIER: Record = {
+ head: 2.0,
+ chest: 1.0,
+ stomach: 1.25,
+ arm: 0.8,
+ leg: 0.65,
+};
+
+export interface MatchSettings {
+ playerTeam: Team;
+ difficulty: Difficulty;
+ firstRound: 'pistol' | 'rifle';
+ pistolArmor: 'none' | 'kevlar';
+ roundsToWin: number;
+ sensitivity: number;
+ volume: number;
+ playerName: string;
+ showFps: boolean;
+ /** 'high': sun shadows + up to 1.75x pixel ratio; 'low': no shadows, 1x pixel ratio. */
+ quality: 'high' | 'low';
+}
+
+export const DEFAULT_SETTINGS: MatchSettings = {
+ playerTeam: 'CT',
+ difficulty: 'normal',
+ firstRound: 'pistol',
+ pistolArmor: 'none',
+ roundsToWin: 8,
+ sensitivity: 2.2,
+ volume: 0.7,
+ playerName: '玩家',
+ showFps: true,
+ quality: 'high',
+};
+
+export type RoundEndReason =
+ | 'ct_eliminated'
+ | 't_eliminated'
+ | 'bomb_exploded'
+ | 'bomb_defused'
+ | 'time_expired';
+
+export const ROUND_END_TEXT: Record = {
+ ct_eliminated: '反恐精英已被全部消灭',
+ t_eliminated: '恐怖分子已被全部消灭',
+ bomb_exploded: '目标已被摧毁 —— C4 爆炸',
+ bomb_defused: '炸弹已被拆除',
+ time_expired: '时间耗尽,目标未被摧毁',
+};
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/GameEngine.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/GameEngine.ts
new file mode 100644
index 0000000..deb41c5
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/GameEngine.ts
@@ -0,0 +1,836 @@
+import * as THREE from 'three';
+import type { AudioEngine, SoundPos } from '../audio/AudioEngine';
+import { RULES, TICK_DT } from '../core/config';
+import { clamp, damp, DEG, dirFromAngles, lerp, lerpAngle, wrapAngle, yawFromDir } from '../core/math';
+import { ROUND_END_TEXT, TEAM_NAME, type MatchSettings, type Team } from '../core/types';
+import { GameRenderer, type CameraView } from '../gfx/Renderer';
+import type { ViewModelState } from '../gfx/ViewModel';
+import type { Actor } from '../sim/Actor';
+import { computeInaccuracy } from '../sim/Combat';
+import { World } from '../sim/World';
+import { SLOT_KEYS, WEAPONS, type WeaponId, type WeaponSlot } from '../weapons/WeaponDefs';
+import { INITIAL_HUD, type Banner, type FastHud, type HudState, type KillfeedEntry, type Notice, type ScoreRow, type SlotInfo } from './HudState';
+import { Input } from './Input';
+import { Store } from './Store';
+
+/** Buy-menu hotkeys (number row while the menu is open). */
+export const BUY_KEYS: { key: string; code: string; item: WeaponId | 'armor' }[] = [
+ { key: '1', code: 'Digit1', item: 'ak47' },
+ { key: '2', code: 'Digit2', item: 'm4a4' },
+ { key: '3', code: 'Digit3', item: 'awp' },
+ { key: '4', code: 'Digit4', item: 'glock' },
+ { key: '5', code: 'Digit5', item: 'usp' },
+ { key: '6', code: 'Digit6', item: 'deagle' },
+ { key: '7', code: 'Digit7', item: 'armor' },
+];
+
+export interface RadarActor {
+ x: number;
+ z: number;
+ yaw: number;
+ team: Team;
+ alive: boolean;
+ self: boolean;
+ hasBomb: boolean;
+ name: string;
+ viewed: boolean;
+}
+
+export interface RadarState {
+ actors: RadarActor[];
+ bomb: { x: number; z: number; state: string } | null;
+ team: Team;
+}
+
+const _v = new THREE.Vector3();
+const _fwd = new THREE.Vector3();
+
+/**
+ * Orchestrates the three update domains:
+ * - simulation: fixed 64 Hz ticks of the World (accumulator, max catch-up)
+ * - rendering: requestAnimationFrame, interpolating actors between ticks, mouse look applied
+ * per frame for zero added latency
+ * - React: throttled immutable HUD snapshots (Store) + a mutable FastHud polled per frame
+ * Also routes simulation events to audio, effects and the HUD, and runs spectator/takeover.
+ */
+export class GameEngine {
+ world: World;
+ readonly renderer: GameRenderer;
+ readonly input = new Input();
+ readonly hud = new Store({ ...INITIAL_HUD });
+ readonly fast: FastHud = {
+ crosshairGap: 6,
+ showCrosshair: true,
+ hitMarker: 0,
+ hitKill: false,
+ hitHead: false,
+ damageDirs: [],
+ damageFlash: 0,
+ flashbang: 0,
+ };
+ private settings: MatchSettings;
+ private readonly audio: AudioEngine;
+ private readonly canvas: HTMLCanvasElement;
+ private raf = 0;
+ private lastFrame = 0;
+ private acc = 0;
+ private disposed = false;
+ private paused = true;
+ private buyOpen = false;
+ private scoreboardOpen = false;
+
+ // spectating
+ private spectateTarget: Actor | null = null;
+ private deathCamActor: Actor | null = null;
+ private deathCamUntil = 0;
+ private deathCamAngle = 0;
+ private viewActor: Actor | null = null;
+ private lastViewYaw = 0;
+ private lastViewPitch = 0;
+ private shake = 0;
+ private fovCurrent = 74;
+
+ // hud bookkeeping
+ private killfeed: KillfeedEntry[] = [];
+ private feedId = 0;
+ private banner: Banner | null = null;
+ private bannerUntil = 0;
+ private notices: Notice[] = [];
+ private noticeId = 0;
+ private nextPublish = 0;
+ private frames = 0;
+ private fpsTime = 0;
+ private fps = 0;
+ private slotsKey = '';
+ private slotsCache: SlotInfo[] = [];
+ private aliveKey = '';
+ private aliveCache: { ct: boolean[]; t: boolean[] } = { ct: [], t: [] };
+ private scoreKey = '';
+ private scoreCache: ScoreRow[] = [];
+ private historyLen = -1;
+ private historyCache: HudState['history'] = [];
+ private readonly unsubs: (() => void)[] = [];
+ private readonly resizeObserver: ResizeObserver;
+
+ constructor(canvas: HTMLCanvasElement, settings: MatchSettings, audio: AudioEngine) {
+ this.canvas = canvas;
+ this.settings = settings;
+ this.audio = audio;
+ this.renderer = new GameRenderer(canvas, settings.quality);
+ this.world = new World(settings, { humanControlled: true });
+ this.renderer.setWorld(this.world);
+ this.bindWorld();
+ this.input.attach(canvas);
+ this.input.onLockChange = (locked) => this.onLockChange(locked);
+ this.audio.occlusion = (a, b) => this.world.collision.lineClear(a.x, a.y + 0.2, a.z, b.x, b.y, b.z);
+ this.audio.setVolume(settings.volume);
+ this.resizeObserver = new ResizeObserver(() => this.resize());
+ this.resizeObserver.observe(canvas.parentElement ?? canvas);
+ this.resize();
+ this.hud.set({ ready: true, playerTeam: settings.playerTeam, showFps: settings.showFps });
+ }
+
+ // ================================================================== lifecycle
+
+ start(): void {
+ this.lastFrame = performance.now();
+ this.raf = requestAnimationFrame(this.frame);
+ }
+
+ dispose(): void {
+ this.disposed = true;
+ cancelAnimationFrame(this.raf);
+ this.input.detach();
+ this.resizeObserver.disconnect();
+ for (const u of this.unsubs) u();
+ this.audio.stopLoop('plant');
+ this.audio.stopLoop('defuse');
+ this.renderer.dispose();
+ }
+
+ /** Request pointer lock (must be called from a user gesture). */
+ resume(): void {
+ this.audio.init();
+ this.audio.resume();
+ this.input.requestLock();
+ }
+
+ restartMatch(settings?: MatchSettings): void {
+ if (settings) this.settings = settings;
+ for (const u of this.unsubs) u();
+ this.unsubs.length = 0;
+ this.world = new World(this.settings, { humanControlled: true });
+ this.renderer.setWorld(this.world);
+ this.bindWorld();
+ this.killfeed = [];
+ this.notices = [];
+ this.banner = null;
+ this.spectateTarget = null;
+ this.deathCamActor = null;
+ this.hud.set({ matchWinner: null, killfeed: [], notices: [], banner: null });
+ }
+
+ /**
+ * Development/automation hook: run without pointer lock (headless browsers cannot lock).
+ * Mouse buttons are accepted as if locked.
+ */
+ debugForceRun(): void {
+ this.input.locked = true;
+ this.paused = false;
+ this.audio.init();
+ this.hud.set({ paused: false, locked: true, started: true });
+ }
+
+ setSensitivity(v: number): void {
+ this.settings = { ...this.settings, sensitivity: v };
+ }
+
+ setVolume(v: number): void {
+ this.settings = { ...this.settings, volume: v };
+ this.audio.setVolume(v);
+ }
+
+ get currentSettings(): MatchSettings {
+ return this.settings;
+ }
+
+ private onLockChange(locked: boolean): void {
+ this.paused = !locked;
+ if (locked) this.audio.resume();
+ else {
+ this.audio.suspend();
+ this.buyOpen = false;
+ }
+ this.hud.set({ paused: this.paused, locked, buyOpen: this.buyOpen, ...(locked ? { started: true } : {}) });
+ }
+
+ private resize(): void {
+ const parent = this.canvas.parentElement;
+ const w = Math.max(1, parent ? parent.clientWidth : window.innerWidth);
+ const h = Math.max(1, parent ? parent.clientHeight : window.innerHeight);
+ const pr = this.settings.quality === 'low' ? 1 : Math.min(window.devicePixelRatio || 1, 1.75);
+ this.renderer.resize(w, h, pr);
+ }
+
+ // ================================================================== frame loop
+
+ private frame = (now: number): void => {
+ if (this.disposed) return;
+ this.raf = requestAnimationFrame(this.frame);
+ const dt = Math.min(0.1, (now - this.lastFrame) / 1000);
+ this.lastFrame = now;
+
+ this.frames++;
+ this.fpsTime += dt;
+ if (this.fpsTime >= 0.5) {
+ this.fps = Math.round(this.frames / this.fpsTime);
+ this.frames = 0;
+ this.fpsTime = 0;
+ }
+
+ const pressed = this.input.consumePressed();
+ const mouse = this.input.consumeMouse();
+ if (!this.paused) {
+ this.handleGlobalKeys(pressed);
+ this.applyLook(mouse.dx, mouse.dy);
+ this.applyControls(pressed, mouse.wheel);
+ this.acc += dt;
+ let steps = 0;
+ while (this.acc >= TICK_DT && steps < 8) {
+ this.world.tick(TICK_DT);
+ this.acc -= TICK_DT;
+ steps++;
+ }
+ if (steps === 8) this.acc = 0;
+ }
+ const alpha = clamp(this.acc / TICK_DT, 0, 1);
+
+ const { view, vm } = this.computeView(alpha, dt);
+ this.renderer.viewModel.setLighting(this.renderer.inShadow(view.pos), dt);
+ this.renderer.render(this.paused ? 0 : dt, alpha, view, vm);
+ dirFromAngles(view.yaw, view.pitch, _fwd);
+ this.audio.updateListener(view.pos, _fwd);
+ this.updateFastHud(dt, view);
+ if (now >= this.nextPublish) {
+ this.nextPublish = now + 50;
+ this.publishHud();
+ }
+ };
+
+ // ================================================================== input
+
+ private handleGlobalKeys(pressed: Set): void {
+ const w = this.world;
+ if (pressed.has('KeyB')) {
+ if (this.buyOpen) this.buyOpen = false;
+ else if (w.controlled?.alive && w.canBuy()) this.buyOpen = true;
+ }
+ if (this.buyOpen && (!w.canBuy() || !w.controlled?.alive)) this.buyOpen = false;
+ if (this.buyOpen) {
+ for (const b of BUY_KEYS) {
+ if (pressed.has(b.code)) {
+ this.buy(b.item);
+ pressed.delete(b.code);
+ }
+ }
+ if (pressed.has('Escape')) this.buyOpen = false;
+ }
+ this.scoreboardOpen = this.input.isDown('Tab');
+ }
+
+ buy(item: WeaponId | 'armor'): boolean {
+ const a = this.world.controlled;
+ if (!a) return false;
+ const ok = item === 'armor' ? this.world.buyArmor(a) : this.world.buy(a, item);
+ if (ok) this.audio.pickup();
+ else this.audio.uiClick();
+ return ok;
+ }
+
+ closeBuy(): void {
+ this.buyOpen = false;
+ }
+
+ private sensitivityScale(): number {
+ // CS: degrees per count = sensitivity * m_yaw(0.022); zoomed sensitivity follows FOV ratio
+ const a = this.world.controlled;
+ let s = this.settings.sensitivity * 0.022 * DEG;
+ if (a && a.scopeLevel > 0) {
+ const def = a.weapon?.def;
+ if (def?.scope) s *= def.scope.fovs[a.scopeLevel - 1] / 74;
+ }
+ return s;
+ }
+
+ private applyLook(dx: number, dy: number): void {
+ const a = this.world.controlled;
+ if (!a || !a.alive) return;
+ const s = this.sensitivityScale();
+ a.yaw = wrapAngle(a.yaw - dx * s);
+ a.pitch = clamp(a.pitch - dy * s, -89 * DEG, 89 * DEG);
+ }
+
+ private applyControls(pressed: Set, wheel: number): void {
+ const w = this.world;
+ const a = w.controlled;
+ const inp = this.input;
+ if (a && a.alive) {
+ const i = a.input;
+ i.forward = (inp.isDown('KeyW') ? 1 : 0) - (inp.isDown('KeyS') ? 1 : 0);
+ i.right = (inp.isDown('KeyD') ? 1 : 0) - (inp.isDown('KeyA') ? 1 : 0);
+ i.jump = inp.isDown('Space');
+ i.walk = inp.isDown('ShiftLeft') || inp.isDown('ShiftRight');
+ i.crouch = inp.isDown('KeyC');
+ const blockFire = this.buyOpen;
+ i.fire = !blockFire && (inp.mouseButtons & 1) !== 0;
+ i.alt = (inp.mouseButtons & 4) !== 0;
+ if (!blockFire && pressed.has('Mouse0')) i.firePressed = true;
+ if (pressed.has('Mouse2')) i.altPressed = true;
+ if (pressed.has('KeyR')) i.reload = true;
+ i.use = inp.isDown('KeyE');
+ if (pressed.has('KeyE')) i.usePressed = true;
+ if (pressed.has('KeyG')) i.drop = true;
+ if (pressed.has('KeyQ')) i.lastWeapon = true;
+ if (!this.buyOpen) {
+ SLOT_KEYS.forEach((k, slot) => {
+ if (pressed.has(`Digit${k}`)) i.slot = slot as WeaponSlot;
+ });
+ if (pressed.has('Digit4') && a.weapons[3]) i.slot = 3;
+ }
+ if (wheel !== 0) {
+ const order: WeaponSlot[] = [0, 1, 2, 3];
+ const avail = order.filter((s) => a.weapons[s]);
+ const idx = avail.indexOf(a.slot);
+ if (avail.length > 1) i.slot = avail[(idx + (wheel > 0 ? 1 : -1) + avail.length) % avail.length];
+ }
+ return;
+ }
+ // ---- spectator controls
+ if (this.deathCamActor && w.time < this.deathCamUntil) {
+ if (pressed.has('Mouse0') || pressed.has('Space')) this.deathCamUntil = 0;
+ return;
+ }
+ if (pressed.has('Mouse0') || pressed.has('ArrowRight')) this.cycleSpectate(1);
+ if (pressed.has('Mouse2') || pressed.has('ArrowLeft')) this.cycleSpectate(-1);
+ if (pressed.has('KeyE')) {
+ const t = this.spectateTarget;
+ if (t && t.alive && t.team === w.human.team && w.takeOver(t)) {
+ t.input.forward = 0;
+ this.pushNotice(`你已接管 ${t.name}`, 'neutral');
+ this.audio.uiClick();
+ }
+ }
+ }
+
+ private spectateCandidates(): Actor[] {
+ const w = this.world;
+ const mates = w.actors.filter((x) => x.alive && x.team === w.human.team);
+ return mates.length ? mates : w.actors.filter((x) => x.alive);
+ }
+
+ private cycleSpectate(dir: number): void {
+ const list = this.spectateCandidates();
+ if (!list.length) return;
+ const idx = this.spectateTarget ? list.indexOf(this.spectateTarget) : -1;
+ this.spectateTarget = list[(idx + dir + list.length) % list.length];
+ }
+
+ // ================================================================== camera
+
+ private computeView(alpha: number, dt: number): { view: CameraView; vm: ViewModelState | null } {
+ const w = this.world;
+ const ctrl = w.controlled;
+ let actor: Actor | null = null;
+ if (ctrl && ctrl.alive) {
+ actor = ctrl;
+ this.deathCamActor = null;
+ } else if (this.deathCamActor && w.time < this.deathCamUntil) {
+ actor = null;
+ } else {
+ if (!this.spectateTarget || !this.spectateTarget.alive) {
+ this.spectateTarget = null;
+ this.cycleSpectate(1);
+ }
+ actor = this.spectateTarget;
+ }
+ this.viewActor = actor;
+ this.shake = damp(this.shake, 0, 3, dt);
+ const shakeX = (Math.random() - 0.5) * this.shake * 0.05;
+ const shakeY = (Math.random() - 0.5) * this.shake * 0.05;
+
+ if (!actor) {
+ // third-person death cam orbiting the corpse (or a free overview if nobody is alive)
+ const corpse = this.deathCamActor;
+ const center = corpse ? _v.copy(corpse.pos) : _v.set(54, 0, 52);
+ center.y += corpse ? 0.8 : 30;
+ this.deathCamAngle += dt * 0.35;
+ const dist = corpse ? 3.6 : 40;
+ const off = new THREE.Vector3(Math.sin(this.deathCamAngle) * dist, corpse ? 2.2 : 25, Math.cos(this.deathCamAngle) * dist);
+ const desired = center.clone().add(off);
+ if (corpse) {
+ const d = desired.clone().sub(center);
+ const len = d.length();
+ d.normalize();
+ const col = w.collision;
+ let t = len;
+ for (let s = 0.4; s <= len; s += 0.2) {
+ if (!col.lineClear(center.x, center.y, center.z, center.x + d.x * s, center.y + d.y * s, center.z + d.z * s)) {
+ t = Math.max(0.6, s - 0.3);
+ break;
+ }
+ }
+ desired.copy(center).addScaledVector(d, t);
+ }
+ const dir = center.clone().sub(desired);
+ const yaw = yawFromDir(dir.x, dir.z);
+ const pitch = Math.atan2(dir.y, Math.hypot(dir.x, dir.z));
+ this.fovCurrent = damp(this.fovCurrent, 74, 10, dt);
+ return { view: { pos: desired, yaw, pitch, roll: 0, fov: this.fovCurrent, firstPerson: null }, vm: null };
+ }
+
+ const human = !actor.isBot;
+ const pos = new THREE.Vector3().lerpVectors(actor.prevPos, actor.pos, alpha);
+ pos.y += actor.eyeHeight;
+ let yaw = human ? actor.yaw : lerpAngle(actor.prevYaw, actor.yaw, alpha);
+ let pitch = human ? actor.pitch : lerp(actor.prevPitch, actor.pitch, alpha);
+ const def = actor.weapon?.def;
+ if (def) {
+ pitch += actor.recoilPitch * def.recoil.viewPunch * DEG;
+ yaw -= actor.recoilYaw * def.recoil.viewPunch * DEG;
+ }
+ pitch += shakeY;
+ yaw += shakeX;
+ let targetFov = 74;
+ if (def?.scope && actor.scopeLevel > 0) targetFov = def.scope.fovs[actor.scopeLevel - 1];
+ this.fovCurrent = actor.scopeLevel > 0 ? targetFov : damp(this.fovCurrent, targetFov, 18, dt);
+
+ const dYaw = wrapAngle(yaw - this.lastViewYaw);
+ const dPitch = pitch - this.lastViewPitch;
+ this.lastViewYaw = yaw;
+ this.lastViewPitch = pitch;
+
+ const renderTime = w.time - TICK_DT + alpha * TICK_DT;
+ let vm: ViewModelState | null = null;
+ if (actor.alive) {
+ const wdef = actor.weapon?.def;
+ vm = {
+ weapon: actor.weaponId,
+ team: actor.team,
+ time: renderTime,
+ drawStart: actor.drawEnd - (wdef?.drawTime ?? 0.5),
+ drawEnd: actor.drawEnd,
+ reloading: actor.reloading,
+ reloadStart: actor.reloadStart,
+ reloadEnd: actor.reloadEnd,
+ kick: actor.kick,
+ scoped: actor.scopeLevel > 0,
+ busy: actor.planting || actor.defusing,
+ speed: Math.hypot(actor.vel.x, actor.vel.z),
+ maxSpeed: actor.maxSpeed(),
+ onGround: actor.onGround,
+ dYaw: Math.abs(dYaw) > 0.5 ? 0 : dYaw,
+ dPitch: Math.abs(dPitch) > 0.5 ? 0 : dPitch,
+ };
+ }
+ return { view: { pos, yaw, pitch, roll: 0, fov: this.fovCurrent, firstPerson: actor }, vm };
+ }
+
+ private updateFastHud(dt: number, view: CameraView): void {
+ const f = this.fast;
+ const a = this.viewActor;
+ f.hitMarker = Math.max(0, f.hitMarker - dt * 3.5);
+ f.damageFlash = Math.max(0, f.damageFlash - dt * 1.8);
+ for (const d of f.damageDirs) d.alpha -= dt * 0.7;
+ f.damageDirs = f.damageDirs.filter((d) => d.alpha > 0);
+ if (!a || !a.alive) {
+ f.showCrosshair = false;
+ return;
+ }
+ const def = a.weapon?.def;
+ f.showCrosshair = !!def && !(def.kind === 'sniper') && a.scopeLevel === 0;
+ if (def) {
+ const inacc = computeInaccuracy(a, def);
+ const h = this.canvas.clientHeight || 800;
+ const px = (h / 2) * (Math.tan(inacc * DEG) / Math.tan((view.fov * DEG) / 2));
+ f.crosshairGap = clamp(3 + px * 0.9 + a.kick * 5, 3, 140);
+ }
+ }
+
+ // ================================================================== world events -> audio / fx / hud
+
+ private isViewed(a: Actor): boolean {
+ return this.viewActor === a;
+ }
+
+ private spatial(a: Actor): SoundPos | null {
+ return this.isViewed(a) ? null : { x: a.pos.x, y: a.pos.y + 1.5, z: a.pos.z };
+ }
+
+ private bindWorld(): void {
+ const ev = this.world.events;
+ const fx = this.renderer.effects;
+ const on = [0]>(k: K, fn: Parameters>[1]) => this.unsubs.push(ev.on(k, fn));
+
+ on('shot', (e) => {
+ const def = WEAPONS[e.weapon];
+ if (this.isViewed(e.actor)) {
+ this.renderer.viewModel.onShot();
+ const cam = this.renderer.camera;
+ const right = _v.set(1, 0, 0).applyQuaternion(cam.quaternion);
+ const start = cam.position.clone().addScaledVector(dirFromAngles(e.actor.yaw, e.actor.pitch, _fwd), 0.9).addScaledVector(right, 0.12);
+ start.y -= 0.12;
+ if (def.id !== 'usp' && Math.random() < 0.6) fx.tracer(start, e.end);
+ this.audio.gunshot(def.sound, null);
+ if (def.id !== 'usp') fx.flashLight(start, 10, 6, 0.05);
+ } else {
+ const muzzle = e.actor.model.muzzleWorld(new THREE.Vector3());
+ if (def.id !== 'usp') fx.muzzleFlash(muzzle, def.kind === 'sniper' ? 0.7 : def.kind === 'pistol' ? 0.32 : 0.45);
+ if (def.id !== 'usp' || Math.random() < 0.3) fx.tracer(muzzle, e.end);
+ this.audio.gunshot(def.sound, { x: e.origin.x, y: e.origin.y, z: e.origin.z });
+ }
+ });
+ on('impact', (e) => fx.impact(e.point, e.normal, e.material));
+ on('damage', (e) => {
+ // no blood cloud inside the first-person camera
+ if (e.group !== null && !this.isViewed(e.victim)) fx.blood(e.point, e.dir, e.group === 'head');
+ const ctrl = this.world.controlled;
+ if (this.isViewed(e.victim)) {
+ this.fast.damageFlash = Math.min(1, this.fast.damageFlash + 0.35 + e.amount / 120);
+ const src = e.attacker ? e.attacker.pos : e.victim.pos.clone().sub(e.dir);
+ const yawTo = yawFromDir(src.x - e.victim.pos.x, src.z - e.victim.pos.z);
+ this.fast.damageDirs.push({ angle: wrapAngle(e.victim.yaw - yawTo), alpha: 1 });
+ if (this.fast.damageDirs.length > 6) this.fast.damageDirs.shift();
+ this.shake = Math.min(1.2, this.shake + e.amount / 60);
+ this.audio.hurt();
+ }
+ if (ctrl && e.attacker === ctrl && e.victim !== ctrl) {
+ this.fast.hitMarker = 1;
+ this.fast.hitHead = e.group === 'head';
+ this.fast.hitKill = false;
+ this.audio.hitMarker(e.group === 'head');
+ }
+ });
+ on('kill', (e) => {
+ const ctrl = this.world.controlled;
+ const human = this.world.human;
+ this.killfeed = [
+ ...this.killfeed.slice(-5),
+ {
+ id: ++this.feedId,
+ killer: e.killer ? e.killer.name : null,
+ killerTeam: e.killer ? e.killer.team : null,
+ victim: e.victim.name,
+ victimTeam: e.victim.team,
+ weapon: e.weapon === 'c4' ? 'C4' : e.weapon === 'fall' ? '坠落' : WEAPONS[e.weapon].feed,
+ headshot: e.headshot,
+ time: performance.now(),
+ mine: e.killer === ctrl || e.victim === ctrl || e.killer === human || e.victim === human,
+ },
+ ];
+ if (ctrl && e.killer === ctrl && e.victim !== ctrl) {
+ this.fast.hitKill = true;
+ this.fast.hitMarker = 1;
+ this.audio.killConfirm(e.headshot);
+ }
+ if (this.viewActor === e.victim || e.victim === ctrl) {
+ // controlled (or spectated) actor died: short death cam, then spectate a teammate
+ this.deathCamActor = e.victim;
+ this.deathCamUntil = this.world.time + 2.6;
+ this.deathCamAngle = e.victim.yaw + Math.PI;
+ this.spectateTarget = null;
+ }
+ if (e.victim === ctrl) {
+ if (e.weapon === 'c4') this.pushNotice('你被 C4 爆炸炸死', 'warn');
+ else if (e.killer) this.pushNotice(`你被 ${e.killer.name}(${WEAPONS[e.weapon as WeaponId]?.name ?? e.weapon}${e.headshot ? ',爆头' : ''})击杀`, 'warn');
+ else this.pushNotice('你阵亡了', 'warn');
+ }
+ });
+ on('reload', (e) => this.audio.reload(WEAPONS[e.weapon].kind, WEAPONS[e.weapon].reloadTime, this.spatial(e.actor)));
+ on('draw', (e) => {
+ if (this.isViewed(e.actor)) this.audio.draw(WEAPONS[e.weapon].kind, null);
+ });
+ on('dryFire', (e) => {
+ if (this.isViewed(e.actor)) this.audio.dryFire();
+ });
+ on('scope', (e) => {
+ if (this.isViewed(e.actor)) this.audio.scope(e.level);
+ });
+ on('footstep', (e) => this.audio.footstep(this.isViewed(e.actor) ? null : { x: e.pos.x, y: e.pos.y + 0.1, z: e.pos.z }));
+ on('land', (e) => {
+ this.audio.land(this.spatial(e.actor), e.speed);
+ if (this.isViewed(e.actor)) this.renderer.viewModel.onLand(e.speed);
+ });
+ on('knife', (e) => {
+ this.audio.knife(this.spatial(e.actor), e.hit);
+ if (this.isViewed(e.actor)) this.renderer.viewModel.onKnife(e.alt);
+ });
+ on('pickup', (e) => {
+ if (e.actor === this.world.controlled) {
+ this.audio.pickup();
+ this.pushNotice(`拾取了 ${WEAPONS[e.weapon].name}`, 'neutral');
+ }
+ });
+ on('bombPickup', (e) => {
+ if (e.actor === this.world.controlled) this.audio.pickup();
+ if (this.world.human.team === 'T') this.pushNotice(`${e.actor.name} 拾取了 C4 炸弹`, 't');
+ });
+ on('bombDrop', (e) => {
+ if (this.world.human.team === 'T') this.pushNotice(`${e.actor.name} 丢下了 C4 炸弹!`, 'warn');
+ });
+ on('plantStart', (e) => this.audio.plantSequence(this.spatial(e.actor), RULES.plantTime));
+ on('plantAbort', () => this.audio.stopLoop('plant'));
+ on('bombPlanted', (e) => {
+ this.audio.bombPlanted(this.isViewed(e.actor) ? null : { x: e.pos.x, y: e.pos.y, z: e.pos.z });
+ this.showBanner(`炸弹已安放在 ${e.site} 点`, `${RULES.bombTimer} 秒后爆炸 —— 反恐精英需要拆除炸弹`, 'warn', 3.5);
+ });
+ on('bombBeep', (e) => this.audio.c4Beep({ x: e.pos.x, y: e.pos.y + 0.2, z: e.pos.z }, e.urgency));
+ on('defuseStart', (e) => {
+ this.audio.defuseLoop(this.spatial(e.actor), e.kit ? RULES.defuseKitTime : RULES.defuseTime);
+ if (this.world.human.team === 'CT' && !this.isViewed(e.actor)) this.pushNotice(`${e.actor.name} 正在拆除炸弹${e.kit ? '(拆弹器)' : ''}`, 'ct');
+ });
+ on('defuseAbort', () => this.audio.stopLoop('defuse'));
+ on('bombDefused', () => this.audio.bombDefused());
+ on('bombExploded', (e) => {
+ fx.explosion(e.pos);
+ this.audio.explosion({ x: e.pos.x, y: e.pos.y + 1, z: e.pos.z });
+ const d = this.viewActor ? this.viewActor.pos.distanceTo(e.pos) : 30;
+ this.shake = Math.max(this.shake, clamp(2.5 - d / 25, 0.3, 2.5));
+ });
+ on('roundStart', (e) => {
+ this.renderer.clearTransient();
+ this.audio.stopLoop('plant');
+ this.audio.stopLoop('defuse');
+ this.spectateTarget = null;
+ this.deathCamActor = null;
+ this.showBanner(
+ e.pistol ? `第 ${e.round} 回合 · 手枪局` : `第 ${e.round} 回合`,
+ e.pistol
+ ? '全员仅持默认手枪(T: Glock-18 / CT: USP-S),无主武器 · 按 B 购买手枪'
+ : '长枪局:全甲 + 主武器 · 冻结时间内按 B 打开购买菜单',
+ 'neutral',
+ RULES.freezeTime,
+ );
+ const h = this.world.human;
+ if (h.hasBomb) this.pushNotice('你携带着 C4!前往 A 点或 B 点,按住 E(或切到 5 号位按住左键)安放', 't');
+ });
+ on('roundLive', () => {
+ this.banner = null;
+ this.pushNotice('回合开始!', 'neutral');
+ });
+ on('roundEnd', (e) => {
+ const humanTeam = this.world.human.team;
+ this.showBanner(`${TEAM_NAME[e.winner]}获胜!`, ROUND_END_TEXT[e.reason], e.winner === 'CT' ? 'ct' : 't', RULES.postRoundTime);
+ this.audio.roundJingle(e.winner === humanTeam);
+ });
+ on('matchEnd', (e) => {
+ this.hud.set({ matchWinner: e.winner });
+ });
+ }
+
+ private showBanner(title: string, sub: string | undefined, tone: Banner['tone'], seconds: number): void {
+ this.banner = { key: Date.now() + Math.random(), title, sub, tone };
+ this.bannerUntil = performance.now() + seconds * 1000;
+ }
+
+ private pushNotice(text: string, tone: Notice['tone']): void {
+ this.notices = [...this.notices.slice(-3), { key: ++this.noticeId, text, tone }];
+ const key = this.noticeId;
+ setTimeout(() => {
+ this.notices = this.notices.filter((n) => n.key !== key);
+ }, 4200);
+ }
+
+ // ================================================================== HUD publishing
+
+ private publishHud(): void {
+ const w = this.world;
+ const a = this.viewActor;
+ const ctrl = w.controlled;
+ const now = performance.now();
+ if (this.banner && now > this.bannerUntil) this.banner = null;
+ this.killfeed = this.killfeed.filter((k) => now - k.time < 7000);
+
+ // alive pips
+ const ct = w.actors.filter((x) => x.team === 'CT').map((x) => x.alive);
+ const t = w.actors.filter((x) => x.team === 'T').map((x) => x.alive);
+ const ak = ct.join() + '|' + t.join();
+ if (ak !== this.aliveKey) {
+ this.aliveKey = ak;
+ this.aliveCache = { ct, t };
+ }
+
+ // slots
+ let slots = this.slotsCache;
+ if (a) {
+ const sk = a.weapons.map((x) => x?.def.id ?? '-').join() + a.slot;
+ if (sk !== this.slotsKey) {
+ this.slotsKey = sk;
+ slots = [];
+ a.weapons.forEach((wi, i) => {
+ if (wi) slots.push({ slot: i, key: SLOT_KEYS[i], id: wi.def.id, name: wi.def.name, active: a.slot === i });
+ });
+ this.slotsCache = slots;
+ }
+ }
+
+ // scoreboard rows (only while open)
+ let scoreboard = this.scoreCache;
+ if (this.scoreboardOpen || w.phase === 'over') {
+ const rows: ScoreRow[] = w.actors.map((x) => ({
+ id: x.id,
+ name: x.name,
+ team: x.team,
+ alive: x.alive,
+ kills: x.kills,
+ deaths: x.deaths,
+ headshots: x.headshots,
+ damage: x.damageDealt,
+ isHuman: x.isHumanSlot,
+ isControlled: x === ctrl,
+ hasBomb: x.hasBomb && x.team === w.human.team,
+ }));
+ const key = JSON.stringify(rows);
+ if (key !== this.scoreKey) {
+ this.scoreKey = key;
+ this.scoreCache = rows;
+ scoreboard = rows;
+ }
+ }
+
+ if (w.history.length !== this.historyLen) {
+ this.historyLen = w.history.length;
+ this.historyCache = [...w.history];
+ }
+
+ const wi = a?.weapon;
+ const site = a && a.alive && a.hasBomb ? w.level.bombsiteAt(a.pos.x, a.pos.z) : null;
+ let actionHint: string | null = null;
+ if (ctrl && ctrl.alive) {
+ if (ctrl.hasBomb && site && w.phase === 'live' && !ctrl.planting) actionHint = `你在 ${site.id} 炸弹安放区 —— 按住 E 安放 C4`;
+ else if (ctrl.team === 'CT' && w.bomb.state === 'planted' && w.inBombReach(ctrl) && !ctrl.defusing) actionHint = `按住 E 拆除炸弹${ctrl.hasKit ? '(拆弹器 5 秒)' : '(10 秒)'}`;
+ else if (ctrl.hasBomb && w.phase === 'live') actionHint = null;
+ } else if (!ctrl && this.spectateTarget && this.spectateTarget.alive && this.spectateTarget.team === w.human.team && !(this.deathCamActor && w.time < this.deathCamUntil)) {
+ actionHint = null;
+ }
+ const spectating = !ctrl || !ctrl.alive;
+ const deathCam = !!this.deathCamActor && w.time < this.deathCamUntil;
+ const canTakeover = spectating && !deathCam && !!this.spectateTarget && this.spectateTarget.alive && this.spectateTarget.team === w.human.team && w.phase !== 'over';
+
+ this.hud.set({
+ paused: this.paused,
+ phase: w.phase,
+ round: w.roundNumber,
+ pistolRound: w.isPistolRound,
+ clock: Math.ceil(w.clock),
+ bombPlanted: w.bomb.state === 'planted',
+ scoreCT: w.score.CT,
+ scoreT: w.score.T,
+ aliveCT: this.aliveCache.ct,
+ aliveT: this.aliveCache.t,
+ playerTeam: w.human.team,
+ viewName: a ? a.name : deathCam && this.deathCamActor ? this.deathCamActor.name : '',
+ viewTeam: a ? a.team : w.human.team,
+ spectating,
+ deathCam,
+ canTakeover,
+ controllingBot: ctrl && ctrl !== w.human ? ctrl.name : null,
+ health: a ? a.health : 0,
+ armor: a ? a.armor : 0,
+ helmet: a ? a.helmet : false,
+ hasKit: a ? a.hasKit : false,
+ hasBomb: a ? a.hasBomb : false,
+ weapon: wi
+ ? { id: wi.def.id, name: wi.def.name, kind: wi.def.kind, ammo: wi.ammo, mag: wi.def.magSize, reserve: wi.reserve }
+ : null,
+ slots,
+ reloading: a ? a.reloading : false,
+ scoped: a ? a.scopeLevel > 0 : false,
+ inBombsite: site ? site.id : null,
+ plantProgress: a && a.planting ? Math.min(1, a.plantProgress / RULES.plantTime) : null,
+ defuseProgress: a && a.defusing ? Math.min(1, a.defuseProgress / (a.hasKit ? RULES.defuseKitTime : RULES.defuseTime)) : null,
+ actionHint,
+ location: a ? w.level.zoneAt(a.pos.x, a.pos.z) : '',
+ killfeed: this.killfeed,
+ banner: this.banner,
+ notices: this.notices,
+ buyOpen: this.buyOpen,
+ canBuy: w.canBuy() && !!ctrl?.alive,
+ scoreboardOpen: this.scoreboardOpen,
+ scoreboard,
+ matchWinner: w.matchWinner && w.phase === 'over' ? w.matchWinner : null,
+ history: this.historyCache,
+ fps: this.fps,
+ lastRoundEnd: w.lastWinner && w.lastReason ? (w.phase === 'post' ? { winner: w.lastWinner, reason: w.lastReason } : null) : null,
+ });
+ }
+
+ // ================================================================== radar
+
+ radar(): RadarState {
+ const w = this.world;
+ const team = w.human.team;
+ const actors: RadarActor[] = [];
+ const ctrl = w.controlled;
+ for (const x of w.actors) {
+ const mate = x.team === team;
+ if (!mate && !w.isSpottedBy(x, team)) continue;
+ actors.push({
+ x: x.pos.x,
+ z: x.pos.z,
+ yaw: x.yaw,
+ team: x.team,
+ alive: x.alive,
+ self: x === ctrl || (!ctrl && x === w.human),
+ hasBomb: x.hasBomb && (mate || w.isSpottedBy(x, team)),
+ name: x.name,
+ viewed: x === this.viewActor,
+ });
+ }
+ const b = w.bomb;
+ let bomb: RadarState['bomb'] = null;
+ if (b.state === 'planted' || b.state === 'defused' || b.state === 'exploded') bomb = { x: b.pos.x, z: b.pos.z, state: b.state };
+ else if (b.state === 'dropped' && team === 'T') bomb = { x: b.pos.x, z: b.pos.z, state: 'dropped' };
+ else if (b.state === 'carried' && b.carrier && (team === 'T' || w.isSpottedBy(b.carrier, team))) bomb = { x: b.pos.x, z: b.pos.z, state: 'carried' };
+ return { actors, bomb, team };
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/HudState.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/HudState.ts
new file mode 100644
index 0000000..d904967
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/HudState.ts
@@ -0,0 +1,175 @@
+import type { RoundEndReason, Team } from '../core/types';
+import type { RoundPhase } from '../sim/World';
+import type { WeaponId, WeaponKind } from '../weapons/WeaponDefs';
+
+export interface KillfeedEntry {
+ id: number;
+ killer: string | null;
+ killerTeam: Team | null;
+ victim: string;
+ victimTeam: Team;
+ weapon: string;
+ headshot: boolean;
+ time: number;
+ /** Highlight entries involving the human. */
+ mine: boolean;
+}
+
+export interface SlotInfo {
+ slot: number;
+ key: string;
+ id: WeaponId;
+ name: string;
+ active: boolean;
+}
+
+export interface WeaponHud {
+ id: WeaponId;
+ name: string;
+ kind: WeaponKind;
+ ammo: number;
+ mag: number;
+ reserve: number;
+}
+
+export interface ScoreRow {
+ id: number;
+ name: string;
+ team: Team;
+ alive: boolean;
+ kills: number;
+ deaths: number;
+ headshots: number;
+ damage: number;
+ isHuman: boolean;
+ isControlled: boolean;
+ hasBomb: boolean;
+}
+
+export interface Banner {
+ key: number;
+ title: string;
+ sub?: string;
+ tone: 'ct' | 't' | 'neutral' | 'warn';
+}
+
+export interface Notice {
+ key: number;
+ text: string;
+ tone: 'ct' | 't' | 'neutral' | 'warn';
+}
+
+export interface HudState {
+ ready: boolean;
+ paused: boolean;
+ locked: boolean;
+ /** True once the player has entered the game at least once (pause vs. first start screen). */
+ started: boolean;
+ phase: RoundPhase;
+ round: number;
+ pistolRound: boolean;
+ clock: number;
+ bombPlanted: boolean;
+ scoreCT: number;
+ scoreT: number;
+ aliveCT: boolean[];
+ aliveT: boolean[];
+ playerTeam: Team;
+ // viewed actor
+ viewName: string;
+ viewTeam: Team;
+ spectating: boolean;
+ deathCam: boolean;
+ canTakeover: boolean;
+ controllingBot: string | null;
+ health: number;
+ armor: number;
+ helmet: boolean;
+ hasKit: boolean;
+ hasBomb: boolean;
+ weapon: WeaponHud | null;
+ slots: SlotInfo[];
+ reloading: boolean;
+ scoped: boolean;
+ inBombsite: string | null;
+ plantProgress: number | null;
+ defuseProgress: number | null;
+ actionHint: string | null;
+ location: string;
+ killfeed: KillfeedEntry[];
+ banner: Banner | null;
+ notices: Notice[];
+ buyOpen: boolean;
+ canBuy: boolean;
+ scoreboardOpen: boolean;
+ scoreboard: ScoreRow[];
+ matchWinner: Team | null;
+ history: { winner: Team; reason: RoundEndReason }[];
+ fps: number;
+ showFps: boolean;
+ lastRoundEnd: { winner: Team; reason: RoundEndReason } | null;
+}
+
+export const INITIAL_HUD: HudState = {
+ ready: false,
+ paused: true,
+ locked: false,
+ started: false,
+ phase: 'freeze',
+ round: 1,
+ pistolRound: true,
+ clock: 0,
+ bombPlanted: false,
+ scoreCT: 0,
+ scoreT: 0,
+ aliveCT: [],
+ aliveT: [],
+ playerTeam: 'CT',
+ viewName: '',
+ viewTeam: 'CT',
+ spectating: false,
+ deathCam: false,
+ canTakeover: false,
+ controllingBot: null,
+ health: 100,
+ armor: 0,
+ helmet: false,
+ hasKit: false,
+ hasBomb: false,
+ weapon: null,
+ slots: [],
+ reloading: false,
+ scoped: false,
+ inBombsite: null,
+ plantProgress: null,
+ defuseProgress: null,
+ actionHint: null,
+ location: '',
+ killfeed: [],
+ banner: null,
+ notices: [],
+ buyOpen: false,
+ canBuy: false,
+ scoreboardOpen: false,
+ scoreboard: [],
+ matchWinner: null,
+ history: [],
+ fps: 0,
+ showFps: true,
+ lastRoundEnd: null,
+};
+
+/** High-frequency state polled each animation frame by canvas/DOM-driven widgets. */
+export interface FastHud {
+ /** Crosshair gap in pixels. */
+ crosshairGap: number;
+ showCrosshair: boolean;
+ /** 0..1 hit marker intensity, and whether it was a kill/headshot. */
+ hitMarker: number;
+ hitKill: boolean;
+ hitHead: boolean;
+ /** Damage indicators: screen-space angles (radians, 0 = up) with intensity. */
+ damageDirs: { angle: number; alpha: number }[];
+ damageFlash: number;
+ flashbang: number;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Input.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Input.ts
new file mode 100644
index 0000000..5b36845
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Input.ts
@@ -0,0 +1,155 @@
+/**
+ * Raw keyboard / mouse state with pointer lock. Continuous state (held keys, buttons) is read
+ * directly; discrete presses are queued as edges and consumed once per frame by the engine.
+ */
+export class Input {
+ readonly down = new Set();
+ private readonly pressedQueue = new Set();
+ mouseButtons = 0;
+ private dx = 0;
+ private dy = 0;
+ private wheel = 0;
+ locked = false;
+ private el: HTMLElement | null = null;
+ onLockChange: ((locked: boolean) => void) | null = null;
+ /** Keys typed while UI overlays are focused should not drive the game. */
+ enabled = true;
+
+ attach(el: HTMLElement): void {
+ this.el = el;
+ window.addEventListener('keydown', this.onKeyDown);
+ window.addEventListener('keyup', this.onKeyUp);
+ window.addEventListener('mousedown', this.onMouseDown);
+ window.addEventListener('mouseup', this.onMouseUp);
+ window.addEventListener('mousemove', this.onMouseMove);
+ window.addEventListener('wheel', this.onWheel, { passive: false });
+ window.addEventListener('blur', this.onBlur);
+ window.addEventListener('contextmenu', this.onContextMenu);
+ document.addEventListener('pointerlockchange', this.onPointerLockChange);
+ }
+
+ detach(): void {
+ window.removeEventListener('keydown', this.onKeyDown);
+ window.removeEventListener('keyup', this.onKeyUp);
+ window.removeEventListener('mousedown', this.onMouseDown);
+ window.removeEventListener('mouseup', this.onMouseUp);
+ window.removeEventListener('mousemove', this.onMouseMove);
+ window.removeEventListener('wheel', this.onWheel);
+ window.removeEventListener('blur', this.onBlur);
+ window.removeEventListener('contextmenu', this.onContextMenu);
+ document.removeEventListener('pointerlockchange', this.onPointerLockChange);
+ if (document.pointerLockElement === this.el) document.exitPointerLock();
+ this.el = null;
+ }
+
+ requestLock(): void {
+ const el = this.el;
+ if (!el || document.pointerLockElement === el) return;
+ try {
+ const p = el.requestPointerLock({ unadjustedMovement: true } as never) as unknown as Promise | undefined;
+ if (p && typeof p.catch === 'function') {
+ p.catch(() => {
+ // some platforms reject unadjustedMovement: retry plain
+ try {
+ void (el.requestPointerLock() as unknown);
+ } catch {
+ /* ignore */
+ }
+ });
+ }
+ } catch {
+ try {
+ el.requestPointerLock();
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+
+ exitLock(): void {
+ if (document.pointerLockElement) document.exitPointerLock();
+ }
+
+ isDown(code: string): boolean {
+ return this.down.has(code);
+ }
+
+ /** Returns and clears the set of keys/buttons pressed since the last call. */
+ consumePressed(): Set {
+ const s = new Set(this.pressedQueue);
+ this.pressedQueue.clear();
+ return s;
+ }
+
+ consumeMouse(): { dx: number; dy: number; wheel: number } {
+ const r = { dx: this.dx, dy: this.dy, wheel: this.wheel };
+ this.dx = 0;
+ this.dy = 0;
+ this.wheel = 0;
+ return r;
+ }
+
+ clear(): void {
+ this.down.clear();
+ this.pressedQueue.clear();
+ this.mouseButtons = 0;
+ this.dx = this.dy = this.wheel = 0;
+ }
+
+ private onKeyDown = (e: KeyboardEvent): void => {
+ if (!this.enabled) return;
+ const target = e.target as HTMLElement | null;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')) return;
+ if (['Tab', 'Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyB', 'Quote', 'Slash'].includes(e.code)) e.preventDefault();
+ if (e.ctrlKey && this.locked) e.preventDefault();
+ if (!e.repeat) this.pressedQueue.add(e.code);
+ this.down.add(e.code);
+ };
+
+ private onKeyUp = (e: KeyboardEvent): void => {
+ this.down.delete(e.code);
+ };
+
+ private onMouseDown = (e: MouseEvent): void => {
+ if (!this.enabled) return;
+ if (!this.locked) return;
+ this.mouseButtons |= 1 << e.button;
+ this.pressedQueue.add(`Mouse${e.button}`);
+ };
+
+ private onMouseUp = (e: MouseEvent): void => {
+ this.mouseButtons &= ~(1 << e.button);
+ };
+
+ private onMouseMove = (e: MouseEvent): void => {
+ if (!this.locked) return;
+ // guard against occasional huge spikes some browsers emit on lock
+ if (Math.abs(e.movementX) > 600 || Math.abs(e.movementY) > 600) return;
+ this.dx += e.movementX;
+ this.dy += e.movementY;
+ };
+
+ private onWheel = (e: WheelEvent): void => {
+ if (!this.locked) return;
+ e.preventDefault();
+ this.wheel += Math.sign(e.deltaY);
+ };
+
+ private onBlur = (): void => {
+ this.down.clear();
+ this.mouseButtons = 0;
+ };
+
+ private onContextMenu = (e: Event): void => {
+ e.preventDefault();
+ };
+
+ private onPointerLockChange = (): void => {
+ this.locked = document.pointerLockElement === this.el && this.el !== null;
+ if (!this.locked) {
+ this.mouseButtons = 0;
+ this.down.clear();
+ }
+ this.onLockChange?.(this.locked);
+ };
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Store.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Store.ts
new file mode 100644
index 0000000..8ba9872
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/engine/Store.ts
@@ -0,0 +1,69 @@
+import { useRef, useSyncExternalStore } from 'react';
+
+/**
+ * Minimal external store bridging the imperative game loop and React. The engine publishes
+ * immutable snapshots at a throttled rate; components subscribe to slices through `useStore`
+ * (useSyncExternalStore + selector memoisation), so only slices that change re-render.
+ */
+export class Store {
+ private state: T;
+ private readonly listeners = new Set<() => void>();
+
+ constructor(initial: T) {
+ this.state = initial;
+ }
+
+ getSnapshot = (): T => this.state;
+
+ subscribe = (fn: () => void): (() => void) => {
+ this.listeners.add(fn);
+ return () => {
+ this.listeners.delete(fn);
+ };
+ };
+
+ /** Shallow-merge a patch; notifies only if some top-level field actually changed. */
+ set(patch: Partial): void {
+ let changed = false;
+ for (const k in patch) {
+ if (!Object.is(patch[k], this.state[k])) {
+ changed = true;
+ break;
+ }
+ }
+ if (!changed) return;
+ this.state = { ...this.state, ...patch };
+ for (const fn of this.listeners) fn();
+ }
+}
+
+export function shallowEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ const ka = Object.keys(a as object);
+ const kb = Object.keys(b as object);
+ if (ka.length !== kb.length) return false;
+ for (const k of ka) {
+ if (!Object.is((a as Record)[k], (b as Record)[k])) return false;
+ }
+ return true;
+}
+
+/** Subscribe to a derived slice of a store. */
+export function useStore(store: Store, selector: (s: T) => S, equal: (a: S, b: S) => boolean = shallowEqual): S {
+ const memo = useRef<{ snap: T; sel: S } | null>(null);
+ const get = (): S => {
+ const snap = store.getSnapshot();
+ const m = memo.current;
+ if (m && m.snap === snap) return m.sel;
+ const sel = selector(snap);
+ if (m && equal(m.sel, sel)) {
+ memo.current = { snap, sel: m.sel };
+ return m.sel;
+ }
+ memo.current = { snap, sel };
+ return sel;
+ };
+ return useSyncExternalStore(store.subscribe, get, get);
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/CharacterModel.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/CharacterModel.ts
new file mode 100644
index 0000000..9990636
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/CharacterModel.ts
@@ -0,0 +1,481 @@
+import * as THREE from 'three';
+import { clamp, lerp, smoothstep } from '../core/math';
+import type { HitGroup, Team } from '../core/types';
+import { WEAPONS, type WeaponId } from '../weapons/WeaponDefs';
+import { GeoBuilder, vcMaterial } from './GeoBuilder';
+import { createWeaponModel, type WeaponModel } from './WeaponModels';
+
+interface Palette {
+ top: number;
+ topDark: number;
+ pants: number;
+ gear: number;
+ gearLight: number;
+ skin: number;
+ gloves: number;
+ boots: number;
+ accent: number;
+ head: number;
+}
+
+const PALETTES: Record = {
+ CT: {
+ top: 0x2d4166,
+ topDark: 0x1f2d47,
+ pants: 0x283753,
+ gear: 0x25282e,
+ gearLight: 0x3a4049,
+ skin: 0xc9956b,
+ gloves: 0x1b1b1d,
+ boots: 0x161618,
+ accent: 0x3f86e8,
+ head: 0x3a4d63,
+ },
+ T: {
+ top: 0x93703f,
+ topDark: 0x6e5330,
+ pants: 0x3d3a31,
+ gear: 0x4f4c38,
+ gearLight: 0x625e46,
+ skin: 0xb98560,
+ gloves: 0x2b2520,
+ boots: 0x3d2c1d,
+ accent: 0xc0352d,
+ head: 0x1c1c1c,
+ },
+};
+
+interface HitPart {
+ mesh: THREE.Mesh;
+ group: HitGroup;
+ min: THREE.Vector3;
+ max: THREE.Vector3;
+ inv: THREE.Matrix4;
+}
+
+export interface CharacterPose {
+ x: number;
+ y: number;
+ z: number;
+ yaw: number;
+ pitch: number;
+ /** Horizontal velocity in world space. */
+ vx: number;
+ vz: number;
+ onGround: boolean;
+ crouch: number;
+ alive: boolean;
+ /** Seconds since death (only when !alive). */
+ deadFor: number;
+ /** Fall direction in body space: +1 backwards, -1 forwards. */
+ fallDir: number;
+ fallSide: number;
+ /** 0..1 reload progress or -1. */
+ reload: number;
+ /** 0..1 recoil kick intensity (decays quickly after a shot). */
+ kick: number;
+ /** Planting / defusing crouch. */
+ busy: boolean;
+}
+
+const UP_NEG = new THREE.Vector3(0, -1, 0);
+const SHOULDER_R = new THREE.Vector3(0.2, 0.46, -0.02);
+const SHOULDER_L = new THREE.Vector3(-0.2, 0.46, -0.04);
+const _v1 = new THREE.Vector3();
+const _v2 = new THREE.Vector3();
+const _v3 = new THREE.Vector3();
+const _rayO = new THREE.Vector3();
+const _rayD = new THREE.Vector3();
+const _ikD = new THREE.Vector3();
+const _ikP = new THREE.Vector3();
+const _ikE = new THREE.Vector3();
+const _ikH = new THREE.Vector3();
+const _ikT = new THREE.Vector3();
+
+const ARM_UPPER = 0.3;
+const ARM_FORE = 0.28;
+
+function partMesh(builder: GeoBuilder, group: HitGroup, min: [number, number, number], max: [number, number, number], parts: HitPart[]): THREE.Mesh {
+ const mesh = new THREE.Mesh(builder.build(), vcMaterial('matte'));
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ parts.push({ mesh, group, min: new THREE.Vector3(...min), max: new THREE.Vector3(...max), inv: new THREE.Matrix4() });
+ return mesh;
+}
+
+/**
+ * Procedural humanoid with independent head, torso, two arms (upper/fore/hand) and two legs
+ * (thigh/shin/foot). Arms are solved with 2-bone IK onto the held weapon's grip points, the upper
+ * body follows aim pitch, legs run a gait cycle. Every limb mesh doubles as a hitbox (oriented box
+ * in mesh space), so hit detection matches exactly what is rendered.
+ */
+export class CharacterModel {
+ readonly root = new THREE.Group();
+ private readonly body = new THREE.Group();
+ private readonly hips = new THREE.Group();
+ private readonly spine = new THREE.Group();
+ private readonly neck = new THREE.Group();
+ private readonly legL = new THREE.Group();
+ private readonly legR = new THREE.Group();
+ private readonly kneeL = new THREE.Group();
+ private readonly kneeR = new THREE.Group();
+ private readonly upperL: THREE.Mesh;
+ private readonly upperR: THREE.Mesh;
+ private readonly foreL: THREE.Mesh;
+ private readonly foreR: THREE.Mesh;
+ private readonly handL: THREE.Mesh;
+ private readonly handR: THREE.Mesh;
+ private readonly weaponMount = new THREE.Group();
+ private readonly parts: HitPart[] = [];
+ private weapon: WeaponModel | null = null;
+ private weaponId: WeaponId | null = null;
+ private gaitPhase = 0;
+ private gaitAmount = 0;
+ private layer = 0;
+ readonly team: Team;
+
+ constructor(team: Team) {
+ this.team = team;
+ const p = PALETTES[team];
+ const parts = this.parts;
+
+ this.root.add(this.body);
+ this.body.add(this.hips);
+ this.hips.position.set(0, 0.92, 0);
+
+ // ---- pelvis (stomach zone)
+ const pelvis = new GeoBuilder()
+ .box(0.34, 0.18, 0.21, 0, 0.0, 0, p.pants)
+ .box(0.35, 0.05, 0.22, 0, 0.07, 0, 0x1a1a1a)
+ .box(0.06, 0.04, 0.02, 0, 0.07, -0.115, 0x8a8a7a);
+ this.hips.add(partMesh(pelvis, 'stomach', [-0.17, -0.09, -0.105], [0.17, 0.095, 0.105], parts));
+
+ // ---- spine: stomach + chest + neck
+ this.spine.position.set(0, 0.08, 0);
+ this.hips.add(this.spine);
+ const stomach = new GeoBuilder().box(0.32, 0.2, 0.2, 0, 0.1, 0, p.top);
+ if (team === 'CT') stomach.box(0.34, 0.12, 0.23, 0, 0.14, 0, p.gear);
+ else stomach.box(0.335, 0.05, 0.21, 0, 0.03, 0, p.topDark);
+ this.spine.add(partMesh(stomach, 'stomach', [-0.17, 0, -0.115], [0.17, 0.2, 0.115], parts));
+
+ const chest = new GeoBuilder().box(0.4, 0.3, 0.24, 0, 0.35, 0, p.top);
+ if (team === 'CT') {
+ chest.box(0.42, 0.27, 0.27, 0, 0.33, 0, p.gear); // plate carrier
+ for (let i = -1; i <= 1; i++) chest.box(0.085, 0.09, 0.045, i * 0.1, 0.27, -0.15, p.gearLight); // pouches
+ chest.box(0.12, 0.05, 0.03, 0.1, 0.42, -0.145, p.gearLight); // radio
+ chest.box(0.06, 0.06, 0.02, -0.21, 0.44, 0, p.accent); // shoulder patches
+ chest.box(0.06, 0.06, 0.02, 0.21, 0.44, 0, p.accent);
+ } else {
+ chest.box(0.3, 0.2, 0.05, 0, 0.3, -0.13, p.gear); // chest rig
+ for (let i = -1; i <= 1; i++) chest.box(0.075, 0.12, 0.05, i * 0.09, 0.28, -0.165, p.gearLight); // mag pouches
+ chest.box(0.05, 0.32, 0.25, -0.12, 0.35, 0, p.gear); // rig straps
+ chest.box(0.05, 0.32, 0.25, 0.12, 0.35, 0, p.gear);
+ chest.box(0.24, 0.06, 0.06, 0, 0.47, -0.08, p.accent); // red scarf
+ }
+ chest.box(0.11, 0.08, 0.11, 0, 0.52, 0, team === 'CT' ? p.skin : p.head); // neck
+ this.spine.add(partMesh(chest, 'chest', [-0.215, 0.2, -0.14], [0.215, 0.56, 0.14], parts));
+
+ // ---- head
+ this.neck.position.set(0, 0.55, 0);
+ this.spine.add(this.neck);
+ const head = new GeoBuilder();
+ if (team === 'CT') {
+ head.box(0.19, 0.21, 0.2, 0, 0.11, 0, p.skin); // face
+ head.sphere(0.125, 0, 0.19, 0.005, p.head, 1.02, 0.78, 1.08); // helmet dome
+ head.box(0.25, 0.05, 0.25, 0, 0.17, 0.01, p.head); // helmet rim
+ head.box(0.17, 0.05, 0.03, 0, 0.13, -0.105, 0x111214); // goggles band
+ head.box(0.06, 0.035, 0.012, -0.045, 0.13, -0.122, 0x5f86b0); // lenses
+ head.box(0.06, 0.035, 0.012, 0.045, 0.13, -0.122, 0x5f86b0);
+ head.box(0.12, 0.05, 0.04, 0, 0.03, -0.09, 0x2a2a2a); // face mask
+ } else {
+ head.box(0.2, 0.235, 0.21, 0, 0.115, 0, p.head); // balaclava
+ head.box(0.15, 0.04, 0.012, 0, 0.14, -0.108, p.skin); // eye slit
+ head.box(0.03, 0.022, 0.006, -0.04, 0.14, -0.116, 0x161616);
+ head.box(0.03, 0.022, 0.006, 0.04, 0.14, -0.116, 0x161616);
+ head.box(0.212, 0.045, 0.222, 0, 0.2, 0, p.accent); // bandana
+ head.box(0.06, 0.05, 0.04, 0, 0.2, 0.12, p.accent); // knot
+ }
+ this.neck.add(partMesh(head, 'head', [-0.115, 0, -0.125], [0.115, 0.29, 0.13], parts));
+
+ // ---- arms (placed by IK every pose)
+ const upperArm = () => {
+ const b = new GeoBuilder().box(0.105, ARM_UPPER, 0.105, 0, -ARM_UPPER / 2, 0, p.top);
+ b.box(0.112, 0.06, 0.112, 0, -0.06, 0, p.accent); // CT shoulder patch / T armband
+ return partMesh(b, 'arm', [-0.055, -ARM_UPPER, -0.055], [0.055, 0, 0.055], parts);
+ };
+ const foreArm = () => {
+ const b = new GeoBuilder().box(0.088, ARM_FORE, 0.088, 0, -ARM_FORE / 2, 0, team === 'CT' ? p.top : p.topDark);
+ b.box(0.095, 0.06, 0.095, 0, -ARM_FORE + 0.03, 0, p.gloves);
+ return partMesh(b, 'arm', [-0.048, -ARM_FORE, -0.048], [0.048, 0, 0.048], parts);
+ };
+ const hand = () => {
+ const b = new GeoBuilder().box(0.078, 0.095, 0.085, 0, -0.03, 0, p.gloves);
+ return partMesh(b, 'arm', [-0.04, -0.08, -0.045], [0.04, 0.02, 0.045], parts);
+ };
+ this.upperL = upperArm();
+ this.upperR = upperArm();
+ this.foreL = foreArm();
+ this.foreR = foreArm();
+ this.handL = hand();
+ this.handR = hand();
+ this.spine.add(this.upperL, this.upperR, this.foreL, this.foreR, this.handL, this.handR);
+
+ // ---- legs
+ const buildLeg = (hip: THREE.Group, knee: THREE.Group, side: number) => {
+ hip.position.set(0.095 * side, -0.02, 0);
+ this.hips.add(hip);
+ const thigh = new GeoBuilder().box(0.15, 0.44, 0.17, 0, -0.22, 0, p.pants);
+ if (team === 'CT') thigh.box(0.13, 0.1, 0.03, 0, -0.39, -0.09, p.gearLight); // knee pad
+ else thigh.box(0.155, 0.1, 0.1, 0.0, -0.2, 0, p.topDark); // cargo pocket
+ hip.add(partMesh(thigh, 'leg', [-0.08, -0.44, -0.09], [0.08, 0, 0.09], parts));
+ knee.position.set(0, -0.44, 0);
+ hip.add(knee);
+ const shin = new GeoBuilder().box(0.12, 0.43, 0.13, 0, -0.215, 0, p.pants).box(0.13, 0.16, 0.145, 0, -0.36, 0, p.boots);
+ knee.add(partMesh(shin, 'leg', [-0.066, -0.43, -0.073], [0.066, 0, 0.073], parts));
+ const foot = new GeoBuilder().box(0.115, 0.08, 0.26, 0, -0.04, -0.05, p.boots);
+ const footMesh = partMesh(foot, 'leg', [-0.058, -0.08, -0.18], [0.058, 0, 0.08], parts);
+ footMesh.position.set(0, -0.43, 0);
+ knee.add(footMesh);
+ };
+ buildLeg(this.legL, this.kneeL, -1);
+ buildLeg(this.legR, this.kneeR, 1);
+
+ this.spine.add(this.weaponMount);
+ }
+
+ // ------------------------------------------------------------------ weapon
+
+ setWeapon(id: WeaponId | null): void {
+ if (id === this.weaponId) return;
+ if (this.weapon) this.weaponMount.remove(this.weapon.root);
+ this.weapon = null;
+ this.weaponId = id;
+ if (!id) return;
+ this.weapon = createWeaponModel(id);
+ const kind = WEAPONS[id].kind;
+ this.weapon.root.scale.setScalar(kind === 'rifle' || kind === 'sniper' ? 0.92 : 1);
+ this.weapon.root.traverse((o) => o.layers.set(this.layer));
+ this.weaponMount.add(this.weapon.root);
+ }
+
+ /** World-space muzzle position of the held weapon (for tracers / flashes). */
+ muzzleWorld(out: THREE.Vector3): THREE.Vector3 {
+ if (this.weapon) return this.weapon.muzzle.getWorldPosition(out);
+ return this.neck.getWorldPosition(out);
+ }
+
+ setVisible(v: boolean): void {
+ this.root.visible = v;
+ }
+
+ /** Render layer for all meshes (used to hide the first-person body while keeping its shadow). */
+ setLayer(layer: number): void {
+ this.layer = layer;
+ this.root.traverse((o) => o.layers.set(layer));
+ }
+
+ // ------------------------------------------------------------------ animation
+
+ /** Advance gait state once per simulation tick. */
+ advance(dt: number, speed: number, onGround: boolean): void {
+ const target = onGround ? clamp(speed / 5.5, 0, 1) : 0.15;
+ this.gaitAmount = lerp(this.gaitAmount, target, 1 - Math.exp(-10 * dt));
+ this.gaitPhase += dt * (4.2 + speed * 1.15) * (speed > 0.3 ? 1 : 0.3);
+ }
+
+ pose(s: CharacterPose): void {
+ this.root.position.set(s.x, s.y, s.z);
+ this.root.rotation.set(0, s.yaw, 0);
+
+ // ------------- death fall
+ if (!s.alive) {
+ const t = smoothstep(0, 0.55, s.deadFor);
+ this.body.rotation.set((Math.PI / 2) * s.fallDir * t, 0, 0.35 * s.fallSide * t);
+ this.body.position.set(0, 0.13 * t, 0);
+ this.hips.position.y = 0.92;
+ this.spine.rotation.set(-0.15 * t * s.fallDir, 0, 0);
+ this.neck.rotation.set(-0.4 * t, 0.5 * s.fallSide * t, 0);
+ this.legL.rotation.set(0.25 * t, 0, -0.18 * t);
+ this.legR.rotation.set(-0.1 * t, 0, 0.25 * t);
+ this.kneeL.rotation.set(-0.5 * t, 0, 0);
+ this.kneeR.rotation.set(-0.15 * t, 0, 0);
+ this.weaponMount.visible = false;
+ this.limpArm(this.upperL, this.foreL, this.handL, -1, t);
+ this.limpArm(this.upperR, this.foreR, this.handR, 1, t);
+ return;
+ }
+ this.body.rotation.set(0, 0, 0);
+ this.body.position.set(0, 0, 0);
+ this.weaponMount.visible = true;
+
+ // ------------- legs: gait cycle in movement direction
+ const c = Math.cos(s.yaw);
+ const sn = Math.sin(s.yaw);
+ // world velocity -> body space (forward = -Z)
+ const localX = s.vx * c - s.vz * sn;
+ const localZ = s.vx * sn + s.vz * c;
+ const sp = Math.hypot(localX, localZ);
+ const fwd = sp > 0.05 ? -localZ / sp : 1;
+ const side = sp > 0.05 ? localX / sp : 0;
+ const amt = this.gaitAmount * (s.onGround ? 1 : 0.4);
+ const swing = Math.sin(this.gaitPhase) * 0.62 * amt;
+ const crouch = clamp(s.crouch + (s.busy ? 1 : 0), 0, 1);
+ const crouchHip = crouch * 0.95;
+ this.legL.rotation.set(swing * fwd + crouchHip, 0, -swing * side * 0.6);
+ this.legR.rotation.set(-swing * fwd + crouchHip, 0, swing * side * 0.6);
+ const bendL = Math.max(0, Math.sin(this.gaitPhase + 1.4)) * 0.9 * amt;
+ const bendR = Math.max(0, Math.sin(this.gaitPhase + 1.4 + Math.PI)) * 0.9 * amt;
+ this.kneeL.rotation.set(-bendL - crouch * 1.75, 0, 0);
+ this.kneeR.rotation.set(-bendR - crouch * 1.75, 0, 0);
+ const bob = Math.abs(Math.cos(this.gaitPhase)) * 0.035 * amt;
+ this.hips.position.y = 0.92 - crouch * 0.33 + bob - 0.017 * amt;
+
+ // ------------- upper body aim
+ const kind = this.weaponId ? WEAPONS[this.weaponId].kind : 'knife';
+ const twoHandedLong = kind === 'rifle' || kind === 'sniper';
+ const twist = twoHandedLong ? -0.42 : -0.08;
+ const pitch = clamp(s.pitch, -1.2, 1.2);
+ this.spine.rotation.set(pitch * 0.45 + crouch * 0.12, twist, 0, 'YXZ');
+ this.neck.rotation.set(pitch * 0.55 - crouch * 0.12, -twist, 0, 'YXZ');
+
+ // weapon mount (spine space): positioned so the barrel points along the aim direction
+ const kick = s.kick;
+ const reload = s.reload;
+ const wm = this.weaponMount;
+ if (twoHandedLong) {
+ wm.position.set(0.13 + 0.03, 0.39, -0.29 + kick * 0.04);
+ } else if (kind === 'pistol') {
+ wm.position.set(0.05, 0.38, -0.42 + kick * 0.05);
+ } else if (kind === 'knife') {
+ wm.position.set(0.24, 0.22, -0.3);
+ } else {
+ wm.position.set(0.02, 0.2, -0.32);
+ }
+ let reloadTilt = 0;
+ if (reload >= 0) reloadTilt = Math.sin(clamp(reload, 0, 1) * Math.PI) * 0.6;
+ wm.rotation.set(pitch * 0.55 - crouch * 0.12 + kick * 0.12 - reloadTilt * 0.35, -twist, reloadTilt * 0.5, 'YXZ');
+ wm.updateMatrix();
+
+ // ------------- IK arms onto the weapon
+ const w = this.weapon;
+ if (w) {
+ const scale = w.root.scale.x;
+ const rh = _v1.copy(w.rightHand).multiplyScalar(scale).applyMatrix4(wm.matrix);
+ const lh = _v2.copy(w.leftHand).multiplyScalar(scale).applyMatrix4(wm.matrix);
+ if (kind === 'knife') lh.set(-0.24, 0.12, -0.12);
+ if (reload >= 0 && reload < 0.75 && w.mag) {
+ // support hand travels to the magazine and back
+ const m = Math.sin(clamp(reload / 0.75, 0, 1) * Math.PI);
+ _v3.copy(w.magRest).multiplyScalar(scale).applyMatrix4(wm.matrix);
+ _v3.y -= 0.12 * m;
+ lh.lerp(_v3, m);
+ }
+ this.solveArm(this.upperR, this.foreR, this.handR, SHOULDER_R, rh, 1);
+ this.solveArm(this.upperL, this.foreL, this.handL, SHOULDER_L, lh, -1);
+ if (w.mag) {
+ w.mag.position.copy(w.magRest);
+ if (reload >= 0 && reload < 0.75) w.mag.position.y -= Math.sin(clamp(reload / 0.75, 0, 1) * Math.PI) * 0.18;
+ }
+ } else {
+ this.limpArm(this.upperL, this.foreL, this.handL, -1, 1);
+ this.limpArm(this.upperR, this.foreR, this.handR, 1, 1);
+ }
+ }
+
+ /** Analytic two-bone IK: shoulder -> elbow -> hand, elbow bent down/outwards. */
+ private solveArm(upper: THREE.Mesh, fore: THREE.Mesh, hand: THREE.Mesh, shoulder: THREE.Vector3, target: THREE.Vector3, side: number): void {
+ const d = _ikD.subVectors(target, shoulder);
+ let len = d.length();
+ len = clamp(len, 0.08, ARM_UPPER + ARM_FORE - 0.005);
+ d.normalize();
+ const a = (ARM_UPPER * ARM_UPPER - ARM_FORE * ARM_FORE + len * len) / (2 * len);
+ const h = Math.sqrt(Math.max(0, ARM_UPPER * ARM_UPPER - a * a));
+ const pole = _ikP.set(0.55 * side, -1, 0.25);
+ pole.addScaledVector(d, -pole.dot(d)).normalize();
+ const elbow = _ikE.copy(shoulder).addScaledVector(d, a).addScaledVector(pole, h);
+ const handPos = _ikH.copy(shoulder).addScaledVector(d, len);
+
+ upper.position.copy(shoulder);
+ upper.quaternion.setFromUnitVectors(UP_NEG, _ikT.subVectors(elbow, shoulder).normalize());
+ fore.position.copy(elbow);
+ fore.quaternion.setFromUnitVectors(UP_NEG, _ikT.subVectors(handPos, elbow).normalize());
+ hand.position.copy(handPos);
+ hand.quaternion.copy(fore.quaternion);
+ }
+
+ private limpArm(upper: THREE.Mesh, fore: THREE.Mesh, hand: THREE.Mesh, side: number, t: number): void {
+ upper.position.set(0.2 * side, 0.46, 0);
+ upper.rotation.set(0.1 * t, 0, 0.25 * side * t);
+ upper.updateMatrix();
+ fore.position.set(0, -ARM_UPPER, 0).applyMatrix4(upper.matrix);
+ fore.rotation.set(0.3 * t, 0, 0.2 * side * t);
+ fore.updateMatrix();
+ hand.position.set(0, -ARM_FORE, 0).applyMatrix4(fore.matrix);
+ hand.quaternion.copy(fore.quaternion);
+ }
+
+ // ------------------------------------------------------------------ hit detection
+
+ /** Refresh world matrices and cached inverses after posing (once per tick). */
+ updateHitboxes(): void {
+ this.root.updateMatrixWorld(true);
+ for (const p of this.parts) p.inv.copy(p.mesh.matrixWorld).invert();
+ }
+
+ /**
+ * Ray vs. every limb box in its own mesh space. Returns nearest distance & hit group.
+ * Hierarchy contains no scale, so local distances equal world distances.
+ */
+ raycast(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, maxT: number): { t: number; group: HitGroup } | null {
+ let best = maxT;
+ let group: HitGroup | null = null;
+ for (const p of this.parts) {
+ _rayO.set(ox, oy, oz).applyMatrix4(p.inv);
+ _rayD.set(dx, dy, dz).transformDirection(p.inv);
+ const t = rayBox(_rayO, _rayD, p.min, p.max, best);
+ if (t >= 0 && t < best) {
+ best = t;
+ group = p.group;
+ }
+ }
+ return group ? { t: best, group } : null;
+ }
+
+ /** Approximate world position of the head centre (for bot aiming). */
+ headWorld(out: THREE.Vector3): THREE.Vector3 {
+ return out.set(0, 0.14, 0).applyMatrix4(this.parts[3].mesh.matrixWorld);
+ }
+
+ dispose(): void {
+ this.root.traverse((o) => {
+ const m = o as THREE.Mesh;
+ if (m.isMesh && m.geometry && !m.userData.sharedGeometry) m.geometry.dispose();
+ });
+ }
+}
+
+function rayBox(o: THREE.Vector3, d: THREE.Vector3, min: THREE.Vector3, max: THREE.Vector3, maxT: number): number {
+ let tmin = 0;
+ let tmax = maxT;
+ for (let a = 0; a < 3; a++) {
+ const oa = a === 0 ? o.x : a === 1 ? o.y : o.z;
+ const da = a === 0 ? d.x : a === 1 ? d.y : d.z;
+ const mn = a === 0 ? min.x : a === 1 ? min.y : min.z;
+ const mx = a === 0 ? max.x : a === 1 ? max.y : max.z;
+ if (Math.abs(da) < 1e-12) {
+ if (oa < mn || oa > mx) return -1;
+ continue;
+ }
+ let t1 = (mn - oa) / da;
+ let t2 = (mx - oa) / da;
+ 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;
+ }
+ return tmin;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Effects.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Effects.ts
new file mode 100644
index 0000000..8d36e5c
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Effects.ts
@@ -0,0 +1,596 @@
+import * as THREE from 'three';
+import type { SurfaceMaterial } from '../core/types';
+import { bulletHoleTexture, muzzleFlashTexture, radialTexture } from './Textures';
+
+const PARTICLE_VERT = /* glsl */ `
+ attribute float size;
+ attribute float alpha;
+ attribute vec3 pcolor;
+ varying float vAlpha;
+ varying vec3 vColor;
+ uniform float scale;
+ void main() {
+ vColor = pcolor;
+ vAlpha = alpha;
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
+ gl_PointSize = size * scale / max(0.1, -mv.z);
+ gl_Position = projectionMatrix * mv;
+ }
+`;
+
+const PARTICLE_FRAG = /* glsl */ `
+ uniform sampler2D map;
+ varying float vAlpha;
+ varying vec3 vColor;
+ void main() {
+ vec4 t = texture2D(map, gl_PointCoord);
+ gl_FragColor = vec4(vColor, vAlpha * t.a);
+ if (gl_FragColor.a < 0.003) discard;
+ #include
+ #include
+ }
+`;
+
+interface ParticleSpawn {
+ x: number;
+ y: number;
+ z: number;
+ vx: number;
+ vy: number;
+ vz: number;
+ life: number;
+ size0: number;
+ size1: number;
+ color: THREE.Color;
+ alpha0: number;
+ alpha1?: number;
+ gravity?: number;
+ drag?: number;
+}
+
+/** CPU particle pool rendered as one Points draw call. */
+class ParticlePool {
+ readonly points: THREE.Points;
+ private readonly n: number;
+ private readonly pos: Float32Array;
+ private readonly vel: Float32Array;
+ private readonly col: Float32Array;
+ private readonly size: Float32Array;
+ private readonly alpha: Float32Array;
+ private readonly life: Float32Array;
+ private readonly maxLife: Float32Array;
+ private readonly s0: Float32Array;
+ private readonly s1: Float32Array;
+ private readonly a0: Float32Array;
+ private readonly a1: Float32Array;
+ private readonly grav: Float32Array;
+ private readonly drag: Float32Array;
+ private cursor = 0;
+ private readonly material: THREE.ShaderMaterial;
+
+ constructor(n: number, additive: boolean, map: THREE.Texture) {
+ this.n = n;
+ this.pos = new Float32Array(n * 3);
+ this.vel = new Float32Array(n * 3);
+ this.col = new Float32Array(n * 3);
+ this.size = new Float32Array(n);
+ this.alpha = new Float32Array(n);
+ this.life = new Float32Array(n);
+ this.maxLife = new Float32Array(n);
+ this.s0 = new Float32Array(n);
+ this.s1 = new Float32Array(n);
+ this.a0 = new Float32Array(n);
+ this.a1 = new Float32Array(n);
+ this.grav = new Float32Array(n);
+ this.drag = new Float32Array(n);
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3).setUsage(THREE.DynamicDrawUsage));
+ g.setAttribute('pcolor', new THREE.BufferAttribute(this.col, 3).setUsage(THREE.DynamicDrawUsage));
+ g.setAttribute('size', new THREE.BufferAttribute(this.size, 1).setUsage(THREE.DynamicDrawUsage));
+ g.setAttribute('alpha', new THREE.BufferAttribute(this.alpha, 1).setUsage(THREE.DynamicDrawUsage));
+ g.boundingSphere = new THREE.Sphere(new THREE.Vector3(54, 0, 52), 1000);
+ this.material = new THREE.ShaderMaterial({
+ uniforms: { map: { value: map }, scale: { value: 600 } },
+ vertexShader: PARTICLE_VERT,
+ fragmentShader: PARTICLE_FRAG,
+ transparent: true,
+ depthWrite: false,
+ blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
+ });
+ this.points = new THREE.Points(g, this.material);
+ this.points.frustumCulled = false;
+ this.points.renderOrder = additive ? 3 : 2;
+ }
+
+ setViewportHeight(h: number, fovDeg: number): void {
+ this.material.uniforms.scale.value = h / (2 * Math.tan((fovDeg * Math.PI) / 360));
+ }
+
+ spawn(p: ParticleSpawn): void {
+ const i = this.cursor;
+ this.cursor = (this.cursor + 1) % this.n;
+ this.pos[i * 3] = p.x;
+ this.pos[i * 3 + 1] = p.y;
+ this.pos[i * 3 + 2] = p.z;
+ this.vel[i * 3] = p.vx;
+ this.vel[i * 3 + 1] = p.vy;
+ this.vel[i * 3 + 2] = p.vz;
+ this.col[i * 3] = p.color.r;
+ this.col[i * 3 + 1] = p.color.g;
+ this.col[i * 3 + 2] = p.color.b;
+ this.life[i] = p.life;
+ this.maxLife[i] = p.life;
+ this.s0[i] = p.size0;
+ this.s1[i] = p.size1;
+ this.a0[i] = p.alpha0;
+ this.a1[i] = p.alpha1 ?? 0;
+ this.grav[i] = p.gravity ?? 0;
+ this.drag[i] = p.drag ?? 0;
+ }
+
+ update(dt: number): void {
+ for (let i = 0; i < this.n; i++) {
+ if (this.life[i] <= 0) {
+ this.alpha[i] = 0;
+ continue;
+ }
+ this.life[i] -= dt;
+ const t = 1 - Math.max(0, this.life[i]) / this.maxLife[i];
+ const k = Math.exp(-this.drag[i] * dt);
+ this.vel[i * 3] *= k;
+ this.vel[i * 3 + 1] = this.vel[i * 3 + 1] * k - this.grav[i] * dt;
+ this.vel[i * 3 + 2] *= k;
+ this.pos[i * 3] += this.vel[i * 3] * dt;
+ this.pos[i * 3 + 1] += this.vel[i * 3 + 1] * dt;
+ this.pos[i * 3 + 2] += this.vel[i * 3 + 2] * dt;
+ this.size[i] = this.s0[i] + (this.s1[i] - this.s0[i]) * t;
+ this.alpha[i] = this.life[i] > 0 ? this.a0[i] + (this.a1[i] - this.a0[i]) * t : 0;
+ }
+ const g = this.points.geometry;
+ g.attributes.position.needsUpdate = true;
+ g.attributes.size.needsUpdate = true;
+ g.attributes.alpha.needsUpdate = true;
+ g.attributes.pcolor.needsUpdate = true;
+ }
+
+ clear(): void {
+ this.life.fill(0);
+ this.alpha.fill(0);
+ }
+}
+
+interface Tracer {
+ ax: number;
+ ay: number;
+ az: number;
+ dx: number;
+ dy: number;
+ dz: number;
+ len: number;
+ t: number;
+ active: boolean;
+ speed: number;
+}
+
+const MAX_TRACERS = 48;
+const MAX_DECALS = 180;
+
+/**
+ * Transient visual effects: tracers (camera-facing streaks), muzzle flashes with dynamic lights,
+ * impact dust/sparks, bullet-hole decals, blood, C4 explosion.
+ */
+export class Effects {
+ readonly group = new THREE.Group();
+ private readonly additive: ParticlePool;
+ private readonly soft: ParticlePool;
+ private readonly tracers: Tracer[] = [];
+ private readonly tracerGeo: THREE.BufferGeometry;
+ private readonly tracerPos: Float32Array;
+ private readonly tracerAlpha: Float32Array;
+ private readonly decals: THREE.Mesh[] = [];
+ private decalCursor = 0;
+ private readonly flashes: { sprite: THREE.Sprite; life: number }[] = [];
+ private readonly lights: { light: THREE.PointLight; life: number; max: number; peak: number }[] = [];
+ private readonly fireballs: { mesh: THREE.Mesh; ring: THREE.Mesh; t: number }[] = [];
+ private readonly tmpColor = new THREE.Color();
+ private readonly v1 = new THREE.Vector3();
+ private readonly v2 = new THREE.Vector3();
+ private readonly v3 = new THREE.Vector3();
+
+ constructor() {
+ const soft = radialTexture('rgba(255,255,255,1)', 'rgba(255,255,255,0)');
+ this.additive = new ParticlePool(900, true, soft);
+ this.soft = new ParticlePool(1400, false, soft);
+ this.group.add(this.additive.points, this.soft.points);
+
+ // tracers: N camera-facing quads in one buffer
+ this.tracerPos = new Float32Array(MAX_TRACERS * 4 * 3);
+ this.tracerAlpha = new Float32Array(MAX_TRACERS * 4 * 3);
+ const idx: number[] = [];
+ for (let i = 0; i < MAX_TRACERS; i++) idx.push(i * 4, i * 4 + 1, i * 4 + 2, i * 4, i * 4 + 2, i * 4 + 3);
+ this.tracerGeo = new THREE.BufferGeometry();
+ this.tracerGeo.setAttribute('position', new THREE.BufferAttribute(this.tracerPos, 3).setUsage(THREE.DynamicDrawUsage));
+ this.tracerGeo.setAttribute('color', new THREE.BufferAttribute(this.tracerAlpha, 3).setUsage(THREE.DynamicDrawUsage));
+ this.tracerGeo.setIndex(idx);
+ const tracerMesh = new THREE.Mesh(
+ this.tracerGeo,
+ new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }),
+ );
+ tracerMesh.frustumCulled = false;
+ tracerMesh.renderOrder = 4;
+ this.group.add(tracerMesh);
+ for (let i = 0; i < MAX_TRACERS; i++) this.tracers.push({ ax: 0, ay: 0, az: 0, dx: 0, dy: 0, dz: 0, len: 0, t: 0, active: false, speed: 400 });
+
+ // bullet hole decals
+ const decalMat = new THREE.MeshBasicMaterial({
+ map: bulletHoleTexture(),
+ transparent: true,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -4,
+ });
+ const decalGeo = new THREE.PlaneGeometry(0.09, 0.09);
+ for (let i = 0; i < MAX_DECALS; i++) {
+ const m = new THREE.Mesh(decalGeo, decalMat);
+ m.visible = false;
+ m.renderOrder = 1;
+ this.decals.push(m);
+ this.group.add(m);
+ }
+
+ // muzzle flash sprites (third-person)
+ const flashMat = new THREE.SpriteMaterial({ map: muzzleFlashTexture(), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true });
+ for (let i = 0; i < 10; i++) {
+ const s = new THREE.Sprite(flashMat);
+ s.visible = false;
+ s.scale.setScalar(0.4);
+ this.flashes.push({ sprite: s, life: 0 });
+ this.group.add(s);
+ }
+
+ // a fixed pool of dynamic point lights (fixed count => no shader recompiles)
+ for (let i = 0; i < 3; i++) {
+ const l = new THREE.PointLight(0xffb060, 0, 9, 2);
+ l.castShadow = false;
+ this.lights.push({ light: l, life: 0, max: 1, peak: 0 });
+ this.group.add(l);
+ }
+ }
+
+ setViewport(height: number, fov: number): void {
+ this.additive.setViewportHeight(height, fov);
+ this.soft.setViewportHeight(height, fov);
+ }
+
+ clear(): void {
+ this.additive.clear();
+ this.soft.clear();
+ for (const t of this.tracers) t.active = false;
+ for (const d of this.decals) d.visible = false;
+ for (const f of this.flashes) {
+ f.life = 0;
+ f.sprite.visible = false;
+ }
+ for (const l of this.lights) {
+ l.life = 0;
+ l.light.intensity = 0;
+ }
+ for (const fb of this.fireballs) {
+ this.group.remove(fb.mesh, fb.ring);
+ fb.mesh.geometry.dispose();
+ fb.ring.geometry.dispose();
+ }
+ this.fireballs.length = 0;
+ }
+
+ // ------------------------------------------------------------------ spawners
+
+ tracer(from: THREE.Vector3, to: THREE.Vector3): void {
+ const t = this.tracers.find((x) => !x.active) ?? this.tracers[0];
+ const d = this.v1.subVectors(to, from);
+ const len = d.length();
+ if (len < 1) return;
+ d.divideScalar(len);
+ t.ax = from.x;
+ t.ay = from.y;
+ t.az = from.z;
+ t.dx = d.x;
+ t.dy = d.y;
+ t.dz = d.z;
+ t.len = len;
+ t.t = 0;
+ t.speed = 380;
+ t.active = true;
+ }
+
+ flashLight(pos: THREE.Vector3, intensity: number, distance: number, life: number, color = 0xffb060): void {
+ const l = this.lights.reduce((a, b) => (a.life <= b.life ? a : b));
+ l.light.position.copy(pos);
+ l.light.color.setHex(color);
+ l.light.distance = distance;
+ l.peak = intensity;
+ l.life = life;
+ l.max = life;
+ l.light.intensity = intensity;
+ }
+
+ muzzleFlash(pos: THREE.Vector3, scale = 0.45, light = true): void {
+ const f = this.flashes.reduce((a, b) => (a.life <= b.life ? a : b));
+ f.sprite.position.copy(pos);
+ f.sprite.scale.setScalar(scale * (0.8 + Math.random() * 0.4));
+ f.sprite.material.rotation = Math.random() * Math.PI;
+ f.sprite.visible = true;
+ f.life = 0.05;
+ if (light) this.flashLight(pos, 18, 7, 0.06);
+ }
+
+ impact(point: THREE.Vector3, normal: THREE.Vector3, material: SurfaceMaterial, decal = true): void {
+ const c = this.tmpColor;
+ const dust = material === 'wood' ? 0x8a6238 : material === 'metal' ? 0x8a8a8a : 0xc9ab7e;
+ for (let i = 0; i < 7; i++) {
+ c.setHex(dust).multiplyScalar(0.8 + Math.random() * 0.3);
+ this.soft.spawn({
+ x: point.x + normal.x * 0.03,
+ y: point.y + normal.y * 0.03,
+ z: point.z + normal.z * 0.03,
+ vx: normal.x * (0.6 + Math.random() * 1.5) + (Math.random() - 0.5) * 1.2,
+ vy: normal.y * (0.6 + Math.random() * 1.5) + Math.random() * 0.9,
+ vz: normal.z * (0.6 + Math.random() * 1.5) + (Math.random() - 0.5) * 1.2,
+ life: 0.5 + Math.random() * 0.5,
+ size0: 0.05,
+ size1: 0.28 + Math.random() * 0.2,
+ color: c,
+ alpha0: 0.7,
+ alpha1: 0,
+ gravity: 1.5,
+ drag: 3,
+ });
+ }
+ const sparks = material === 'metal' ? 8 : material === 'stone' ? 3 : 0;
+ for (let i = 0; i < sparks; i++) {
+ c.setRGB(1.6, 1.1, 0.5);
+ this.additive.spawn({
+ x: point.x,
+ y: point.y,
+ z: point.z,
+ vx: normal.x * 3 + (Math.random() - 0.5) * 6,
+ vy: normal.y * 3 + Math.random() * 4,
+ vz: normal.z * 3 + (Math.random() - 0.5) * 6,
+ life: 0.15 + Math.random() * 0.2,
+ size0: 0.03,
+ size1: 0.01,
+ color: c,
+ alpha0: 1,
+ alpha1: 0.2,
+ gravity: 9,
+ });
+ }
+ if (material === 'wood') {
+ for (let i = 0; i < 4; i++) {
+ c.setHex(0x6b4422);
+ this.soft.spawn({
+ x: point.x,
+ y: point.y,
+ z: point.z,
+ vx: normal.x * 2 + (Math.random() - 0.5) * 3,
+ vy: 1 + Math.random() * 2,
+ vz: normal.z * 2 + (Math.random() - 0.5) * 3,
+ life: 0.6,
+ size0: 0.04,
+ size1: 0.04,
+ color: c,
+ alpha0: 1,
+ alpha1: 1,
+ gravity: 12,
+ });
+ }
+ }
+ if (decal) this.decal(point, normal);
+ }
+
+ private decal(point: THREE.Vector3, normal: THREE.Vector3): void {
+ const m = this.decals[this.decalCursor];
+ this.decalCursor = (this.decalCursor + 1) % MAX_DECALS;
+ m.position.copy(point).addScaledVector(normal, 0.006);
+ m.lookAt(this.v2.copy(point).add(normal));
+ m.rotateZ(Math.random() * Math.PI * 2);
+ const s = 0.8 + Math.random() * 0.5;
+ m.scale.set(s, s, s);
+ m.visible = true;
+ }
+
+ blood(point: THREE.Vector3, dir: THREE.Vector3, headshot: boolean): void {
+ const c = this.tmpColor;
+ const n = headshot ? 22 : 12;
+ for (let i = 0; i < n; i++) {
+ c.setRGB(0.42 + Math.random() * 0.15, 0.02, 0.02);
+ this.soft.spawn({
+ x: point.x,
+ y: point.y,
+ z: point.z,
+ vx: dir.x * (1 + Math.random() * 2.5) + (Math.random() - 0.5) * 1.6,
+ vy: dir.y * 2 + Math.random() * 1.8,
+ vz: dir.z * (1 + Math.random() * 2.5) + (Math.random() - 0.5) * 1.6,
+ life: 0.35 + Math.random() * 0.45,
+ size0: 0.06,
+ size1: headshot ? 0.3 : 0.2,
+ color: c,
+ alpha0: 0.95,
+ alpha1: 0,
+ gravity: 6,
+ drag: 2,
+ });
+ }
+ // fine mist
+ c.setRGB(0.55, 0.05, 0.05);
+ this.soft.spawn({ x: point.x, y: point.y, z: point.z, vx: dir.x, vy: 0.2, vz: dir.z, life: 0.4, size0: 0.15, size1: headshot ? 0.9 : 0.5, color: c, alpha0: 0.6, alpha1: 0, drag: 4 });
+ }
+
+ explosion(pos: THREE.Vector3): void {
+ const c = this.tmpColor;
+ for (let i = 0; i < 160; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const e = Math.random() * Math.PI * 0.5;
+ const sp = 4 + Math.random() * 16;
+ c.setRGB(2.2, 1.0 + Math.random() * 0.4, 0.3);
+ this.additive.spawn({
+ x: pos.x,
+ y: pos.y + 0.5,
+ z: pos.z,
+ vx: Math.cos(a) * Math.cos(e) * sp,
+ vy: Math.sin(e) * sp * 0.8 + 2,
+ vz: Math.sin(a) * Math.cos(e) * sp,
+ life: 0.5 + Math.random() * 0.8,
+ size0: 0.8,
+ size1: 2.6,
+ color: c,
+ alpha0: 1,
+ alpha1: 0,
+ drag: 2.5,
+ gravity: -1,
+ });
+ }
+ for (let i = 0; i < 110; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const sp = 1 + Math.random() * 7;
+ const g = 0.25 + Math.random() * 0.2;
+ c.setRGB(g, g * 0.92, g * 0.85);
+ this.soft.spawn({
+ x: pos.x + (Math.random() - 0.5) * 2,
+ y: pos.y + 0.5 + Math.random() * 2,
+ z: pos.z + (Math.random() - 0.5) * 2,
+ vx: Math.cos(a) * sp,
+ vy: 2 + Math.random() * 5,
+ vz: Math.sin(a) * sp,
+ life: 2.5 + Math.random() * 2.5,
+ size0: 1.5,
+ size1: 7 + Math.random() * 4,
+ color: c,
+ alpha0: 0.75,
+ alpha1: 0,
+ drag: 1.4,
+ gravity: -0.6,
+ });
+ }
+ const ball = new THREE.Mesh(
+ new THREE.SphereGeometry(1, 24, 16),
+ new THREE.MeshBasicMaterial({ color: 0xffa040, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }),
+ );
+ ball.position.copy(pos);
+ const ring = new THREE.Mesh(
+ new THREE.RingGeometry(0.8, 1.2, 48),
+ new THREE.MeshBasicMaterial({ color: 0xfff0d0, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending }),
+ );
+ ring.position.copy(pos).add(this.v3.set(0, 0.2, 0));
+ ring.rotation.x = -Math.PI / 2;
+ this.group.add(ball, ring);
+ this.fireballs.push({ mesh: ball, ring, t: 0 });
+ this.flashLight(this.v3.copy(pos).add(this.v1.set(0, 3, 0)), 4000, 70, 1.2, 0xffa050);
+ }
+
+ // ------------------------------------------------------------------ per frame
+
+ update(dt: number, camera: THREE.Camera): void {
+ this.additive.update(dt);
+ this.soft.update(dt);
+
+ for (const f of this.flashes) {
+ if (f.life > 0) {
+ f.life -= dt;
+ if (f.life <= 0) f.sprite.visible = false;
+ }
+ }
+ for (const l of this.lights) {
+ if (l.life > 0) {
+ l.life -= dt;
+ l.light.intensity = Math.max(0, l.peak * (l.life / l.max));
+ } else l.light.intensity = 0;
+ }
+ for (let i = this.fireballs.length - 1; i >= 0; i--) {
+ const fb = this.fireballs[i];
+ fb.t += dt;
+ const s = 2 + fb.t * 26;
+ fb.mesh.scale.setScalar(Math.min(s, 13));
+ (fb.mesh.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 0.95 - fb.t * 1.3);
+ fb.ring.scale.setScalar(1 + fb.t * 45);
+ (fb.ring.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 0.8 - fb.t * 1.6);
+ if (fb.t > 1.2) {
+ this.group.remove(fb.mesh, fb.ring);
+ fb.mesh.geometry.dispose();
+ fb.ring.geometry.dispose();
+ (fb.mesh.material as THREE.Material).dispose();
+ (fb.ring.material as THREE.Material).dispose();
+ this.fireballs.splice(i, 1);
+ }
+ }
+
+ // tracers
+ const camPos = this.v3.setFromMatrixPosition(camera.matrixWorld);
+ const P = this.tracerPos;
+ const C = this.tracerAlpha;
+ for (let i = 0; i < MAX_TRACERS; i++) {
+ const t = this.tracers[i];
+ const o = i * 12;
+ if (!t.active) {
+ for (let k = 0; k < 12; k++) {
+ P[o + k] = 0;
+ C[o + k] = 0;
+ }
+ continue;
+ }
+ t.t += dt;
+ const head = Math.min(t.len, t.t * t.speed);
+ const tail = Math.max(0, head - 4.5);
+ if (tail >= t.len - 0.01) {
+ t.active = false;
+ continue;
+ }
+ const ax = t.ax + t.dx * tail;
+ const ay = t.ay + t.dy * tail;
+ const az = t.az + t.dz * tail;
+ const bx = t.ax + t.dx * head;
+ const by = t.ay + t.dy * head;
+ const bz = t.az + t.dz * head;
+ // side vector perpendicular to the streak and the view direction
+ const vx = camPos.x - ax;
+ const vy = camPos.y - ay;
+ const vz = camPos.z - az;
+ let sx = t.dy * vz - t.dz * vy;
+ let sy = t.dz * vx - t.dx * vz;
+ let sz = t.dx * vy - t.dy * vx;
+ const sl = Math.hypot(sx, sy, sz) || 1;
+ const w = 0.012;
+ sx = (sx / sl) * w;
+ sy = (sy / sl) * w;
+ sz = (sz / sl) * w;
+ P[o] = ax - sx;
+ P[o + 1] = ay - sy;
+ P[o + 2] = az - sz;
+ P[o + 3] = ax + sx;
+ P[o + 4] = ay + sy;
+ P[o + 5] = az + sz;
+ P[o + 6] = bx + sx;
+ P[o + 7] = by + sy;
+ P[o + 8] = bz + sz;
+ P[o + 9] = bx - sx;
+ P[o + 10] = by - sy;
+ P[o + 11] = bz - sz;
+ const ta = 0.15;
+ const ha = 1.0;
+ C[o] = 1.0 * ta;
+ C[o + 1] = 0.8 * ta;
+ C[o + 2] = 0.45 * ta;
+ C[o + 3] = 1.0 * ta;
+ C[o + 4] = 0.8 * ta;
+ C[o + 5] = 0.45 * ta;
+ C[o + 6] = 1.0 * ha;
+ C[o + 7] = 0.85 * ha;
+ C[o + 8] = 0.55 * ha;
+ C[o + 9] = 1.0 * ha;
+ C[o + 10] = 0.85 * ha;
+ C[o + 11] = 0.55 * ha;
+ }
+ this.tracerGeo.attributes.position.needsUpdate = true;
+ this.tracerGeo.attributes.color.needsUpdate = true;
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/GeoBuilder.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/GeoBuilder.ts
new file mode 100644
index 0000000..a464093
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/GeoBuilder.ts
@@ -0,0 +1,117 @@
+import * as THREE from 'three';
+
+const _m = new THREE.Matrix4();
+const _q = new THREE.Quaternion();
+const _e = new THREE.Euler();
+const _s = new THREE.Vector3(1, 1, 1);
+const _p = new THREE.Vector3();
+const _n = new THREE.Matrix3();
+const _v = new THREE.Vector3();
+const _c = new THREE.Color();
+
+const boxCache = new Map();
+const unitBox = new THREE.BoxGeometry(1, 1, 1);
+
+/**
+ * Accumulates primitive parts (with transforms and per-part colors) into one indexed
+ * BufferGeometry with vertex colors: a whole gun or prop becomes a single draw call.
+ */
+export class GeoBuilder {
+ private pos: number[] = [];
+ private nor: number[] = [];
+ private col: number[] = [];
+ private idx: number[] = [];
+
+ add(geo: THREE.BufferGeometry, matrix: THREE.Matrix4, color: THREE.ColorRepresentation, shade = 1): this {
+ const p = geo.getAttribute('position');
+ const n = geo.getAttribute('normal');
+ const base = this.pos.length / 3;
+ _n.getNormalMatrix(matrix);
+ _c.set(color);
+ for (let i = 0; i < p.count; i++) {
+ _v.fromBufferAttribute(p, i).applyMatrix4(matrix);
+ this.pos.push(_v.x, _v.y, _v.z);
+ _v.fromBufferAttribute(n, i).applyMatrix3(_n).normalize();
+ this.nor.push(_v.x, _v.y, _v.z);
+ this.col.push(_c.r * shade, _c.g * shade, _c.b * shade);
+ }
+ const index = geo.getIndex();
+ if (index) for (let i = 0; i < index.count; i++) this.idx.push(base + index.getX(i));
+ else for (let i = 0; i < p.count; i++) this.idx.push(base + i);
+ return this;
+ }
+
+ /** Box of size (w,h,d) centred at (x,y,z) with optional Euler rotation (radians, XYZ). */
+ box(w: number, h: number, d: number, x: number, y: number, z: number, color: THREE.ColorRepresentation, rx = 0, ry = 0, rz = 0): this {
+ _e.set(rx, ry, rz);
+ _q.setFromEuler(_e);
+ _s.set(w, h, d);
+ _p.set(x, y, z);
+ _m.compose(_p, _q, _s);
+ return this.add(unitBox, _m, color);
+ }
+
+ /** Cylinder of radius r and length len centred at (x,y,z), oriented along `axis`. */
+ cyl(r: number, len: number, x: number, y: number, z: number, color: THREE.ColorRepresentation, axis: 'x' | 'y' | 'z' = 'z', seg = 10, r2 = r): this {
+ const key = `${r.toFixed(4)}_${r2.toFixed(4)}_${len.toFixed(4)}_${seg}`;
+ let g = boxCache.get(key);
+ if (!g) {
+ g = new THREE.CylinderGeometry(r2, r, len, seg, 1, false);
+ boxCache.set(key, g);
+ }
+ if (axis === 'x') _e.set(0, 0, Math.PI / 2);
+ else if (axis === 'z') _e.set(Math.PI / 2, 0, 0);
+ else _e.set(0, 0, 0);
+ _q.setFromEuler(_e);
+ _s.set(1, 1, 1);
+ _p.set(x, y, z);
+ _m.compose(_p, _q, _s);
+ return this.add(g, _m, color);
+ }
+
+ sphere(r: number, x: number, y: number, z: number, color: THREE.ColorRepresentation, sx = 1, sy = 1, sz = 1): this {
+ const key = `sphere_${r.toFixed(4)}`;
+ let g = boxCache.get(key);
+ if (!g) {
+ g = new THREE.SphereGeometry(r, 12, 8);
+ boxCache.set(key, g);
+ }
+ _q.identity();
+ _s.set(sx, sy, sz);
+ _p.set(x, y, z);
+ _m.compose(_p, _q, _s);
+ return this.add(g, _m, color);
+ }
+
+ get empty(): boolean {
+ return this.pos.length === 0;
+ }
+
+ build(): THREE.BufferGeometry {
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(this.nor, 3));
+ g.setAttribute('color', new THREE.Float32BufferAttribute(this.col, 3));
+ g.setIndex(this.idx);
+ g.computeBoundingSphere();
+ g.computeBoundingBox();
+ return g;
+ }
+}
+
+/** Shared vertex-colored materials (one per surface response). */
+const materialCache = new Map();
+export function vcMaterial(kind: 'matte' | 'metal' | 'gloss' = 'matte'): THREE.MeshStandardMaterial {
+ let m = materialCache.get(kind);
+ if (!m) {
+ const params =
+ kind === 'metal'
+ ? { roughness: 0.42, metalness: 0.55 }
+ : kind === 'gloss'
+ ? { roughness: 0.3, metalness: 0.2 }
+ : { roughness: 0.78, metalness: 0.05 };
+ m = new THREE.MeshStandardMaterial({ vertexColors: true, ...params });
+ materialCache.set(kind, m);
+ }
+ return m;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/MapMeshes.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/MapMeshes.ts
new file mode 100644
index 0000000..b950772
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/MapMeshes.ts
@@ -0,0 +1,449 @@
+import * as THREE from 'three';
+import { hash01, smoothstep } from '../core/math';
+import { RASTER_RES, type Level } from '../world/Level';
+import type { BoxDef, FloorDef } from '../world/MapData';
+import { GeoBuilder, vcMaterial } from './GeoBuilder';
+import { getMaterials, siteLetterTexture, type MaterialSet } from './Textures';
+
+/**
+ * Turns the compiled Level into a handful of merged meshes (one per material) with world-space
+ * UVs and baked vertex-colour ambient occlusion. Static geometry is ~10 draw calls total.
+ */
+class Bucket {
+ pos: number[] = [];
+ nor: number[] = [];
+ uv: number[] = [];
+ col: number[] = [];
+ idx: number[] = [];
+
+ quad(
+ a: THREE.Vector3Tuple,
+ b: THREE.Vector3Tuple,
+ c: THREE.Vector3Tuple,
+ d: THREE.Vector3Tuple,
+ n: THREE.Vector3Tuple,
+ uvs: [number, number][],
+ cols: [number, number, number][],
+ ): void {
+ const base = this.pos.length / 3;
+ for (const p of [a, b, c, d]) this.pos.push(p[0], p[1], p[2]);
+ for (let i = 0; i < 4; i++) this.nor.push(n[0], n[1], n[2]);
+ for (const t of uvs) this.uv.push(t[0], t[1]);
+ for (const k of cols) this.col.push(k[0], k[1], k[2]);
+ this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
+ }
+
+ mesh(material: THREE.Material, name: string): THREE.Mesh | null {
+ if (!this.pos.length) return null;
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(this.nor, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2));
+ g.setAttribute('color', new THREE.Float32BufferAttribute(this.col, 3));
+ g.setIndex(this.idx);
+ g.computeBoundingSphere();
+ const m = new THREE.Mesh(g, material);
+ m.name = name;
+ m.castShadow = true;
+ m.receiveShadow = true;
+ return m;
+ }
+}
+
+const TEX_SCALE = 1 / 4; // textures cover 4 m
+
+/** Fake ambient occlusion: darker near ground level, brighter towards the top. */
+function ao(y: number, ground: number): number {
+ return 0.58 + 0.42 * smoothstep(ground - 0.3, ground + 2.6, y);
+}
+
+type Tint = [number, number, number];
+
+interface BoxOpts {
+ tint?: Tint;
+ ground?: number;
+ /** World-space UVs (walls/floors) vs. 0..1 per face (crates/doors). */
+ uvMode?: 'world' | 'face';
+ /** Skip faces: bitmask 1=+x 2=-x 4=+y 8=-y 16=+z 32=-z */
+ skip?: number;
+ /** Separate bucket for the top face. */
+ top?: Bucket;
+ /** Vertical split heights for smoother AO gradients on tall faces. */
+ splits?: number[];
+}
+
+/** Emit an axis-aligned box into buckets. */
+function addBox(bucket: Bucket, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, o: BoxOpts = {}): void {
+ const tint = o.tint ?? [1, 1, 1];
+ const ground = o.ground ?? 0;
+ const skip = o.skip ?? 0;
+ const face = o.uvMode === 'face';
+ const col = (y: number): [number, number, number] => {
+ const s = face ? 1 : ao(y, ground);
+ return [tint[0] * s, tint[1] * s, tint[2] * s];
+ };
+ // vertical bands for AO
+ const ys = [y0];
+ for (const s of o.splits ?? [ground + 0.05, ground + 1.2, ground + 3]) if (s > y0 + 0.05 && s < y1 - 0.05) ys.push(s);
+ ys.push(y1);
+
+ const side = (axis: 'x' | 'z', sign: number) => {
+ for (let k = 0; k < ys.length - 1; k++) {
+ const ya = ys[k];
+ const yb = ys[k + 1];
+ if (axis === 'x') {
+ const x = sign > 0 ? x1 : x0;
+ const za = sign > 0 ? z1 : z0;
+ const zb = sign > 0 ? z0 : z1;
+ const u0 = face ? 0 : za * TEX_SCALE * -sign;
+ const u1 = face ? 1 : zb * TEX_SCALE * -sign;
+ const v0 = face ? (ya - y0) / (y1 - y0) : ya * TEX_SCALE;
+ const v1 = face ? (yb - y0) / (y1 - y0) : yb * TEX_SCALE;
+ bucket.quad(
+ [x, ya, za],
+ [x, ya, zb],
+ [x, yb, zb],
+ [x, yb, za],
+ [sign, 0, 0],
+ [
+ [u0, v0],
+ [u1, v0],
+ [u1, v1],
+ [u0, v1],
+ ],
+ [col(ya), col(ya), col(yb), col(yb)],
+ );
+ } else {
+ const z = sign > 0 ? z1 : z0;
+ const xa = sign > 0 ? x0 : x1;
+ const xb = sign > 0 ? x1 : x0;
+ const u0 = face ? 0 : xa * TEX_SCALE * sign;
+ const u1 = face ? 1 : xb * TEX_SCALE * sign;
+ const v0 = face ? (ya - y0) / (y1 - y0) : ya * TEX_SCALE;
+ const v1 = face ? (yb - y0) / (y1 - y0) : yb * TEX_SCALE;
+ bucket.quad(
+ [xa, ya, z],
+ [xb, ya, z],
+ [xb, yb, z],
+ [xa, yb, z],
+ [0, 0, sign],
+ [
+ [u0, v0],
+ [u1, v0],
+ [u1, v1],
+ [u0, v1],
+ ],
+ [col(ya), col(ya), col(yb), col(yb)],
+ );
+ }
+ }
+ };
+ if (!(skip & 1)) side('x', 1);
+ if (!(skip & 2)) side('x', -1);
+ if (!(skip & 16)) side('z', 1);
+ if (!(skip & 32)) side('z', -1);
+ if (!(skip & 4)) {
+ const tb = o.top ?? bucket;
+ const c = face ? tint : ([tint[0] * 0.97, tint[1] * 0.97, tint[2] * 0.97] as Tint);
+ tb.quad(
+ [x0, y1, z1],
+ [x1, y1, z1],
+ [x1, y1, z0],
+ [x0, y1, z0],
+ [0, 1, 0],
+ face
+ ? [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ [0, 1],
+ ]
+ : [
+ [x0 * TEX_SCALE, -z1 * TEX_SCALE],
+ [x1 * TEX_SCALE, -z1 * TEX_SCALE],
+ [x1 * TEX_SCALE, -z0 * TEX_SCALE],
+ [x0 * TEX_SCALE, -z0 * TEX_SCALE],
+ ],
+ [c, c, c, c],
+ );
+ }
+ if (!(skip & 8)) {
+ const c = col(y0);
+ bucket.quad(
+ [x0, y0, z0],
+ [x1, y0, z0],
+ [x1, y0, z1],
+ [x0, y0, z1],
+ [0, -1, 0],
+ [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ [0, 1],
+ ],
+ [c, c, c, c],
+ );
+ }
+}
+
+/** Ramp wedge: sloped top + sides. */
+function addRamp(top: Bucket, sides: Bucket, f: FloorDef, bottom: number): void {
+ const { x0, z0, x1, z1 } = f;
+ const yA = f.y; // at min edge
+ const yB = f.y2!;
+ const h = (x: number, z: number) => (f.axis === 'x' ? yA + ((x - x0) / (x1 - x0)) * (yB - yA) : yA + ((z - z0) / (z1 - z0)) * (yB - yA));
+ const p00: THREE.Vector3Tuple = [x0, h(x0, z0), z0];
+ const p10: THREE.Vector3Tuple = [x1, h(x1, z0), z0];
+ const p11: THREE.Vector3Tuple = [x1, h(x1, z1), z1];
+ const p01: THREE.Vector3Tuple = [x0, h(x0, z1), z1];
+ const n = new THREE.Vector3()
+ .crossVectors(new THREE.Vector3(...p01).sub(new THREE.Vector3(...p00)), new THREE.Vector3(...p10).sub(new THREE.Vector3(...p00)))
+ .normalize();
+ const uv = (p: THREE.Vector3Tuple): [number, number] => [p[0] * TEX_SCALE, -p[2] * TEX_SCALE];
+ const c: [number, number, number] = [0.98, 0.98, 0.98];
+ top.quad(p01, p11, p10, p00, [n.x, n.y, n.z], [uv(p01), uv(p11), uv(p10), uv(p00)], [c, c, c, c]);
+ // side walls of the wedge (quads with a sloped top edge)
+ const sideQuad = (a: THREE.Vector3Tuple, b: THREE.Vector3Tuple, nn: THREE.Vector3Tuple, alongX: boolean) => {
+ const a0: THREE.Vector3Tuple = [a[0], bottom, a[2]];
+ const b0: THREE.Vector3Tuple = [b[0], bottom, b[2]];
+ const u = (p: THREE.Vector3Tuple) => (alongX ? p[0] : p[2]) * TEX_SCALE;
+ const cc = (y: number): [number, number, number] => {
+ const s = ao(y, 0);
+ return [s, s, s];
+ };
+ sides.quad(a0, b0, b, a, nn, [
+ [u(a0), bottom * TEX_SCALE],
+ [u(b0), bottom * TEX_SCALE],
+ [u(b), b[1] * TEX_SCALE],
+ [u(a), a[1] * TEX_SCALE],
+ ], [cc(bottom), cc(bottom), cc(b[1]), cc(a[1])]);
+ };
+ sideQuad(p10, p00, [0, 0, -1], true);
+ sideQuad(p01, p11, [0, 0, 1], true);
+ sideQuad(p00, p01, [-1, 0, 0], false);
+ sideQuad(p11, p10, [1, 0, 0], false);
+}
+
+export interface MapVisual {
+ group: THREE.Group;
+ materials: MaterialSet;
+}
+
+/** Build all static scenery for a level. */
+export function buildMapMeshes(level: Level): MapVisual {
+ const M = getMaterials();
+ const group = new THREE.Group();
+ group.name = 'map';
+ const B = {
+ wall: new Bucket(),
+ wallAlt: new Bucket(),
+ wallTop: new Bucket(),
+ floorSand: new Bucket(),
+ floorTiles: new Bucket(),
+ floorStone: new Bucket(),
+ floorConcrete: new Bucket(),
+ crate: new Bucket(),
+ door: new Bucket(),
+ metal: new Bucket(),
+ wood: new Bucket(),
+ trim: new Bucket(),
+ cap: new Bucket(),
+ };
+ const floorBucket = (f: FloorDef): Bucket =>
+ f.tex === 'tiles' ? B.floorTiles : f.tex === 'stone' ? B.floorStone : f.tex === 'concrete' ? B.floorConcrete : B.floorSand;
+
+ // ---- floors
+ for (const f of level.def.floors) {
+ if (f.y2 !== undefined && f.axis) {
+ addRamp(floorBucket(f), B.wall, f, -4);
+ } else {
+ addBox(B.wall, f.x0, -4, f.z0, f.x1, f.y, f.z1, { top: floorBucket(f), skip: 8, ground: f.y - 1.2, splits: [f.y - 0.6] });
+ }
+ }
+
+ // ---- walls (buildings): plaster vs stone blocks, warm tint variations
+ for (const w of level.walls) {
+ const h = hash01(w.x0, w.z0, 3);
+ const alt = h < 0.3;
+ const t = 0.9 + hash01(w.x0, w.z0, 7) * 0.16;
+ const tint: Tint = [t, t * (0.97 + h * 0.04), t * (0.92 + h * 0.06)];
+ const bucket = alt ? B.wallAlt : B.wall;
+ const ground = level.nav.heightAt((w.x0 + w.x1) / 2, (w.z0 + w.z1) / 2);
+ const g = Number.isFinite(ground) ? Math.min(ground, 1.6) : 0;
+ if (w.window) {
+ addBox(bucket, w.x0, w.bottom, w.z0, w.x1, w.window.sill, w.z1, { tint, ground: g, top: B.trim });
+ addBox(bucket, w.x0, w.window.top, w.z0, w.x1, w.top, w.z1, { tint, ground: g, top: B.wallTop, skip: 0 });
+ } else {
+ addBox(bucket, w.x0, w.bottom, w.z0, w.x1, w.top, w.z1, { tint, ground: g, top: B.wallTop });
+ }
+ }
+
+ // ---- adobe details: parapet caps on every block and protruding roof beams ("vigas")
+ const bnd = level.def.bounds;
+ const floorAt = (x: number, z: number): number => {
+ const i = Math.floor((x - bnd.x0) / RASTER_RES);
+ const j = Math.floor((z - bnd.z0) / RASTER_RES);
+ if (i < 0 || j < 0 || i >= level.cols || j >= level.rows) return NaN;
+ return level.floorHeight[j * level.cols + i];
+ };
+ for (const w of level.walls) {
+ const top = w.top;
+ addBox(B.cap, w.x0 - 0.1, top, w.z0 - 0.1, w.x1 + 0.1, top + 0.22, w.z1 + 0.1, { tint: [0.95, 0.95, 0.95], ground: top - 20, top: B.cap });
+ if (hash01(w.x0, w.z1, 11) < 0.4) continue;
+ const y = top - 0.9;
+ const faces: [boolean, number, number, number, number][] = [
+ [true, w.x1, 1, w.z0, w.z1],
+ [true, w.x0, -1, w.z0, w.z1],
+ [false, w.z1, 1, w.x0, w.x1],
+ [false, w.z0, -1, w.x0, w.x1],
+ ];
+ for (const [alongZ, fixed, dir, a, b] of faces) {
+ if (b - a < 3) continue;
+ for (let s = a + 1.1; s < b - 0.7; s += 2.3) {
+ const px = alongZ ? fixed + dir * 0.3 : s;
+ const pz = alongZ ? s : fixed + dir * 0.3;
+ const fh = floorAt(px, pz);
+ if (Number.isNaN(fh) || y - fh < 3.4) continue;
+ const o = fixed + dir * 0.42;
+ if (alongZ) addBox(B.wood, Math.min(fixed, o), y - 0.08, s - 0.08, Math.max(fixed, o), y + 0.08, s + 0.08, { uvMode: 'face', tint: [0.7, 0.7, 0.7] });
+ else addBox(B.wood, s - 0.08, y - 0.08, Math.min(fixed, o), s + 0.08, y + 0.08, Math.max(fixed, o), { uvMode: 'face', tint: [0.7, 0.7, 0.7] });
+ }
+ }
+ }
+
+ // ---- decorative blue doors set into walls (visual only)
+ const decoDoors: [number, number, number, number, number][] = [
+ [64, 3.92, 65.5, 4.06, 0],
+ [50, 97.94, 51.5, 98.08, 1.6],
+ [76.5, 91.94, 78, 92.08, 0],
+ [3.92, 12, 4.06, 13.5, 0],
+ [41.94, 36, 42.08, 37.5, 0],
+ [103.94, 48, 104.08, 49.5, 0],
+ [44, 9.92, 45.5, 10.06, 0],
+ [29.94, 70, 30.08, 71.5, 0.8],
+ ];
+ for (const [x0, z0, x1, z1, y0] of decoDoors) {
+ addBox(B.door, x0, y0, z0, x1, y0 + 2.7, z1, { uvMode: 'face', tint: [1, 1, 1] });
+ addBox(B.wood, Math.min(x0, x1) - 0.1, y0 + 2.7, Math.min(z0, z1) - 0.1, Math.max(x0, x1) + 0.1, y0 + 2.92, Math.max(z0, z1) + 0.1, { uvMode: 'face', tint: [0.7, 0.7, 0.7] });
+ }
+
+ // ---- props
+ for (const b of level.def.boxes) addProp(B, b, group);
+
+ // ---- door leaves (rotated meshes, colliders are approximations in Level)
+ const doorGeo = new GeoBuilder();
+ for (const d of level.def.doors) {
+ const leaf = new THREE.Mesh(new THREE.BoxGeometry(d.length, d.height, d.thickness), M.doorLeaf);
+ const mid = d.length / 2;
+ leaf.position.set(d.hx + Math.cos(d.angle) * mid, d.y0 + d.height / 2, d.hz + Math.sin(d.angle) * mid);
+ leaf.rotation.y = -d.angle;
+ leaf.castShadow = true;
+ leaf.receiveShadow = true;
+ group.add(leaf);
+ // hinges & frame posts
+ doorGeo.box(0.16, d.height + 0.2, 0.16, d.hx, d.y0 + d.height / 2, d.hz, 0x4a3a28);
+ }
+ const doorFrame = new THREE.Mesh(doorGeo.build(), vcMaterial('matte'));
+ doorFrame.castShadow = true;
+ group.add(doorFrame);
+
+ // ---- bombsite letters painted on nearby walls
+ addSiteLetter(group, 'A', 104 - 0.02, 3.6, 17, -Math.PI / 2);
+ addSiteLetter(group, 'B', 4 + 0.02, 2.6, 17, Math.PI / 2);
+
+ const add = (b: Bucket, m: THREE.Material, name: string) => {
+ const mesh = b.mesh(m, name);
+ if (mesh) group.add(mesh);
+ };
+ add(B.wall, M.wall, 'walls');
+ add(B.wallAlt, M.wallAlt, 'walls-plaster');
+ add(B.wallTop, M.wallTop, 'wall-tops');
+ add(B.floorSand, M.floorSand, 'floor-sand');
+ add(B.floorTiles, M.floorTiles, 'floor-tiles');
+ add(B.floorStone, M.floorStone, 'floor-stone');
+ add(B.floorConcrete, M.floorConcrete, 'floor-concrete');
+ add(B.crate, M.crate, 'crates');
+ add(B.door, M.door, 'doors');
+ add(B.metal, M.metal, 'metal');
+ add(B.wood, M.wood, 'wood');
+ add(B.trim, M.trim, 'trim');
+ add(B.cap, M.wallTop, 'wall-caps');
+ return { group, materials: M };
+}
+
+function addProp(B: Record, b: BoxDef, group: THREE.Group): void {
+ switch (b.kind) {
+ case 'crate':
+ case 'crateBig': {
+ const t = 0.92 + hash01(b.x0, b.z0) * 0.16;
+ addBox(B.crate, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, { uvMode: 'face', tint: [t, t, t] });
+ break;
+ }
+ case 'container': {
+ addBox(B.metal, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, { uvMode: 'face', tint: [1, 1, 1] });
+ break;
+ }
+ case 'ceiling':
+ addBox(B.wood, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, { tint: [0.8, 0.8, 0.8], ground: b.y0 - 3, top: B.wallTop });
+ // support beams under the roof
+ for (let z = b.z0 + 1; z < b.z1 - 0.5; z += 3.2) addBox(B.wood, b.x0, b.y0 - 0.22, z, b.x1, b.y0, z + 0.3, { uvMode: 'face', tint: [0.75, 0.75, 0.75] });
+ break;
+ case 'lintel':
+ addBox(B.wall, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, { tint: [0.95, 0.93, 0.88], ground: b.y0 - 2.5, top: B.wallTop });
+ addBox(B.wood, b.x0 - 0.05, b.y0 - 0.25, b.z0 - 0.05, b.x1 + 0.05, b.y0, b.z1 + 0.05, { uvMode: 'face', tint: [0.8, 0.8, 0.8] });
+ break;
+ case 'beam':
+ addBox(B.wood, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, { uvMode: 'face', tint: [0.8, 0.8, 0.8] });
+ break;
+ case 'barrel': {
+ const r = (b.x1 - b.x0) / 2;
+ const g = new GeoBuilder()
+ .cyl(r, b.y1 - b.y0, 0, 0, 0, 0x6b4a2b, 'y', 14)
+ .cyl(r + 0.015, 0.06, 0, (b.y1 - b.y0) * 0.3, 0, 0x2a2a2a, 'y', 14)
+ .cyl(r + 0.015, 0.06, 0, -(b.y1 - b.y0) * 0.3, 0, 0x2a2a2a, 'y', 14);
+ const m = new THREE.Mesh(g.build(), vcMaterial('metal'));
+ m.position.set((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, (b.z0 + b.z1) / 2);
+ m.castShadow = true;
+ m.receiveShadow = true;
+ group.add(m);
+ break;
+ }
+ case 'car':
+ group.add(buildCar(b));
+ break;
+ default:
+ addBox(B.trim, b.x0, b.y0, b.z0, b.x1, b.y1, b.z1, {});
+ }
+}
+
+/** Boxy old sedan (Dust2 has a couple of wrecks), fits the collider footprint. */
+function buildCar(b: BoxDef): THREE.Object3D {
+ const len = Math.max(b.x1 - b.x0, b.z1 - b.z0);
+ const wid = Math.min(b.x1 - b.x0, b.z1 - b.z0);
+ const alongX = b.x1 - b.x0 >= b.z1 - b.z0;
+ const g = new GeoBuilder();
+ const body = 0x7d8f96;
+ const rust = 0x7a4a2a;
+ g.box(len, 0.55, wid, 0, 0.55, 0, body);
+ g.box(len * 0.55, 0.5, wid * 0.92, -len * 0.05, 1.07, 0, body);
+ g.box(len * 0.5, 0.42, wid * 0.94, -len * 0.05, 1.07, 0, 0x2b3a44); // windows
+ g.box(len * 0.22, 0.12, wid * 0.9, len * 0.36, 0.86, 0, rust);
+ for (const sx of [-1, 1]) for (const sz of [-1, 1]) g.cyl(0.32, 0.22, sx * len * 0.32, 0.32, sz * wid * 0.48, 0x161616, 'z', 12);
+ g.box(0.06, 0.18, wid * 0.9, len / 2, 0.55, 0, 0x2a2a2a);
+ g.box(0.06, 0.18, wid * 0.9, -len / 2, 0.55, 0, 0x2a2a2a);
+ const m = new THREE.Mesh(g.build(), vcMaterial('metal'));
+ m.position.set((b.x0 + b.x1) / 2, b.y0, (b.z0 + b.z1) / 2);
+ if (!alongX) m.rotation.y = Math.PI / 2;
+ m.castShadow = true;
+ m.receiveShadow = true;
+ return m;
+}
+
+function addSiteLetter(group: THREE.Group, letter: string, x: number, y: number, z: number, rotY: number): void {
+ const mat = new THREE.MeshStandardMaterial({ map: siteLetterTexture(letter), transparent: true, roughness: 0.9, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 });
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 2.6), mat);
+ m.position.set(x, y + 1.3, z);
+ m.rotation.y = rotY;
+ m.receiveShadow = true;
+ group.add(m);
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Renderer.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Renderer.ts
new file mode 100644
index 0000000..5359408
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Renderer.ts
@@ -0,0 +1,337 @@
+import * as THREE from 'three';
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
+import { lerp, lerpAngle } from '../core/math';
+import type { Actor } from '../sim/Actor';
+import type { World } from '../sim/World';
+import { WEAPONS } from '../weapons/WeaponDefs';
+import { Effects } from './Effects';
+import { buildMapMeshes } from './MapMeshes';
+import { ViewModel, type ViewModelState } from './ViewModel';
+import { createWeaponModel } from './WeaponModels';
+
+export interface CameraView {
+ pos: THREE.Vector3;
+ yaw: number;
+ pitch: number;
+ roll: number;
+ fov: number;
+ /** Actor rendered in first person (body hidden, shadow kept). */
+ firstPerson: Actor | null;
+}
+
+const SKY_VERT = /* glsl */ `
+ varying vec3 vDir;
+ void main() {
+ vDir = normalize(position);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+`;
+const SKY_FRAG = /* glsl */ `
+ uniform vec3 top;
+ uniform vec3 horizon;
+ uniform vec3 ground;
+ uniform vec3 sunDir;
+ varying vec3 vDir;
+ void main() {
+ vec3 d = normalize(vDir);
+ float h = d.y;
+ vec3 col = h > 0.0 ? mix(horizon, top, pow(clamp(h, 0.0, 1.0), 0.5)) : mix(horizon, ground, pow(clamp(-h, 0.0, 1.0), 0.35));
+ float s = max(0.0, dot(d, sunDir));
+ col += vec3(1.0, 0.86, 0.62) * (pow(s, 900.0) * 6.0 + pow(s, 16.0) * 0.22 + pow(s, 3.0) * 0.06);
+ gl_FragColor = vec4(col, 1.0);
+ #include
+ #include
+ }
+`;
+
+const MAP_CENTER = new THREE.Vector3(54, 0, 52);
+const SUN_DIR = new THREE.Vector3(-0.52, 0.74, 0.42).normalize();
+
+const LAYER_FIRST_PERSON = 1;
+
+/**
+ * Owns the WebGL renderer and scene graph. Each frame it interpolates every actor between the
+ * last two simulation ticks, syncs dropped items / bomb, updates effects, and renders the world
+ * pass followed by the first-person view-model pass.
+ */
+export class GameRenderer {
+ readonly renderer: THREE.WebGLRenderer;
+ readonly scene = new THREE.Scene();
+ readonly camera = new THREE.PerspectiveCamera(74, 1, 0.05, 700);
+ readonly viewModel = new ViewModel();
+ readonly effects = new Effects();
+ readonly sun: THREE.DirectionalLight;
+ private readonly sky: THREE.Mesh;
+ private mapGroup: THREE.Group | null = null;
+ private world: World | null = null;
+ private readonly actorRoot = new THREE.Group();
+ private readonly itemRoot = new THREE.Group();
+ private readonly droppedMeshes = new Map();
+ private readonly bombObj: THREE.Group;
+ private readonly bombLed: THREE.Mesh;
+ private readonly nameTags = new Map();
+ private hiddenActor: Actor | null = null;
+ private height = 1;
+ private readonly tmpPos = new THREE.Vector3();
+
+ constructor(canvas: HTMLCanvasElement, readonly quality: 'high' | 'low' = 'high') {
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = 1.05;
+ this.renderer.shadowMap.enabled = quality === 'high';
+ this.renderer.shadowMap.type = THREE.PCFShadowMap;
+
+ const horizon = new THREE.Color(0xd9d6c8);
+ this.scene.background = horizon.clone();
+ this.scene.fog = new THREE.Fog(horizon, 90, 300);
+
+ // sky dome follows the camera
+ this.sky = new THREE.Mesh(
+ new THREE.SphereGeometry(500, 32, 16),
+ new THREE.ShaderMaterial({
+ uniforms: {
+ top: { value: new THREE.Color(0x3d7fd8) },
+ horizon: { value: horizon },
+ ground: { value: new THREE.Color(0xb59d78) },
+ sunDir: { value: SUN_DIR },
+ },
+ vertexShader: SKY_VERT,
+ fragmentShader: SKY_FRAG,
+ side: THREE.BackSide,
+ depthWrite: false,
+ }),
+ );
+ this.sky.renderOrder = -10;
+ this.sky.frustumCulled = false;
+ this.scene.add(this.sky);
+
+ // lighting
+ const hemi = new THREE.HemisphereLight(0xc7dcf5, 0xa8835a, 1.15);
+ this.scene.add(hemi);
+ this.sun = new THREE.DirectionalLight(0xffefd4, 3.1);
+ this.sun.position.copy(MAP_CENTER).addScaledVector(SUN_DIR, 120);
+ this.sun.target.position.copy(MAP_CENTER);
+ this.sun.castShadow = true;
+ const maxTex = this.renderer.capabilities.maxTextureSize;
+ const size = maxTex >= 8192 ? 4096 : 2048;
+ this.sun.shadow.mapSize.set(size, size);
+ const sc = this.sun.shadow.camera;
+ sc.left = -78;
+ sc.right = 78;
+ sc.top = 78;
+ sc.bottom = -78;
+ sc.near = 20;
+ sc.far = 260;
+ sc.layers.enable(LAYER_FIRST_PERSON);
+ this.sun.shadow.bias = -0.0004;
+ this.sun.shadow.normalBias = 0.045;
+ this.scene.add(this.sun, this.sun.target);
+ this.camera.layers.set(0);
+
+ this.scene.add(this.actorRoot, this.itemRoot, this.effects.group);
+
+ // procedural image-based lighting so metallic surfaces (guns, barrels, car) are not black
+ const pmrem = new THREE.PMREMGenerator(this.renderer);
+ const env = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
+ pmrem.dispose();
+ this.scene.environment = env;
+ this.scene.environmentIntensity = 0.3;
+ this.viewModel.scene.environment = env;
+ this.viewModel.scene.environmentIntensity = 0.75;
+
+ // bomb object (dropped / planted)
+ const c4 = createWeaponModel('c4');
+ this.bombObj = new THREE.Group();
+ c4.root.scale.setScalar(1.4);
+ c4.root.position.y = 0.06;
+ this.bombObj.add(c4.root);
+ this.bombLed = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6), new THREE.MeshBasicMaterial({ color: 0xff2020 }));
+ this.bombLed.position.set(0.05, 0.13, -0.05);
+ this.bombObj.add(this.bombLed);
+ this.bombObj.visible = false;
+ this.scene.add(this.bombObj);
+ }
+
+ /** Attach a new match. The static map is built once and reused. */
+ setWorld(world: World): void {
+ if (this.world) {
+ for (const a of this.world.actors) {
+ this.actorRoot.remove(a.model.root);
+ a.model.dispose();
+ }
+ for (const s of this.nameTags.values()) {
+ s.material.map?.dispose();
+ s.material.dispose();
+ }
+ this.nameTags.clear();
+ }
+ this.world = world;
+ this.hiddenActor = null;
+ if (!this.mapGroup) {
+ this.mapGroup = buildMapMeshes(world.level).group;
+ this.scene.add(this.mapGroup);
+ }
+ for (const a of world.actors) {
+ this.actorRoot.add(a.model.root);
+ if (a.team === world.human.team && a !== world.human) {
+ const tag = makeNameTag(a.name, a.team === 'CT' ? '#8fc1ff' : '#ffd27a');
+ a.model.root.add(tag);
+ tag.position.set(0, 2.15, 0);
+ this.nameTags.set(a.id, tag);
+ }
+ }
+ this.clearTransient();
+ this.compileShaders();
+ }
+
+ /** Pre-compile programs to avoid hitches on first sight of materials. */
+ private compileShaders(): void {
+ try {
+ this.renderer.compile(this.scene, this.camera);
+ this.renderer.compile(this.viewModel.scene, this.viewModel.camera);
+ } catch {
+ // compile is an optimisation only
+ }
+ }
+
+ clearTransient(): void {
+ this.effects.clear();
+ for (const m of this.droppedMeshes.values()) this.itemRoot.remove(m);
+ this.droppedMeshes.clear();
+ }
+
+ resize(width: number, height: number, pixelRatio: number): void {
+ this.height = height;
+ this.renderer.setPixelRatio(pixelRatio);
+ this.renderer.setSize(width, height, false);
+ this.camera.aspect = width / height;
+ this.camera.updateProjectionMatrix();
+ this.viewModel.setAspect(width / height);
+ this.effects.setViewport(height * pixelRatio, this.camera.fov);
+ }
+
+ private setFirstPerson(a: Actor | null): void {
+ if (this.hiddenActor === a) return;
+ if (this.hiddenActor) this.hiddenActor.model.setLayer(0);
+ this.hiddenActor = a;
+ if (a) a.model.setLayer(LAYER_FIRST_PERSON);
+ // tags live under model roots; keep them on the normal layer for others
+ for (const [id, tag] of this.nameTags) tag.layers.set(a && a.id === id ? LAYER_FIRST_PERSON : 0);
+ }
+
+ /** Interpolate actors between ticks and draw a frame. */
+ render(dt: number, alpha: number, view: CameraView, vm: ViewModelState | null): void {
+ const world = this.world;
+ if (!world) return;
+
+ // ---- actors
+ for (const a of world.actors) {
+ const p = this.tmpPos.lerpVectors(a.prevPos, a.pos, alpha);
+ const yaw = a.isBot ? lerpAngle(a.prevYaw, a.yaw, alpha) : a.yaw;
+ const pitch = a.isBot ? lerp(a.prevPitch, a.pitch, alpha) : a.pitch;
+ world.poseActor(a, p, yaw, pitch);
+ const tag = this.nameTags.get(a.id);
+ if (tag) tag.visible = a.alive;
+ }
+ this.setFirstPerson(view.firstPerson);
+
+ // ---- items
+ this.syncItems(world);
+
+ // ---- camera
+ const cam = this.camera;
+ cam.position.copy(view.pos);
+ cam.rotation.set(view.pitch, view.yaw, view.roll, 'YXZ');
+ if (Math.abs(cam.fov - view.fov) > 0.01) {
+ cam.fov = view.fov;
+ cam.updateProjectionMatrix();
+ this.effects.setViewport(this.height * this.renderer.getPixelRatio(), cam.fov);
+ }
+ cam.updateMatrixWorld();
+ this.sky.position.copy(cam.position);
+
+ this.effects.update(dt, cam);
+
+ const r = this.renderer;
+ r.autoClear = true;
+ r.render(this.scene, cam);
+ if (vm) {
+ this.viewModel.update(dt, vm);
+ r.autoClear = false;
+ r.clearDepth();
+ r.render(this.viewModel.scene, this.viewModel.camera);
+ r.autoClear = true;
+ }
+ }
+
+ private syncItems(world: World): void {
+ // dropped guns
+ const alive = new Set();
+ for (const it of world.dropped) {
+ alive.add(it.id);
+ let m = this.droppedMeshes.get(it.id);
+ if (!m) {
+ const model = createWeaponModel(it.inst.def.id);
+ m = new THREE.Group();
+ model.root.rotation.set(0, 0, Math.PI / 2);
+ model.root.position.y = 0.04;
+ m.add(model.root);
+ if (WEAPONS[it.inst.def.id].kind === 'rifle' || WEAPONS[it.inst.def.id].kind === 'sniper') model.root.scale.setScalar(0.92);
+ this.droppedMeshes.set(it.id, m);
+ this.itemRoot.add(m);
+ }
+ m.position.copy(it.pos);
+ m.rotation.y = it.yaw;
+ }
+ for (const [id, m] of this.droppedMeshes) {
+ if (!alive.has(id)) {
+ this.itemRoot.remove(m);
+ this.droppedMeshes.delete(id);
+ }
+ }
+ // bomb
+ const b = world.bomb;
+ const show = b.state === 'dropped' || b.state === 'planted' || b.state === 'defused';
+ this.bombObj.visible = show;
+ if (show) {
+ this.bombObj.position.copy(b.pos);
+ const blink = b.state === 'planted' && world.time % Math.max(0.12, Math.min(1, b.timeLeft / 40)) < 0.06;
+ (this.bombLed.material as THREE.MeshBasicMaterial).color.setHex(b.state === 'defused' ? 0x20ff40 : blink ? 0xff3030 : 0x401010);
+ }
+ }
+
+ /** True if the point is in the sun's shadow (used to light the view model). */
+ inShadow(p: THREE.Vector3): boolean {
+ const w = this.world;
+ if (!w) return false;
+ return !w.collision.lineClear(p.x, p.y, p.z, p.x + SUN_DIR.x * 60, p.y + SUN_DIR.y * 60, p.z + SUN_DIR.z * 60);
+ }
+
+ dispose(): void {
+ this.effects.clear();
+ this.renderer.dispose();
+ this.renderer.forceContextLoss();
+ }
+}
+
+function makeNameTag(name: string, color: string): THREE.Sprite {
+ const c = document.createElement('canvas');
+ c.width = 256;
+ c.height = 64;
+ const ctx = c.getContext('2d')!;
+ ctx.font = 'bold 30px "Segoe UI", "Microsoft YaHei", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.lineWidth = 6;
+ ctx.strokeStyle = 'rgba(0,0,0,0.75)';
+ ctx.strokeText(name, 128, 32);
+ ctx.fillStyle = color;
+ ctx.fillText(name, 128, 32);
+ const tex = new THREE.CanvasTexture(c);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, sizeAttenuation: false }));
+ s.scale.set(0.09, 0.0225, 1);
+ s.renderOrder = 5;
+ return s;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Textures.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Textures.ts
new file mode 100644
index 0000000..0981413
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/Textures.ts
@@ -0,0 +1,542 @@
+import * as THREE from 'three';
+
+/**
+ * Procedural canvas textures (no image files). Every texture is painted at load time with
+ * value-noise + simple 2D drawing, then uploaded as a repeating sRGB texture (+ optional bump).
+ */
+
+function makeCanvas(w: number, h: number): [HTMLCanvasElement, CanvasRenderingContext2D] {
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const ctx = c.getContext('2d', { willReadFrequently: true })!;
+ return [c, ctx];
+}
+
+/** Deterministic lattice hash noise. */
+function hash2(x: number, y: number, seed: number): number {
+ let h = (x * 374761393 + y * 668265263 + seed * 2147483647) | 0;
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
+ h ^= h >>> 16;
+ return (h >>> 0) / 4294967296;
+}
+
+function valueNoise(x: number, y: number, seed: number, period: number): number {
+ const xi = Math.floor(x);
+ const yi = Math.floor(y);
+ const xf = x - xi;
+ const yf = y - yi;
+ const u = xf * xf * (3 - 2 * xf);
+ const v = yf * yf * (3 - 2 * yf);
+ const p = (n: number) => ((n % period) + period) % period;
+ const a = hash2(p(xi), p(yi), seed);
+ const b = hash2(p(xi + 1), p(yi), seed);
+ const c = hash2(p(xi), p(yi + 1), seed);
+ const d = hash2(p(xi + 1), p(yi + 1), seed);
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
+}
+
+/** Tileable fractal noise in [0,1] for pixel (x,y) of a size x size texture. */
+function fbm(x: number, y: number, size: number, baseCells: number, octaves: number, seed: number): number {
+ let sum = 0;
+ let amp = 0.5;
+ let norm = 0;
+ let cells = baseCells;
+ for (let o = 0; o < octaves; o++) {
+ sum += amp * valueNoise((x / size) * cells, (y / size) * cells, seed + o * 17, cells);
+ norm += amp;
+ amp *= 0.5;
+ cells *= 2;
+ }
+ return sum / norm;
+}
+
+/** Applies per-pixel grain/variation over whatever was drawn. */
+function grain(ctx: CanvasRenderingContext2D, size: number, strength: number, baseCells: number, seed: number, speck = 0.0): void {
+ const img = ctx.getImageData(0, 0, size, size);
+ const d = img.data;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const i = (y * size + x) * 4;
+ const n = fbm(x, y, size, baseCells, 4, seed) - 0.5;
+ let m = 1 + n * strength;
+ if (speck > 0) {
+ const s = hash2(x, y, seed + 99);
+ if (s < speck) m *= 0.78;
+ else if (s > 1 - speck) m *= 1.12;
+ }
+ d[i] = Math.min(255, d[i] * m);
+ d[i + 1] = Math.min(255, d[i + 1] * m);
+ d[i + 2] = Math.min(255, d[i + 2] * m);
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+}
+
+function toTexture(canvas: HTMLCanvasElement, srgb = true): THREE.CanvasTexture {
+ const t = new THREE.CanvasTexture(canvas);
+ t.wrapS = THREE.RepeatWrapping;
+ t.wrapT = THREE.RepeatWrapping;
+ t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
+ t.anisotropy = 8;
+ t.generateMipmaps = true;
+ t.minFilter = THREE.LinearMipmapLinearFilter;
+ t.magFilter = THREE.LinearFilter;
+ return t;
+}
+
+/** Grayscale bump map from the luminance of a color canvas (with optional contrast). */
+function bumpFrom(src: HTMLCanvasElement, contrast = 1.4): THREE.CanvasTexture {
+ const size = src.width;
+ const [c, ctx] = makeCanvas(size, src.height);
+ ctx.drawImage(src, 0, 0);
+ const img = ctx.getImageData(0, 0, size, src.height);
+ const d = img.data;
+ for (let i = 0; i < d.length; i += 4) {
+ let l = (d[i] * 0.3 + d[i + 1] * 0.59 + d[i + 2] * 0.11) / 255;
+ l = Math.min(1, Math.max(0, (l - 0.5) * contrast + 0.5));
+ d[i] = d[i + 1] = d[i + 2] = l * 255;
+ }
+ ctx.putImageData(img, 0, 0);
+ return toTexture(c, false);
+}
+
+// ------------------------------------------------------------------------------------------
+
+function sandstoneBlocks(seed: number, base: [number, number, number], mortar: string): HTMLCanvasElement {
+ const S = 512; // 4 m
+ const [c, ctx] = makeCanvas(S, S);
+ ctx.fillStyle = mortar;
+ ctx.fillRect(0, 0, S, S);
+ const rowH = 64; // 0.5 m courses
+ let r = seed;
+ const rnd = () => {
+ r = (r * 16807) % 2147483647;
+ return r / 2147483647;
+ };
+ for (let row = 0; row < S / rowH; row++) {
+ let x = -Math.floor(rnd() * 120);
+ while (x < S) {
+ const w = 70 + Math.floor(rnd() * 110);
+ const shade = 0.9 + rnd() * 0.2;
+ const [br, bg, bb] = base;
+ ctx.fillStyle = `rgb(${Math.min(255, br * shade)},${Math.min(255, bg * shade)},${Math.min(255, bb * shade)})`;
+ const y = row * rowH;
+ const drawBlock = (bx: number) => {
+ ctx.fillRect(bx + 2, y + 2, w - 3, rowH - 3);
+ // soft bevel highlights
+ ctx.fillStyle = 'rgba(255,240,210,0.10)';
+ ctx.fillRect(bx + 2, y + 2, w - 3, 3);
+ ctx.fillStyle = 'rgba(60,40,20,0.12)';
+ ctx.fillRect(bx + 2, y + rowH - 5, w - 3, 3);
+ ctx.fillStyle = `rgb(${Math.min(255, br * shade)},${Math.min(255, bg * shade)},${Math.min(255, bb * shade)})`;
+ };
+ drawBlock(x);
+ if (x + w > S) drawBlock(x - S);
+ if (x < 0) drawBlock(x + S);
+ x += w;
+ }
+ }
+ // erosion pits & cracks
+ for (let i = 0; i < 60; i++) {
+ ctx.fillStyle = `rgba(90,62,30,${0.08 + rnd() * 0.12})`;
+ ctx.beginPath();
+ ctx.ellipse(rnd() * S, rnd() * S, 2 + rnd() * 9, 1 + rnd() * 5, rnd() * 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.strokeStyle = 'rgba(80,55,30,0.25)';
+ ctx.lineWidth = 1;
+ for (let i = 0; i < 10; i++) {
+ let px = rnd() * S;
+ let py = rnd() * S;
+ ctx.beginPath();
+ ctx.moveTo(px, py);
+ for (let k = 0; k < 6; k++) {
+ px += (rnd() - 0.5) * 30;
+ py += rnd() * 18;
+ ctx.lineTo(px, py);
+ }
+ ctx.stroke();
+ }
+ grain(ctx, S, 0.28, 8, seed, 0.02);
+ return c;
+}
+
+function plasterWall(seed: number): HTMLCanvasElement {
+ const S = 512;
+ const [c, ctx] = makeCanvas(S, S);
+ const img = ctx.createImageData(S, S);
+ const d = img.data;
+ for (let y = 0; y < S; y++) {
+ for (let x = 0; x < S; x++) {
+ const n = fbm(x, y, S, 4, 5, seed);
+ const stain = fbm(x, y, S, 2, 3, seed + 50);
+ const v = 0.86 + (n - 0.5) * 0.25 - Math.max(0, stain - 0.6) * 0.4;
+ const i = (y * S + x) * 4;
+ d[i] = 222 * v;
+ d[i + 1] = 196 * v;
+ d[i + 2] = 152 * v;
+ d[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ // exposed bricks where plaster fell off
+ let r = seed;
+ const rnd = () => {
+ r = (r * 48271) % 2147483647;
+ return r / 2147483647;
+ };
+ for (let p = 0; p < 4; p++) {
+ const px = rnd() * S;
+ const py = rnd() * S;
+ for (let k = 0; k < 7; k++) {
+ ctx.fillStyle = `rgba(${150 + rnd() * 30},${105 + rnd() * 20},${70},0.85)`;
+ ctx.fillRect(px + (k % 3) * 22 - 20, py + Math.floor(k / 3) * 12, 20, 10);
+ }
+ }
+ grain(ctx, S, 0.12, 16, seed + 3, 0.015);
+ return c;
+}
+
+function sandFloor(seed: number): HTMLCanvasElement {
+ const S = 512;
+ const [c, ctx] = makeCanvas(S, S);
+ const img = ctx.createImageData(S, S);
+ const d = img.data;
+ for (let y = 0; y < S; y++) {
+ for (let x = 0; x < S; x++) {
+ const n = fbm(x, y, S, 6, 5, seed);
+ const m = fbm(x, y, S, 2, 3, seed + 9);
+ const v = 0.82 + (n - 0.5) * 0.3 + (m - 0.5) * 0.15;
+ const i = (y * S + x) * 4;
+ d[i] = 214 * v;
+ d[i + 1] = 182 * v;
+ d[i + 2] = 132 * v;
+ d[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ let r = seed;
+ const rnd = () => {
+ r = (r * 16807) % 2147483647;
+ return r / 2147483647;
+ };
+ for (let i = 0; i < 260; i++) {
+ const g = 120 + rnd() * 80;
+ ctx.fillStyle = `rgba(${g},${g * 0.85},${g * 0.65},0.55)`;
+ ctx.beginPath();
+ ctx.arc(rnd() * S, rnd() * S, 0.8 + rnd() * 2.6, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ grain(ctx, S, 0.1, 32, seed + 1, 0.04);
+ return c;
+}
+
+function flagstones(seed: number, base: [number, number, number], small: boolean): HTMLCanvasElement {
+ const S = 512;
+ const [c, ctx] = makeCanvas(S, S);
+ ctx.fillStyle = '#7d6a4f';
+ ctx.fillRect(0, 0, S, S);
+ let r = seed;
+ const rnd = () => {
+ r = (r * 16807) % 2147483647;
+ return r / 2147483647;
+ };
+ const cell = small ? 42 : 96;
+ for (let y = 0; y < S; y += cell) {
+ const off = (Math.floor(y / cell) % 2) * (cell / 2);
+ for (let x = -cell; x < S + cell; x += cell) {
+ const shade = 0.82 + rnd() * 0.26;
+ const [br, bg, bb] = base;
+ ctx.fillStyle = `rgb(${br * shade},${bg * shade},${bb * shade})`;
+ const jx = (rnd() - 0.5) * 4;
+ const jy = (rnd() - 0.5) * 4;
+ const bx = x + off + jx + 3;
+ const by = y + jy + 3;
+ ctx.fillRect(bx, by, cell - 6, cell - 6);
+ if (bx + cell > S) ctx.fillRect(bx - S, by, cell - 6, cell - 6);
+ ctx.fillStyle = 'rgba(255,245,220,0.08)';
+ ctx.fillRect(bx, by, cell - 6, 2);
+ }
+ }
+ grain(ctx, S, 0.2, 16, seed, 0.03);
+ return c;
+}
+
+function concrete(seed: number): HTMLCanvasElement {
+ const S = 512;
+ const [c, ctx] = makeCanvas(S, S);
+ const img = ctx.createImageData(S, S);
+ const d = img.data;
+ for (let y = 0; y < S; y++) {
+ for (let x = 0; x < S; x++) {
+ const n = fbm(x, y, S, 8, 5, seed);
+ const v = 0.78 + (n - 0.5) * 0.28;
+ const i = (y * S + x) * 4;
+ d[i] = 178 * v;
+ d[i + 1] = 166 * v;
+ d[i + 2] = 146 * v;
+ d[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ ctx.strokeStyle = 'rgba(70,60,48,0.45)';
+ ctx.lineWidth = 2;
+ for (let i = 0; i <= 2; i++) {
+ ctx.beginPath();
+ ctx.moveTo(0, i * 256);
+ ctx.lineTo(S, i * 256);
+ ctx.moveTo(i * 256, 0);
+ ctx.lineTo(i * 256, S);
+ ctx.stroke();
+ }
+ grain(ctx, S, 0.1, 32, seed + 2, 0.05);
+ return c;
+}
+
+function crateFace(): HTMLCanvasElement {
+ const S = 256;
+ const [c, ctx] = makeCanvas(S, S);
+ ctx.fillStyle = '#9a6a35';
+ ctx.fillRect(0, 0, S, S);
+ // planks
+ for (let i = 0; i < 6; i++) {
+ const shade = 0.85 + ((i * 37) % 10) / 40;
+ ctx.fillStyle = `rgb(${158 * shade},${110 * shade},${58 * shade})`;
+ ctx.fillRect(0, i * (S / 6) + 1, S, S / 6 - 2);
+ ctx.fillStyle = 'rgba(60,35,15,0.5)';
+ ctx.fillRect(0, i * (S / 6), S, 2);
+ }
+ // frame
+ ctx.fillStyle = '#7a4f24';
+ const f = 26;
+ ctx.fillRect(0, 0, S, f);
+ ctx.fillRect(0, S - f, S, f);
+ ctx.fillRect(0, 0, f, S);
+ ctx.fillRect(S - f, 0, f, S);
+ // diagonal brace
+ ctx.save();
+ ctx.translate(S / 2, S / 2);
+ ctx.rotate(-Math.PI / 4);
+ ctx.fillRect(-S * 0.7, -f / 2, S * 1.4, f);
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(40,22,8,0.6)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(1, 1, S - 2, S - 2);
+ ctx.strokeRect(f, f, S - 2 * f, S - 2 * f);
+ // nails
+ ctx.fillStyle = '#3a3a3a';
+ for (const [x, y] of [
+ [12, 12],
+ [S - 12, 12],
+ [12, S - 12],
+ [S - 12, S - 12],
+ ]) {
+ ctx.beginPath();
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ grain(ctx, S, 0.25, 8, 77, 0.03);
+ return c;
+}
+
+function paintedDoor(): HTMLCanvasElement {
+ const S = 256;
+ const [c, ctx] = makeCanvas(S, S);
+ for (let i = 0; i < 8; i++) {
+ const shade = 0.88 + ((i * 53) % 12) / 50;
+ ctx.fillStyle = `rgb(${46 * shade},${110 * shade},${128 * shade})`;
+ ctx.fillRect(i * 32, 0, 31, S);
+ ctx.fillStyle = 'rgba(20,40,40,0.5)';
+ ctx.fillRect(i * 32 + 31, 0, 1, S);
+ }
+ // peeling paint revealing wood
+ let r = 31;
+ const rnd = () => {
+ r = (r * 16807) % 2147483647;
+ return r / 2147483647;
+ };
+ for (let i = 0; i < 40; i++) {
+ ctx.fillStyle = `rgba(${130 + rnd() * 30},${90 + rnd() * 20},50,0.9)`;
+ ctx.beginPath();
+ ctx.ellipse(rnd() * S, rnd() * S, 2 + rnd() * 10, 1 + rnd() * 4, rnd() * 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.fillStyle = 'rgba(30,30,30,0.85)';
+ ctx.fillRect(0, 40, S, 10);
+ ctx.fillRect(0, S - 50, S, 10);
+ grain(ctx, S, 0.2, 8, 5, 0.03);
+ return c;
+}
+
+function corrugated(): HTMLCanvasElement {
+ const S = 256;
+ const [c, ctx] = makeCanvas(S, S);
+ for (let x = 0; x < S; x++) {
+ const v = 0.75 + 0.25 * Math.sin((x / S) * Math.PI * 2 * 16);
+ ctx.fillStyle = `rgb(${50 * v},${86 * v},${132 * v})`;
+ ctx.fillRect(x, 0, 1, S);
+ }
+ ctx.fillStyle = 'rgba(120,70,30,0.35)';
+ let r = 9;
+ const rnd = () => {
+ r = (r * 16807) % 2147483647;
+ return r / 2147483647;
+ };
+ for (let i = 0; i < 50; i++) ctx.fillRect(rnd() * S, rnd() * S, 3 + rnd() * 12, 1 + rnd() * 20);
+ grain(ctx, S, 0.2, 8, 12, 0.02);
+ return c;
+}
+
+function darkWood(): HTMLCanvasElement {
+ const S = 256;
+ const [c, ctx] = makeCanvas(S, S);
+ for (let i = 0; i < 8; i++) {
+ const shade = 0.75 + ((i * 29) % 10) / 30;
+ ctx.fillStyle = `rgb(${92 * shade},${64 * shade},${40 * shade})`;
+ ctx.fillRect(0, i * 32, S, 31);
+ ctx.fillStyle = 'rgba(20,12,6,0.7)';
+ ctx.fillRect(0, i * 32 + 31, S, 1);
+ }
+ grain(ctx, S, 0.3, 16, 44, 0.04);
+ return c;
+}
+
+/** Painted site letter decal (white paint, worn). */
+export function siteLetterTexture(letter: string): THREE.CanvasTexture {
+ const S = 256;
+ const [c, ctx] = makeCanvas(S, S);
+ ctx.clearRect(0, 0, S, S);
+ ctx.fillStyle = 'rgba(245,240,230,0.92)';
+ ctx.font = 'bold 200px Arial Black, Impact, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(letter, S / 2, S / 2 + 10);
+ // wear
+ const img = ctx.getImageData(0, 0, S, S);
+ for (let i = 0; i < img.data.length; i += 4) {
+ const x = (i / 4) % S;
+ const y = Math.floor(i / 4 / S);
+ if (fbm(x, y, S, 16, 3, 5) < 0.38) img.data[i + 3] *= 0.3;
+ }
+ ctx.putImageData(img, 0, 0);
+ const t = new THREE.CanvasTexture(c);
+ t.colorSpace = THREE.SRGBColorSpace;
+ return t;
+}
+
+/** Soft radial sprite (particles, flashes). */
+export function radialTexture(inner = 'rgba(255,255,255,1)', outer = 'rgba(255,255,255,0)'): THREE.CanvasTexture {
+ const S = 64;
+ const [c, ctx] = makeCanvas(S, S);
+ const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
+ g.addColorStop(0, inner);
+ g.addColorStop(1, outer);
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, S, S);
+ const t = new THREE.CanvasTexture(c);
+ t.colorSpace = THREE.SRGBColorSpace;
+ return t;
+}
+
+/** Star-shaped muzzle flash sprite. */
+export function muzzleFlashTexture(): THREE.CanvasTexture {
+ const S = 128;
+ const [c, ctx] = makeCanvas(S, S);
+ ctx.translate(S / 2, S / 2);
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, S / 2);
+ g.addColorStop(0, 'rgba(255,255,230,1)');
+ g.addColorStop(0.25, 'rgba(255,210,120,0.95)');
+ g.addColorStop(0.6, 'rgba(255,140,40,0.4)');
+ g.addColorStop(1, 'rgba(255,100,20,0)');
+ ctx.fillStyle = g;
+ for (let i = 0; i < 6; i++) {
+ ctx.rotate(Math.PI / 3);
+ ctx.beginPath();
+ ctx.moveTo(0, -6);
+ ctx.lineTo(S / 2, 0);
+ ctx.lineTo(0, 6);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.beginPath();
+ ctx.arc(0, 0, S / 5, 0, Math.PI * 2);
+ ctx.fill();
+ const t = new THREE.CanvasTexture(c);
+ t.colorSpace = THREE.SRGBColorSpace;
+ return t;
+}
+
+/** Bullet hole decal. */
+export function bulletHoleTexture(): THREE.CanvasTexture {
+ const S = 64;
+ const [c, ctx] = makeCanvas(S, S);
+ const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
+ g.addColorStop(0, 'rgba(10,8,6,1)');
+ g.addColorStop(0.3, 'rgba(25,18,12,0.95)');
+ g.addColorStop(0.55, 'rgba(60,45,30,0.5)');
+ g.addColorStop(1, 'rgba(60,45,30,0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, S, S);
+ const t = new THREE.CanvasTexture(c);
+ t.colorSpace = THREE.SRGBColorSpace;
+ return t;
+}
+
+export interface MaterialSet {
+ wall: THREE.MeshStandardMaterial;
+ wallAlt: THREE.MeshStandardMaterial;
+ wallTop: THREE.MeshStandardMaterial;
+ floorSand: THREE.MeshStandardMaterial;
+ floorTiles: THREE.MeshStandardMaterial;
+ floorStone: THREE.MeshStandardMaterial;
+ floorConcrete: THREE.MeshStandardMaterial;
+ crate: THREE.MeshStandardMaterial;
+ door: THREE.MeshStandardMaterial;
+ doorLeaf: THREE.MeshStandardMaterial;
+ metal: THREE.MeshStandardMaterial;
+ wood: THREE.MeshStandardMaterial;
+ trim: THREE.MeshStandardMaterial;
+}
+
+let cached: MaterialSet | null = null;
+
+/** Build (once) the full procedural material set used by the level. */
+export function getMaterials(): MaterialSet {
+ if (cached) return cached;
+ const wallC = sandstoneBlocks(11, [214, 182, 128], '#a88a5c');
+ const wallAltC = plasterWall(23);
+ const sandC = sandFloor(5);
+ const tilesC = flagstones(8, [196, 170, 126], false);
+ const stoneC = flagstones(13, [186, 160, 118], true);
+ const concreteC = concrete(17);
+ const crateC = crateFace();
+ const doorC = paintedDoor();
+ const metalC = corrugated();
+ const woodC = darkWood();
+ const std = (canvas: HTMLCanvasElement, rough: number, bump: number, metal = 0) =>
+ new THREE.MeshStandardMaterial({
+ map: toTexture(canvas),
+ bumpMap: bump > 0 ? bumpFrom(canvas) : null,
+ bumpScale: bump,
+ roughness: rough,
+ metalness: metal,
+ vertexColors: true,
+ });
+ cached = {
+ wall: std(wallC, 0.92, 2.2),
+ wallAlt: std(wallAltC, 0.95, 1.2),
+ wallTop: new THREE.MeshStandardMaterial({ color: 0xc9ab7b, roughness: 0.95, vertexColors: true }),
+ floorSand: std(sandC, 0.97, 0.8),
+ floorTiles: std(tilesC, 0.9, 2),
+ floorStone: std(stoneC, 0.9, 2),
+ floorConcrete: std(concreteC, 0.92, 0.8),
+ crate: std(crateC, 0.85, 1.5),
+ door: std(doorC, 0.75, 1.2),
+ doorLeaf: new THREE.MeshStandardMaterial({ map: toTexture(doorC), roughness: 0.75 }),
+ metal: std(metalC, 0.55, 2.5, 0.45),
+ wood: std(woodC, 0.85, 1.2),
+ trim: new THREE.MeshStandardMaterial({ color: 0x8e7350, roughness: 0.9, vertexColors: true }),
+ };
+ return cached;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/ViewModel.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/ViewModel.ts
new file mode 100644
index 0000000..7801cd4
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/ViewModel.ts
@@ -0,0 +1,312 @@
+import * as THREE from 'three';
+import { clamp, damp, lerp, smoothstep } from '../core/math';
+import type { Team } from '../core/types';
+import { WEAPONS, type WeaponId } from '../weapons/WeaponDefs';
+import { GeoBuilder } from './GeoBuilder';
+import { muzzleFlashTexture } from './Textures';
+import { createWeaponModel, type WeaponModel } from './WeaponModels';
+
+export interface ViewModelState {
+ weapon: WeaponId | null;
+ team: Team;
+ time: number;
+ drawStart: number;
+ drawEnd: number;
+ reloading: boolean;
+ reloadStart: number;
+ reloadEnd: number;
+ kick: number;
+ scoped: boolean;
+ busy: boolean;
+ speed: number;
+ maxSpeed: number;
+ onGround: boolean;
+ /** View angle change since last frame (radians) for weapon sway. */
+ dYaw: number;
+ dPitch: number;
+}
+
+interface Placement {
+ pos: THREE.Vector3;
+ rot: THREE.Euler;
+}
+
+const PLACEMENT: Record = {
+ rifle: { pos: new THREE.Vector3(0.185, -0.2, -0.5), rot: new THREE.Euler(0.0, 0.075, 0) },
+ sniper: { pos: new THREE.Vector3(0.2, -0.235, -0.56), rot: new THREE.Euler(0.0, 0.07, 0) },
+ pistol: { pos: new THREE.Vector3(0.165, -0.165, -0.5), rot: new THREE.Euler(0.02, 0.12, 0) },
+ knife: { pos: new THREE.Vector3(0.2, -0.19, -0.42), rot: new THREE.Euler(0.35, 0.3, -0.4) },
+ c4: { pos: new THREE.Vector3(0.0, -0.25, -0.48), rot: new THREE.Euler(0.6, 0, 0) },
+};
+
+const SLEEVE: Record = { CT: 0x2d4166, T: 0x93703f };
+const GLOVE: Record = { CT: 0x1b1b1d, T: 0x2b2520 };
+const _a = new THREE.Vector3();
+const _b = new THREE.Vector3();
+
+/**
+ * First-person weapon rendered in its own scene/camera pass (never clips into walls).
+ * Procedural animations: draw, recoil kick, reload (mag out/in with support hand), knife swings,
+ * bomb planting, movement bob, mouse sway; hidden while scoped.
+ */
+export class ViewModel {
+ readonly scene = new THREE.Scene();
+ readonly camera: THREE.PerspectiveCamera;
+ private readonly sway = new THREE.Group();
+ private readonly holder = new THREE.Group();
+ private readonly models = new Map();
+ private current: WeaponModel | null = null;
+ private currentId: WeaponId | null = null;
+ private readonly armR: THREE.Mesh;
+ private readonly armL: THREE.Mesh;
+ private readonly handR: THREE.Mesh;
+ private readonly handL: THREE.Mesh;
+ private team: Team = 'CT';
+ private readonly flash: THREE.Sprite;
+ private flashLife = 0;
+ readonly sun: THREE.DirectionalLight;
+ readonly hemi: THREE.HemisphereLight;
+ private swayX = 0;
+ private swayY = 0;
+ private bobPhase = 0;
+ private bobAmt = 0;
+ private knifeT = 10;
+ private knifeAlt = false;
+ private knifeSide = 1;
+ private landDip = 0;
+
+ constructor() {
+ this.camera = new THREE.PerspectiveCamera(64, 1, 0.01, 10);
+ this.scene.add(this.camera);
+ this.camera.add(this.sway);
+ this.sway.add(this.holder);
+ this.hemi = new THREE.HemisphereLight(0xcfe0f5, 0x9c7a52, 1.3);
+ this.sun = new THREE.DirectionalLight(0xfff0d8, 2.2);
+ this.sun.position.set(-0.5, 1, 0.4);
+ this.scene.add(this.hemi, this.sun);
+
+ const sleeve = () => {
+ const g = new GeoBuilder().box(0.085, 0.085, 1, 0, 0, -0.5, 0xffffff);
+ return new THREE.Mesh(g.build(), new THREE.MeshStandardMaterial({ color: SLEEVE.CT, roughness: 0.9 }));
+ };
+ const hand = () => {
+ const g = new GeoBuilder().box(0.075, 0.085, 0.1, 0, 0, 0, 0xffffff).box(0.08, 0.03, 0.04, 0, -0.03, -0.06, 0xdddddd);
+ return new THREE.Mesh(g.build(), new THREE.MeshStandardMaterial({ color: GLOVE.CT, roughness: 0.8, vertexColors: true }));
+ };
+ this.armR = sleeve();
+ this.armL = sleeve();
+ this.handR = hand();
+ this.handL = hand();
+ this.sway.add(this.armR, this.armL, this.handR, this.handL);
+
+ this.flash = new THREE.Sprite(new THREE.SpriteMaterial({ map: muzzleFlashTexture(), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true }));
+ this.flash.visible = false;
+ this.flash.renderOrder = 10;
+ this.scene.add(this.flash);
+ }
+
+ setAspect(aspect: number): void {
+ this.camera.aspect = aspect;
+ this.camera.updateProjectionMatrix();
+ }
+
+ setTeam(team: Team): void {
+ if (team === this.team) return;
+ this.team = team;
+ for (const m of [this.armR, this.armL]) (m.material as THREE.MeshStandardMaterial).color.setHex(SLEEVE[team]);
+ for (const m of [this.handR, this.handL]) (m.material as THREE.MeshStandardMaterial).color.setHex(GLOVE[team]);
+ }
+
+ private setWeapon(id: WeaponId | null): void {
+ if (id === this.currentId) return;
+ if (this.current) this.holder.remove(this.current.root);
+ this.currentId = id;
+ this.current = null;
+ if (!id) return;
+ let m = this.models.get(id);
+ if (!m) {
+ m = createWeaponModel(id, false);
+ m.root.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) {
+ o.castShadow = false;
+ o.frustumCulled = false;
+ }
+ });
+ this.models.set(id, m);
+ }
+ this.current = m;
+ this.holder.add(m.root);
+ }
+
+ onShot(): void {
+ this.flashLife = 0.045;
+ }
+
+ onKnife(alt: boolean): void {
+ this.knifeT = 0;
+ this.knifeAlt = alt;
+ this.knifeSide = -this.knifeSide;
+ }
+
+ onLand(speed: number): void {
+ this.landDip = Math.min(0.06, speed * 0.006);
+ }
+
+ /** World-space muzzle point for tracers, given the main camera. */
+ muzzleWorld(mainCamera: THREE.Camera, out: THREE.Vector3): THREE.Vector3 {
+ if (!this.current) return out.setFromMatrixPosition(mainCamera.matrixWorld);
+ this.scene.updateMatrixWorld(true);
+ // view-model camera sits at the origin of its scene, looking down -Z like the main camera
+ const local = this.current.muzzle.getWorldPosition(_a);
+ // compensate the FOV difference roughly by pushing the point along its ray
+ return out.copy(local).applyMatrix4(mainCamera.matrixWorld);
+ }
+
+ update(dt: number, s: ViewModelState): void {
+ this.setTeam(s.team);
+ this.setWeapon(s.weapon);
+ const m = this.current;
+ const visible = !!m && !s.scoped;
+ this.holder.visible = visible;
+ this.armR.visible = this.armL.visible = this.handR.visible = this.handL.visible = visible;
+ this.flash.visible = false;
+ if (!m || !s.weapon) return;
+
+ const def = WEAPONS[s.weapon];
+ const place = PLACEMENT[def.kind] ?? PLACEMENT.rifle;
+ const t = s.time;
+
+ // ---- sway (lags behind view rotation) and bob
+ this.swayX = damp(this.swayX, clamp(-s.dYaw * 2.2, -0.08, 0.08), 9, dt);
+ this.swayY = damp(this.swayY, clamp(s.dPitch * 2.2, -0.08, 0.08), 9, dt);
+ const moving = s.onGround ? clamp(s.speed / Math.max(1, s.maxSpeed), 0, 1) : 0;
+ this.bobAmt = damp(this.bobAmt, moving, 8, dt);
+ this.bobPhase += dt * (6 + s.speed * 1.2);
+ this.landDip = damp(this.landDip, 0, 7, dt);
+ this.sway.position.set(
+ this.swayX * 0.3 + Math.sin(this.bobPhase) * 0.009 * this.bobAmt,
+ -this.swayY * 0.3 - Math.abs(Math.cos(this.bobPhase)) * 0.007 * this.bobAmt - this.landDip + Math.sin(t * 1.6) * 0.0015,
+ 0,
+ );
+ this.sway.rotation.set(this.swayY * 0.6, this.swayX * 0.8, this.swayX * 0.6);
+
+ // ---- base placement
+ const h = this.holder;
+ h.position.copy(place.pos);
+ h.rotation.copy(place.rot);
+
+ // draw animation
+ const drawDur = Math.max(0.01, s.drawEnd - s.drawStart);
+ const dp = smoothstep(0, 1, (t - s.drawStart) / drawDur);
+ if (dp < 1) {
+ h.position.y -= (1 - dp) * 0.22;
+ h.rotation.x -= (1 - dp) * 0.7;
+ h.rotation.z += (1 - dp) * 0.3;
+ }
+
+ // recoil kick
+ const kick = s.kick;
+ if (def.kind !== 'knife' && def.kind !== 'c4') {
+ const strength = def.kind === 'sniper' ? 2.2 : def.id === 'deagle' ? 1.8 : def.kind === 'pistol' ? 1.1 : def.id === 'ak47' ? 1.25 : 0.9;
+ h.position.z += kick * 0.035 * strength;
+ h.position.y += kick * 0.008 * strength;
+ h.rotation.x += kick * 0.07 * strength;
+ h.rotation.z += kick * 0.015 * strength * Math.sin(t * 50);
+ }
+
+ // reload
+ let magDrop = 0;
+ let support = 0;
+ if (s.reloading) {
+ const p = clamp((t - s.reloadStart) / Math.max(0.01, s.reloadEnd - s.reloadStart), 0, 1);
+ const tilt = Math.sin(p * Math.PI);
+ h.rotation.z += tilt * 0.45;
+ h.rotation.x += tilt * 0.22;
+ h.position.y -= tilt * 0.035;
+ h.position.x -= tilt * 0.02;
+ // mag out at 15-45%, new mag in 50-80%
+ if (p < 0.45) magDrop = smoothstep(0.12, 0.42, p);
+ else magDrop = 1 - smoothstep(0.5, 0.8, p);
+ support = Math.sin(clamp((p - 0.1) / 0.75, 0, 1) * Math.PI);
+ if (def.kind === 'sniper' && p > 0.82) h.rotation.z -= Math.sin(((p - 0.82) / 0.18) * Math.PI) * 0.2;
+ }
+ if (m.mag) {
+ m.mag.position.copy(m.magRest);
+ m.mag.position.y -= magDrop * 0.28;
+ m.mag.position.z += magDrop * 0.05;
+ m.mag.visible = magDrop < 0.95;
+ }
+
+ // knife swings
+ if (def.kind === 'knife') {
+ this.knifeT += dt;
+ const dur = this.knifeAlt ? 0.55 : 0.32;
+ const k = this.knifeT / dur;
+ if (k < 1) {
+ const arc = Math.sin(k * Math.PI);
+ if (this.knifeAlt) {
+ h.position.z -= arc * 0.18;
+ h.position.y += arc * 0.04;
+ h.rotation.x -= arc * 0.6;
+ } else {
+ h.position.x -= arc * 0.2 * this.knifeSide;
+ h.rotation.y += arc * 1.1 * this.knifeSide;
+ h.rotation.z -= arc * 0.6 * this.knifeSide;
+ }
+ }
+ }
+
+ // bomb planting / defusing: arms move down
+ if (s.busy) {
+ h.position.y -= 0.05 + Math.sin(t * 9) * 0.006;
+ h.rotation.x += 0.15;
+ }
+
+ // ---- arms follow the grip points
+ this.holder.updateMatrix();
+ this.sway.updateMatrix();
+ const rh = _a.copy(m.rightHand).applyMatrix4(h.matrix);
+ const lh = _b.copy(m.leftHand).applyMatrix4(h.matrix);
+ if (def.kind === 'knife') lh.set(-0.3, -0.42, -0.2);
+ if (support > 0 && m.mag) {
+ const mag = new THREE.Vector3().copy(m.mag.position).applyMatrix4(h.matrix);
+ lh.lerp(mag, support);
+ }
+ if (def.kind === 'c4' && s.busy) {
+ rh.y += Math.max(0, Math.sin(t * 14)) * 0.02;
+ }
+ this.placeArm(this.armR, this.handR, rh, new THREE.Vector3(0.34, -0.5, -0.12));
+ this.placeArm(this.armL, this.handL, lh, new THREE.Vector3(-0.1, -0.55, -0.22));
+
+ // muzzle flash in view space
+ if (this.flashLife > 0 && def.kind !== 'knife' && def.kind !== 'c4' && def.id !== 'usp') {
+ this.flashLife -= dt;
+ this.camera.updateMatrixWorld(true);
+ const mp = m.muzzle.getWorldPosition(_a);
+ this.flash.position.copy(mp);
+ this.flash.scale.setScalar(lerp(0.09, 0.16, Math.random()) * (def.kind === 'sniper' ? 1.6 : 1));
+ this.flash.material.rotation = Math.random() * Math.PI;
+ this.flash.visible = true;
+ } else if (this.flashLife > 0) {
+ this.flashLife -= dt;
+ }
+ }
+
+ private placeArm(arm: THREE.Mesh, hand: THREE.Mesh, handPos: THREE.Vector3, elbow: THREE.Vector3): void {
+ hand.position.copy(handPos);
+ const dir = new THREE.Vector3().subVectors(handPos, elbow);
+ const len = dir.length();
+ dir.normalize();
+ // sleeve: unit box along -Z scaled to length, from elbow to hand
+ arm.position.copy(elbow);
+ arm.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, -1), dir);
+ arm.scale.set(1, 1, len);
+ hand.quaternion.copy(arm.quaternion);
+ }
+
+ /** Lighting cue: dim the view model when the player stands in shadow. */
+ setLighting(inShadow: boolean, dt: number): void {
+ const target = inShadow ? 0.35 : 2.2;
+ this.sun.intensity = damp(this.sun.intensity, target, 6, dt);
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/WeaponModels.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/WeaponModels.ts
new file mode 100644
index 0000000..a5aaf59
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/gfx/WeaponModels.ts
@@ -0,0 +1,274 @@
+import * as THREE from 'three';
+import type { WeaponId } from '../weapons/WeaponDefs';
+import { GeoBuilder, vcMaterial } from './GeoBuilder';
+
+/**
+ * Procedural weapon meshes (no external assets). Convention: barrel along -Z, +Y up,
+ * origin near the trigger. The same model is used first-person (view model) and third-person.
+ */
+export interface WeaponModel {
+ root: THREE.Group;
+ muzzle: THREE.Object3D;
+ mag: THREE.Object3D | null;
+ magRest: THREE.Vector3;
+ rightHand: THREE.Vector3;
+ leftHand: THREE.Vector3;
+}
+
+const C = {
+ steel: 0x2b2e33,
+ steelLight: 0x50555d,
+ black: 0x141517,
+ polymer: 0x232427,
+ wood: 0x8c5429,
+ woodDark: 0x5a3216,
+ bakelite: 0x5c2c12,
+ green: 0x5a6b3e,
+ greenDark: 0x3d4a2a,
+ silver: 0xaeb3b9,
+ chrome: 0xd4d8dd,
+ blade: 0xc7ccd2,
+ tan: 0xb88d52,
+ tape: 0x3a3a34,
+ c4Pad: 0x2c3a2b,
+ red: 0xb32222,
+ blue: 0x2346a8,
+ yellow: 0xc9a124,
+ lcd: 0x9cff7a,
+};
+
+interface ModelSpec {
+ metal: THREE.BufferGeometry | null;
+ matte: THREE.BufferGeometry | null;
+ mag: THREE.BufferGeometry | null;
+ magPivot: [number, number, number];
+ muzzle: [number, number, number];
+ rightHand: [number, number, number];
+ leftHand: [number, number, number];
+}
+
+function spec(
+ metal: GeoBuilder,
+ matte: GeoBuilder,
+ mag: GeoBuilder | null,
+ magPivot: [number, number, number],
+ muzzle: [number, number, number],
+ rightHand: [number, number, number],
+ leftHand: [number, number, number],
+): ModelSpec {
+ return {
+ metal: metal.empty ? null : metal.build(),
+ matte: matte.empty ? null : matte.build(),
+ mag: mag && !mag.empty ? mag.build() : null,
+ magPivot,
+ muzzle,
+ rightHand,
+ leftHand,
+ };
+}
+
+function buildAK(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ metal.box(0.05, 0.065, 0.3, 0, 0.032, -0.07, C.steel); // receiver
+ metal.box(0.046, 0.026, 0.27, 0, 0.076, -0.055, C.steelLight); // dust cover
+ metal.box(0.008, 0.016, 0.05, 0.026, 0.052, -0.03, C.black); // ejection port
+ metal.box(0.024, 0.009, 0.014, 0.036, 0.05, -0.13, C.steelLight); // charging handle
+ metal.box(0.028, 0.022, 0.035, 0, 0.09, -0.215, C.steel); // rear sight block
+ matte.box(0.058, 0.052, 0.2, 0, 0.028, -0.325, C.wood); // lower handguard
+ matte.box(0.042, 0.03, 0.17, 0, 0.078, -0.32, C.wood); // upper handguard
+ metal.cyl(0.011, 0.14, 0, 0.078, -0.47, C.steel); // gas tube
+ metal.box(0.03, 0.034, 0.032, 0, 0.06, -0.52, C.steel); // gas block
+ metal.cyl(0.0105, 0.33, 0, 0.042, -0.585, C.black); // barrel
+ metal.box(0.01, 0.05, 0.016, 0, 0.084, -0.668, C.black); // front sight post
+ metal.box(0.026, 0.024, 0.03, 0, 0.056, -0.668, C.steel); // front sight base
+ metal.cyl(0.0145, 0.06, 0, 0.042, -0.78, C.black, 'z', 8); // muzzle brake
+ matte.box(0.032, 0.105, 0.046, 0, -0.045, 0.058, C.woodDark, -0.34); // pistol grip
+ metal.box(0.006, 0.006, 0.07, 0, -0.013, 0.004, C.steel); // trigger guard
+ metal.box(0.005, 0.025, 0.006, 0, -0.012, -0.012, C.black); // trigger
+ matte.box(0.042, 0.07, 0.25, 0, 0.004, 0.215, C.wood, 0.1); // stock
+ matte.box(0.044, 0.108, 0.03, 0, -0.022, 0.338, C.woodDark, 0.1); // butt
+ mag.box(0.032, 0.075, 0.058, 0, -0.032, 0, C.bakelite, 0.12);
+ mag.box(0.031, 0.07, 0.055, 0, -0.098, -0.016, C.bakelite, 0.34);
+ mag.box(0.03, 0.065, 0.052, 0, -0.156, -0.046, C.bakelite, 0.58);
+ return spec(metal, matte, mag, [0, 0.0, -0.118], [0, 0.042, -0.815], [0, -0.055, 0.06], [0, 0.01, -0.33]);
+}
+
+function buildM4(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ metal.box(0.05, 0.058, 0.27, 0, 0.05, -0.065, C.steel); // upper receiver
+ metal.box(0.045, 0.05, 0.2, 0, 0.0, -0.03, C.polymer); // lower receiver
+ metal.box(0.026, 0.012, 0.26, 0, 0.085, -0.075, C.black); // top rail
+ for (let i = 0; i < 9; i++) metal.box(0.03, 0.006, 0.012, 0, 0.093, 0.04 - i * 0.026, C.steelLight); // rail teeth
+ metal.box(0.03, 0.035, 0.02, 0, 0.112, 0.035, C.black); // rear sight
+ metal.box(0.008, 0.016, 0.04, 0.026, 0.052, -0.02, C.black); // ejection port
+ matte.box(0.062, 0.062, 0.26, 0, 0.046, -0.33, C.polymer); // handguard
+ metal.box(0.024, 0.01, 0.26, 0, 0.082, -0.33, C.black); // handguard top rail
+ metal.box(0.01, 0.024, 0.26, 0.033, 0.046, -0.33, C.black); // side rails
+ metal.box(0.01, 0.024, 0.26, -0.033, 0.046, -0.33, C.black);
+ metal.box(0.012, 0.065, 0.02, 0, 0.098, -0.485, C.black); // front sight A-frame
+ metal.cyl(0.0098, 0.3, 0, 0.045, -0.6, C.black); // barrel
+ metal.cyl(0.0135, 0.055, 0, 0.045, -0.775, C.steel, 'z', 6); // flash hider
+ matte.box(0.032, 0.098, 0.044, 0, -0.05, 0.07, C.polymer, -0.36); // grip
+ metal.box(0.006, 0.006, 0.065, 0, -0.025, 0.01, C.black); // trigger guard
+ metal.cyl(0.016, 0.17, 0, 0.042, 0.17, C.black); // buffer tube
+ matte.box(0.046, 0.088, 0.14, 0, 0.02, 0.27, C.polymer); // stock
+ matte.box(0.046, 0.1, 0.02, 0, 0.015, 0.345, C.black);
+ mag.box(0.03, 0.17, 0.064, 0, -0.085, -0.004, C.steelLight, 0.1);
+ return spec(metal, matte, mag, [0, -0.01, -0.1], [0, 0.045, -0.81], [0, -0.06, 0.072], [0, 0.0, -0.34]);
+}
+
+function buildAWP(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ matte.box(0.062, 0.085, 0.42, 0, 0.026, -0.06, C.green); // receiver/chassis
+ matte.box(0.066, 0.07, 0.22, 0, 0.02, -0.36, C.green); // fore-end
+ matte.box(0.056, 0.13, 0.3, 0, -0.005, 0.3, C.green, 0.04); // stock
+ matte.box(0.058, 0.03, 0.12, 0, -0.035, 0.18, C.greenDark); // thumbhole shading
+ matte.box(0.06, 0.15, 0.03, 0, -0.01, 0.46, C.black); // butt pad
+ matte.box(0.04, 0.115, 0.05, 0, -0.065, 0.1, C.green, -0.22); // grip
+ metal.cyl(0.0145, 0.62, 0, 0.045, -0.6, C.black); // barrel
+ metal.cyl(0.021, 0.08, 0, 0.045, -0.93, C.steel, 'z', 8); // muzzle brake
+ metal.cyl(0.022, 0.34, 0, 0.135, -0.07, C.black); // scope tube
+ metal.cyl(0.033, 0.085, 0, 0.135, -0.27, C.black, 'z', 12, 0.024); // objective bell
+ metal.cyl(0.028, 0.06, 0, 0.135, 0.13, C.black, 'z', 12); // eyepiece
+ metal.cyl(0.012, 0.03, 0, 0.165, -0.07, C.steel, 'y'); // turret
+ metal.cyl(0.012, 0.03, 0.03, 0.135, -0.07, C.steel, 'x'); // windage
+ metal.box(0.02, 0.05, 0.022, 0, 0.09, -0.17, C.black); // mounts
+ metal.box(0.02, 0.05, 0.022, 0, 0.09, 0.03, C.black);
+ metal.cyl(0.006, 0.065, 0.045, 0.05, 0.08, C.steel, 'x'); // bolt handle
+ metal.sphere(0.012, 0.08, 0.05, 0.08, C.steelLight);
+ metal.box(0.006, 0.028, 0.006, 0, -0.025, 0.02, C.black); // trigger
+ mag.box(0.042, 0.06, 0.085, 0, -0.03, 0, C.black);
+ return spec(metal, matte, mag, [0, -0.02, -0.08], [0, 0.045, -0.97], [0, -0.07, 0.11], [0, -0.02, -0.36]);
+}
+
+function buildGlock(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ metal.box(0.028, 0.032, 0.19, 0, 0.036, -0.065, C.steel); // slide
+ for (let i = 0; i < 5; i++) metal.box(0.03, 0.022, 0.004, 0, 0.036, 0.0 + i * 0.008, C.black); // serrations
+ metal.box(0.012, 0.008, 0.008, 0, 0.056, 0.022, C.black); // rear sight
+ metal.box(0.005, 0.008, 0.006, 0, 0.056, -0.152, C.black); // front sight
+ metal.cyl(0.0065, 0.01, 0, 0.036, -0.161, C.black, 'z', 8);
+ matte.box(0.026, 0.022, 0.15, 0, 0.01, -0.07, C.polymer); // frame
+ matte.box(0.03, 0.105, 0.05, 0, -0.045, 0.018, C.polymer, -0.3); // grip
+ matte.box(0.006, 0.006, 0.05, 0, -0.012, -0.03, C.polymer); // trigger guard
+ mag.box(0.026, 0.03, 0.046, 0, -0.012, 0, C.black, -0.3);
+ return spec(metal, matte, mag, [0, -0.09, 0.035], [0, 0.036, -0.17], [0, -0.045, 0.025], [-0.012, -0.06, 0.03]);
+}
+
+function buildUSP(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ metal.box(0.03, 0.034, 0.2, 0, 0.037, -0.068, C.polymer); // slide
+ for (let i = 0; i < 5; i++) metal.box(0.032, 0.024, 0.004, 0, 0.037, 0.0 + i * 0.008, C.black);
+ metal.box(0.014, 0.009, 0.008, 0, 0.058, 0.024, C.black);
+ metal.cyl(0.0175, 0.17, 0, 0.037, -0.255, C.steel, 'z', 14); // suppressor
+ metal.cyl(0.0185, 0.012, 0, 0.037, -0.17, C.black, 'z', 14);
+ matte.box(0.028, 0.024, 0.16, 0, 0.01, -0.07, C.black); // frame
+ matte.box(0.032, 0.108, 0.052, 0, -0.046, 0.02, C.black, -0.26); // grip
+ matte.box(0.006, 0.006, 0.05, 0, -0.012, -0.03, C.black);
+ mag.box(0.028, 0.03, 0.048, 0, -0.012, 0, C.steel, -0.26);
+ return spec(metal, matte, mag, [0, -0.092, 0.036], [0, 0.037, -0.345], [0, -0.045, 0.027], [-0.012, -0.062, 0.03]);
+}
+
+function buildDeagle(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ const mag = new GeoBuilder();
+ metal.box(0.034, 0.046, 0.25, 0, 0.046, -0.075, C.chrome); // slide
+ metal.box(0.022, 0.014, 0.255, 0, 0.075, -0.078, C.silver); // top rib
+ for (let i = 0; i < 6; i++) metal.box(0.036, 0.034, 0.004, 0, 0.046, 0.02 + i * 0.007, C.silver);
+ metal.box(0.014, 0.012, 0.01, 0, 0.087, 0.035, C.black); // rear sight
+ metal.box(0.006, 0.012, 0.01, 0, 0.087, -0.19, C.black); // front sight
+ metal.cyl(0.009, 0.012, 0, 0.05, -0.2, C.black, 'z', 8);
+ metal.box(0.03, 0.03, 0.2, 0, 0.008, -0.065, C.silver); // frame
+ matte.box(0.036, 0.12, 0.056, 0, -0.058, 0.03, C.black, -0.24); // grip
+ metal.box(0.008, 0.008, 0.06, 0, -0.016, -0.03, C.silver);
+ mag.box(0.03, 0.03, 0.05, 0, -0.012, 0, C.steel, -0.24);
+ return spec(metal, matte, mag, [0, -0.112, 0.05], [0, 0.05, -0.21], [0, -0.05, 0.032], [-0.012, -0.068, 0.035]);
+}
+
+function buildKnife(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ matte.box(0.026, 0.032, 0.115, 0, 0, 0.03, C.black); // handle
+ for (let i = 0; i < 4; i++) matte.box(0.028, 0.034, 0.006, 0, 0, 0.0 + i * 0.022, C.polymer);
+ metal.box(0.07, 0.014, 0.012, 0, 0.004, -0.032, C.steel); // guard
+ metal.box(0.006, 0.036, 0.15, 0, 0.006, -0.11, C.blade); // blade
+ metal.box(0.0055, 0.024, 0.05, 0, 0.0, -0.2, C.blade, -0.38); // tip
+ metal.box(0.0065, 0.006, 0.12, 0, 0.022, -0.1, C.steelLight); // spine
+ return spec(metal, matte, null, [0, 0, 0], [0, 0.01, -0.24], [0, 0, 0.03], [0, -0.02, 0.06]);
+}
+
+function buildC4(): ModelSpec {
+ const metal = new GeoBuilder();
+ const matte = new GeoBuilder();
+ for (let i = 0; i < 3; i++) matte.box(0.06, 0.06, 0.2, -0.065 + i * 0.065, 0, -0.06, C.tan); // bricks
+ matte.box(0.2, 0.064, 0.02, 0, 0, -0.12, C.tape); // tape bands
+ matte.box(0.2, 0.064, 0.02, 0, 0, 0.0, C.tape);
+ metal.box(0.09, 0.016, 0.07, 0, 0.038, -0.06, C.c4Pad); // keypad module
+ for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) metal.box(0.014, 0.006, 0.012, -0.022 + c * 0.022, 0.048, -0.075 + r * 0.018, C.steelLight);
+ metal.box(0.05, 0.006, 0.016, 0, 0.048, -0.032, C.lcd); // display
+ metal.cyl(0.003, 0.12, 0.08, 0.035, -0.06, C.red, 'z', 5);
+ metal.cyl(0.003, 0.12, 0.088, 0.03, -0.06, C.blue, 'z', 5);
+ metal.cyl(0.003, 0.12, 0.096, 0.025, -0.06, C.yellow, 'z', 5);
+ return spec(metal, matte, null, [0, 0, 0], [0, 0.05, -0.06], [0.05, -0.03, 0.0], [-0.05, -0.03, -0.1]);
+}
+
+const builders: Record ModelSpec> = {
+ ak47: buildAK,
+ m4a4: buildM4,
+ awp: buildAWP,
+ glock: buildGlock,
+ usp: buildUSP,
+ deagle: buildDeagle,
+ knife: buildKnife,
+ c4: buildC4,
+};
+
+const specCache = new Map();
+
+/** Instantiate a weapon model; geometry is shared between instances of the same weapon. */
+export function createWeaponModel(id: WeaponId, castShadow = true): WeaponModel {
+ let s = specCache.get(id);
+ if (!s) {
+ s = builders[id]();
+ specCache.set(id, s);
+ }
+ const root = new THREE.Group();
+ root.name = `weapon_${id}`;
+ if (s.metal) root.add(new THREE.Mesh(s.metal, vcMaterial('metal')));
+ if (s.matte) root.add(new THREE.Mesh(s.matte, vcMaterial('matte')));
+ let mag: THREE.Object3D | null = null;
+ if (s.mag) {
+ mag = new THREE.Mesh(s.mag, vcMaterial('matte'));
+ mag.position.fromArray(s.magPivot);
+ root.add(mag);
+ }
+ const muzzle = new THREE.Object3D();
+ muzzle.position.fromArray(s.muzzle);
+ root.add(muzzle);
+ root.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) {
+ o.castShadow = castShadow;
+ o.receiveShadow = false;
+ o.userData.sharedGeometry = true;
+ }
+ });
+ return {
+ root,
+ muzzle,
+ mag,
+ magRest: mag ? mag.position.clone() : new THREE.Vector3(),
+ rightHand: new THREE.Vector3().fromArray(s.rightHand),
+ leftHand: new THREE.Vector3().fromArray(s.leftHand),
+ };
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Actor.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Actor.ts
new file mode 100644
index 0000000..25919b7
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Actor.ts
@@ -0,0 +1,244 @@
+import * as THREE from 'three';
+import { PHYS } from '../core/config';
+import { dirFromAngles, lerp } from '../core/math';
+import type { Team } from '../core/types';
+import { CharacterModel } from '../gfx/CharacterModel';
+import type { Hull } from '../world/Collision';
+import { WEAPONS, WeaponInstance, type WeaponId, type WeaponSlot } from '../weapons/WeaponDefs';
+import type { BotBrain } from '../ai/BotBrain';
+
+/** Per-tick intent produced by a controller (human input or bot brain). */
+export interface ActorInput {
+ forward: number;
+ right: number;
+ jump: boolean;
+ walk: boolean;
+ crouch: boolean;
+ fire: boolean;
+ firePressed: boolean;
+ alt: boolean;
+ altPressed: boolean;
+ reload: boolean;
+ use: boolean;
+ usePressed: boolean;
+ slot: WeaponSlot | null;
+ lastWeapon: boolean;
+ drop: boolean;
+}
+
+export function emptyInput(): ActorInput {
+ return {
+ forward: 0,
+ right: 0,
+ jump: false,
+ walk: false,
+ crouch: false,
+ fire: false,
+ firePressed: false,
+ alt: false,
+ altPressed: false,
+ reload: false,
+ use: false,
+ usePressed: false,
+ slot: null,
+ lastWeapon: false,
+ drop: false,
+ };
+}
+
+export function clearInput(i: ActorInput): void {
+ i.forward = 0;
+ i.right = 0;
+ i.jump = false;
+ i.walk = false;
+ i.crouch = false;
+ i.fire = false;
+ i.firePressed = false;
+ i.alt = false;
+ i.altPressed = false;
+ i.reload = false;
+ i.use = false;
+ i.usePressed = false;
+ i.slot = null;
+ i.lastWeapon = false;
+ i.drop = false;
+}
+
+/**
+ * A combatant (human or bot). Holds physics hull, vitals, inventory and weapon-handling state.
+ * Controllers only write `input`, `yaw` and `pitch`; all game rules live in the World systems.
+ */
+export class Actor implements Hull {
+ // identity
+ readonly id: number;
+ name: string;
+ readonly team: Team;
+ /** Driven by AI. False while the human controls this actor. */
+ isBot = true;
+ /** This is the human's own roster slot. */
+ readonly isHumanSlot: boolean;
+
+ // physics (Hull)
+ readonly pos = new THREE.Vector3();
+ readonly prevPos = new THREE.Vector3();
+ readonly vel = new THREE.Vector3();
+ radius: number = PHYS.radius;
+ height: number = PHYS.height;
+ onGround = true;
+ crouchAmt = 0;
+ jumpHeld = false;
+
+ // view
+ yaw = 0;
+ pitch = 0;
+ prevYaw = 0;
+ prevPitch = 0;
+
+ // vitals
+ health = 100;
+ armor = 0;
+ helmet = false;
+ hasKit = false;
+ alive = true;
+ deathTime = 0;
+ fallDir = 1;
+ fallSide = 0;
+ lastDamageTime = -100;
+ lastAttacker: Actor | null = null;
+ /** World-space direction the last damage came from (for HUD indicators). */
+ readonly lastDamageFrom = new THREE.Vector3();
+
+ // inventory: [primary, secondary, melee, bomb]
+ readonly weapons: (WeaponInstance | null)[] = [null, null, null, null];
+ slot: WeaponSlot = 2;
+ lastSlot: WeaponSlot = 1;
+
+ // weapon handling
+ nextAttack = 0;
+ drawEnd = 0;
+ reloading = false;
+ reloadStart = 0;
+ reloadEnd = 0;
+ scopeLevel = 0;
+ rezoomAt = 0;
+ rezoomLevel = 0;
+ spreadBloom = 0;
+ recoilIndex = 0;
+ /** Accumulated recoil in degrees (pitch up, yaw right). */
+ recoilPitch = 0;
+ recoilYaw = 0;
+ lastShotTime = -100;
+ kick = 0;
+
+ // bomb interaction
+ planting = false;
+ plantProgress = 0;
+ defusing = false;
+ defuseProgress = 0;
+
+ // spotting: game time until which this actor shows on the enemy radar
+ readonly spottedUntil: Record = { CT: -1, T: -1 };
+
+ // stats
+ kills = 0;
+ deaths = 0;
+ headshots = 0;
+ roundKills = 0;
+ damageDealt = 0;
+
+ // audio helpers
+ stepAccum = 0;
+
+ readonly input: ActorInput = emptyInput();
+ readonly model: CharacterModel;
+ brain: BotBrain | null = null;
+
+ constructor(id: number, name: string, team: Team, isHumanSlot: boolean) {
+ this.id = id;
+ this.name = name;
+ this.team = team;
+ this.isHumanSlot = isHumanSlot;
+ this.model = new CharacterModel(team);
+ }
+
+ get weapon(): WeaponInstance | null {
+ return this.weapons[this.slot];
+ }
+
+ get weaponId(): WeaponId | null {
+ return this.weapons[this.slot]?.def.id ?? null;
+ }
+
+ get hasBomb(): boolean {
+ return this.weapons[3] !== null;
+ }
+
+ get eyeHeight(): number {
+ return lerp(PHYS.eyeHeight, PHYS.crouchEyeHeight, this.crouchAmt);
+ }
+
+ eyePos(out: THREE.Vector3): THREE.Vector3 {
+ return out.set(this.pos.x, this.pos.y + this.eyeHeight, this.pos.z);
+ }
+
+ viewDir(out: THREE.Vector3): THREE.Vector3 {
+ return dirFromAngles(this.yaw, this.pitch, out);
+ }
+
+ /** Horizontal speed cap for current weapon/stance. */
+ maxSpeed(): number {
+ const w = this.weapon;
+ let s = w ? w.def.moveSpeed : 5.75;
+ if (w?.def.scope && this.scopeLevel > 0) s = w.def.scope.scopedMoveSpeed;
+ if (this.input.walk) s *= PHYS.walkFactor;
+ if (this.crouchAmt > 0.5) s *= PHYS.crouchFactor;
+ return s;
+ }
+
+ /** Bomb planting / defusing roots the actor in place. */
+ get rooted(): boolean {
+ return this.planting || this.defusing;
+ }
+
+ give(id: WeaponId): WeaponInstance {
+ const inst = new WeaponInstance(WEAPONS[id]);
+ this.weapons[inst.def.slot] = inst;
+ return inst;
+ }
+
+ /** Reset per-round state (vitals and handling, not stats). */
+ resetForRound(): void {
+ this.health = 100;
+ this.armor = 0;
+ this.helmet = false;
+ this.hasKit = false;
+ this.alive = true;
+ this.vel.set(0, 0, 0);
+ this.onGround = true;
+ this.crouchAmt = 0;
+ this.height = PHYS.height;
+ this.weapons.fill(null);
+ this.slot = 2;
+ this.lastSlot = 1;
+ this.nextAttack = 0;
+ this.drawEnd = 0;
+ this.reloading = false;
+ this.scopeLevel = 0;
+ this.rezoomAt = 0;
+ this.spreadBloom = 0;
+ this.recoilIndex = 0;
+ this.recoilPitch = 0;
+ this.recoilYaw = 0;
+ this.kick = 0;
+ this.planting = false;
+ this.plantProgress = 0;
+ this.defusing = false;
+ this.defuseProgress = 0;
+ this.lastAttacker = null;
+ this.lastDamageTime = -100;
+ this.roundKills = 0;
+ this.spottedUntil.CT = -1;
+ this.spottedUntil.T = -1;
+ this.model.setVisible(true);
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Combat.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Combat.ts
new file mode 100644
index 0000000..1cd3615
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Combat.ts
@@ -0,0 +1,343 @@
+import * as THREE from 'three';
+import { PHYS } from '../core/config';
+import { clamp, DEG, dirFromAngles } from '../core/math';
+import { HITGROUP_MULTIPLIER, type HitGroup } from '../core/types';
+import { makeRayHit, MASK_BULLET } from '../world/Collision';
+import type { WeaponDef, WeaponInstance, WeaponSlot } from '../weapons/WeaponDefs';
+import type { Actor } from './Actor';
+import type { DamageSource } from './Events';
+import type { World } from './World';
+
+const _eye = new THREE.Vector3();
+const _dir = new THREE.Vector3();
+const _right = new THREE.Vector3();
+const _up = new THREE.Vector3();
+const _end = new THREE.Vector3();
+const _n = new THREE.Vector3();
+const _hitPoint = new THREE.Vector3();
+const UP = new THREE.Vector3(0, 1, 0);
+const worldHit = makeRayHit();
+
+/** Current inaccuracy cone (degrees) for an actor's held weapon. Drives both bullets and the crosshair. */
+export function computeInaccuracy(a: Actor, def: WeaponDef): number {
+ const sp = def.spread;
+ let inacc = a.crouchAmt > 0.5 ? sp.crouch : sp.stand;
+ const hs = Math.hypot(a.vel.x, a.vel.z);
+ const frac = hs / Math.max(0.1, def.moveSpeed);
+ const moveFactor = clamp((frac - PHYS.accurateSpeedFraction) / (1 - PHYS.accurateSpeedFraction), 0, 1);
+ inacc += sp.move * moveFactor;
+ if (!a.onGround) inacc += sp.air;
+ inacc += Math.min(a.spreadBloom, sp.max);
+ if (def.scope && a.scopeLevel === 0) inacc += def.scope.unscopedPenalty;
+ return inacc;
+}
+
+export function switchSlot(world: World, a: Actor, slot: WeaponSlot): void {
+ if (slot === a.slot || !a.weapons[slot]) return;
+ if (a.planting) world.abortPlant(a);
+ a.lastSlot = a.slot;
+ a.slot = slot;
+ a.reloading = false;
+ a.scopeLevel = 0;
+ a.rezoomAt = 0;
+ const def = a.weapons[slot]!.def;
+ a.drawEnd = world.time + def.drawTime;
+ world.events.emit('draw', { actor: a, weapon: def.id });
+}
+
+/** Pick the best available slot (primary > secondary > knife). */
+export function bestSlot(a: Actor): WeaponSlot {
+ if (a.weapons[0]) return 0;
+ if (a.weapons[1]) return 1;
+ return 2;
+}
+
+export function startReload(world: World, a: Actor): void {
+ const w = a.weapon;
+ if (!w || a.reloading) return;
+ const def = w.def;
+ if (def.magSize === 0 || w.ammo >= def.magSize || w.reserve <= 0) return;
+ if (world.time < a.drawEnd) return;
+ a.reloading = true;
+ a.reloadStart = world.time;
+ a.reloadEnd = world.time + def.reloadTime;
+ a.scopeLevel = 0;
+ a.rezoomAt = 0;
+ world.events.emit('reload', { actor: a, weapon: def.id });
+}
+
+/** Per-tick weapon handling: switching, reload, scope, recoil recovery, firing. */
+export function updateWeapons(world: World, a: Actor, dt: number, canFire: boolean): void {
+ const inp = a.input;
+ const now = world.time;
+
+ if (inp.slot !== null) switchSlot(world, a, inp.slot);
+ if (inp.lastWeapon) switchSlot(world, a, a.lastSlot);
+
+ const w = a.weapon;
+ if (!w) return;
+ const def = w.def;
+
+ // recovery
+ if (now - a.lastShotTime > def.fireInterval + 0.04) {
+ const k = Math.exp(-def.recoil.recover * dt);
+ a.recoilPitch *= k;
+ a.recoilYaw *= k;
+ a.recoilIndex = Math.max(0, a.recoilIndex - dt * def.recoil.recover * 3);
+ }
+ a.spreadBloom *= Math.exp(-def.spread.recover * dt);
+ a.kick = Math.max(0, a.kick - dt * 7);
+
+ if (a.reloading && now >= a.reloadEnd) {
+ const take = Math.min(def.magSize - w.ammo, w.reserve);
+ w.ammo += take;
+ w.reserve -= take;
+ a.reloading = false;
+ }
+
+ if (a.rezoomAt > 0 && now >= a.rezoomAt) {
+ if (def.scope && !a.reloading && a.alive) {
+ a.scopeLevel = a.rezoomLevel;
+ world.events.emit('scope', { actor: a, level: a.scopeLevel });
+ }
+ a.rezoomAt = 0;
+ }
+
+ if (inp.reload) startReload(world, a);
+
+ if (inp.altPressed && now >= a.drawEnd) {
+ if (def.scope && !a.reloading) {
+ a.scopeLevel = (a.scopeLevel + 1) % (def.scope.fovs.length + 1);
+ a.rezoomAt = 0;
+ world.events.emit('scope', { actor: a, level: a.scopeLevel });
+ } else if (def.melee && canFire && now >= a.nextAttack) {
+ knifeAttack(world, a, def, true);
+ }
+ }
+
+ if (!inp.fire || !canFire || def.kind === 'c4') return;
+ if (now < a.nextAttack || now < a.drawEnd || a.reloading) return;
+
+ if (def.melee) {
+ knifeAttack(world, a, def, false);
+ return;
+ }
+ if (w.ammo <= 0) {
+ if (inp.firePressed) {
+ world.events.emit('dryFire', { actor: a, weapon: def.id });
+ a.nextAttack = now + 0.25;
+ if (w.reserve > 0) startReload(world, a);
+ }
+ return;
+ }
+ if (!def.automatic && !inp.firePressed) return;
+ shoot(world, a, w);
+}
+
+function shoot(world: World, a: Actor, w: WeaponInstance): void {
+ const def = w.def;
+ const now = world.time;
+ w.ammo--;
+ a.nextAttack = now + def.fireInterval;
+ a.lastShotTime = now;
+
+ const inacc = computeInaccuracy(a, def) * DEG;
+ const yaw = a.yaw - a.recoilYaw * DEG;
+ const pitch = a.pitch + a.recoilPitch * DEG;
+ dirFromAngles(yaw, pitch, _dir);
+ if (inacc > 0) {
+ _right.crossVectors(_dir, UP);
+ if (_right.lengthSq() < 1e-8) _right.set(1, 0, 0);
+ _right.normalize();
+ _up.crossVectors(_right, _dir).normalize();
+ const r = Math.tan(inacc * world.rng.next());
+ const th = world.rng.next() * Math.PI * 2;
+ _dir.addScaledVector(_right, Math.cos(th) * r).addScaledVector(_up, Math.sin(th) * r).normalize();
+ }
+ a.eyePos(_eye);
+ traceBullet(world, a, def, _eye, _dir);
+
+ // recoil for the following shot
+ const pat = def.recoil.pattern;
+ a.recoilIndex += 1;
+ const kickIdx = Math.min(pat.length - 1, Math.floor(a.recoilIndex));
+ a.recoilPitch += pat[kickIdx][0];
+ a.recoilYaw += pat[kickIdx][1];
+ a.spreadBloom = Math.min(def.spread.max, a.spreadBloom + def.spread.perShot);
+ a.kick = 1;
+
+ if (def.scope && a.scopeLevel > 0) {
+ a.rezoomLevel = a.scopeLevel;
+ a.scopeLevel = 0;
+ a.rezoomAt = w.ammo > 0 ? now + def.fireInterval * 0.92 : 0;
+ }
+}
+
+function traceBullet(world: World, a: Actor, def: WeaponDef, origin: THREE.Vector3, dir: THREE.Vector3): void {
+ const maxD = def.range;
+ const hitWorld = world.collision.raycast(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, maxD, MASK_BULLET, worldHit);
+ let bestT = hitWorld ? worldHit.t : maxD;
+ let victim: Actor | null = null;
+ let group: HitGroup | null = null;
+ for (const b of world.actors) {
+ if (!b.alive || b === a || b.team === a.team) continue;
+ if (!rayHitsBounds(origin, dir, b, bestT)) continue;
+ const r = b.model.raycast(origin.x, origin.y, origin.z, dir.x, dir.y, dir.z, bestT);
+ if (r && r.t < bestT) {
+ bestT = r.t;
+ victim = b;
+ group = r.group;
+ }
+ }
+ _end.copy(origin).addScaledVector(dir, bestT);
+ if (victim && group) {
+ const falloff = Math.pow(def.rangeMod, bestT / 10);
+ applyDamage(world, victim, a, def.damage * falloff, group, def, _end, dir, def.id);
+ } else if (hitWorld) {
+ _n.set(worldHit.nx, worldHit.ny, worldHit.nz);
+ world.events.emit('impact', { point: _end.clone(), normal: _n.clone(), material: worldHit.collider?.material ?? 'stone' });
+ }
+ world.events.emit('shot', { actor: a, weapon: def.id, origin: origin.clone(), end: _end.clone(), hitActor: victim !== null });
+ world.noise(a, origin, def.id === 'usp' ? 14 : def.kind === 'sniper' ? 60 : 42);
+}
+
+/** Cheap broad phase: ray vs the actor's bounding box. */
+function rayHitsBounds(o: THREE.Vector3, d: THREE.Vector3, b: Actor, maxT: number): boolean {
+ const pad = 0.75;
+ let tmin = 0;
+ let tmax = maxT;
+ for (let axis = 0; axis < 3; axis++) {
+ const oa = axis === 0 ? o.x : axis === 1 ? o.y : o.z;
+ const da = axis === 0 ? d.x : axis === 1 ? d.y : d.z;
+ const mn = axis === 0 ? b.pos.x - pad : axis === 1 ? b.pos.y - 0.3 : b.pos.z - pad;
+ const mx = axis === 0 ? b.pos.x + pad : axis === 1 ? b.pos.y + 2.1 : b.pos.z + pad;
+ if (Math.abs(da) < 1e-12) {
+ if (oa < mn || oa > mx) return false;
+ continue;
+ }
+ let t1 = (mn - oa) / da;
+ let t2 = (mx - oa) / da;
+ if (t1 > t2) {
+ const t = t1;
+ t1 = t2;
+ t2 = t;
+ }
+ if (t1 > tmin) tmin = t1;
+ if (t2 < tmax) tmax = t2;
+ if (tmin > tmax) return false;
+ }
+ return true;
+}
+
+function knifeAttack(world: World, a: Actor, def: WeaponDef, alt: boolean): void {
+ const melee = def.melee!;
+ const now = world.time;
+ const range = alt ? melee.altRange : melee.range;
+ a.nextAttack = now + (alt ? melee.altInterval : def.fireInterval);
+ a.lastShotTime = now;
+ a.kick = 1;
+ a.eyePos(_eye);
+
+ // a small fan of rays makes melee forgiving
+ const offsets: [number, number][] = [
+ [0, 0],
+ [5, 0],
+ [-5, 0],
+ [0, 4],
+ [0, -6],
+ ];
+ let bestT = range;
+ let victim: Actor | null = null;
+ let group: HitGroup | null = null;
+ const hitDir = new THREE.Vector3();
+ for (const [oy, op] of offsets) {
+ dirFromAngles(a.yaw + oy * DEG, a.pitch + op * DEG, _dir);
+ for (const b of world.actors) {
+ if (!b.alive || b === a || b.team === a.team) continue;
+ if (b.pos.distanceToSquared(a.pos) > 9) continue;
+ b.model.updateHitboxes();
+ const r = b.model.raycast(_eye.x, _eye.y, _eye.z, _dir.x, _dir.y, _dir.z, bestT);
+ if (r && r.t < bestT) {
+ bestT = r.t;
+ victim = b;
+ group = r.group;
+ hitDir.copy(_dir);
+ }
+ }
+ }
+ let hit: 'none' | 'wall' | 'flesh' = 'none';
+ if (victim && group) {
+ hit = 'flesh';
+ // backstab: attacking along the victim's facing direction
+ const vf = dirFromAngles(victim.yaw, 0, _n);
+ const back = vf.x * hitDir.x + vf.z * hitDir.z > 0.55;
+ let dmg = alt ? melee.altDamage : def.damage;
+ if (back) dmg = alt ? 180 : 90;
+ _end.copy(_eye).addScaledVector(hitDir, bestT);
+ applyDamage(world, victim, a, dmg, group, def, _end, hitDir, def.id);
+ } else {
+ dirFromAngles(a.yaw, a.pitch, _dir);
+ if (world.collision.raycast(_eye.x, _eye.y, _eye.z, _dir.x, _dir.y, _dir.z, range, MASK_BULLET, worldHit)) {
+ hit = 'wall';
+ _hitPoint.set(worldHit.x, worldHit.y, worldHit.z);
+ _n.set(worldHit.nx, worldHit.ny, worldHit.nz);
+ world.events.emit('impact', { point: _hitPoint.clone(), normal: _n.clone(), material: worldHit.collider?.material ?? 'stone' });
+ }
+ }
+ world.events.emit('knife', { actor: a, alt, hit });
+}
+
+/**
+ * Applies hitbox-zone multipliers and CS-style armor: protected zones (helmet for head, kevlar for
+ * chest/stomach/arms; legs never) let only `armorPen` of the damage through and wear the armor.
+ */
+export function applyDamage(
+ world: World,
+ victim: Actor,
+ attacker: Actor | null,
+ base: number,
+ group: HitGroup | null,
+ def: WeaponDef | null,
+ point: THREE.Vector3,
+ dir: THREE.Vector3,
+ source: DamageSource,
+): void {
+ if (!victim.alive) return;
+ let dmg = base * (group ? HITGROUP_MULTIPLIER[group] : 1);
+ let armorDamage = 0;
+ const protectedZone = group === null ? true : group === 'head' ? victim.helmet : group !== 'leg';
+ if (victim.armor > 0 && protectedZone) {
+ const pen = def ? def.armorPen : 0.5;
+ let healthDmg = dmg * pen;
+ armorDamage = (dmg - healthDmg) * 0.5;
+ if (armorDamage > victim.armor) {
+ healthDmg += (armorDamage - victim.armor) * 2;
+ armorDamage = victim.armor;
+ }
+ victim.armor = Math.max(0, Math.floor(victim.armor - armorDamage));
+ dmg = healthDmg;
+ }
+ const amount = Math.max(1, Math.floor(dmg));
+ const dealt = Math.min(amount, victim.health);
+ victim.health -= amount;
+ victim.lastDamageTime = world.time;
+ victim.lastAttacker = attacker;
+ victim.lastDamageFrom.copy(dir).negate();
+ if (attacker && attacker.team !== victim.team) attacker.damageDealt += dealt;
+ world.events.emit('damage', {
+ attacker,
+ victim,
+ amount,
+ armorDamage,
+ group,
+ point: point.clone(),
+ dir: dir.clone(),
+ source,
+ });
+ if (victim.brain && attacker) victim.brain.onDamaged(attacker);
+ if (victim.health <= 0) {
+ victim.health = 0;
+ world.killActor(victim, attacker, source, group === 'head', dir);
+ }
+}
+
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Events.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Events.ts
new file mode 100644
index 0000000..6c91566
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Events.ts
@@ -0,0 +1,54 @@
+import type * as THREE from 'three';
+import type { HitGroup, RoundEndReason, SiteId, SurfaceMaterial, Team } from '../core/types';
+import type { WeaponId } from '../weapons/WeaponDefs';
+import type { Actor } from './Actor';
+
+export type DamageSource = WeaponId | 'c4' | 'fall';
+
+/** Every gameplay event the simulation can emit. Consumers: audio, effects, HUD, AI, stats. */
+export interface GameEvents {
+ shot: {
+ actor: Actor;
+ weapon: WeaponId;
+ origin: THREE.Vector3;
+ end: THREE.Vector3;
+ hitActor: boolean;
+ };
+ impact: { point: THREE.Vector3; normal: THREE.Vector3; material: SurfaceMaterial };
+ damage: {
+ attacker: Actor | null;
+ victim: Actor;
+ amount: number;
+ armorDamage: number;
+ group: HitGroup | null;
+ point: THREE.Vector3;
+ dir: THREE.Vector3;
+ source: DamageSource;
+ };
+ kill: { killer: Actor | null; victim: Actor; weapon: DamageSource; headshot: boolean };
+ reload: { actor: Actor; weapon: WeaponId };
+ draw: { actor: Actor; weapon: WeaponId };
+ dryFire: { actor: Actor; weapon: WeaponId };
+ scope: { actor: Actor; level: number };
+ footstep: { actor: Actor; pos: THREE.Vector3; loud: number };
+ jump: { actor: Actor };
+ land: { actor: Actor; speed: number };
+ knife: { actor: Actor; alt: boolean; hit: 'none' | 'wall' | 'flesh' };
+ pickup: { actor: Actor; weapon: WeaponId };
+ weaponDrop: { actor: Actor; weapon: WeaponId };
+ bombPickup: { actor: Actor };
+ bombDrop: { actor: Actor; pos: THREE.Vector3 };
+ plantStart: { actor: Actor };
+ plantAbort: { actor: Actor };
+ bombPlanted: { actor: Actor; site: SiteId; pos: THREE.Vector3 };
+ bombBeep: { pos: THREE.Vector3; urgency: number };
+ defuseStart: { actor: Actor; kit: boolean };
+ defuseAbort: { actor: Actor };
+ bombDefused: { actor: Actor };
+ bombExploded: { pos: THREE.Vector3 };
+ roundStart: { round: number; pistol: boolean };
+ roundLive: { round: number };
+ roundEnd: { winner: Team; reason: RoundEndReason };
+ matchEnd: { winner: Team };
+ message: { text: string; team?: Team; duration?: number };
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Movement.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Movement.ts
new file mode 100644
index 0000000..dc0e9e2
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/Movement.ts
@@ -0,0 +1,108 @@
+import { PHYS } from '../core/config';
+import { lerp, moveTowards } from '../core/math';
+import type { Actor } from './Actor';
+import type { World } from './World';
+
+/**
+ * Source-style ground/air movement: friction, acceleration towards a wish velocity, air strafing,
+ * crouch with head-room checks, jump on fresh press, footstep emission. Collision is resolved by
+ * CollisionWorld.moveHull.
+ */
+export function updateMovement(world: World, a: Actor, dt: number, frozen: boolean): void {
+ const inp = a.input;
+
+ // ---- crouch
+ const wantCrouch = inp.crouch && !frozen;
+ let target = wantCrouch ? 1 : 0;
+ if (target < a.crouchAmt) {
+ const r = a.radius - 0.01;
+ const blocked = world.collision.overlapsBox(
+ a.pos.x - r,
+ a.pos.y + a.height,
+ a.pos.z - r,
+ a.pos.x + r,
+ a.pos.y + PHYS.height,
+ a.pos.z + r,
+ );
+ if (blocked) target = a.crouchAmt;
+ }
+ a.crouchAmt = moveTowards(a.crouchAmt, target, dt * 6);
+ a.height = lerp(PHYS.height, PHYS.crouchHeight, a.crouchAmt);
+
+ // ---- wish direction
+ let wx = 0;
+ let wz = 0;
+ if (!frozen && !a.rooted) {
+ const sy = Math.sin(a.yaw);
+ const cy = Math.cos(a.yaw);
+ wx = -sy * inp.forward + cy * inp.right;
+ wz = -cy * inp.forward - sy * inp.right;
+ }
+ let wishLen = Math.hypot(wx, wz);
+ if (wishLen > 1e-4) {
+ wx /= wishLen;
+ wz /= wishLen;
+ }
+ wishLen = Math.min(1, wishLen);
+ const wishSpeed = a.maxSpeed() * wishLen;
+
+ // ---- jump (fresh press only, no auto bunny hop)
+ if (inp.jump && !a.jumpHeld && a.onGround && !frozen && !a.rooted) {
+ a.vel.y = PHYS.jumpSpeed;
+ a.onGround = false;
+ world.events.emit('jump', { actor: a });
+ }
+ a.jumpHeld = inp.jump;
+
+ if (a.onGround) {
+ const speed = Math.hypot(a.vel.x, a.vel.z);
+ if (speed > 1e-4) {
+ const control = Math.max(speed, PHYS.stopSpeed);
+ const drop = control * PHYS.friction * dt * (a.rooted ? 3 : 1);
+ const ns = Math.max(0, speed - drop) / speed;
+ a.vel.x *= ns;
+ a.vel.z *= ns;
+ }
+ accelerate(a, wx, wz, wishSpeed, PHYS.accelerate, dt);
+ } else {
+ const capped = Math.min(wishSpeed, PHYS.airWishCap);
+ const current = a.vel.x * wx + a.vel.z * wz;
+ const add = capped - current;
+ if (add > 0) {
+ const acc = Math.min(PHYS.airAccelerate * wishSpeed * dt, add);
+ a.vel.x += acc * wx;
+ a.vel.z += acc * wz;
+ }
+ a.vel.y -= PHYS.gravity * dt;
+ }
+
+ // ---- integrate against geometry
+ const landSpeed = world.collision.moveHull(a, dt);
+ if (landSpeed > 2.5) world.events.emit('land', { actor: a, speed: landSpeed });
+
+ // ---- footsteps (running only; walking/crouching is silent like CS)
+ if (a.onGround) {
+ const hs = Math.hypot(a.vel.x, a.vel.z);
+ if (hs > 3.4 && a.crouchAmt < 0.5) {
+ a.stepAccum += hs * dt;
+ if (a.stepAccum > 2.05) {
+ a.stepAccum = 0;
+ world.footstep(a);
+ }
+ } else if (a.stepAccum > 1.4) {
+ a.stepAccum = 1.4;
+ }
+ }
+
+ if (a.pos.y < -25) world.killActor(a, null, 'fall', false, a.vel);
+}
+
+function accelerate(a: Actor, wx: number, wz: number, wishSpeed: number, accel: number, dt: number): void {
+ if (wishSpeed <= 0) return;
+ const current = a.vel.x * wx + a.vel.z * wz;
+ const add = wishSpeed - current;
+ if (add <= 0) return;
+ const acc = Math.min(accel * wishSpeed * dt, add);
+ a.vel.x += acc * wx;
+ a.vel.z += acc * wz;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/World.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/World.ts
new file mode 100644
index 0000000..185c3a6
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/World.ts
@@ -0,0 +1,846 @@
+import * as THREE from 'three';
+import { BotBrain } from '../ai/BotBrain';
+import { TeamAI } from '../ai/TeamAI';
+import { PHYS, RULES } from '../core/config';
+import { EventBus } from '../core/EventBus';
+import { dirFromAngles, yawFromDir } from '../core/math';
+import { Random } from '../core/Random';
+import { otherTeam, type MatchSettings, type RoundEndReason, type SiteId, type Team } from '../core/types';
+import { MASK_MOVE } from '../world/Collision';
+import { Level } from '../world/Level';
+import { DUST2 } from '../world/MapData';
+import {
+ BUYABLE_PRIMARY,
+ BUYABLE_SECONDARY,
+ DEFAULT_PISTOL,
+ DEFAULT_RIFLE,
+ WEAPONS,
+ WeaponInstance,
+ type WeaponId,
+ type WeaponSlot,
+} from '../weapons/WeaponDefs';
+import { Actor } from './Actor';
+import { applyDamage, bestSlot, switchSlot, updateWeapons } from './Combat';
+import type { DamageSource, GameEvents } from './Events';
+import { updateMovement } from './Movement';
+
+export type RoundPhase = 'freeze' | 'live' | 'post' | 'over';
+export type BombState = 'none' | 'carried' | 'dropped' | 'planted' | 'defused' | 'exploded';
+
+export interface DroppedItem {
+ id: number;
+ inst: WeaponInstance;
+ pos: THREE.Vector3;
+ vel: THREE.Vector3;
+ yaw: number;
+ resting: boolean;
+ time: number;
+}
+
+export interface BombInfo {
+ state: BombState;
+ carrier: Actor | null;
+ pos: THREE.Vector3;
+ vel: THREE.Vector3;
+ resting: boolean;
+ site: SiteId | null;
+ timeLeft: number;
+ defuser: Actor | null;
+ nextBeep: number;
+ plantedBy: Actor | null;
+}
+
+export interface IntelEntry {
+ x: number;
+ y: number;
+ z: number;
+ time: number;
+}
+
+export interface WorldOptions {
+ /** If false every slot is a bot (headless soak tests / attract mode). */
+ humanControlled: boolean;
+ seed?: number;
+}
+
+const BOT_NAMES: Record = {
+ CT: ['Hawk', 'Viper', 'Falcon', 'Shield', 'Ranger', 'Bishop'],
+ T: ['Cobra', 'Jackal', 'Rook', 'Ghost', 'Mamba', 'Scorpion'],
+};
+
+const _v = new THREE.Vector3();
+const _eye = new THREE.Vector3();
+const _head = new THREE.Vector3();
+
+/**
+ * The authoritative game state and fixed-step simulation ("global state engine"):
+ * actors, weapons, bomb, rounds, spotting/intel and AI. Rendering, audio and UI only observe it
+ * through its public state and the event bus.
+ */
+export class World {
+ readonly events = new EventBus();
+ readonly level: Level;
+ readonly rng: Random;
+ readonly actors: Actor[] = [];
+ readonly settings: MatchSettings;
+ readonly options: WorldOptions;
+ time = 0;
+
+ // ---- round state
+ phase: RoundPhase = 'freeze';
+ roundNumber = 0;
+ readonly score: Record = { CT: 0, T: 0 };
+ phaseEndsAt = 0;
+ roundEndsAt = 0;
+ isPistolRound = false;
+ lastWinner: Team | null = null;
+ lastReason: RoundEndReason | null = null;
+ matchWinner: Team | null = null;
+ readonly history: { winner: Team; reason: RoundEndReason }[] = [];
+
+ // ---- objects
+ readonly bomb: BombInfo = {
+ state: 'none',
+ carrier: null,
+ pos: new THREE.Vector3(),
+ vel: new THREE.Vector3(),
+ resting: true,
+ site: null,
+ timeLeft: 0,
+ defuser: null,
+ nextBeep: 0,
+ plantedBy: null,
+ };
+ readonly dropped: DroppedItem[] = [];
+ private nextItemId = 1;
+
+ // ---- control
+ readonly human: Actor;
+ /** Actor currently driven by the human (null while dead/spectating). */
+ controlled: Actor | null = null;
+ readonly humanLoadout: { primary: WeaponId | null; secondary: WeaponId | null } = { primary: null, secondary: null };
+
+ // ---- AI & perception
+ readonly teamAI: Record;
+ /** vis[observer.id][target.id] — refreshed every 100 ms. */
+ readonly vis: boolean[][];
+ readonly intel: Record> = { CT: new Map(), T: new Map() };
+ private nextVisibility = 0;
+
+ constructor(settings: MatchSettings, options: WorldOptions = { humanControlled: true }) {
+ this.settings = settings;
+ this.options = options;
+ this.rng = new Random(options.seed ?? (Date.now() & 0xffffffff));
+ this.level = new Level(DUST2);
+
+ let id = 0;
+ let human: Actor | null = null;
+ for (const team of ['CT', 'T'] as Team[]) {
+ const names = this.rng.shuffle([...BOT_NAMES[team]]);
+ for (let i = 0; i < 5; i++) {
+ const isHuman = team === settings.playerTeam && i === 0;
+ const a = new Actor(id++, isHuman ? settings.playerName || '玩家' : names[i], team, isHuman);
+ a.brain = new BotBrain(this, a, settings.difficulty);
+ this.actors.push(a);
+ if (isHuman) human = a;
+ }
+ }
+ this.human = human!;
+ this.vis = this.actors.map(() => this.actors.map(() => false));
+ this.teamAI = { CT: new TeamAI(this, 'CT'), T: new TeamAI(this, 'T') };
+ this.startRound();
+ }
+
+ get collision() {
+ return this.level.collision;
+ }
+
+ get nav() {
+ return this.level.nav;
+ }
+
+ // ================================================================== main tick
+
+ tick(dt: number): void {
+ this.time += dt;
+ this.updateRoundPhase();
+ if (this.phase === 'over') return;
+ const frozen = this.phase === 'freeze';
+
+ for (const a of this.actors) {
+ a.prevPos.copy(a.pos);
+ a.prevYaw = a.yaw;
+ a.prevPitch = a.pitch;
+ }
+
+ this.teamAI.CT.update();
+ this.teamAI.T.update();
+ for (const a of this.actors) {
+ if (a.alive && a.isBot && a.brain) a.brain.update(dt);
+ }
+
+ for (const a of this.actors) {
+ if (!a.alive) continue;
+ this.updateItemsFor(a);
+ updateWeapons(this, a, dt, !frozen);
+ updateMovement(this, a, dt, frozen);
+ const inp = a.input;
+ inp.firePressed = false;
+ inp.altPressed = false;
+ inp.reload = false;
+ inp.usePressed = false;
+ inp.slot = null;
+ inp.lastWeapon = false;
+ inp.drop = false;
+ }
+
+ this.separateActors();
+ this.updateBomb(dt);
+ this.updateDropped(dt);
+
+ if (this.time >= this.nextVisibility) {
+ this.nextVisibility = this.time + 0.1;
+ this.updateVisibility();
+ }
+
+ for (const a of this.actors) {
+ a.model.advance(dt, Math.hypot(a.vel.x, a.vel.z), a.onGround);
+ this.poseActor(a, a.pos, a.yaw, a.pitch);
+ a.model.updateHitboxes();
+ }
+
+ this.checkRoundEnd();
+ }
+
+ /** Apply the character pose for an arbitrary (possibly interpolated) transform. */
+ poseActor(a: Actor, pos: THREE.Vector3, yaw: number, pitch: number): void {
+ const reload = a.reloading ? (this.time - a.reloadStart) / Math.max(0.01, a.reloadEnd - a.reloadStart) : -1;
+ a.model.setWeapon(a.alive ? a.weaponId : null);
+ a.model.pose({
+ x: pos.x,
+ y: pos.y,
+ z: pos.z,
+ yaw,
+ pitch,
+ vx: a.vel.x,
+ vz: a.vel.z,
+ onGround: a.onGround,
+ crouch: a.crouchAmt,
+ alive: a.alive,
+ deadFor: this.time - a.deathTime,
+ fallDir: a.fallDir,
+ fallSide: a.fallSide,
+ reload,
+ kick: a.kick,
+ busy: a.planting || a.defusing,
+ });
+ }
+
+ // ================================================================== rounds
+
+ startRound(): void {
+ this.roundNumber++;
+ this.isPistolRound = this.roundNumber === 1 && this.settings.firstRound === 'pistol';
+ this.dropped.length = 0;
+ const b = this.bomb;
+ b.state = 'none';
+ b.carrier = null;
+ b.site = null;
+ b.defuser = null;
+ b.plantedBy = null;
+ b.timeLeft = 0;
+ this.intel.CT.clear();
+ this.intel.T.clear();
+
+ const def = this.level.def;
+ const spawns: Record = {
+ CT: this.rng.shuffle([...def.spawns.CT]),
+ T: this.rng.shuffle([...def.spawns.T]),
+ };
+ const counters: Record = { CT: 0, T: 0 };
+ const sniper: Record = { CT: null, T: null };
+ if (!this.isPistolRound) {
+ for (const team of ['CT', 'T'] as Team[]) {
+ if (this.rng.chance(0.65)) {
+ const bots = this.actors.filter((a) => a.team === team && !a.isHumanSlot);
+ sniper[team] = this.rng.pick(bots);
+ }
+ }
+ }
+
+ for (const a of this.actors) {
+ a.resetForRound();
+ const [sx, sz] = spawns[a.team][counters[a.team]++ % spawns[a.team].length];
+ const gy = this.level.groundAt(sx, sz);
+ a.pos.set(sx, gy, sz);
+ a.prevPos.copy(a.pos);
+ const look = def.spawnLook[a.team];
+ a.yaw = yawFromDir(look[0] - sx, look[1] - sz);
+ a.pitch = 0;
+ a.prevYaw = a.yaw;
+ a.prevPitch = 0;
+ a.isBot = !(a.isHumanSlot && this.options.humanControlled);
+ this.giveLoadout(a, sniper[a.team] === a);
+ a.brain?.resetForRound();
+ }
+ this.controlled = this.options.humanControlled ? this.human : null;
+
+ const ts = this.actors.filter((a) => a.team === 'T');
+ const carrier = this.rng.pick(ts);
+ carrier.give('c4');
+ b.state = 'carried';
+ b.carrier = carrier;
+ b.pos.copy(carrier.pos);
+
+ this.phase = 'freeze';
+ this.phaseEndsAt = this.time + RULES.freezeTime;
+ this.roundEndsAt = this.phaseEndsAt + RULES.roundTime;
+ this.teamAI.CT.onRoundStart();
+ this.teamAI.T.onRoundStart();
+ for (const a of this.actors) {
+ a.model.advance(0, 0, true);
+ this.poseActor(a, a.pos, a.yaw, a.pitch);
+ a.model.updateHitboxes();
+ }
+ this.events.emit('roundStart', { round: this.roundNumber, pistol: this.isPistolRound });
+ }
+
+ private giveLoadout(a: Actor, awp: boolean): void {
+ a.give('knife');
+ const human = a.isHumanSlot && this.options.humanControlled;
+ let pistol = DEFAULT_PISTOL[a.team];
+ if (human && this.humanLoadout.secondary && !this.isPistolRound) pistol = this.humanLoadout.secondary;
+ a.give(pistol);
+ if (this.isPistolRound) {
+ const kevlar = this.settings.pistolArmor === 'kevlar';
+ a.armor = kevlar ? 100 : 0;
+ a.helmet = false;
+ a.hasKit = false;
+ } else {
+ a.armor = 100;
+ a.helmet = true;
+ a.hasKit = a.team === 'CT';
+ let primary: WeaponId = DEFAULT_RIFLE[a.team];
+ if (human && this.humanLoadout.primary) primary = this.humanLoadout.primary;
+ else if (awp) primary = 'awp';
+ a.give(primary);
+ }
+ a.slot = bestSlot(a);
+ a.lastSlot = a.weapons[1] && a.slot !== 1 ? 1 : 2;
+ a.drawEnd = this.time + 0.3;
+ }
+
+ /** Free buy menu (no economy): only during freeze time / first 20 s, pistols only in a pistol round. */
+ canBuy(): boolean {
+ return this.phase === 'freeze' || (this.phase === 'live' && this.time - this.phaseEndsAt < 20 && this.roundEndsAt - this.time > RULES.roundTime - 20);
+ }
+
+ buy(a: Actor, id: WeaponId): boolean {
+ if (!a.alive || !this.canBuy()) return false;
+ const def = WEAPONS[id];
+ if (def.slot === 0 && (this.isPistolRound || !BUYABLE_PRIMARY.includes(id))) return false;
+ if (def.slot === 1 && !BUYABLE_SECONDARY.includes(id)) return false;
+ if (def.slot > 1) return false;
+ a.weapons[def.slot] = new WeaponInstance(def);
+ if (a.isHumanSlot) {
+ if (def.slot === 0) this.humanLoadout.primary = id;
+ else this.humanLoadout.secondary = id;
+ }
+ a.slot = 2 as WeaponSlot;
+ switchSlot(this, a, def.slot);
+ return true;
+ }
+
+ buyArmor(a: Actor): boolean {
+ if (!a.alive || !this.canBuy()) return false;
+ a.armor = 100;
+ if (!this.isPistolRound) a.helmet = true;
+ return true;
+ }
+
+ private updateRoundPhase(): void {
+ if (this.phase === 'freeze' && this.time >= this.phaseEndsAt) {
+ this.phase = 'live';
+ this.events.emit('roundLive', { round: this.roundNumber });
+ } else if (this.phase === 'post' && this.time >= this.phaseEndsAt) {
+ if (this.matchWinner) {
+ this.phase = 'over';
+ this.events.emit('matchEnd', { winner: this.matchWinner });
+ } else {
+ this.startRound();
+ }
+ }
+ }
+
+ private checkRoundEnd(): void {
+ if (this.phase !== 'live') return;
+ let tAlive = 0;
+ let ctAlive = 0;
+ for (const a of this.actors) {
+ if (!a.alive) continue;
+ if (a.team === 'T') tAlive++;
+ else ctAlive++;
+ }
+ const planted = this.bomb.state === 'planted';
+ if (ctAlive === 0) this.endRound('T', 'ct_eliminated');
+ else if (!planted && tAlive === 0) this.endRound('CT', 't_eliminated');
+ else if (!planted && this.time >= this.roundEndsAt) this.endRound('CT', 'time_expired');
+ }
+
+ endRound(winner: Team, reason: RoundEndReason): void {
+ if (this.phase !== 'live') return;
+ this.phase = 'post';
+ this.phaseEndsAt = this.time + RULES.postRoundTime;
+ this.score[winner]++;
+ this.lastWinner = winner;
+ this.lastReason = reason;
+ this.history.push({ winner, reason });
+ if (this.score[winner] >= this.settings.roundsToWin) this.matchWinner = winner;
+ this.events.emit('roundEnd', { winner, reason });
+ }
+
+ /** Seconds left on the round clock (or bomb timer once planted). */
+ get clock(): number {
+ if (this.phase === 'freeze') return Math.max(0, this.phaseEndsAt - this.time);
+ if (this.phase === 'live') {
+ if (this.bomb.state === 'planted') return Math.max(0, this.bomb.timeLeft);
+ return Math.max(0, this.roundEndsAt - this.time);
+ }
+ if (this.phase === 'post') return Math.max(0, this.phaseEndsAt - this.time);
+ return 0;
+ }
+
+ /** Round seconds elapsed since freeze time ended. */
+ get roundElapsed(): number {
+ return this.phase === 'freeze' ? 0 : this.time - (this.roundEndsAt - RULES.roundTime);
+ }
+
+ /** Round clock seconds remaining (ignores the bomb). */
+ get roundTimeLeft(): number {
+ return Math.max(0, this.roundEndsAt - this.time);
+ }
+
+ aliveCount(team: Team): number {
+ let n = 0;
+ for (const a of this.actors) if (a.alive && a.team === team) n++;
+ return n;
+ }
+
+ // ================================================================== deaths & items
+
+ killActor(victim: Actor, killer: Actor | null, source: DamageSource, headshot: boolean, dir?: THREE.Vector3): void {
+ if (!victim.alive) return;
+ victim.alive = false;
+ victim.health = 0;
+ victim.deathTime = this.time;
+ if (victim.planting) this.abortPlant(victim);
+ if (this.bomb.defuser === victim) this.abortDefuse(victim);
+ victim.reloading = false;
+ victim.scopeLevel = 0;
+ // fall away from the shot
+ if (dir) {
+ const f = dirFromAngles(victim.yaw, 0, _v);
+ victim.fallDir = f.x * dir.x + f.z * dir.z < 0 ? 1 : -1;
+ } else victim.fallDir = 1;
+ victim.fallSide = this.rng.range(-1, 1);
+
+ // drop best gun and the bomb
+ if (victim.weapons[0]) this.dropWeapon(victim, 0, false);
+ else if (victim.weapons[1]) this.dropWeapon(victim, 1, false);
+ if (victim.weapons[3]) this.dropWeapon(victim, 3, false);
+
+ victim.deaths++;
+ if (killer && killer !== victim && killer.team !== victim.team) {
+ killer.kills++;
+ killer.roundKills++;
+ if (headshot) killer.headshots++;
+ }
+ this.events.emit('kill', { killer, victim, weapon: source, headshot });
+ if (this.controlled === victim) this.controlled = null;
+ }
+
+ dropWeapon(a: Actor, slot: WeaponSlot, thrown: boolean): void {
+ const inst = a.weapons[slot];
+ if (!inst || slot === 2) return;
+ a.weapons[slot] = null;
+ a.eyePos(_eye);
+ const vel = new THREE.Vector3();
+ if (thrown) dirFromAngles(a.yaw, Math.max(a.pitch, -0.2) + 0.25, vel).multiplyScalar(4.5);
+ else vel.set(this.rng.range(-0.6, 0.6), 1, this.rng.range(-0.6, 0.6));
+ vel.x += a.vel.x * 0.5;
+ vel.z += a.vel.z * 0.5;
+ const start = new THREE.Vector3(a.pos.x, a.pos.y + (a.alive ? 1.3 : 0.6), a.pos.z);
+ if (slot === 3) {
+ const b = this.bomb;
+ b.state = 'dropped';
+ b.carrier = null;
+ b.pos.copy(start);
+ b.vel.copy(vel);
+ b.resting = false;
+ if (a.planting) this.abortPlant(a);
+ this.events.emit('bombDrop', { actor: a, pos: b.pos.clone() });
+ } else {
+ this.dropped.push({ id: this.nextItemId++, inst, pos: start, vel, yaw: a.yaw + Math.PI / 2, resting: false, time: this.time });
+ this.events.emit('weaponDrop', { actor: a, weapon: inst.def.id });
+ }
+ if (a.alive && a.slot === slot) {
+ a.slot = bestSlot(a);
+ a.drawEnd = this.time + a.weapons[a.slot]!.def.drawTime;
+ a.reloading = false;
+ a.scopeLevel = 0;
+ this.events.emit('draw', { actor: a, weapon: a.weapons[a.slot]!.def.id });
+ }
+ }
+
+ private updateItemsFor(a: Actor): void {
+ const inp = a.input;
+ if (inp.drop && a.slot !== 2) this.dropWeapon(a, a.slot, true);
+
+ // auto-pickup of guns into empty slots, E to swap the looked-at gun
+ for (let i = this.dropped.length - 1; i >= 0; i--) {
+ const it = this.dropped[i];
+ if (this.time - it.time < 0.6) continue;
+ const dx = it.pos.x - a.pos.x;
+ const dz = it.pos.z - a.pos.z;
+ const dy = it.pos.y - a.pos.y;
+ const d2 = dx * dx + dz * dz;
+ if (dy < -0.5 || dy > 1.8) continue;
+ const slot = it.inst.def.slot;
+ if (d2 < 1.0 && !a.weapons[slot]) {
+ this.pickup(a, i);
+ } else if (inp.usePressed && !a.isBot && d2 < 2.6 * 2.6) {
+ const f = dirFromAngles(a.yaw, 0, _v);
+ const len = Math.sqrt(d2) || 1;
+ if ((f.x * dx + f.z * dz) / len > 0.6) {
+ this.dropWeapon(a, slot, true);
+ this.pickup(a, i);
+ inp.usePressed = false;
+ }
+ }
+ }
+ }
+
+ private pickup(a: Actor, index: number): void {
+ const it = this.dropped[index];
+ this.dropped.splice(index, 1);
+ const slot = it.inst.def.slot;
+ a.weapons[slot] = it.inst;
+ this.events.emit('pickup', { actor: a, weapon: it.inst.def.id });
+ if (slot === 0 || (slot === 1 && !a.weapons[0])) {
+ if (a.slot !== slot) switchSlot(this, a, slot);
+ }
+ }
+
+ private updateDropped(dt: number): void {
+ for (const it of this.dropped) this.itemPhysics(it.pos, it.vel, dt, it);
+ if (this.bomb.state === 'dropped') {
+ this.itemPhysics(this.bomb.pos, this.bomb.vel, dt, this.bomb);
+ }
+ }
+
+ private itemPhysics(pos: THREE.Vector3, vel: THREE.Vector3, dt: number, holder: { resting: boolean }): void {
+ if (holder.resting) return;
+ vel.y -= PHYS.gravity * dt;
+ const nx = pos.x + vel.x * dt;
+ const nz = pos.z + vel.z * dt;
+ if (this.collision.overlapsBox(nx - 0.12, pos.y + 0.05, nz - 0.12, nx + 0.12, pos.y + 0.3, nz + 0.12, MASK_MOVE)) {
+ vel.x *= -0.25;
+ vel.z *= -0.25;
+ } else {
+ pos.x = nx;
+ pos.z = nz;
+ }
+ const ground = this.collision.groundHeight(pos.x, pos.z, pos.y + 0.3, 0.12);
+ pos.y += vel.y * dt;
+ if (pos.y <= ground) {
+ pos.y = ground;
+ if (Math.abs(vel.y) < 2) {
+ vel.set(0, 0, 0);
+ holder.resting = true;
+ } else {
+ vel.y *= -0.25;
+ vel.x *= 0.5;
+ vel.z *= 0.5;
+ }
+ }
+ if (pos.y < -30) {
+ pos.y = 0;
+ holder.resting = true;
+ }
+ }
+
+ // ================================================================== bomb
+
+ private updateBomb(dt: number): void {
+ const b = this.bomb;
+ if (b.state === 'carried' && b.carrier) {
+ b.pos.copy(b.carrier.pos);
+ this.handlePlanting(b.carrier, dt);
+ } else if (b.state === 'dropped') {
+ for (const a of this.actors) {
+ if (!a.alive || a.team !== 'T') continue;
+ const dx = a.pos.x - b.pos.x;
+ const dz = a.pos.z - b.pos.z;
+ const dy = b.pos.y - a.pos.y;
+ if (dx * dx + dz * dz < RULES.pickupDistance * RULES.pickupDistance && dy > -0.6 && dy < 1.6) {
+ a.give('c4');
+ b.state = 'carried';
+ b.carrier = a;
+ this.events.emit('bombPickup', { actor: a });
+ break;
+ }
+ }
+ } else if (b.state === 'planted') {
+ b.timeLeft -= dt;
+ if (this.time >= b.nextBeep) {
+ const frac = Math.max(0, b.timeLeft / RULES.bombTimer);
+ const interval = Math.max(0.1, 0.1 + 0.95 * Math.pow(frac, 1.4));
+ b.nextBeep = this.time + interval;
+ this.events.emit('bombBeep', { pos: b.pos, urgency: 1 - frac });
+ }
+ this.handleDefuse(dt);
+ if (b.timeLeft <= 0 && b.state === 'planted') this.explode();
+ }
+ }
+
+ private handlePlanting(c: Actor, dt: number): void {
+ const inp = c.input;
+ const wants = c.alive && this.phase === 'live' && (inp.use || (c.slot === 3 && inp.fire));
+ const site = this.level.bombsiteAt(c.pos.x, c.pos.z);
+ if (wants && site && c.onGround) {
+ if (!c.planting) {
+ if (c.slot !== 3) switchSlot(this, c, 3);
+ c.planting = true;
+ c.plantProgress = 0;
+ this.events.emit('plantStart', { actor: c });
+ }
+ c.plantProgress += dt;
+ if (c.plantProgress >= RULES.plantTime) this.plant(c, site.id);
+ } else if (c.planting) {
+ this.abortPlant(c);
+ }
+ }
+
+ abortPlant(a: Actor): void {
+ if (!a.planting) return;
+ a.planting = false;
+ a.plantProgress = 0;
+ this.events.emit('plantAbort', { actor: a });
+ }
+
+ private plant(c: Actor, site: SiteId): void {
+ const b = this.bomb;
+ c.planting = false;
+ c.plantProgress = 0;
+ c.weapons[3] = null;
+ if (c.slot === 3) {
+ c.slot = bestSlot(c);
+ c.drawEnd = this.time + 0.5;
+ this.events.emit('draw', { actor: c, weapon: c.weapons[c.slot]!.def.id });
+ }
+ b.state = 'planted';
+ b.site = site;
+ b.carrier = null;
+ b.plantedBy = c;
+ const f = dirFromAngles(c.yaw, 0, _v);
+ b.pos.set(c.pos.x + f.x * 0.45, c.pos.y, c.pos.z + f.z * 0.45);
+ b.pos.y = this.level.groundAt(b.pos.x, b.pos.z, c.pos.y + 0.5);
+ if (!Number.isFinite(b.pos.y)) b.pos.y = c.pos.y;
+ b.timeLeft = RULES.bombTimer;
+ b.nextBeep = this.time + 1;
+ this.events.emit('bombPlanted', { actor: c, site, pos: b.pos.clone() });
+ }
+
+ private defuseDuration(a: Actor): number {
+ return a.hasKit ? RULES.defuseKitTime : RULES.defuseTime;
+ }
+
+ private handleDefuse(dt: number): void {
+ const b = this.bomb;
+ const d = b.defuser;
+ if (d) {
+ if (!d.alive || !d.input.use || !this.inBombReach(d)) {
+ this.abortDefuse(d);
+ } else {
+ d.defuseProgress += dt;
+ if (d.defuseProgress >= this.defuseDuration(d)) {
+ d.defusing = false;
+ b.state = 'defused';
+ b.defuser = null;
+ this.events.emit('bombDefused', { actor: d });
+ this.endRound('CT', 'bomb_defused');
+ }
+ }
+ return;
+ }
+ for (const a of this.actors) {
+ if (!a.alive || a.team !== 'CT' || !a.input.use || !a.onGround) continue;
+ if (!this.inBombReach(a)) continue;
+ b.defuser = a;
+ a.defusing = true;
+ a.defuseProgress = 0;
+ a.reloading = false;
+ a.scopeLevel = 0;
+ this.events.emit('defuseStart', { actor: a, kit: a.hasKit });
+ break;
+ }
+ }
+
+ inBombReach(a: Actor): boolean {
+ const b = this.bomb;
+ const dx = a.pos.x - b.pos.x;
+ const dz = a.pos.z - b.pos.z;
+ const dy = b.pos.y - a.pos.y;
+ return dx * dx + dz * dz <= RULES.useDistance * RULES.useDistance && dy > -1.2 && dy < 1.2;
+ }
+
+ private abortDefuse(a: Actor): void {
+ a.defusing = false;
+ a.defuseProgress = 0;
+ if (this.bomb.defuser === a) this.bomb.defuser = null;
+ this.events.emit('defuseAbort', { actor: a });
+ }
+
+ private explode(): void {
+ const b = this.bomb;
+ b.state = 'exploded';
+ if (b.defuser) this.abortDefuse(b.defuser);
+ this.events.emit('bombExploded', { pos: b.pos.clone() });
+ const sigma = RULES.bombSigma;
+ for (const a of this.actors) {
+ if (!a.alive) continue;
+ _v.set(a.pos.x, a.pos.y + 1, a.pos.z).sub(b.pos);
+ const d = _v.length();
+ const dmg = RULES.bombMaxDamage * Math.exp(-(d * d) / (2 * sigma * sigma));
+ if (dmg < 1) continue;
+ const dir = _v.clone().normalize();
+ applyDamage(this, a, null, dmg, null, null, a.pos, dir, 'c4');
+ }
+ this.endRound('T', 'bomb_exploded');
+ }
+
+ // ================================================================== perception
+
+ private updateVisibility(): void {
+ const n = this.actors.length;
+ for (let i = 0; i < n; i++) {
+ const o = this.actors[i];
+ for (let j = 0; j < n; j++) this.vis[i][j] = false;
+ if (!o.alive) continue;
+ const humanView = !o.isBot;
+ const fovHalf = humanView ? 1.0 : o.brain ? o.brain.fovHalf : 1.05;
+ o.eyePos(_eye);
+ for (let j = 0; j < n; j++) {
+ const t = this.actors[j];
+ if (!t.alive || t.team === o.team) continue;
+ if (this.canSee(o, t, _eye, fovHalf)) {
+ this.vis[i][j] = true;
+ t.spottedUntil[o.team] = this.time + 0.4;
+ const e = this.intel[o.team].get(t.id);
+ if (e) {
+ e.x = t.pos.x;
+ e.y = t.pos.y;
+ e.z = t.pos.z;
+ e.time = this.time;
+ } else this.intel[o.team].set(t.id, { x: t.pos.x, y: t.pos.y, z: t.pos.z, time: this.time });
+ }
+ }
+ }
+ for (const team of ['CT', 'T'] as Team[]) {
+ for (const [id] of this.intel[team]) {
+ const a = this.actors[id];
+ if (!a.alive) this.intel[team].delete(id);
+ }
+ }
+ }
+
+ canSee(o: Actor, t: Actor, eye: THREE.Vector3, fovHalf: number): boolean {
+ const dx = t.pos.x - eye.x;
+ const dz = t.pos.z - eye.z;
+ const d2 = dx * dx + dz * dz;
+ if (d2 > 130 * 130) return false;
+ if (d2 > 2.5 * 2.5) {
+ const yawTo = yawFromDir(dx, dz);
+ let diff = Math.abs(yawTo - o.yaw) % (Math.PI * 2);
+ if (diff > Math.PI) diff = Math.PI * 2 - diff;
+ if (diff > fovHalf) return false;
+ }
+ const col = this.collision;
+ t.model.headWorld(_head);
+ if (col.lineClear(eye.x, eye.y, eye.z, _head.x, _head.y, _head.z)) return true;
+ const chestY = t.pos.y + 1.2 - t.crouchAmt * 0.35;
+ if (col.lineClear(eye.x, eye.y, eye.z, t.pos.x, chestY, t.pos.z)) return true;
+ return col.lineClear(eye.x, eye.y, eye.z, t.pos.x, t.pos.y + 0.45, t.pos.z);
+ }
+
+ /** Visible to observer as of the last perception pass. */
+ sees(o: Actor, t: Actor): boolean {
+ return this.vis[o.id][t.id];
+ }
+
+ /** Is this enemy currently shown on `team`'s radar. */
+ isSpottedBy(a: Actor, team: Team): boolean {
+ return a.alive && a.spottedUntil[team] > this.time;
+ }
+
+ footstep(a: Actor): void {
+ const p = a.pos.clone();
+ this.events.emit('footstep', { actor: a, pos: p, loud: 1 });
+ this.noise(a, p, 18);
+ }
+
+ /** Propagate an audible event to enemy bots in range. */
+ noise(source: Actor, pos: THREE.Vector3, radius: number): void {
+ const r2 = radius * radius;
+ for (const a of this.actors) {
+ if (!a.alive || !a.brain || !a.isBot || a.team === source.team) continue;
+ if (a.pos.distanceToSquared(pos) <= r2) a.brain.hear(source, pos);
+ }
+ }
+
+ // ================================================================== misc
+
+ private separateActors(): void {
+ const n = this.actors.length;
+ const minD = PHYS.radius * 2;
+ for (let i = 0; i < n; i++) {
+ const a = this.actors[i];
+ if (!a.alive) continue;
+ for (let j = i + 1; j < n; j++) {
+ const b = this.actors[j];
+ if (!b.alive) continue;
+ const dx = b.pos.x - a.pos.x;
+ const dz = b.pos.z - a.pos.z;
+ const dy = b.pos.y - a.pos.y;
+ if (Math.abs(dy) > 1.6) continue;
+ const d2 = dx * dx + dz * dz;
+ if (d2 >= minD * minD) continue;
+ const d = Math.sqrt(d2) || 0.01;
+ const push = (minD - d) * 0.5;
+ const nx = d2 > 1e-6 ? dx / d : 1;
+ const nz = d2 > 1e-6 ? dz / d : 0;
+ // rooted (planting/defusing) actors are not pushed
+ const wa = a.rooted ? 0 : b.rooted ? 1 : 0.5;
+ const wb = 1 - wa;
+ if (wa > 0) this.collision.nudgeHull(a, -nx * push * wa * 2, -nz * push * wa * 2);
+ if (wb > 0) this.collision.nudgeHull(b, nx * push * wb * 2, nz * push * wb * 2);
+ }
+ }
+ }
+
+ /** Hand control of a living teammate bot to the human (spectator takeover). */
+ takeOver(a: Actor): boolean {
+ if (!a.alive || a.team !== this.human.team || this.controlled) return false;
+ a.isBot = false;
+ this.controlled = a;
+ return true;
+ }
+
+ teamOf(team: Team): Actor[] {
+ return this.actors.filter((a) => a.team === team);
+ }
+
+ enemiesOf(team: Team): Actor[] {
+ return this.actors.filter((a) => a.team === otherTeam(team));
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/headless.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/headless.ts
new file mode 100644
index 0000000..c73da7d
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/sim/headless.ts
@@ -0,0 +1,95 @@
+import { TICK_DT } from '../core/config';
+import { DEFAULT_SETTINGS, type Difficulty, type MatchSettings, type Team } from '../core/types';
+import { World } from './World';
+
+export interface SimOptions {
+ seed: number;
+ difficulty: Difficulty;
+ roundsToWin: number;
+ firstRound: MatchSettings['firstRound'];
+ verbose: boolean;
+ /** Safety cap in simulated seconds. */
+ maxSeconds: number;
+}
+
+export interface SimReport {
+ winner: Team | null;
+ score: Record;
+ rounds: { winner: Team; reason: string }[];
+ simulatedSeconds: number;
+ wallMs: number;
+ kills: number;
+ headshots: number;
+ plants: number;
+ defuses: number;
+ explosions: number;
+ /** Longest time (s) any bot wanted to move but stayed in place. */
+ worstStall: number;
+}
+
+/**
+ * Runs a complete 10-bot match without rendering, audio or DOM — the simulation layer is fully
+ * decoupled from presentation. Used by `npm run sim` as an integration/soak test of rules + AI.
+ */
+export function runSimulation(opts: Partial = {}, log: (s: string) => void = console.log): SimReport {
+ const o: SimOptions = { seed: 1, difficulty: 'normal', roundsToWin: 8, firstRound: 'pistol', verbose: false, maxSeconds: 3600, ...opts };
+ const world = new World({ ...DEFAULT_SETTINGS, difficulty: o.difficulty, roundsToWin: o.roundsToWin, firstRound: o.firstRound }, { humanControlled: false, seed: o.seed });
+ const report: SimReport = {
+ winner: null,
+ score: world.score,
+ rounds: [],
+ simulatedSeconds: 0,
+ wallMs: 0,
+ kills: 0,
+ headshots: 0,
+ plants: 0,
+ defuses: 0,
+ explosions: 0,
+ worstStall: 0,
+ };
+ const ev = world.events;
+ const stamp = () => `[R${world.roundNumber} ${world.time.toFixed(1)}s]`;
+ ev.on('kill', (e) => {
+ report.kills++;
+ if (e.headshot) report.headshots++;
+ if (o.verbose) log(`${stamp()} ${e.killer?.name ?? '-'} ⟶ ${e.victim.name} (${e.weapon}${e.headshot ? ', HS' : ''}) @ ${world.level.zoneAt(e.victim.pos.x, e.victim.pos.z)}`);
+ });
+ ev.on('bombPlanted', (e) => {
+ report.plants++;
+ if (o.verbose) log(`${stamp()} C4 planted at ${e.site} by ${e.actor.name}`);
+ });
+ ev.on('bombDefused', (e) => {
+ report.defuses++;
+ if (o.verbose) log(`${stamp()} C4 defused by ${e.actor.name}`);
+ });
+ ev.on('bombExploded', () => {
+ report.explosions++;
+ if (o.verbose) log(`${stamp()} C4 exploded`);
+ });
+ ev.on('roundStart', (e) => o.verbose && log(`${stamp()} --- round ${e.round}${e.pistol ? ' (pistol)' : ''}, T plan: ${world.teamAI.T.site}`));
+ ev.on('roundEnd', (e) => {
+ report.rounds.push({ winner: e.winner, reason: e.reason });
+ log(`${stamp()} round ${world.roundNumber}: ${e.winner} wins (${e.reason}) CT ${world.score.CT} : ${world.score.T} T`);
+ });
+
+ const stall = new Map();
+ const t0 = performance.now();
+ const maxTicks = Math.ceil(o.maxSeconds / TICK_DT);
+ for (let i = 0; i < maxTicks && world.phase !== 'over'; i++) {
+ const before = world.actors.map((a) => [a.pos.x, a.pos.z]);
+ world.tick(TICK_DT);
+ if (world.phase !== 'live') continue;
+ world.actors.forEach((a, idx) => {
+ if (!a.alive || a.rooted) return stall.set(a.id, 0);
+ const wants = Math.abs(a.input.forward) + Math.abs(a.input.right) > 0.3;
+ const moved = Math.hypot(a.pos.x - before[idx][0], a.pos.z - before[idx][1]);
+ const s = wants && moved < 0.01 ? (stall.get(a.id) ?? 0) + TICK_DT : 0;
+ stall.set(a.id, s);
+ report.worstStall = Math.max(report.worstStall, s);
+ });
+ }
+ report.wallMs = performance.now() - t0;
+ report.simulatedSeconds = world.time;
+ report.winner = world.matchWinner;
+ return report;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/weapons/WeaponDefs.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/weapons/WeaponDefs.ts
new file mode 100644
index 0000000..88bc9b8
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/weapons/WeaponDefs.ts
@@ -0,0 +1,336 @@
+import type { Team } from '../core/types';
+
+export type WeaponId = 'ak47' | 'm4a4' | 'awp' | 'glock' | 'usp' | 'deagle' | 'knife' | 'c4';
+export type WeaponKind = 'rifle' | 'sniper' | 'pistol' | 'knife' | 'c4';
+export type SoundProfile = 'ak' | 'm4' | 'awp' | 'glock' | 'usp' | 'deagle' | 'knife' | 'none';
+
+/** 0 = primary, 1 = secondary, 2 = melee, 3 = bomb. Mirrors CS keys 1/2/3/5. */
+export type WeaponSlot = 0 | 1 | 2 | 3;
+export const SLOT_KEYS = ['1', '2', '3', '5'] as const;
+
+/** All angles in degrees. */
+export interface SpreadSpec {
+ /** Base inaccuracy standing still. */
+ stand: number;
+ crouch: number;
+ /** Extra inaccuracy at full running speed. */
+ move: number;
+ /** Extra inaccuracy while airborne. */
+ air: number;
+ /** Bloom added per shot. */
+ perShot: number;
+ /** Cap of accumulated bloom. */
+ max: number;
+ /** Exponential bloom recovery rate (1/s). */
+ recover: number;
+}
+
+export interface RecoilSpec {
+ /** Per-shot [pitch up, yaw] kick in degrees, indexed by shot number in the spray. */
+ pattern: [number, number][];
+ /** Fraction of the recoil applied to the camera ("view punch"). Bullets always get the full offset. */
+ viewPunch: number;
+ /** Exponential recoil recovery when not firing (1/s). */
+ recover: number;
+}
+
+export interface ScopeSpec {
+ /** Vertical FOV for each zoom level (degrees). */
+ fovs: number[];
+ /** Inaccuracy added when NOT scoped. */
+ unscopedPenalty: number;
+ scopedMoveSpeed: number;
+}
+
+export interface MeleeSpec {
+ range: number;
+ altDamage: number;
+ altInterval: number;
+ altRange: number;
+}
+
+export interface WeaponDef {
+ id: WeaponId;
+ name: string;
+ slot: WeaponSlot;
+ kind: WeaponKind;
+ team: Team | 'any';
+ price: number;
+ damage: number;
+ /** Fraction of damage that reaches health when armor protects the hit zone. */
+ armorPen: number;
+ /** Damage multiplier per 10 m travelled (CS "range modifier"). */
+ rangeMod: number;
+ range: number;
+ fireInterval: number;
+ automatic: boolean;
+ magSize: number;
+ reserveMax: number;
+ reloadTime: number;
+ drawTime: number;
+ /** Max run speed (m/s) while this weapon is held. */
+ moveSpeed: number;
+ spread: SpreadSpec;
+ recoil: RecoilSpec;
+ scope?: ScopeSpec;
+ melee?: MeleeSpec;
+ sound: SoundProfile;
+ /** Short label shown in the kill feed. */
+ feed: string;
+}
+
+/** Builds a 30-shot spray pattern from segments of (shots, pitch per shot, yaw per shot). */
+function sprayPattern(segments: [number, number, number][], jitter: number, seed: number): [number, number][] {
+ const out: [number, number][] = [[0, 0]];
+ let s = seed;
+ const rand = () => {
+ s = (s * 16807) % 2147483647;
+ return (s / 2147483647) * 2 - 1;
+ };
+ for (const [count, up, yaw] of segments) {
+ for (let i = 0; i < count; i++) out.push([up, yaw + rand() * jitter]);
+ }
+ return out;
+}
+
+const AK_PATTERN = sprayPattern(
+ [
+ [3, 1.95, 0.05],
+ [4, 1.65, -0.1],
+ [4, 0.75, -0.75],
+ [6, 0.32, 0.95],
+ [5, 0.22, -0.85],
+ [8, 0.15, 0.7],
+ ],
+ 0.18,
+ 7,
+);
+
+const M4_PATTERN = sprayPattern(
+ [
+ [3, 0.95, 0.02],
+ [4, 0.82, 0.08],
+ [4, 0.36, 0.42],
+ [6, 0.18, -0.5],
+ [5, 0.12, 0.42],
+ [8, 0.1, -0.35],
+ ],
+ 0.1,
+ 11,
+);
+
+const single = (up: number, yaw: number): [number, number][] => {
+ const out: [number, number][] = [[0, 0]];
+ for (let i = 0; i < 40; i++) out.push([up * (i < 3 ? 1 : 0.7), (i % 2 ? 1 : -1) * yaw]);
+ return out;
+};
+
+export const WEAPONS: Record = {
+ ak47: {
+ id: 'ak47',
+ name: 'AK-47',
+ slot: 0,
+ kind: 'rifle',
+ team: 'T',
+ price: 2700,
+ damage: 50,
+ armorPen: 0.775,
+ rangeMod: 0.985,
+ range: 200,
+ fireInterval: 0.1,
+ automatic: true,
+ magSize: 30,
+ reserveMax: 90,
+ reloadTime: 2.45,
+ drawTime: 0.9,
+ moveSpeed: 5.4,
+ spread: { stand: 0.32, crouch: 0.22, move: 5.8, air: 9, perShot: 0.85, max: 6.5, recover: 5 },
+ recoil: { pattern: AK_PATTERN, viewPunch: 0.55, recover: 8 },
+ sound: 'ak',
+ feed: 'AK-47',
+ },
+ m4a4: {
+ id: 'm4a4',
+ name: 'M4A4',
+ slot: 0,
+ kind: 'rifle',
+ team: 'CT',
+ price: 3100,
+ damage: 38,
+ armorPen: 0.7,
+ rangeMod: 0.975,
+ range: 200,
+ fireInterval: 0.09,
+ automatic: true,
+ magSize: 30,
+ reserveMax: 90,
+ reloadTime: 3.05,
+ drawTime: 0.9,
+ moveSpeed: 5.55,
+ spread: { stand: 0.24, crouch: 0.16, move: 4.6, air: 8, perShot: 0.42, max: 4.5, recover: 6 },
+ recoil: { pattern: M4_PATTERN, viewPunch: 0.5, recover: 9 },
+ sound: 'm4',
+ feed: 'M4A4',
+ },
+ awp: {
+ id: 'awp',
+ name: 'AWP',
+ slot: 0,
+ kind: 'sniper',
+ team: 'any',
+ price: 4750,
+ damage: 125,
+ armorPen: 0.975,
+ rangeMod: 0.995,
+ range: 300,
+ fireInterval: 1.46,
+ automatic: false,
+ magSize: 10,
+ reserveMax: 30,
+ reloadTime: 3.6,
+ drawTime: 1.15,
+ moveSpeed: 4.9,
+ spread: { stand: 0.03, crouch: 0.02, move: 9, air: 16, perShot: 0, max: 0, recover: 4 },
+ recoil: { pattern: single(3.6, 0.3), viewPunch: 0.6, recover: 4.5 },
+ scope: { fovs: [30, 9], unscopedPenalty: 7.5, scopedMoveSpeed: 2.6 },
+ sound: 'awp',
+ feed: 'AWP',
+ },
+ glock: {
+ id: 'glock',
+ name: 'Glock-18',
+ slot: 1,
+ kind: 'pistol',
+ team: 'T',
+ price: 200,
+ damage: 24,
+ armorPen: 0.47,
+ rangeMod: 0.8,
+ range: 120,
+ fireInterval: 0.15,
+ automatic: false,
+ magSize: 20,
+ reserveMax: 120,
+ reloadTime: 2.2,
+ drawTime: 0.55,
+ moveSpeed: 5.75,
+ spread: { stand: 0.55, crouch: 0.45, move: 2.2, air: 5, perShot: 1.1, max: 4.5, recover: 6.5 },
+ recoil: { pattern: single(1.25, 0.25), viewPunch: 0.6, recover: 10 },
+ sound: 'glock',
+ feed: 'Glock-18',
+ },
+ usp: {
+ id: 'usp',
+ name: 'USP-S',
+ slot: 1,
+ kind: 'pistol',
+ team: 'CT',
+ price: 200,
+ damage: 32,
+ armorPen: 0.505,
+ rangeMod: 0.91,
+ range: 120,
+ fireInterval: 0.17,
+ automatic: false,
+ magSize: 12,
+ reserveMax: 24,
+ reloadTime: 2.2,
+ drawTime: 0.6,
+ moveSpeed: 5.75,
+ spread: { stand: 0.3, crouch: 0.22, move: 2.0, air: 5, perShot: 1.25, max: 4.5, recover: 6.5 },
+ recoil: { pattern: single(1.55, 0.25), viewPunch: 0.6, recover: 10 },
+ sound: 'usp',
+ feed: 'USP-S',
+ },
+ deagle: {
+ id: 'deagle',
+ name: 'Desert Eagle',
+ slot: 1,
+ kind: 'pistol',
+ team: 'any',
+ price: 700,
+ damage: 46,
+ armorPen: 0.93,
+ rangeMod: 0.82,
+ range: 150,
+ fireInterval: 0.225,
+ automatic: false,
+ magSize: 7,
+ reserveMax: 35,
+ reloadTime: 2.2,
+ drawTime: 0.7,
+ moveSpeed: 5.5,
+ spread: { stand: 0.38, crouch: 0.3, move: 3.6, air: 7, perShot: 3.4, max: 8, recover: 4.2 },
+ recoil: { pattern: single(4.2, 0.6), viewPunch: 0.65, recover: 6 },
+ sound: 'deagle',
+ feed: 'Deagle',
+ },
+ knife: {
+ id: 'knife',
+ name: '刀',
+ slot: 2,
+ kind: 'knife',
+ team: 'any',
+ price: 0,
+ damage: 34,
+ armorPen: 0.85,
+ rangeMod: 1,
+ range: 1.6,
+ fireInterval: 0.42,
+ automatic: true,
+ magSize: 0,
+ reserveMax: 0,
+ reloadTime: 0,
+ drawTime: 0.5,
+ moveSpeed: 6.1,
+ spread: { stand: 0, crouch: 0, move: 0, air: 0, perShot: 0, max: 0, recover: 1 },
+ recoil: { pattern: [[0, 0]], viewPunch: 0, recover: 10 },
+ melee: { range: 1.6, altDamage: 65, altInterval: 1.0, altRange: 1.35 },
+ sound: 'knife',
+ feed: 'Knife',
+ },
+ c4: {
+ id: 'c4',
+ name: 'C4 炸弹',
+ slot: 3,
+ kind: 'c4',
+ team: 'T',
+ price: 0,
+ damage: 0,
+ armorPen: 1,
+ rangeMod: 1,
+ range: 0,
+ fireInterval: 0.2,
+ automatic: true,
+ magSize: 0,
+ reserveMax: 0,
+ reloadTime: 0,
+ drawTime: 0.6,
+ moveSpeed: 5.75,
+ spread: { stand: 0, crouch: 0, move: 0, air: 0, perShot: 0, max: 0, recover: 1 },
+ recoil: { pattern: [[0, 0]], viewPunch: 0, recover: 10 },
+ sound: 'none',
+ feed: 'C4',
+ },
+};
+
+export const DEFAULT_PISTOL: Record = { T: 'glock', CT: 'usp' };
+export const DEFAULT_RIFLE: Record = { T: 'ak47', CT: 'm4a4' };
+
+export const BUYABLE_PRIMARY: WeaponId[] = ['ak47', 'm4a4', 'awp'];
+export const BUYABLE_SECONDARY: WeaponId[] = ['glock', 'usp', 'deagle'];
+
+/** Runtime state of one carried weapon. */
+export class WeaponInstance {
+ ammo: number;
+ reserve: number;
+
+ constructor(readonly def: WeaponDef) {
+ this.ammo = def.magSize;
+ this.reserve = def.reserveMax;
+ }
+
+ get id(): WeaponId {
+ return this.def.id;
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Collision.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Collision.ts
new file mode 100644
index 0000000..d8ecd79
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Collision.ts
@@ -0,0 +1,589 @@
+import type * as THREE from 'three';
+import { PHYS } from '../core/config';
+import type { SurfaceMaterial } from '../core/types';
+
+/** Collider participation masks. */
+export const MASK_MOVE = 1;
+export const MASK_BULLET = 2;
+export const MASK_SIGHT = 4;
+export const MASK_ALL = MASK_MOVE | MASK_BULLET | MASK_SIGHT;
+
+const SKIN = 0.002;
+
+export interface RampSpec {
+ axis: 'x' | 'z';
+ /** +1: surface rises towards +axis, -1: surface rises towards -axis. */
+ rise: 1 | -1;
+ yLow: number;
+ yHigh: number;
+}
+
+/**
+ * Axis-aligned solid. Optionally a "ramp": the AABB clipped by a sloped top plane
+ * (a wedge), which lets the 2.5D world contain slopes without general polyhedra.
+ */
+export class Collider {
+ id = -1;
+ /** query de-duplication stamp */
+ stamp = 0;
+
+ constructor(
+ public minX: number,
+ public minY: number,
+ public minZ: number,
+ public maxX: number,
+ public maxY: number,
+ public maxZ: number,
+ public mask: number = MASK_ALL,
+ public material: SurfaceMaterial = 'stone',
+ public ramp: RampSpec | null = null,
+ ) {}
+
+ /** Height of the walkable top surface at (x, z). */
+ topAt(x: number, z: number): number {
+ const r = this.ramp;
+ if (!r) return this.maxY;
+ let t: number;
+ if (r.axis === 'x') {
+ const c = x < this.minX ? this.minX : x > this.maxX ? this.maxX : x;
+ t = (c - this.minX) / (this.maxX - this.minX);
+ } else {
+ const c = z < this.minZ ? this.minZ : z > this.maxZ ? this.maxZ : z;
+ t = (c - this.minZ) / (this.maxZ - this.minZ);
+ }
+ if (r.rise < 0) t = 1 - t;
+ return r.yLow + (r.yHigh - r.yLow) * t;
+ }
+
+ /** Highest point of the top surface within a footprint (AABB hulls rest on the highest point). */
+ topOver(x0: number, z0: number, x1: number, z1: number): number {
+ const r = this.ramp;
+ if (!r) return this.maxY;
+ if (r.axis === 'x') return this.topAt(r.rise > 0 ? Math.min(x1, this.maxX) : Math.max(x0, this.minX), 0);
+ return this.topAt(0, r.rise > 0 ? Math.min(z1, this.maxZ) : Math.max(z0, this.minZ));
+ }
+
+ /** Slope plane n·p <= d describing the solid side of a ramp. */
+ rampPlane(out: { nx: number; ny: number; nz: number; d: number }): void {
+ const r = this.ramp!;
+ if (r.axis === 'x') {
+ const k = (r.yHigh - r.yLow) / (this.maxX - this.minX);
+ if (r.rise > 0) {
+ out.nx = -k;
+ out.d = r.yLow - k * this.minX;
+ } else {
+ out.nx = k;
+ out.d = r.yHigh + k * this.minX;
+ }
+ out.ny = 1;
+ out.nz = 0;
+ } else {
+ const k = (r.yHigh - r.yLow) / (this.maxZ - this.minZ);
+ if (r.rise > 0) {
+ out.nz = -k;
+ out.d = r.yLow - k * this.minZ;
+ } else {
+ out.nz = k;
+ out.d = r.yHigh + k * this.minZ;
+ }
+ out.ny = 1;
+ out.nx = 0;
+ }
+ }
+}
+
+export interface RayHit {
+ t: number;
+ x: number;
+ y: number;
+ z: number;
+ nx: number;
+ ny: number;
+ nz: number;
+ collider: Collider | null;
+}
+
+export function makeRayHit(): RayHit {
+ return { t: 0, x: 0, y: 0, z: 0, nx: 0, ny: 1, nz: 0, collider: null };
+}
+
+/** Kinematic hull used by every character: an AABB of `radius` half-width standing on `pos` (feet). */
+export interface Hull {
+ pos: THREE.Vector3;
+ vel: THREE.Vector3;
+ radius: number;
+ height: number;
+ onGround: boolean;
+}
+
+const plane = { nx: 0, ny: 0, nz: 0, d: 0 };
+
+/** Ray (normalized dir) vs collider; writes entry distance/normal into `out`. */
+function rayCollider(
+ c: Collider,
+ ox: number,
+ oy: number,
+ oz: number,
+ dx: number,
+ dy: number,
+ dz: number,
+ maxT: number,
+ out: RayHit,
+): boolean {
+ let tmin = 0;
+ let tmax = maxT;
+ let nAxis = -1;
+ let nSign = 0;
+
+ // X slab
+ if (Math.abs(dx) < 1e-12) {
+ if (ox < c.minX || ox > c.maxX) return false;
+ } else {
+ const inv = 1 / dx;
+ let t1 = (c.minX - ox) * inv;
+ let t2 = (c.maxX - ox) * inv;
+ let s = -1;
+ if (t1 > t2) {
+ const tmp = t1;
+ t1 = t2;
+ t2 = tmp;
+ s = 1;
+ }
+ if (t1 > tmin) {
+ tmin = t1;
+ nAxis = 0;
+ nSign = s;
+ }
+ if (t2 < tmax) tmax = t2;
+ if (tmin > tmax) return false;
+ }
+ // Y slab
+ if (Math.abs(dy) < 1e-12) {
+ if (oy < c.minY || oy > c.maxY) return false;
+ } else {
+ const inv = 1 / dy;
+ let t1 = (c.minY - oy) * inv;
+ let t2 = (c.maxY - oy) * inv;
+ let s = -1;
+ if (t1 > t2) {
+ const tmp = t1;
+ t1 = t2;
+ t2 = tmp;
+ s = 1;
+ }
+ if (t1 > tmin) {
+ tmin = t1;
+ nAxis = 1;
+ nSign = s;
+ }
+ if (t2 < tmax) tmax = t2;
+ if (tmin > tmax) return false;
+ }
+ // Z slab
+ if (Math.abs(dz) < 1e-12) {
+ if (oz < c.minZ || oz > c.maxZ) return false;
+ } else {
+ const inv = 1 / dz;
+ let t1 = (c.minZ - oz) * inv;
+ let t2 = (c.maxZ - oz) * inv;
+ let s = -1;
+ if (t1 > t2) {
+ const tmp = t1;
+ t1 = t2;
+ t2 = tmp;
+ s = 1;
+ }
+ if (t1 > tmin) {
+ tmin = t1;
+ nAxis = 2;
+ nSign = s;
+ }
+ if (t2 < tmax) tmax = t2;
+ if (tmin > tmax) return false;
+ }
+
+ let nx = 0;
+ let ny = 0;
+ let nz = 0;
+ if (nAxis === 0) nx = nSign;
+ else if (nAxis === 1) ny = nSign;
+ else if (nAxis === 2) nz = nSign;
+ else {
+ nx = -dx;
+ ny = -dy;
+ nz = -dz;
+ }
+
+ if (c.ramp) {
+ c.rampPlane(plane);
+ const denom = plane.nx * dx + plane.ny * dy + plane.nz * dz;
+ const dist = plane.d - (plane.nx * ox + plane.ny * oy + plane.nz * oz);
+ if (Math.abs(denom) < 1e-12) {
+ if (dist < 0) return false;
+ } else {
+ const t = dist / denom;
+ if (denom < 0) {
+ if (t > tmin) {
+ tmin = t;
+ const len = Math.hypot(plane.nx, plane.ny, plane.nz);
+ nx = plane.nx / len;
+ ny = plane.ny / len;
+ nz = plane.nz / len;
+ }
+ } else if (t < tmax) {
+ tmax = t;
+ }
+ if (tmin > tmax) return false;
+ }
+ }
+
+ if (tmin > maxT) return false;
+ out.t = tmin;
+ out.nx = nx;
+ out.ny = ny;
+ out.nz = nz;
+ return true;
+}
+
+/**
+ * Static collision world: colliders bucketed in a uniform XZ grid. Provides hull movement
+ * (axis-separated slide + step-up + ground snap, Source-style) and grid-traversed raycasts.
+ */
+export class CollisionWorld {
+ readonly colliders: Collider[] = [];
+ private cellSize = 4;
+ private originX = 0;
+ private originZ = 0;
+ private nx = 1;
+ private nz = 1;
+ private cells: Collider[][] = [];
+ private stampCounter = 1;
+ private readonly tmpList: Collider[] = [];
+ private readonly tmpHit = makeRayHit();
+ private readonly lineHit = makeRayHit();
+
+ add(c: Collider): Collider {
+ c.id = this.colliders.length;
+ this.colliders.push(c);
+ return c;
+ }
+
+ addBox(
+ x0: number,
+ y0: number,
+ z0: number,
+ x1: number,
+ y1: number,
+ z1: number,
+ mask = MASK_ALL,
+ material: SurfaceMaterial = 'stone',
+ ): Collider {
+ return this.add(
+ new Collider(Math.min(x0, x1), Math.min(y0, y1), Math.min(z0, z1), Math.max(x0, x1), Math.max(y0, y1), Math.max(z0, z1), mask, material),
+ );
+ }
+
+ /** Must be called after all colliders are added. */
+ build(): void {
+ let minX = Infinity;
+ let minZ = Infinity;
+ let maxX = -Infinity;
+ let maxZ = -Infinity;
+ for (const c of this.colliders) {
+ minX = Math.min(minX, c.minX);
+ minZ = Math.min(minZ, c.minZ);
+ maxX = Math.max(maxX, c.maxX);
+ maxZ = Math.max(maxZ, c.maxZ);
+ }
+ this.originX = Math.floor(minX) - this.cellSize;
+ this.originZ = Math.floor(minZ) - this.cellSize;
+ this.nx = Math.ceil((maxX - this.originX) / this.cellSize) + 2;
+ this.nz = Math.ceil((maxZ - this.originZ) / this.cellSize) + 2;
+ this.cells = new Array(this.nx * this.nz);
+ for (let i = 0; i < this.cells.length; i++) this.cells[i] = [];
+ for (const c of this.colliders) {
+ const cx0 = this.cellX(c.minX);
+ const cx1 = this.cellX(c.maxX);
+ const cz0 = this.cellZ(c.minZ);
+ const cz1 = this.cellZ(c.maxZ);
+ for (let z = cz0; z <= cz1; z++) for (let x = cx0; x <= cx1; x++) this.cells[z * this.nx + x].push(c);
+ }
+ }
+
+ private cellX(x: number): number {
+ const c = Math.floor((x - this.originX) / this.cellSize);
+ return c < 0 ? 0 : c >= this.nx ? this.nx - 1 : c;
+ }
+
+ private cellZ(z: number): number {
+ const c = Math.floor((z - this.originZ) / this.cellSize);
+ return c < 0 ? 0 : c >= this.nz ? this.nz - 1 : c;
+ }
+
+ /** Colliders whose XZ footprint overlaps the rectangle (strictly) and match `mask`. */
+ query(x0: number, z0: number, x1: number, z1: number, mask: number, out: Collider[]): Collider[] {
+ out.length = 0;
+ const stamp = ++this.stampCounter;
+ const cx0 = this.cellX(x0);
+ const cx1 = this.cellX(x1);
+ const cz0 = this.cellZ(z0);
+ const cz1 = this.cellZ(z1);
+ for (let z = cz0; z <= cz1; z++) {
+ for (let x = cx0; x <= cx1; x++) {
+ const cell = this.cells[z * this.nx + x];
+ for (let i = 0; i < cell.length; i++) {
+ const c = cell[i];
+ if (c.stamp === stamp) continue;
+ c.stamp = stamp;
+ if ((c.mask & mask) === 0) continue;
+ if (c.maxX > x0 && c.minX < x1 && c.maxZ > z0 && c.minZ < z1) out.push(c);
+ }
+ }
+ }
+ return out;
+ }
+
+ /** True if the box intersects any solid with `mask`. */
+ overlapsBox(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, mask = MASK_MOVE): boolean {
+ const list = this.query(x0, z0, x1, z1, mask, this.tmpList);
+ for (const c of list) {
+ if (c.minY >= y1) continue;
+ const top = c.topOver(x0, z0, x1, z1);
+ if (top <= y0) continue;
+ return true;
+ }
+ return false;
+ }
+
+ /** Highest walkable surface at (x,z) that is not above `maxY`. */
+ groundHeight(x: number, z: number, maxY: number, radius = 0.05): number {
+ const list = this.query(x - radius, z - radius, x + radius, z + radius, MASK_MOVE, this.tmpList);
+ let best = -Infinity;
+ for (const c of list) {
+ if (c.minY > maxY) continue;
+ const top = c.topOver(x - radius, z - radius, x + radius, z + radius);
+ if (top <= maxY + 1e-3 && top > best) best = top;
+ }
+ return best;
+ }
+
+ // ---------------------------------------------------------------- hull movement
+
+ /**
+ * Integrates hull velocity for `dt` against static geometry. Returns the downward speed at
+ * the moment of landing (0 if the hull did not land this step) for landing feedback.
+ */
+ moveHull(h: Hull, dt: number): number {
+ if (h.vel.x !== 0) this.slideAxis(h, 0, h.pos.x + h.vel.x * dt);
+ if (h.vel.z !== 0) this.slideAxis(h, 2, h.pos.z + h.vel.z * dt);
+ return this.moveVertical(h, dt);
+ }
+
+ /** Displace a hull horizontally (used for soft actor-actor separation) with full collision. */
+ nudgeHull(h: Hull, dx: number, dz: number): void {
+ if (dx !== 0) this.slideAxis(h, 0, h.pos.x + dx, true);
+ if (dz !== 0) this.slideAxis(h, 2, h.pos.z + dz, true);
+ }
+
+ private slideAxis(h: Hull, axis: 0 | 2, target: number, keepVelocity = false): void {
+ const r = h.radius;
+ const old = axis === 0 ? h.pos.x : h.pos.z;
+ if (axis === 0) h.pos.x = target;
+ else h.pos.z = target;
+ const x0 = h.pos.x - r;
+ const x1 = h.pos.x + r;
+ const z0 = h.pos.z - r;
+ const z1 = h.pos.z + r;
+ const list = this.query(x0, z0, x1, z1, MASK_MOVE, this.tmpList);
+ const feet = h.pos.y;
+ const stepMax = h.onGround ? PHYS.stepHeight : PHYS.airStepHeight;
+ let stepTo = feet;
+ for (let i = 0; i < list.length; i++) {
+ const c = list[i];
+ if (c.minY >= feet + h.height - 0.02) continue; // above head
+ const top = c.topOver(x0, z0, x1, z1);
+ if (top <= feet + 0.001) continue; // below feet
+ if (top <= feet + stepMax && c.minY <= feet + stepMax) {
+ if (top <= stepTo) continue;
+ if (this.hasHeadroom(list, c, top, h.height)) {
+ stepTo = top;
+ continue;
+ }
+ }
+ const cMin = axis === 0 ? c.minX : c.minZ;
+ const cMax = axis === 0 ? c.maxX : c.maxZ;
+ let p = axis === 0 ? h.pos.x : h.pos.z;
+ if (old + r <= cMin + 1e-3) p = Math.min(p, cMin - r - SKIN);
+ else if (old - r >= cMax - 1e-3) p = Math.max(p, cMax + r + SKIN);
+ else continue; // overlap pre-existed on this axis: the other axis owns it
+ if (axis === 0) {
+ h.pos.x = p;
+ if (!keepVelocity) h.vel.x = 0;
+ } else {
+ h.pos.z = p;
+ if (!keepVelocity) h.vel.z = 0;
+ }
+ }
+ if (stepTo > feet) {
+ h.pos.y = stepTo;
+ if (h.vel.y < 0) h.vel.y = 0;
+ }
+ }
+
+ private hasHeadroom(list: Collider[], self: Collider, top: number, height: number): boolean {
+ for (let i = 0; i < list.length; i++) {
+ const c = list[i];
+ if (c === self) continue;
+ if (c.minY > top - 0.001 && c.minY < top + height - 0.02) return false;
+ }
+ return true;
+ }
+
+ private moveVertical(h: Hull, dt: number): number {
+ const r = h.radius;
+ const x0 = h.pos.x - r;
+ const x1 = h.pos.x + r;
+ const z0 = h.pos.z - r;
+ const z1 = h.pos.z + r;
+ const list = this.query(x0, z0, x1, z1, MASK_MOVE, this.tmpList);
+ const prevY = h.pos.y;
+ let newY = prevY + h.vel.y * dt;
+ let landed = 0;
+ if (h.vel.y <= 0) {
+ let ground = -Infinity;
+ for (let i = 0; i < list.length; i++) {
+ const c = list[i];
+ if (c.minY > prevY + 0.05) continue;
+ const top = c.topOver(x0, z0, x1, z1);
+ if (top <= prevY + 0.05 && top > ground) ground = top;
+ }
+ if (h.onGround && ground > -Infinity && prevY - ground <= PHYS.snapDown) {
+ newY = ground;
+ h.vel.y = 0;
+ h.onGround = true;
+ } else if (newY <= ground) {
+ landed = -h.vel.y;
+ newY = ground;
+ h.vel.y = 0;
+ h.onGround = true;
+ } else {
+ h.onGround = false;
+ }
+ } else {
+ for (let i = 0; i < list.length; i++) {
+ const c = list[i];
+ if (c.minY < prevY + h.height - 0.02) continue;
+ if (newY + h.height > c.minY) {
+ newY = c.minY - h.height;
+ h.vel.y = 0;
+ }
+ }
+ h.onGround = false;
+ }
+ h.pos.y = newY;
+ return landed;
+ }
+
+ // ---------------------------------------------------------------- raycasts
+
+ /** Grid-traversed raycast (dir must be normalized). */
+ raycast(
+ ox: number,
+ oy: number,
+ oz: number,
+ dx: number,
+ dy: number,
+ dz: number,
+ maxDist: number,
+ mask: number,
+ out: RayHit,
+ ): boolean {
+ const stamp = ++this.stampCounter;
+ const cs = this.cellSize;
+ let best = maxDist;
+ let found = false;
+
+ // Clip the ray to the grid rectangle in XZ.
+ const gx0 = this.originX;
+ const gz0 = this.originZ;
+ const gx1 = gx0 + this.nx * cs;
+ const gz1 = gz0 + this.nz * cs;
+ let t0 = 0;
+ let t1 = maxDist;
+ if (Math.abs(dx) < 1e-12) {
+ if (ox < gx0 || ox > gx1) return false;
+ } else {
+ const a = (gx0 - ox) / dx;
+ const b = (gx1 - ox) / dx;
+ t0 = Math.max(t0, Math.min(a, b));
+ t1 = Math.min(t1, Math.max(a, b));
+ }
+ if (Math.abs(dz) < 1e-12) {
+ if (oz < gz0 || oz > gz1) return false;
+ } else {
+ const a = (gz0 - oz) / dz;
+ const b = (gz1 - oz) / dz;
+ t0 = Math.max(t0, Math.min(a, b));
+ t1 = Math.min(t1, Math.max(a, b));
+ }
+ if (t0 > t1) return false;
+
+ const sx = ox + dx * t0;
+ const sz = oz + dz * t0;
+ let cx = this.cellX(sx);
+ let cz = this.cellZ(sz);
+ const stepX = dx > 0 ? 1 : -1;
+ const stepZ = dz > 0 ? 1 : -1;
+ const tDeltaX = Math.abs(dx) < 1e-12 ? Infinity : cs / Math.abs(dx);
+ const tDeltaZ = Math.abs(dz) < 1e-12 ? Infinity : cs / Math.abs(dz);
+ let tMaxX = Math.abs(dx) < 1e-12 ? Infinity : (gx0 + (cx + (dx > 0 ? 1 : 0)) * cs - ox) / dx;
+ let tMaxZ = Math.abs(dz) < 1e-12 ? Infinity : (gz0 + (cz + (dz > 0 ? 1 : 0)) * cs - oz) / dz;
+ const tmp = this.tmpHit;
+
+ for (let guard = 0; guard < 4096; guard++) {
+ const cell = this.cells[cz * this.nx + cx];
+ for (let i = 0; i < cell.length; i++) {
+ const c = cell[i];
+ if (c.stamp === stamp) continue;
+ c.stamp = stamp;
+ if ((c.mask & mask) === 0) continue;
+ if (rayCollider(c, ox, oy, oz, dx, dy, dz, best, tmp) && tmp.t <= best) {
+ best = tmp.t;
+ found = true;
+ out.t = tmp.t;
+ out.nx = tmp.nx;
+ out.ny = tmp.ny;
+ out.nz = tmp.nz;
+ out.collider = c;
+ }
+ }
+ const tNext = Math.min(tMaxX, tMaxZ);
+ if (tNext > best || tNext > t1) break;
+ if (tMaxX < tMaxZ) {
+ cx += stepX;
+ tMaxX += tDeltaX;
+ if (cx < 0 || cx >= this.nx) break;
+ } else {
+ cz += stepZ;
+ tMaxZ += tDeltaZ;
+ if (cz < 0 || cz >= this.nz) break;
+ }
+ }
+ if (found) {
+ out.x = ox + dx * out.t;
+ out.y = oy + dy * out.t;
+ out.z = oz + dz * out.t;
+ }
+ return found;
+ }
+
+ /** Unobstructed straight line between two points for the given mask. */
+ lineClear(ax: number, ay: number, az: number, bx: number, by: number, bz: number, mask = MASK_SIGHT): boolean {
+ const dx = bx - ax;
+ const dy = by - ay;
+ const dz = bz - az;
+ const len = Math.hypot(dx, dy, dz);
+ if (len < 1e-6) return true;
+ return !this.raycast(ax, ay, az, dx / len, dy / len, dz / len, len, mask, this.lineHit);
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Level.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Level.ts
new file mode 100644
index 0000000..378a056
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/Level.ts
@@ -0,0 +1,239 @@
+import { hash01 } from '../core/math';
+import type { SiteId, SurfaceMaterial } from '../core/types';
+import { Collider, CollisionWorld, MASK_ALL } from './Collision';
+import type { BombsiteDef, BoxDef, DoorLeafDef, FloorDef, MapDef } from './MapData';
+import { NavGrid } from './NavGrid';
+
+export interface WallRect {
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+ bottom: number;
+ top: number;
+ /** For window cells: the opening. */
+ window?: { sill: number; top: number };
+}
+
+export const RASTER_RES = 0.5;
+export const WORLD_BOTTOM = -4;
+
+const BOX_MATERIAL: Record = {
+ crate: 'wood',
+ crateBig: 'wood',
+ container: 'metal',
+ lintel: 'stone',
+ ceiling: 'wood',
+ beam: 'wood',
+ barrel: 'metal',
+ car: 'metal',
+ pillar: 'stone',
+};
+
+/**
+ * Compiled level: rasterises the designer floor plan, derives closed wall geometry by greedy
+ * rectangle merging, creates every collider and the navigation grid. Pure data (no rendering),
+ * so it also runs headless for simulation tests.
+ */
+export class Level {
+ readonly collision = new CollisionWorld();
+ readonly nav: NavGrid;
+ readonly walls: WallRect[] = [];
+ readonly cols: number;
+ readonly rows: number;
+ /** Designer floor height per raster cell, NaN where solid. */
+ readonly floorHeight: Float32Array;
+ readonly doorLeaves: DoorLeafDef[];
+
+ constructor(readonly def: MapDef) {
+ const b = def.bounds;
+ this.cols = Math.round((b.x1 - b.x0) / RASTER_RES);
+ this.rows = Math.round((b.z1 - b.z0) / RASTER_RES);
+ this.floorHeight = new Float32Array(this.cols * this.rows).fill(NaN);
+ this.doorLeaves = def.doors;
+
+ this.rasterizeFloors(def.floors);
+ this.buildWalls();
+ this.buildColliders();
+ this.collision.build();
+
+ const seeds = [...def.spawns.T, ...def.spawns.CT].map(([x, z]) => ({ x, z }));
+ this.nav = new NavGrid(b.x0, b.z0, RASTER_RES, this.cols, this.rows, this.floorHeight, this.collision, seeds);
+ }
+
+ // ------------------------------------------------------------------ build steps
+
+ private rasterizeFloors(floors: FloorDef[]): void {
+ const b = this.def.bounds;
+ for (const f of floors) {
+ const i0 = Math.max(0, Math.round((f.x0 - b.x0) / RASTER_RES));
+ const i1 = Math.min(this.cols, Math.round((f.x1 - b.x0) / RASTER_RES));
+ const j0 = Math.max(0, Math.round((f.z0 - b.z0) / RASTER_RES));
+ const j1 = Math.min(this.rows, Math.round((f.z1 - b.z0) / RASTER_RES));
+ for (let j = j0; j < j1; j++) {
+ for (let i = i0; i < i1; i++) {
+ const cx = b.x0 + (i + 0.5) * RASTER_RES;
+ const cz = b.z0 + (j + 0.5) * RASTER_RES;
+ const h = floorHeightAt(f, cx, cz);
+ const idx = j * this.cols + i;
+ const cur = this.floorHeight[idx];
+ if (Number.isNaN(cur) || h > cur) this.floorHeight[idx] = h;
+ }
+ }
+ }
+ }
+
+ private buildWalls(): void {
+ const { cols, rows } = this;
+ const b = this.def.bounds;
+ // 0 = floor, 1 = wall, 2+ = window index + 2
+ const type = new Int16Array(cols * rows);
+ for (let i = 0; i < type.length; i++) type[i] = Number.isNaN(this.floorHeight[i]) ? 1 : 0;
+ this.def.windows.forEach((w, wi) => {
+ for (let j = 0; j < rows; j++) {
+ for (let i = 0; i < cols; i++) {
+ const cx = b.x0 + (i + 0.5) * RASTER_RES;
+ const cz = b.z0 + (j + 0.5) * RASTER_RES;
+ if (cx > w.x0 && cx < w.x1 && cz > w.z0 && cz < w.z1 && type[j * cols + i] === 1) type[j * cols + i] = 2 + wi;
+ }
+ }
+ });
+
+ // Greedy rectangle merge per cell type.
+ const used = new Uint8Array(cols * rows);
+ const [hMin, hMax] = this.def.wallHeight;
+ for (let j = 0; j < rows; j++) {
+ for (let i = 0; i < cols; i++) {
+ const idx = j * cols + i;
+ const t = type[idx];
+ if (t === 0 || used[idx]) continue;
+ let w = 1;
+ while (i + w < cols && type[j * cols + i + w] === t && !used[j * cols + i + w] && w < 48) w++;
+ let h = 1;
+ outer: while (j + h < rows && h < 48) {
+ for (let k = 0; k < w; k++) {
+ const n = (j + h) * cols + i + k;
+ if (type[n] !== t || used[n]) break outer;
+ }
+ h++;
+ }
+ for (let dj = 0; dj < h; dj++) for (let k = 0; k < w; k++) used[(j + dj) * cols + i + k] = 1;
+ const x0 = b.x0 + i * RASTER_RES;
+ const z0 = b.z0 + j * RASTER_RES;
+ const x1 = x0 + w * RASTER_RES;
+ const z1 = z0 + h * RASTER_RES;
+ // Quantised, position-hashed skyline so neighbouring blocks read as separate buildings.
+ const hv = hash01(Math.floor((x0 + x1) / 8), Math.floor((z0 + z1) / 8));
+ const top = Math.round((hMin + (hMax - hMin) * hv) / 0.4) * 0.4;
+ const rect: WallRect = { x0, z0, x1, z1, bottom: WORLD_BOTTOM, top };
+ if (t >= 2) {
+ const win = this.def.windows[t - 2];
+ rect.window = { sill: win.sill, top: win.top };
+ }
+ this.walls.push(rect);
+ }
+ }
+ }
+
+ private buildColliders(): void {
+ const col = this.collision;
+ // floors
+ for (const f of this.def.floors) {
+ if (f.y2 !== undefined && f.axis) {
+ const rise: 1 | -1 = f.y2 > f.y ? 1 : -1;
+ const yLow = Math.min(f.y, f.y2);
+ const yHigh = Math.max(f.y, f.y2);
+ col.add(new Collider(f.x0, WORLD_BOTTOM, f.z0, f.x1, yHigh, f.z1, MASK_ALL, 'sand', { axis: f.axis, rise, yLow, yHigh }));
+ } else {
+ col.addBox(f.x0, WORLD_BOTTOM, f.z0, f.x1, f.y, f.z1, MASK_ALL, f.tex === 'tiles' || f.tex === 'stone' ? 'stone' : 'sand');
+ }
+ }
+ // walls & windows
+ for (const w of this.walls) {
+ if (w.window) {
+ col.addBox(w.x0, w.bottom, w.z0, w.x1, w.window.sill, w.z1, MASK_ALL, 'stone');
+ col.addBox(w.x0, w.window.top, w.z0, w.x1, w.top, w.z1, MASK_ALL, 'stone');
+ } else {
+ col.addBox(w.x0, w.bottom, w.z0, w.x1, w.top, w.z1, MASK_ALL, 'stone');
+ }
+ }
+ // props
+ for (const bx of this.def.boxes) {
+ if (bx.solid === false) continue;
+ col.addBox(bx.x0, bx.y0, bx.z0, bx.x1, bx.y1, bx.z1, MASK_ALL, BOX_MATERIAL[bx.kind]);
+ }
+ // door leaves: rotated slabs approximated by a chain of small boxes
+ for (const d of this.def.doors) {
+ for (const c of doorLeafBoxes(d)) {
+ col.addBox(c[0], d.y0, c[1], c[2], d.y0 + d.height, c[3], MASK_ALL, 'wood');
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------ queries
+
+ /** Callout name of the zone containing (x,z). */
+ zoneAt(x: number, z: number): string {
+ for (const zn of this.def.zones) {
+ if (x >= zn.x0 && x <= zn.x1 && z >= zn.z0 && z <= zn.z1) return zn.name;
+ }
+ return '';
+ }
+
+ bombsiteAt(x: number, z: number): BombsiteDef | null {
+ for (const s of this.def.bombsites) {
+ if (x >= s.x0 && x <= s.x1 && z >= s.z0 && z <= s.z1) return s;
+ }
+ return null;
+ }
+
+ site(id: SiteId): BombsiteDef {
+ return this.def.bombsites.find((s) => s.id === id)!;
+ }
+
+ point(name: string): { x: number; z: number } {
+ const p = this.def.points[name];
+ if (!p) throw new Error(`Unknown map point ${name}`);
+ return { x: p[0], z: p[1] };
+ }
+
+ /** Ground height under (x,z) (walkable surfaces only). */
+ groundAt(x: number, z: number, fromY = 50): number {
+ const g = this.collision.groundHeight(x, z, fromY, 0.05);
+ return Number.isFinite(g) ? g : this.nav.heightAt(x, z);
+ }
+}
+
+export function floorHeightAt(f: FloorDef, x: number, z: number): number {
+ if (f.y2 === undefined || !f.axis) return f.y;
+ const t = f.axis === 'x' ? (x - f.x0) / (f.x1 - f.x0) : (z - f.z0) / (f.z1 - f.z0);
+ const c = t < 0 ? 0 : t > 1 ? 1 : t;
+ return f.y + (f.y2 - f.y) * c;
+}
+
+/** Axis-aligned boxes approximating a rotated door leaf: [x0, z0, x1, z1][]. */
+export function doorLeafBoxes(d: DoorLeafDef): [number, number, number, number][] {
+ const out: [number, number, number, number][] = [];
+ const dx = Math.cos(d.angle);
+ const dz = Math.sin(d.angle);
+ const axisAligned = Math.abs(dx) < 1e-3 || Math.abs(dz) < 1e-3;
+ const half = d.thickness / 2;
+ if (axisAligned) {
+ const ex = d.hx + dx * d.length;
+ const ez = d.hz + dz * d.length;
+ out.push([Math.min(d.hx, ex) - half, Math.min(d.hz, ez) - half, Math.max(d.hx, ex) + half, Math.max(d.hz, ez) + half]);
+ return out;
+ }
+ const seg = 0.25;
+ const n = Math.ceil(d.length / seg);
+ for (let i = 0; i < n; i++) {
+ const a = (i * d.length) / n;
+ const b2 = ((i + 1) * d.length) / n;
+ const ax = d.hx + dx * a;
+ const az = d.hz + dz * a;
+ const bx = d.hx + dx * b2;
+ const bz = d.hz + dz * b2;
+ out.push([Math.min(ax, bx) - half, Math.min(az, bz) - half, Math.max(ax, bx) + half, Math.max(az, bz) + half]);
+ }
+ return out;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/MapData.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/MapData.ts
new file mode 100644
index 0000000..f8ac787
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/MapData.ts
@@ -0,0 +1,421 @@
+import type { SiteId, Team } from '../core/types';
+
+/**
+ * Dust2 core-area layout, authored in meters on a 0.5 m grid.
+ * Axes: +X = east (radar right), +Z = south (radar down). T spawn is south, CT spawn north,
+ * B site north-west, A site north-east — the classic radar orientation.
+ *
+ * Walkable space is described as floor rectangles (flat, ramps or stairs). Everything inside the
+ * map bounds that is not floor becomes solid building mass, so every area is closed by walls.
+ */
+
+export type FloorTex = 'sand' | 'stone' | 'tiles' | 'concrete';
+export type BoxKind = 'crate' | 'crateBig' | 'container' | 'lintel' | 'ceiling' | 'beam' | 'barrel' | 'car' | 'pillar';
+
+export interface FloorDef {
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+ /** Flat height, or ramp height at the min edge of `axis`. */
+ y: number;
+ /** Ramp height at the max edge of `axis`. */
+ y2?: number;
+ axis?: 'x' | 'z';
+ tex: FloorTex;
+}
+
+export interface BoxDef {
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+ y0: number;
+ y1: number;
+ kind: BoxKind;
+ /** Collides with movement, bullets and sight (default true). */
+ solid?: boolean;
+}
+
+export interface DoorLeafDef {
+ hx: number;
+ hz: number;
+ /** Direction the leaf extends from the hinge, radians in the XZ plane (0 = +X, PI/2 = +Z). */
+ angle: number;
+ length: number;
+ height: number;
+ y0: number;
+ thickness: number;
+}
+
+export interface WindowDef {
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+ sill: number;
+ top: number;
+}
+
+export interface ZoneDef {
+ name: string;
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+}
+
+export interface BombsiteDef {
+ id: SiteId;
+ x0: number;
+ z0: number;
+ x1: number;
+ z1: number;
+ /** Default plant spot used by bots. */
+ plant: [number, number];
+}
+
+export interface HoldSpot {
+ id: string;
+ pos: [number, number];
+ look: [number, number];
+ area: SiteId | 'mid';
+}
+
+export interface MapLabel {
+ text: string;
+ x: number;
+ z: number;
+ big?: boolean;
+}
+
+export interface MapDef {
+ name: string;
+ bounds: { x0: number; z0: number; x1: number; z1: number };
+ wallHeight: [number, number];
+ floors: FloorDef[];
+ boxes: BoxDef[];
+ doors: DoorLeafDef[];
+ windows: WindowDef[];
+ zones: ZoneDef[];
+ bombsites: BombsiteDef[];
+ spawns: Record;
+ spawnLook: Record;
+ points: Record;
+ /** Named T approach routes as point-name sequences. */
+ routes: Record;
+ ctHolds: HoldSpot[];
+ postPlant: Record;
+ labels: MapLabel[];
+}
+
+const floors: FloorDef[] = [];
+const boxes: BoxDef[] = [];
+
+function flat(x0: number, z0: number, x1: number, z1: number, y: number, tex: FloorTex = 'sand'): void {
+ floors.push({ x0, z0, x1, z1, y, tex });
+}
+
+/** Ramp: height `yMin` at the min edge of `axis`, `yMax` at the max edge. */
+function ramp(x0: number, z0: number, x1: number, z1: number, yMin: number, yMax: number, axis: 'x' | 'z', tex: FloorTex = 'stone'): void {
+ floors.push({ x0, z0, x1, z1, y: yMin, y2: yMax, axis, tex });
+}
+
+/**
+ * Stairs between two floor levels. The slice touching the higher floor is level with it,
+ * the slice touching the lower floor is one step above it.
+ */
+function stairs(
+ x0: number,
+ z0: number,
+ x1: number,
+ z1: number,
+ yAtMin: number,
+ yAtMax: number,
+ axis: 'x' | 'z',
+ steps: number,
+ tex: FloorTex = 'stone',
+): void {
+ const len = axis === 'x' ? x1 - x0 : z1 - z0;
+ const rising = yAtMax > yAtMin;
+ const d = Math.abs(yAtMax - yAtMin) / steps;
+ for (let i = 0; i < steps; i++) {
+ const a = (len * i) / steps;
+ const b = (len * (i + 1)) / steps;
+ const h = rising ? yAtMin + d * (i + 1) : yAtMin - d * i;
+ if (axis === 'x') flat(x0 + a, z0, x0 + b, z1, h, tex);
+ else flat(x0, z0 + a, x1, z0 + b, h, tex);
+ }
+}
+
+function box(x0: number, z0: number, x1: number, z1: number, y0: number, y1: number, kind: BoxKind, solid = true): void {
+ boxes.push({ x0, z0, x1, z1, y0, y1, kind, solid });
+}
+
+/** Wooden crate of footprint w x d standing on y0. */
+function crate(x: number, z: number, w: number, d: number, h: number, y0: number): void {
+ box(x, z, x + w, z + d, y0, y0 + h, h > 1.5 ? 'crateBig' : 'crate');
+}
+
+// ----------------------------------------------------------------------------------------------
+// Heights
+const H_T = 1.6; // T spawn plateau
+const H_A = 1.6; // A site / catwalk / short level
+const H_UT = 0.8; // upper tunnels
+const H_PIT = -1.2;
+
+// ---------------------------------------------------------------- T spawn & exits
+flat(38, 82, 64, 98, H_T, 'sand'); // T spawn
+ramp(30, 84, 38, 92, H_UT, H_T, 'x', 'sand'); // west exit -> outside tunnels
+ramp(42, 70, 52, 82, 0, H_T, 'z', 'sand'); // "suicide" slope down to top mid
+ramp(64, 84, 72, 92, H_T, 0, 'x', 'sand'); // east exit -> outside long
+
+// ---------------------------------------------------------------- Outside long / long doors / long A
+flat(72, 80, 83, 92, 0, 'sand'); // outside long
+flat(83, 83, 86, 89, 0, 'stone'); // long doors (A大门) doorway through the wall
+flat(86, 76, 104, 92, 0, 'sand'); // long corner
+flat(88, 40, 104, 76, 0, 'sand'); // long A (A大)
+flat(88, 36, 98, 40, 0, 'sand'); // end of long
+ramp(98, 36, 104, 40, H_PIT, 0, 'z', 'sand'); // slope into the pit
+flat(98, 28, 104, 36, H_PIT, 'sand'); // pit (A坑)
+ramp(88, 28, 98, 36, H_A, 0, 'z', 'stone'); // A ramp up to the site
+
+// ---------------------------------------------------------------- A site
+flat(76, 4, 104, 28, H_A, 'tiles'); // A site
+flat(94, 4, 96, 10, H_A + 0.4, 'stone'); // step to A plat
+flat(96, 4, 104, 10, H_A + 0.8, 'stone'); // A plat / goose corner
+
+// ---------------------------------------------------------------- CT spawn & CT ramp
+flat(56, 4, 72, 22, 0, 'concrete'); // CT spawn
+ramp(72, 8, 76, 18, 0, H_A, 'x', 'stone'); // CT ramp up to A
+
+// ---------------------------------------------------------------- CT mid, mid doors, mid
+flat(34, 10, 56, 24, 0, 'sand'); // CT mid
+flat(45, 24, 50, 26, 0, 'stone'); // mid doors (中门) doorway
+flat(42, 26, 52, 64, 0, 'sand'); // mid (中路)
+flat(40, 64, 58, 70, 0, 'sand'); // top mid
+
+// ---------------------------------------------------------------- Catwalk / short A
+stairs(52, 56, 58, 64, H_A, 0, 'z', 4, 'stone'); // catwalk stairs from mid
+flat(53, 28, 58, 56, H_A, 'stone'); // catwalk (猫道)
+flat(58, 28, 80, 33, H_A, 'stone'); // short A (A小)
+
+// ---------------------------------------------------------------- Tunnels
+flat(25.5, 46, 42, 51, 0, 'concrete'); // lower tunnels (下B洞)
+flat(24, 46, 25.5, 51, 0.4, 'concrete'); // step up to upper tunnels
+flat(16, 31.5, 24, 64, H_UT, 'concrete'); // upper tunnels (B洞)
+flat(17, 30, 23, 31.5, 0.4, 'concrete'); // tunnel exit step into B
+flat(14, 64, 30, 92, H_UT, 'sand'); // outside tunnels (B洞外)
+
+// ---------------------------------------------------------------- B site
+flat(4, 4, 32, 30, 0, 'tiles'); // B site
+flat(32, 12, 34, 17, 0, 'stone'); // B doors doorway
+flat(12, 4, 13.5, 10, 0.4, 'stone'); // step to B back plat
+flat(4, 4, 12, 10, 0.8, 'stone'); // B back plat
+
+// ---------------------------------------------------------------- Ceilings & lintels (no movement impact)
+box(16, 31.5, 24, 58, H_UT + 3.3, H_UT + 3.9, 'ceiling'); // upper tunnel roof
+box(24, 46, 42, 51, 3.2, 3.8, 'ceiling'); // lower tunnel roof
+box(45, 24, 50, 26, 3.3, 7.2, 'lintel'); // mid doors
+box(83, 83, 86, 89, 3.7, 7.2, 'lintel'); // long doors
+box(32, 12, 34, 17, 3.0, 7.2, 'lintel'); // B doors
+box(17, 30, 23, 31.5, 3.4, 7.2, 'lintel'); // tunnel exit
+box(14, 58, 30, 58.6, 4.0, 5.0, 'beam', false); // decorative beam at the tunnel mouth
+box(71.5, 84, 72.5, 92, 3.7, 7.6, 'lintel'); // arch: T spawn ramp -> outside long
+box(42, 81.5, 52, 82.5, H_T + 3.5, H_T + 6.4, 'lintel'); // arch over the suicide slope
+box(76, 27.6, 80, 28.4, H_A + 3.1, H_A + 6.4, 'lintel'); // arch: short A -> A site
+box(53, 55.6, 58, 56.4, H_A + 3.0, H_A + 6.2, 'lintel'); // arch at the top of the catwalk stairs
+box(30.5, 84, 31.5, 92, H_UT + 3.4, H_UT + 6.6, 'lintel'); // arch: T spawn -> outside tunnels
+
+// ---------------------------------------------------------------- Cover objects
+// A site
+crate(85.6, 10.6, 2.2, 2.2, 2.2, H_A); // A big boxes
+crate(87.8, 10.6, 1.2, 1.2, 1.1, H_A);
+crate(85.6, 12.8, 1.2, 1.2, 1.1, H_A);
+crate(92, 18.5, 1.2, 1.2, 1.1, H_A); // default plant box
+crate(101.8, 4.6, 1.4, 1.4, 1.2, H_A + 0.8); // goose
+crate(77, 24.2, 1.2, 1.2, 1.1, H_A); // short corner box
+box(79.2, 19.6, 83.6, 21.8, H_A, H_A + 1.35, 'car'); // A car
+// B site
+crate(14, 15, 2.4, 2.4, 2.2, 0); // B double stack
+crate(16.4, 15, 1.2, 1.2, 1.1, 0);
+crate(14, 17.4, 1.2, 1.2, 1.1, 0);
+crate(24.2, 23.6, 1.2, 1.2, 1.1, 0); // boxes near the tunnel exit
+crate(25.4, 23.6, 1.2, 1.2, 1.1, 0);
+crate(24.6, 23.6, 1.2, 1.2, 1.1, 1.1);
+box(5.4, 23.6, 9.8, 25.8, 0, 1.35, 'car'); // B car
+crate(5, 4.6, 1.2, 1.2, 1.1, 0.8); // on back plat
+box(28.6, 5, 29.6, 6, 0, 1.2, 'barrel');
+box(29.8, 5, 30.8, 6, 0, 1.2, 'barrel');
+// Mid & CT mid
+crate(49.6, 65.2, 1.8, 1.8, 1.15, 0); // Xbox
+crate(52, 19, 2.2, 2.2, 2.2, 0); // CT mid boxes
+crate(38, 11, 1.2, 1.2, 1.1, 0);
+crate(42.4, 27.2, 1.2, 1.2, 1.1, 0); // mid doors box (T side)
+// CT spawn
+crate(62, 5.4, 2.2, 2.2, 2.2, 0);
+crate(64.2, 5.4, 1.2, 1.2, 1.1, 0);
+crate(68, 19, 1.2, 1.2, 1.1, 0);
+// Long A
+box(100, 63, 104, 71, 0, 2.6, 'container'); // blue container
+crate(92.6, 52, 1.2, 1.2, 1.1, 0);
+crate(95, 84, 2.2, 2.2, 2.2, 0); // long corner boxes
+crate(97.2, 84, 1.2, 1.2, 1.1, 0);
+box(88.4, 72.6, 89.4, 73.6, 0, 1.2, 'barrel');
+// Outside long / T spawn / outside tunnels
+crate(75.6, 81, 1.2, 1.2, 1.1, 0);
+crate(44, 86, 2.2, 2.2, 2.2, H_T);
+crate(58.6, 94.6, 1.2, 1.2, 1.1, H_T);
+box(39, 95.4, 40, 96.4, H_T, H_T + 1.2, 'barrel');
+crate(20, 72, 2.2, 2.2, 2.2, H_UT);
+crate(25, 82, 1.2, 1.2, 1.1, H_UT);
+box(26.4, 86, 27.4, 87, H_UT, H_UT + 1.2, 'barrel');
+
+// ---------------------------------------------------------------- Door leaves
+const DEG = Math.PI / 180;
+const doors: DoorLeafDef[] = [
+ // Mid doors: west leaf closed across the doorway, east leaf swung open towards CT side.
+ { hx: 45, hz: 25, angle: 0, length: 2.5, height: 3.2, y0: 0, thickness: 0.14 },
+ { hx: 50, hz: 25, angle: -90 * DEG, length: 2.5, height: 3.2, y0: 0, thickness: 0.14 },
+ // Long doors: both leaves half open towards long, leaving the famous gap.
+ { hx: 84.5, hz: 83, angle: 35 * DEG, length: 2.9, height: 3.6, y0: 0, thickness: 0.14 },
+ { hx: 84.5, hz: 89, angle: -35 * DEG, length: 2.9, height: 3.6, y0: 0, thickness: 0.14 },
+ // B doors: one leaf left hanging open against the B site wall.
+ { hx: 32.6, hz: 12, angle: 180 * DEG, length: 2.4, height: 2.9, y0: 0, thickness: 0.14 },
+];
+
+const windows: WindowDef[] = [
+ { x0: 32, z0: 20, x1: 34, z1: 23, sill: 1.1, top: 2.5 }, // B window (CT mid -> B site)
+];
+
+export const DUST2: MapDef = {
+ name: 'de_dust2',
+ bounds: { x0: 0, z0: 0, x1: 108, z1: 104 },
+ wallHeight: [6.6, 9.4],
+ floors,
+ boxes,
+ doors,
+ windows,
+ zones: [
+ { name: 'T 出生点', x0: 30, z0: 80, x1: 72, z1: 100 },
+ { name: 'A 门外', x0: 72, z0: 80, x1: 83, z1: 92 },
+ { name: 'A 大门', x0: 83, z0: 80, x1: 88, z1: 92 },
+ { name: 'A 大', x0: 86, z0: 36, x1: 104, z1: 92 },
+ { name: 'A 坑', x0: 98, z0: 28, x1: 104, z1: 40 },
+ { name: 'A 点', x0: 76, z0: 4, x1: 104, z1: 28 },
+ { name: 'A 斜坡', x0: 88, z0: 28, x1: 98, z1: 36 },
+ { name: 'CT 出生点', x0: 56, z0: 4, x1: 76, z1: 22 },
+ { name: '中门', x0: 34, z0: 10, x1: 56, z1: 26 },
+ { name: '中路', x0: 40, z0: 26, x1: 52, z1: 82 },
+ { name: '猫道', x0: 52, z0: 33, x1: 58, z1: 64 },
+ { name: 'A 小', x0: 53, z0: 28, x1: 80, z1: 33 },
+ { name: '下 B 洞', x0: 24, z0: 45, x1: 42, z1: 52 },
+ { name: 'B 洞', x0: 15, z0: 30, x1: 25, z1: 64 },
+ { name: 'B 洞外', x0: 14, z0: 64, x1: 30, z1: 92 },
+ { name: 'B 门', x0: 30, z0: 10, x1: 34, z1: 18 },
+ { name: 'B 点', x0: 4, z0: 4, x1: 32, z1: 30 },
+ ],
+ bombsites: [
+ { id: 'A', x0: 79, z0: 6, x1: 102, z1: 26, plant: [90.4, 15.2] },
+ { id: 'B', x0: 5, z0: 6, x1: 28, z1: 28, plant: [18.6, 20.2] },
+ ],
+ spawns: {
+ T: [
+ [44.5, 91],
+ [48.5, 93],
+ [52.5, 90.5],
+ [56.5, 93],
+ [60.5, 91],
+ ],
+ CT: [
+ [60, 10],
+ [63.5, 8.5],
+ [67, 10],
+ [62, 14.5],
+ [66, 15],
+ ],
+ },
+ spawnLook: { T: [51, 70], CT: [47.5, 25] },
+ points: {
+ tSpawn: [52, 89],
+ topMid: [46, 67],
+ midLow: [47, 56],
+ mid: [47, 44],
+ midDoorsT: [47.5, 29.5],
+ midDoorsCT: [48.6, 21],
+ ctMid: [42, 17],
+ ctSpawn: [64, 12],
+ outsideLong: [78, 86],
+ longDoorsIn: [90, 86],
+ longCorner: [95, 79],
+ longMid: [95, 58],
+ longEnd: [93, 40],
+ aRamp: [93, 31],
+ catBottom: [55, 66.5],
+ catTop: [55.5, 50],
+ catEnd: [55.5, 30.5],
+ short: [70, 30.5],
+ shortEnd: [78, 26],
+ aSite: [88, 16],
+ outsideTunnels: [22, 80],
+ upperTunnelsSouth: [20, 60],
+ upperTunnels: [20, 44],
+ tunnelExit: [20, 32.5],
+ lowerTunnels: [34, 48.5],
+ bDoors: [33, 14.5],
+ bSite: [16, 21],
+ ctRamp: [73, 13],
+ },
+ routes: {
+ long: ['tSpawn', 'outsideLong', 'longDoorsIn', 'longCorner', 'longMid', 'longEnd', 'aRamp'],
+ short: ['tSpawn', 'topMid', 'catBottom', 'catTop', 'catEnd', 'short', 'shortEnd'],
+ midToA: ['tSpawn', 'topMid', 'midLow', 'mid', 'midDoorsT'],
+ tunnels: ['tSpawn', 'outsideTunnels', 'upperTunnelsSouth', 'upperTunnels', 'tunnelExit'],
+ midToB: ['tSpawn', 'topMid', 'midLow', 'mid', 'lowerTunnels', 'upperTunnels', 'tunnelExit'],
+ midDoors: ['tSpawn', 'topMid', 'midLow', 'mid', 'midDoorsT', 'midDoorsCT', 'ctMid', 'bDoors'],
+ },
+ ctHolds: [
+ { id: 'aLong', pos: [93, 23.5], look: [95, 60], area: 'A' },
+ { id: 'aShort', pos: [84.5, 8.5], look: [77, 30], area: 'A' },
+ { id: 'aGoose', pos: [100.5, 7.5], look: [93, 36], area: 'A' },
+ { id: 'aCt', pos: [80, 12], look: [78, 28], area: 'A' },
+ { id: 'mid', pos: [43.5, 17.5], look: [47.5, 30], area: 'mid' },
+ { id: 'midBox', pos: [55, 21.5], look: [48, 28], area: 'mid' },
+ { id: 'bSite', pos: [9.5, 13], look: [20, 31], area: 'B' },
+ { id: 'bPlat', pos: [7, 7], look: [20, 31], area: 'B' },
+ { id: 'bDoors', pos: [28.5, 17.5], look: [20, 31], area: 'B' },
+ { id: 'bCar', pos: [11, 27.5], look: [21, 30], area: 'B' },
+ ],
+ postPlant: {
+ A: [
+ { id: 'pA1', pos: [83, 7], look: [74, 13], area: 'A' },
+ { id: 'pA2', pos: [88.5, 24], look: [78, 30], area: 'A' },
+ { id: 'pA3', pos: [97, 15], look: [74, 13], area: 'A' },
+ { id: 'pA4', pos: [86, 16.5], look: [76, 13], area: 'A' },
+ ],
+ B: [
+ { id: 'pB1', pos: [24, 12], look: [33, 14.5], area: 'B' },
+ { id: 'pB2', pos: [10, 22], look: [33, 14.5], area: 'B' },
+ { id: 'pB3', pos: [17, 8], look: [33, 14.5], area: 'B' },
+ { id: 'pB4', pos: [27, 27], look: [33, 21.5], area: 'B' },
+ ],
+ },
+ labels: [
+ { text: 'A', x: 89, z: 15.5, big: true },
+ { text: 'B', x: 16.5, z: 20.5, big: true },
+ { text: 'T出生点', x: 51, z: 94.5 },
+ { text: 'CT出生点', x: 64, z: 19 },
+ { text: 'A大', x: 95.5, z: 56 },
+ { text: 'A大门', x: 84.5, z: 79.5 },
+ { text: 'A坑', x: 101, z: 32 },
+ { text: '中路', x: 47, z: 46 },
+ { text: '中门', x: 47.5, z: 21.5 },
+ { text: '猫道', x: 55.5, z: 44 },
+ { text: 'A小', x: 68, z: 30.6 },
+ { text: 'B洞', x: 20, z: 44 },
+ { text: '下B洞', x: 33.5, z: 48.6 },
+ { text: 'B洞外', x: 22, z: 78 },
+ { text: 'B门', x: 36.5, z: 14.5 },
+ { text: 'A门外', x: 77.5, z: 89.5 },
+ ],
+};
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/NavGrid.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/NavGrid.ts
new file mode 100644
index 0000000..2e3dd65
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/game/world/NavGrid.ts
@@ -0,0 +1,405 @@
+import { PHYS } from '../core/config';
+import { MASK_MOVE, type Collider, type CollisionWorld } from './Collision';
+
+export interface NavPoint {
+ x: number;
+ z: number;
+}
+
+const MAX_CLIMB = 0.5;
+const SQRT2 = Math.SQRT2;
+
+/** Binary min-heap over cell indices keyed by an external score array. */
+class IndexHeap {
+ private items: Int32Array;
+ private size = 0;
+
+ constructor(capacity: number, private readonly score: Float32Array) {
+ this.items = new Int32Array(capacity);
+ }
+
+ clear(): void {
+ this.size = 0;
+ }
+
+ get length(): number {
+ return this.size;
+ }
+
+ push(i: number): void {
+ if (this.size >= this.items.length) {
+ const grown = new Int32Array(this.items.length * 2);
+ grown.set(this.items);
+ this.items = grown;
+ }
+ let k = this.size++;
+ const items = this.items;
+ const s = this.score[i];
+ while (k > 0) {
+ const p = (k - 1) >> 1;
+ if (this.score[items[p]] <= s) break;
+ items[k] = items[p];
+ k = p;
+ }
+ items[k] = i;
+ }
+
+ pop(): number {
+ const items = this.items;
+ const top = items[0];
+ const last = items[--this.size];
+ if (this.size > 0) {
+ let k = 0;
+ const s = this.score[last];
+ for (;;) {
+ let c = 2 * k + 1;
+ if (c >= this.size) break;
+ if (c + 1 < this.size && this.score[items[c + 1]] < this.score[items[c]]) c++;
+ if (this.score[items[c]] >= s) break;
+ items[k] = items[c];
+ k = c;
+ }
+ items[k] = last;
+ }
+ return top;
+ }
+}
+
+/**
+ * 2.5D navigation grid (one walkable surface per cell). Built from the designer floor raster and
+ * validated against the collision world with the agent hull inflated, so a path through walkable
+ * cells is always physically traversable. A* with octile heuristic + string-pulling smoothing.
+ */
+export class NavGrid {
+ readonly cols: number;
+ readonly rows: number;
+ readonly walkable: Uint8Array;
+ readonly height: Float32Array;
+ /** Chebyshev distance (in cells) to the nearest non-walkable cell, capped. */
+ readonly clearance: Uint8Array;
+
+ private readonly g: Float32Array;
+ private readonly f: Float32Array;
+ private readonly parent: Int32Array;
+ private readonly visit: Uint32Array;
+ private readonly closed: Uint32Array;
+ private generation = 1;
+ private readonly heap: IndexHeap;
+
+ constructor(
+ readonly originX: number,
+ readonly originZ: number,
+ readonly res: number,
+ cols: number,
+ rows: number,
+ floorHeight: Float32Array,
+ collision: CollisionWorld,
+ seeds: NavPoint[],
+ ) {
+ this.cols = cols;
+ this.rows = rows;
+ const n = cols * rows;
+ this.walkable = new Uint8Array(n);
+ this.height = new Float32Array(n);
+ this.clearance = new Uint8Array(n);
+ this.g = new Float32Array(n);
+ this.f = new Float32Array(n);
+ this.parent = new Int32Array(n);
+ this.visit = new Uint32Array(n);
+ this.closed = new Uint32Array(n);
+ this.heap = new IndexHeap(4096, this.f);
+
+ // 1. hull-inflated obstacle test per floor cell. Inflating by hull radius + half a cell means
+ // any point inside a walkable cell keeps the square hull clear of geometry, so straight
+ // lines through walkable cells (smoothed paths) never clip convex corners.
+ const R = PHYS.radius + res * 0.5;
+ const list: Collider[] = [];
+ for (let j = 0; j < rows; j++) {
+ for (let i = 0; i < cols; i++) {
+ const idx = j * cols + i;
+ const h = floorHeight[idx];
+ this.height[idx] = Number.isNaN(h) ? 0 : h;
+ if (Number.isNaN(h)) continue;
+ const cx = originX + (i + 0.5) * res;
+ const cz = originZ + (j + 0.5) * res;
+ collision.query(cx - R, cz - R, cx + R, cz + R, MASK_MOVE, list);
+ let blocked = false;
+ for (const c of list) {
+ if (c.minY >= h + PHYS.height - 0.05) continue;
+ const top = c.topOver(cx - R, cz - R, cx + R, cz + R);
+ if (top <= h + PHYS.stepHeight - 0.02) continue;
+ blocked = true;
+ break;
+ }
+ if (!blocked) this.walkable[idx] = 1;
+ }
+ }
+
+ // 2. keep only cells reachable from the spawns (removes unreachable islands)
+ const reach = new Uint8Array(n);
+ const queue = new Int32Array(n);
+ let qh = 0;
+ let qt = 0;
+ for (const s of seeds) {
+ const idx = this.nearestWalkableIndex(s.x, s.z, 6);
+ if (idx >= 0 && !reach[idx]) {
+ reach[idx] = 1;
+ queue[qt++] = idx;
+ }
+ }
+ while (qh < qt) {
+ const cur = queue[qh++];
+ const ci = cur % cols;
+ const cj = (cur - ci) / cols;
+ for (let dj = -1; dj <= 1; dj++) {
+ for (let di = -1; di <= 1; di++) {
+ if (!di && !dj) continue;
+ const ni = ci + di;
+ const nj = cj + dj;
+ if (ni < 0 || nj < 0 || ni >= cols || nj >= rows) continue;
+ const nIdx = nj * cols + ni;
+ if (reach[nIdx] || !this.canStep(cur, ci, cj, di, dj)) continue;
+ reach[nIdx] = 1;
+ queue[qt++] = nIdx;
+ }
+ }
+ }
+ for (let i = 0; i < n; i++) if (!reach[i]) this.walkable[i] = 0;
+
+ // 3. clearance field (multi-source BFS from blocked cells)
+ const dist = this.clearance;
+ dist.fill(255);
+ qh = 0;
+ qt = 0;
+ for (let i = 0; i < n; i++) {
+ if (!this.walkable[i]) {
+ dist[i] = 0;
+ queue[qt++] = i;
+ }
+ }
+ while (qh < qt) {
+ const cur = queue[qh++];
+ const ci = cur % cols;
+ const cj = (cur - ci) / cols;
+ const d = dist[cur];
+ if (d >= 8) continue;
+ for (let dj = -1; dj <= 1; dj++) {
+ for (let di = -1; di <= 1; di++) {
+ const ni = ci + di;
+ const nj = cj + dj;
+ if (ni < 0 || nj < 0 || ni >= cols || nj >= rows) continue;
+ const nIdx = nj * cols + ni;
+ if (dist[nIdx] > d + 1) {
+ dist[nIdx] = d + 1;
+ queue[qt++] = nIdx;
+ }
+ }
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------ queries
+
+ cellIndex(x: number, z: number): number {
+ const i = Math.floor((x - this.originX) / this.res);
+ const j = Math.floor((z - this.originZ) / this.res);
+ if (i < 0 || j < 0 || i >= this.cols || j >= this.rows) return -1;
+ return j * this.cols + i;
+ }
+
+ cellCenterX(idx: number): number {
+ return this.originX + ((idx % this.cols) + 0.5) * this.res;
+ }
+
+ cellCenterZ(idx: number): number {
+ return this.originZ + (Math.floor(idx / this.cols) + 0.5) * this.res;
+ }
+
+ isWalkable(x: number, z: number): boolean {
+ const idx = this.cellIndex(x, z);
+ return idx >= 0 && this.walkable[idx] === 1;
+ }
+
+ heightAt(x: number, z: number): number {
+ const idx = this.cellIndex(x, z);
+ return idx >= 0 ? this.height[idx] : 0;
+ }
+
+ /**
+ * Nearest walkable cell index within `maxRadius` meters (ring search), or -1.
+ * With `y`, only cells whose floor is within a step of that height qualify (so a bot standing
+ * below a ledge never "recovers" towards the top of it).
+ */
+ nearestWalkableIndex(x: number, z: number, maxRadius = 4, y?: number): number {
+ const ci = Math.floor((x - this.originX) / this.res);
+ const cj = Math.floor((z - this.originZ) / this.res);
+ const maxR = Math.ceil(maxRadius / this.res);
+ let best = -1;
+ let bestD = Infinity;
+ for (let r = 0; r <= maxR; r++) {
+ for (let dj = -r; dj <= r; dj++) {
+ for (let di = -r; di <= r; di++) {
+ if (Math.max(Math.abs(di), Math.abs(dj)) !== r) continue;
+ const i = ci + di;
+ const j = cj + dj;
+ if (i < 0 || j < 0 || i >= this.cols || j >= this.rows) continue;
+ const idx = j * this.cols + i;
+ if (!this.walkable[idx]) continue;
+ if (y !== undefined && Math.abs(this.height[idx] - y) > MAX_CLIMB + 0.1) continue;
+ const d = di * di + dj * dj;
+ if (d < bestD) {
+ bestD = d;
+ best = idx;
+ }
+ }
+ }
+ if (best >= 0) return best;
+ }
+ return -1;
+ }
+
+ nearestWalkable(x: number, z: number, maxRadius = 4): NavPoint | null {
+ const idx = this.nearestWalkableIndex(x, z, maxRadius);
+ if (idx < 0) return null;
+ return { x: this.cellCenterX(idx), z: this.cellCenterZ(idx) };
+ }
+
+ private canStep(cur: number, ci: number, cj: number, di: number, dj: number): boolean {
+ const cols = this.cols;
+ const nIdx = (cj + dj) * cols + (ci + di);
+ if (!this.walkable[nIdx]) return false;
+ const h = this.height[cur];
+ if (Math.abs(this.height[nIdx] - h) > MAX_CLIMB) return false;
+ if (di !== 0 && dj !== 0) {
+ const a = cj * cols + (ci + di);
+ const b = (cj + dj) * cols + ci;
+ if (!this.walkable[a] || !this.walkable[b]) return false;
+ if (Math.abs(this.height[a] - h) > MAX_CLIMB || Math.abs(this.height[b] - h) > MAX_CLIMB) return false;
+ }
+ return true;
+ }
+
+ /** Straight walk check between two points on the grid (used for path smoothing & steering). */
+ lineWalkable(ax: number, az: number, bx: number, bz: number): boolean {
+ const dx = bx - ax;
+ const dz = bz - az;
+ const len = Math.hypot(dx, dz);
+ const steps = Math.max(1, Math.ceil(len / (this.res * 0.35)));
+ let prevIdx = this.cellIndex(ax, az);
+ if (prevIdx < 0 || !this.walkable[prevIdx]) return false;
+ let prevH = this.height[prevIdx];
+ for (let s = 1; s <= steps; s++) {
+ const t = s / steps;
+ const idx = this.cellIndex(ax + dx * t, az + dz * t);
+ if (idx < 0 || !this.walkable[idx]) return false;
+ if (idx !== prevIdx) {
+ const h = this.height[idx];
+ if (Math.abs(h - prevH) > MAX_CLIMB) return false;
+ // reject cutting diagonally through two blocked corner cells
+ const pi = prevIdx % this.cols;
+ const pj = (prevIdx - pi) / this.cols;
+ const ni = idx % this.cols;
+ const nj = (idx - ni) / this.cols;
+ if (pi !== ni && pj !== nj) {
+ if (!this.walkable[pj * this.cols + ni] || !this.walkable[nj * this.cols + pi]) return false;
+ }
+ prevIdx = idx;
+ prevH = h;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * A* from (sx,sz) to (gx,gz). Writes a smoothed waypoint list into `out` (excluding the start).
+ * Returns false if no path exists.
+ */
+ findPath(sx: number, sz: number, gx: number, gz: number, out: NavPoint[], sy?: number): boolean {
+ out.length = 0;
+ let start = sy !== undefined ? this.nearestWalkableIndex(sx, sz, 3, sy) : -1;
+ if (start < 0) start = this.nearestWalkableIndex(sx, sz, 3);
+ const goal = this.nearestWalkableIndex(gx, gz, 4);
+ if (start < 0 || goal < 0) return false;
+ if (start === goal) {
+ out.push({ x: gx, z: gz });
+ return true;
+ }
+ const cols = this.cols;
+ const gen = ++this.generation;
+ const gI = goal % cols;
+ const gJ = (goal - gI) / cols;
+ const heur = (idx: number): number => {
+ const i = idx % cols;
+ const j = (idx - i) / cols;
+ const dx = Math.abs(i - gI);
+ const dz = Math.abs(j - gJ);
+ return dx + dz + (SQRT2 - 2) * Math.min(dx, dz);
+ };
+ const heap = this.heap;
+ heap.clear();
+ this.g[start] = 0;
+ this.f[start] = heur(start);
+ this.parent[start] = -1;
+ this.visit[start] = gen;
+ heap.push(start);
+ let found = false;
+ let expansions = 0;
+ while (heap.length > 0) {
+ const cur = heap.pop();
+ if (this.closed[cur] === gen) continue;
+ this.closed[cur] = gen;
+ if (cur === goal) {
+ found = true;
+ break;
+ }
+ if (++expansions > 60000) break;
+ const ci = cur % cols;
+ const cj = (cur - ci) / cols;
+ const gCur = this.g[cur];
+ for (let dj = -1; dj <= 1; dj++) {
+ for (let di = -1; di <= 1; di++) {
+ if (!di && !dj) continue;
+ const ni = ci + di;
+ const nj = cj + dj;
+ if (ni < 0 || nj < 0 || ni >= cols || nj >= this.rows) continue;
+ const nIdx = nj * cols + ni;
+ if (this.closed[nIdx] === gen) continue;
+ if (!this.canStep(cur, ci, cj, di, dj)) continue;
+ const clr = this.clearance[nIdx];
+ const penalty = clr <= 1 ? 1.6 : clr === 2 ? 0.5 : clr === 3 ? 0.15 : 0;
+ const ng = gCur + (di && dj ? SQRT2 : 1) * (1 + penalty);
+ if (this.visit[nIdx] !== gen || ng < this.g[nIdx]) {
+ this.visit[nIdx] = gen;
+ this.g[nIdx] = ng;
+ this.f[nIdx] = ng + heur(nIdx);
+ this.parent[nIdx] = cur;
+ heap.push(nIdx);
+ }
+ }
+ }
+ }
+ if (!found) return false;
+
+ // reconstruct cell chain
+ const chain: number[] = [];
+ for (let c = goal; c !== -1; c = this.parent[c]) chain.push(c);
+ chain.reverse();
+
+ // string pulling
+ const pts: NavPoint[] = chain.map((c) => ({ x: this.cellCenterX(c), z: this.cellCenterZ(c) }));
+ const goalPt: NavPoint = this.isWalkable(gx, gz) ? { x: gx, z: gz } : pts[pts.length - 1];
+ pts[pts.length - 1] = goalPt;
+ let anchor: NavPoint = this.isWalkable(sx, sz) ? { x: sx, z: sz } : pts[0];
+ for (let k = 1; k < pts.length; k++) {
+ if (!this.lineWalkable(anchor.x, anchor.z, pts[k].x, pts[k].z)) {
+ const corner = pts[k - 1];
+ if (corner !== anchor) {
+ out.push(corner);
+ anchor = corner;
+ }
+ }
+ }
+ out.push(goalPt);
+ return true;
+ }
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/main.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/main.tsx
new file mode 100644
index 0000000..17fdf90
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/main.tsx
@@ -0,0 +1,7 @@
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+// StrictMode is intentionally not used: it double-mounts effects in development, which would
+// create and immediately destroy a WebGL context + game loop.
+createRoot(document.getElementById('root')!).render();
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/styles.css b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/styles.css
new file mode 100644
index 0000000..59e4371
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/styles.css
@@ -0,0 +1,1329 @@
+:root {
+ --ct: #5aa2ff;
+ --ct-deep: #2a5ea8;
+ --t: #f2b233;
+ --t-deep: #a8701a;
+ --danger: #ff4b3e;
+ --ok: #3fdc6a;
+ --panel: rgba(12, 16, 22, 0.55);
+ --panel-strong: rgba(12, 16, 22, 0.82);
+ --line: rgba(255, 255, 255, 0.12);
+ --text: #eef2f6;
+ --muted: rgba(238, 242, 246, 0.62);
+ --font: 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans SC', system-ui, sans-serif;
+ --mono: 'Consolas', 'SFMono-Regular', 'Menlo', monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: #0b0e12;
+ color: var(--text);
+ font-family: var(--font);
+ user-select: none;
+}
+
+button {
+ font-family: inherit;
+}
+
+kbd {
+ display: inline-block;
+ min-width: 1.6em;
+ padding: 1px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-bottom-width: 2px;
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.06);
+ font-family: var(--mono);
+ font-size: 12px;
+ text-align: center;
+ color: #fff;
+}
+
+/* ------------------------------------------------------------------ menu */
+
+.menu-root {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ overflow: auto;
+}
+
+.menu-bg {
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(1200px 600px at 20% 10%, rgba(242, 178, 51, 0.22), transparent 60%),
+ radial-gradient(900px 600px at 85% 90%, rgba(90, 162, 255, 0.18), transparent 60%),
+ linear-gradient(160deg, #2a2116 0%, #14110d 45%, #0b0e12 100%);
+}
+
+.menu-bg::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 64px),
+ repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 64px);
+}
+
+.menu-card {
+ position: relative;
+ width: min(1060px, calc(100vw - 32px));
+ margin: 24px 0;
+ padding: 28px 32px 24px;
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(14, 17, 22, 0.78);
+ backdrop-filter: blur(14px);
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
+}
+
+.menu-head {
+ margin-bottom: 18px;
+}
+
+.logo {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ font-weight: 900;
+ letter-spacing: 6px;
+}
+
+.logo-mark {
+ font-size: 56px;
+ background: linear-gradient(180deg, #ffe2a3, #e0a33b 60%, #9c6a1f);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ text-shadow: 0 6px 30px rgba(242, 178, 51, 0.25);
+}
+
+.logo-num {
+ font-size: 40px;
+ color: var(--ct);
+}
+
+.logo-sub {
+ font-size: 18px;
+ font-weight: 600;
+ margin-top: 2px;
+}
+
+.logo-meta {
+ font-size: 13px;
+ color: var(--muted);
+ margin-top: 4px;
+}
+
+.menu-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 26px;
+}
+
+@media (max-width: 760px) {
+ .menu-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.menu-section h3,
+.pause-card h3,
+.controls h3 {
+ margin: 14px 0 8px;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ color: var(--muted);
+ text-transform: uppercase;
+}
+
+.menu-section h3 em,
+.pause-card h3 em {
+ font-style: normal;
+ color: var(--t);
+ margin-left: 6px;
+}
+
+.segmented {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.seg {
+ flex: 1;
+ min-width: 90px;
+ padding: 9px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.seg:hover {
+ background: rgba(255, 255, 255, 0.09);
+}
+
+.seg.active {
+ border-color: rgba(242, 178, 51, 0.8);
+ background: rgba(242, 178, 51, 0.16);
+ color: #fff;
+}
+
+.seg.ct.active {
+ border-color: var(--ct);
+ background: rgba(90, 162, 255, 0.2);
+}
+
+.seg.t.active {
+ border-color: var(--t);
+ background: rgba(242, 178, 51, 0.2);
+}
+
+.text-input {
+ width: 100%;
+ padding: 9px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+ font-size: 14px;
+ outline: none;
+ user-select: text;
+}
+
+input[type='range'] {
+ width: 100%;
+ accent-color: var(--t);
+}
+
+.check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 12px;
+ font-size: 14px;
+ color: var(--muted);
+}
+
+.start-btn {
+ display: block;
+ width: 100%;
+ margin-top: 22px;
+ padding: 15px;
+ border: none;
+ border-radius: 12px;
+ background: linear-gradient(180deg, #f6c35a, #d48f21);
+ color: #1b1307;
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: 4px;
+ cursor: pointer;
+ box-shadow: 0 10px 30px rgba(242, 178, 51, 0.25);
+ transition: transform 0.1s, filter 0.15s;
+}
+
+.start-btn:hover {
+ filter: brightness(1.08);
+}
+
+.start-btn:active {
+ transform: translateY(1px);
+}
+
+.btn {
+ margin-top: 16px;
+ padding: 10px 18px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.btn.ghost {
+ width: 100%;
+ background: transparent;
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.menu-foot {
+ margin-top: 10px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.controls {
+ margin-top: 6px;
+}
+
+.controls-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 5px;
+}
+
+.controls.compact .controls-grid {
+ font-size: 12.5px;
+}
+
+.controls-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--muted);
+}
+
+.controls-row kbd {
+ min-width: 112px;
+}
+
+/* ------------------------------------------------------------------ game */
+
+.game-root {
+ position: fixed;
+ inset: 0;
+ background: #000;
+}
+
+.game-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ cursor: crosshair;
+}
+
+.loading {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ gap: 16px;
+ background: #0b0e12;
+ color: var(--muted);
+ font-size: 15px;
+}
+
+.error-title {
+ font-size: 22px;
+ color: var(--danger);
+ font-weight: 700;
+}
+
+.spinner {
+ width: 42px;
+ height: 42px;
+ border: 4px solid rgba(255, 255, 255, 0.12);
+ border-top-color: var(--t);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.hud {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
+}
+
+/* crosshair */
+.crosshair {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 0;
+ height: 0;
+ transition: opacity 0.08s;
+}
+
+.ch-line {
+ position: absolute;
+ left: 0;
+ top: 0;
+ background: #7dff7a;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
+}
+
+.ch-line.v {
+ width: 2px;
+ height: 7px;
+}
+
+.ch-line.h {
+ width: 7px;
+ height: 2px;
+}
+
+.ch-dot {
+ position: absolute;
+ left: -1px;
+ top: -1px;
+ width: 2px;
+ height: 2px;
+ background: #7dff7a;
+}
+
+.hitmarker {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 28px;
+ height: 28px;
+ opacity: 0;
+}
+
+.hitmarker span {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 9px;
+ height: 2px;
+ background: #fff;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
+}
+
+.hitmarker span:nth-child(1) {
+ transform: translate(-150%, -400%) rotate(45deg);
+}
+.hitmarker span:nth-child(2) {
+ transform: translate(50%, -400%) rotate(-45deg);
+}
+.hitmarker span:nth-child(3) {
+ transform: translate(-150%, 300%) rotate(-45deg);
+}
+.hitmarker span:nth-child(4) {
+ transform: translate(50%, 300%) rotate(45deg);
+}
+
+.hitmarker.head span {
+ background: #ffd84a;
+}
+
+.hitmarker.kill span {
+ background: var(--danger);
+ width: 12px;
+}
+
+.damage-vignette {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ background: radial-gradient(ellipse at center, transparent 45%, rgba(190, 0, 0, 0.55) 100%);
+}
+
+.damage-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+/* scope */
+.scope {
+ position: absolute;
+ inset: 0;
+}
+
+.scope-mask {
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at center, transparent 0, transparent min(42vh, 42vw), rgba(0, 0, 0, 0.92) calc(min(42vh, 42vw) + 1px), #000 100%);
+}
+
+.scope-ring {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: min(84vh, 84vw);
+ height: min(84vh, 84vw);
+ transform: translate(-50%, -50%);
+ border-radius: 50%;
+ box-shadow: inset 0 0 60px 20px rgba(0, 0, 0, 0.75);
+}
+
+.scope-line {
+ position: absolute;
+ background: #000;
+}
+
+.scope-line.h {
+ left: 0;
+ right: 0;
+ top: 50%;
+ height: 1.5px;
+ transform: translateY(-50%);
+}
+
+.scope-line.v {
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1.5px;
+ transform: translateX(-50%);
+}
+
+/* top bar */
+.topbar {
+ position: absolute;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+}
+
+.team-side {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 12px;
+ border-radius: 10px;
+ background: var(--panel);
+ backdrop-filter: blur(6px);
+ border: 1px solid var(--line);
+}
+
+.pips {
+ display: flex;
+ gap: 4px;
+}
+
+.pip {
+ width: 10px;
+ height: 18px;
+ border-radius: 3px;
+}
+
+.pip.ct {
+ background: var(--ct);
+}
+
+.pip.t {
+ background: var(--t);
+}
+
+.pip.dead {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.score {
+ font-size: 26px;
+ font-weight: 800;
+ min-width: 26px;
+ text-align: center;
+}
+
+.score.ct {
+ color: var(--ct);
+}
+
+.score.t {
+ color: var(--t);
+}
+
+.center-clock {
+ min-width: 132px;
+ padding: 4px 14px;
+ border-radius: 10px;
+ background: var(--panel-strong);
+ border: 1px solid var(--line);
+ text-align: center;
+}
+
+.clock {
+ font-size: 26px;
+ font-weight: 800;
+ line-height: 1.15;
+}
+
+.clock.freeze {
+ color: #b9d7ff;
+}
+
+.clock.low {
+ color: var(--danger);
+}
+
+.clock.bomb {
+ color: var(--danger);
+ animation: pulse 0.8s ease-in-out infinite;
+}
+
+.bomb-icon {
+ font-size: 22px;
+}
+
+@keyframes pulse {
+ 50% {
+ opacity: 0.45;
+ }
+}
+
+.round-label {
+ font-size: 11.5px;
+ color: var(--muted);
+ letter-spacing: 1px;
+}
+
+/* top-left radar */
+.hud-tl {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+}
+
+.minimap {
+ display: block;
+ border-radius: 10px;
+ background: rgba(10, 12, 16, 0.55);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+}
+
+.location {
+ margin-top: 6px;
+ padding: 4px 10px;
+ display: inline-block;
+ border-radius: 8px;
+ background: var(--panel);
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 1px;
+}
+
+/* kill feed */
+.killfeed {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+}
+
+.kf-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 10px;
+ border-radius: 7px;
+ background: rgba(10, 12, 16, 0.62);
+ font-size: 14px;
+ font-weight: 600;
+ animation: slideIn 0.25s ease-out;
+}
+
+.kf-row.mine {
+ outline: 1.5px solid rgba(255, 70, 60, 0.85);
+ background: rgba(60, 10, 10, 0.6);
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(30px);
+ opacity: 0;
+ }
+}
+
+.kf-name.ct,
+.ct {
+ color: var(--ct);
+}
+
+.kf-name.t,
+.t {
+ color: var(--t);
+}
+
+.kf-weapon {
+ font-size: 12px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ font-family: var(--mono);
+}
+
+.kf-hs {
+ color: #ff5a4a;
+ font-size: 15px;
+}
+
+/* notices */
+.notices {
+ position: absolute;
+ left: 12px;
+ top: 300px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.notice {
+ padding: 6px 12px;
+ border-radius: 8px;
+ background: var(--panel);
+ border-left: 3px solid rgba(255, 255, 255, 0.4);
+ font-size: 13.5px;
+ animation: slideIn 0.25s ease-out;
+ max-width: 380px;
+}
+
+.notice.ct {
+ border-left-color: var(--ct);
+}
+
+.notice.t {
+ border-left-color: var(--t);
+}
+
+.notice.warn {
+ border-left-color: var(--danger);
+}
+
+/* banner */
+.banner {
+ position: absolute;
+ top: 92px;
+ left: 50%;
+ transform: translateX(-50%);
+ min-width: 420px;
+ padding: 12px 28px;
+ text-align: center;
+ border-radius: 12px;
+ background: var(--panel-strong);
+ border: 1px solid var(--line);
+ animation: bannerIn 0.35s ease-out;
+}
+
+.banner.ct {
+ border-color: var(--ct);
+ box-shadow: 0 0 40px rgba(90, 162, 255, 0.25);
+}
+
+.banner.t {
+ border-color: var(--t);
+ box-shadow: 0 0 40px rgba(242, 178, 51, 0.25);
+}
+
+.banner.warn {
+ border-color: var(--danger);
+ box-shadow: 0 0 40px rgba(255, 75, 62, 0.3);
+}
+
+.banner-title {
+ font-size: 26px;
+ font-weight: 800;
+ letter-spacing: 2px;
+}
+
+.banner.ct .banner-title {
+ color: var(--ct);
+}
+
+.banner.t .banner-title {
+ color: var(--t);
+}
+
+.banner.warn .banner-title {
+ color: var(--danger);
+}
+
+.banner-sub {
+ margin-top: 4px;
+ font-size: 14px;
+ color: var(--muted);
+}
+
+@keyframes bannerIn {
+ from {
+ transform: translate(-50%, -12px);
+ opacity: 0;
+ }
+}
+
+/* vitals */
+.vitals {
+ position: absolute;
+ left: 16px;
+ bottom: 16px;
+ padding: 12px 16px;
+ min-width: 260px;
+ border-radius: 12px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ backdrop-filter: blur(6px);
+}
+
+.vitals.ct {
+ border-bottom: 3px solid var(--ct);
+}
+
+.vitals.t {
+ border-bottom: 3px solid var(--t);
+}
+
+.vital {
+ display: grid;
+ grid-template-columns: 22px 56px 1fr;
+ align-items: center;
+ gap: 8px;
+ margin: 3px 0;
+}
+
+.v-icon {
+ font-size: 18px;
+ opacity: 0.9;
+}
+
+.v-num {
+ font-size: 30px;
+ font-weight: 800;
+}
+
+.v-num.mid {
+ color: #ffc24a;
+}
+
+.v-num.low {
+ color: var(--danger);
+ animation: pulse 0.9s infinite;
+}
+
+.v-bar {
+ height: 6px;
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.12);
+ overflow: hidden;
+}
+
+.v-fill {
+ height: 100%;
+ transition: width 0.2s;
+}
+
+.v-fill.hp {
+ background: linear-gradient(90deg, #6fe38a, #3fdc6a);
+}
+
+.v-fill.hp.mid {
+ background: #ffc24a;
+}
+
+.v-fill.hp.low {
+ background: var(--danger);
+}
+
+.v-fill.armor {
+ background: linear-gradient(90deg, #8fb8ff, #5aa2ff);
+}
+
+.v-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.tag {
+ font-size: 11.5px;
+ padding: 2px 8px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.tag.ct {
+ background: rgba(90, 162, 255, 0.25);
+ color: #fff;
+}
+
+.tag.bomb {
+ background: rgba(255, 140, 42, 0.35);
+ color: #fff;
+ animation: pulse 1.4s infinite;
+}
+
+/* ammo */
+.ammo {
+ position: absolute;
+ right: 16px;
+ bottom: 16px;
+ min-width: 250px;
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ backdrop-filter: blur(6px);
+ text-align: right;
+}
+
+.slots {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 3px;
+ margin-bottom: 8px;
+}
+
+.slot {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12.5px;
+ color: var(--muted);
+}
+
+.slot.active {
+ color: #fff;
+ font-weight: 700;
+}
+
+.slot.active kbd {
+ border-color: var(--t);
+ color: var(--t);
+}
+
+.weapon-name {
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ color: #ffe6b0;
+}
+
+.ammo-count {
+ display: flex;
+ justify-content: flex-end;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.mag {
+ font-size: 40px;
+ font-weight: 800;
+ line-height: 1.05;
+}
+
+.mag.low {
+ color: var(--danger);
+}
+
+.mag.reloading {
+ font-size: 22px;
+ color: #ffc24a;
+ animation: pulse 0.6s infinite;
+}
+
+.sep {
+ font-size: 22px;
+ color: var(--muted);
+}
+
+.reserve {
+ font-size: 20px;
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.bullets {
+ display: flex;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 2px;
+ margin-top: 6px;
+ max-width: 250px;
+ margin-left: auto;
+}
+
+.bullet {
+ width: 5px;
+ height: 12px;
+ border-radius: 2px 2px 1px 1px;
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.bullet.on {
+ background: #ffd98a;
+}
+
+/* objective */
+.objective {
+ position: absolute;
+ left: 50%;
+ bottom: 140px;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.action-hint {
+ padding: 6px 14px;
+ border-radius: 8px;
+ background: var(--panel-strong);
+ font-size: 15px;
+ font-weight: 600;
+ border: 1px solid rgba(255, 200, 80, 0.6);
+}
+
+.progress {
+ width: 340px;
+ text-align: center;
+}
+
+.progress-label {
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.progress-bar {
+ height: 10px;
+ border-radius: 5px;
+ background: rgba(0, 0, 0, 0.55);
+ border: 1px solid var(--line);
+ overflow: hidden;
+}
+
+.progress-fill {
+ height: 100%;
+}
+
+.progress.t .progress-fill {
+ background: linear-gradient(90deg, #ffb347, #ff7a2a);
+}
+
+.progress.ct .progress-fill {
+ background: linear-gradient(90deg, #8fb8ff, #3e8bff);
+}
+
+/* spectator */
+.spectator {
+ position: absolute;
+ left: 50%;
+ bottom: 70px;
+ transform: translateX(-50%);
+ padding: 10px 22px;
+ border-radius: 12px;
+ background: var(--panel-strong);
+ border: 1px solid var(--line);
+ text-align: center;
+}
+
+.spec-title {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.spec-sub {
+ margin-top: 3px;
+ font-size: 13px;
+ color: var(--muted);
+}
+
+.takeover-tag {
+ position: absolute;
+ left: 50%;
+ bottom: 16px;
+ transform: translateX(-50%);
+ padding: 4px 12px;
+ border-radius: 8px;
+ background: rgba(90, 162, 255, 0.18);
+ border: 1px solid rgba(90, 162, 255, 0.5);
+ font-size: 13px;
+}
+
+.fps {
+ position: absolute;
+ right: 16px;
+ bottom: 4px;
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ font-family: var(--mono);
+}
+
+/* buy menu */
+.buy-menu {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: min(760px, calc(100vw - 40px));
+ padding: 18px;
+ border-radius: 14px;
+ background: rgba(10, 13, 18, 0.9);
+ border: 1px solid var(--line);
+ pointer-events: auto;
+}
+
+.buy-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 12px;
+ font-size: 20px;
+ font-weight: 800;
+}
+
+.buy-head em {
+ font-style: normal;
+ font-size: 12.5px;
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.buy-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 8px;
+}
+
+.buy-item {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ column-gap: 10px;
+ align-items: center;
+ text-align: left;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+ cursor: pointer;
+}
+
+.buy-item kbd {
+ grid-row: span 2;
+}
+
+.buy-item:hover {
+ background: rgba(242, 178, 51, 0.15);
+ border-color: rgba(242, 178, 51, 0.6);
+}
+
+.buy-item.owned {
+ border-color: rgba(63, 220, 106, 0.6);
+}
+
+.buy-item.disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.buy-name {
+ font-weight: 700;
+}
+
+.buy-meta {
+ font-size: 11.5px;
+ color: var(--muted);
+}
+
+/* scoreboard */
+.scoreboard {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: min(900px, calc(100vw - 40px));
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ padding: 16px;
+ border-radius: 14px;
+ background: rgba(10, 13, 18, 0.9);
+ border: 1px solid var(--line);
+}
+
+.sb-team table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13.5px;
+}
+
+.sb-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 6px;
+ font-size: 17px;
+ font-weight: 800;
+}
+
+.sb-team.ct .sb-head {
+ color: var(--ct);
+}
+
+.sb-team.t .sb-head {
+ color: var(--t);
+}
+
+.sb-score {
+ font-size: 28px;
+}
+
+.sb-team th,
+.sb-team td {
+ padding: 5px 6px;
+ text-align: right;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.sb-team th:first-child,
+.sb-team td:first-child {
+ text-align: left;
+}
+
+.sb-team th {
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+.sb-team tr.dead td {
+ opacity: 0.45;
+}
+
+.sb-team tr.me td {
+ color: #fff;
+ font-weight: 700;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.sb-history {
+ grid-column: span 2;
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.hist {
+ width: 26px;
+ height: 26px;
+ display: grid;
+ place-items: center;
+ border-radius: 6px;
+ font-size: 13px;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.hist.ct {
+ border-bottom: 3px solid var(--ct);
+}
+
+.hist.t {
+ border-bottom: 3px solid var(--t);
+}
+
+/* pause / match over */
+.pause {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(4, 6, 9, 0.6);
+ backdrop-filter: blur(3px);
+ pointer-events: auto;
+}
+
+.pause-card {
+ width: min(820px, calc(100vw - 40px));
+ max-height: calc(100vh - 40px);
+ overflow: auto;
+ padding: 22px 26px;
+ border-radius: 16px;
+ background: rgba(14, 17, 22, 0.92);
+ border: 1px solid var(--line);
+ user-select: none;
+}
+
+.pause-card h2 {
+ margin: 0;
+ font-size: 26px;
+ letter-spacing: 2px;
+}
+
+.pause-intro {
+ margin: 8px 0 0;
+ color: var(--muted);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.pause-grid {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: 24px;
+ margin-top: 10px;
+}
+
+@media (max-width: 760px) {
+ .pause-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.matchover {
+ text-align: center;
+ max-width: 520px;
+}
+
+.matchover h2 {
+ font-size: 42px;
+}
+
+.matchover.ct h2 {
+ color: var(--ct);
+}
+
+.matchover.t h2 {
+ color: var(--t);
+}
+
+.mo-score {
+ margin-top: 10px;
+ font-size: 34px;
+ font-weight: 800;
+}
+
+.mo-sub,
+.mo-mvp {
+ margin-top: 6px;
+ color: var(--muted);
+}
+
+.mo-actions {
+ margin-top: 6px;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/ControlsHelp.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/ControlsHelp.tsx
new file mode 100644
index 0000000..8036a35
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/ControlsHelp.tsx
@@ -0,0 +1,32 @@
+const ROWS: [string, string][] = [
+ ['W A S D', '移动'],
+ ['空格', '跳跃'],
+ ['Shift', '静步(无脚步声)'],
+ ['C', '下蹲'],
+ ['鼠标左键', '开火 / 刀轻击'],
+ ['鼠标右键', 'AWP 开镜 / 刀重击'],
+ ['R', '换弹'],
+ ['1 / 2 / 3 / 5', '主武器 / 副武器 / 刀 / C4'],
+ ['Q · 滚轮', '上一把武器 · 切换武器'],
+ ['E', '安放 / 拆除炸弹 · 拾取武器 · 观战时接管队友'],
+ ['G', '丢弃当前武器 / C4'],
+ ['B', '购买菜单(冻结时间 / 回合前 20 秒)'],
+ ['Tab', '计分板'],
+ ['左/右键 · ←/→', '死亡后切换观察的队友'],
+];
+
+export function ControlsHelp({ compact = false }: { compact?: boolean }) {
+ return (
+
+
操作说明
+
+ {ROWS.map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Crosshair.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Crosshair.tsx
new file mode 100644
index 0000000..3ebf7e9
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Crosshair.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useRef } from 'react';
+import type { GameEngine } from '../game/engine/GameEngine';
+
+/**
+ * Per-frame widgets driven directly from the engine's FastHud (no React re-render):
+ * dynamic crosshair, hit marker, damage direction arcs and red damage vignette.
+ */
+export function Crosshair({ engine }: { engine: GameEngine }) {
+ const root = useRef(null);
+ const lines = useRef<(HTMLDivElement | null)[]>([]);
+ const hit = useRef(null);
+ const dmgCanvas = useRef(null);
+ const vignette = useRef(null);
+
+ useEffect(() => {
+ let raf = 0;
+ const tick = () => {
+ raf = requestAnimationFrame(tick);
+ const f = engine.fast;
+ const r = root.current;
+ if (r) r.style.opacity = f.showCrosshair ? '1' : '0';
+ const gap = f.crosshairGap;
+ const len = 7;
+ const [top, bottom, left, right] = lines.current;
+ if (top) top.style.transform = `translate(-50%, ${-gap - len}px)`;
+ if (bottom) bottom.style.transform = `translate(-50%, ${gap}px)`;
+ if (left) left.style.transform = `translate(${-gap - len}px, -50%)`;
+ if (right) right.style.transform = `translate(${gap}px, -50%)`;
+ const h = hit.current;
+ if (h) {
+ h.style.opacity = String(Math.min(1, f.hitMarker * 1.4));
+ h.className = `hitmarker ${f.hitKill ? 'kill' : f.hitHead ? 'head' : ''}`;
+ h.style.transform = `translate(-50%, -50%) scale(${1 + (1 - f.hitMarker) * 0.25})`;
+ }
+ const v = vignette.current;
+ if (v) v.style.opacity = String(Math.min(0.85, f.damageFlash));
+ const c = dmgCanvas.current;
+ if (c) {
+ const w = c.clientWidth;
+ const hh = c.clientHeight;
+ if (c.width !== w || c.height !== hh) {
+ c.width = w;
+ c.height = hh;
+ }
+ const ctx = c.getContext('2d')!;
+ ctx.clearRect(0, 0, w, hh);
+ const cx = w / 2;
+ const cy = hh / 2;
+ const rad = Math.min(w, hh) * 0.16;
+ for (const d of f.damageDirs) {
+ // angle 0 = straight ahead (up on screen), positive = to the left
+ const a = -Math.PI / 2 - d.angle;
+ ctx.strokeStyle = `rgba(255,40,30,${Math.max(0, d.alpha) * 0.85})`;
+ ctx.lineWidth = 7;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.arc(cx, cy, rad, a - 0.32, a + 0.32);
+ ctx.stroke();
+ }
+ }
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [engine]);
+
+ return (
+ <>
+
+
+
+
(lines.current[0] = el)} />
+
(lines.current[1] = el)} />
+
(lines.current[2] = el)} />
+
(lines.current[3] = el)} />
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/GameView.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/GameView.tsx
new file mode 100644
index 0000000..5549300
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/GameView.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useRef, useState } from 'react';
+import type { AudioEngine } from '../game/audio/AudioEngine';
+import type { MatchSettings } from '../game/core/types';
+import { GameEngine } from '../game/engine/GameEngine';
+import { Hud } from './Hud';
+
+interface Props {
+ settings: MatchSettings;
+ audio: AudioEngine;
+ onExit: (s?: MatchSettings) => void;
+}
+
+/**
+ * React owns the lifecycle (mount → create engine, unmount → dispose); the engine owns the
+ * canvas and its own requestAnimationFrame loop. HUD components subscribe to the engine store.
+ */
+export function GameView({ settings, audio, onExit }: Props) {
+ const canvasRef = useRef
(null);
+ // settings/audio are fixed for the lifetime of a session (GameView is keyed per session)
+ const initial = useRef({ settings, audio });
+ const [engine, setEngine] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ let e: GameEngine | null = null;
+ // defer one frame so the loading screen paints before the (synchronous) world build
+ const id = requestAnimationFrame(() => {
+ try {
+ e = new GameEngine(canvas, initial.current.settings, initial.current.audio);
+ e.start();
+ setEngine(e);
+ if (import.meta.env.DEV) (window as unknown as { __engine?: GameEngine }).__engine = e;
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ });
+ return () => {
+ cancelAnimationFrame(id);
+ e?.dispose();
+ };
+ }, []);
+
+ return (
+
+
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Hud.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Hud.tsx
new file mode 100644
index 0000000..e3e246a
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Hud.tsx
@@ -0,0 +1,292 @@
+import { memo } from 'react';
+import { formatClock } from '../game/core/math';
+import type { Team } from '../game/core/types';
+import type { GameEngine } from '../game/engine/GameEngine';
+import { useStore } from '../game/engine/Store';
+import type { MatchSettings } from '../game/core/types';
+import { Crosshair } from './Crosshair';
+import { Minimap } from './Minimap';
+import { BuyMenu, MatchOver, PauseMenu, Scoreboard } from './Overlays';
+
+interface HudProps {
+ engine: GameEngine;
+ onExit: (s?: MatchSettings) => void;
+}
+
+export function Hud({ engine, onExit }: HudProps) {
+ const paused = useStore(engine.hud, (s) => s.paused);
+ const matchWinner = useStore(engine.hud, (s) => s.matchWinner);
+ const scoped = useStore(engine.hud, (s) => s.scoped);
+ return (
+
+ {scoped &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {paused && !matchWinner &&
}
+ {matchWinner &&
}
+
+ );
+}
+
+const ScopeOverlay = memo(function ScopeOverlay() {
+ return (
+
+ );
+});
+
+function TopBar({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({
+ clock: h.clock,
+ phase: h.phase,
+ round: h.round,
+ pistol: h.pistolRound,
+ planted: h.bombPlanted,
+ ct: h.scoreCT,
+ t: h.scoreT,
+ aliveCT: h.aliveCT,
+ aliveT: h.aliveT,
+ }));
+ const clockClass = s.planted ? 'clock bomb' : s.phase === 'freeze' ? 'clock freeze' : s.clock <= 10 && s.phase === 'live' ? 'clock low' : 'clock';
+ return (
+
+
+
+ {s.aliveCT.map((a, i) => (
+
+ ))}
+
+
{s.ct}
+
+
+
{s.planted ? 💣 : formatClock(s.clock)}
+
+ {s.phase === 'freeze' ? '冻结时间' : s.planted ? '炸弹已安放' : `第 ${s.round} 回合`}
+ {s.pistol ? ' · 手枪局' : ''}
+
+
+
+
{s.t}
+
+ {s.aliveT.map((a, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+function Location({ engine }: { engine: GameEngine }) {
+ const loc = useStore(engine.hud, (h) => h.location);
+ return {loc || '—'}
;
+}
+
+const teamClass = (t: Team | null) => (t === 'CT' ? 'ct' : t === 'T' ? 't' : '');
+
+function Killfeed({ engine }: { engine: GameEngine }) {
+ const feed = useStore(engine.hud, (h) => h.killfeed);
+ return (
+
+ {feed.map((k) => (
+
+ {k.killer && {k.killer}}
+ {k.weapon}
+ {k.headshot && ◎}
+ {k.victim}
+
+ ))}
+
+ );
+}
+
+function Notices({ engine }: { engine: GameEngine }) {
+ const notices = useStore(engine.hud, (h) => h.notices);
+ return (
+
+ {notices.map((n) => (
+
+ {n.text}
+
+ ))}
+
+ );
+}
+
+function BannerView({ engine }: { engine: GameEngine }) {
+ const b = useStore(engine.hud, (h) => h.banner);
+ if (!b) return null;
+ return (
+
+
{b.title}
+ {b.sub &&
{b.sub}
}
+
+ );
+}
+
+function Vitals({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({
+ hp: h.health,
+ armor: h.armor,
+ helmet: h.helmet,
+ kit: h.hasKit,
+ bomb: h.hasBomb,
+ team: h.viewTeam,
+ spectating: h.spectating,
+ name: h.viewName,
+ deathCam: h.deathCam,
+ }));
+ if (s.deathCam) return null;
+ const hpClass = s.hp <= 25 ? 'low' : s.hp <= 50 ? 'mid' : '';
+ return (
+
+
+
+
{s.helmet ? '⛑' : '⛨'}
+
{s.armor}
+
+
+
+ {s.helmet && 头盔}
+ {s.kit && 拆弹器}
+ {s.bomb && C4}
+ {s.spectating && 观战:{s.name}}
+
+
+ );
+}
+
+function Ammo({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({ w: h.weapon, slots: h.slots, reloading: h.reloading, deathCam: h.deathCam }));
+ if (s.deathCam || !s.w) return null;
+ const w = s.w;
+ const hasMag = w.mag > 0;
+ const low = hasMag && w.ammo <= Math.ceil(w.mag * 0.2);
+ // a compact bullet column reflecting magazine fill
+ const pips = hasMag ? Math.min(w.mag, 30) : 0;
+ const filled = hasMag ? Math.round((w.ammo / w.mag) * pips) : 0;
+ return (
+
+
+ {s.slots.map((sl) => (
+
+ {sl.key}
+ {sl.name}
+
+ ))}
+
+
{w.name}
+ {hasMag ? (
+ <>
+
+ {s.reloading ? '换弹中' : w.ammo}
+ /
+ {w.reserve}
+
+
+ {Array.from({ length: pips }, (_, i) => (
+
+ ))}
+
+ >
+ ) : (
+
+ ∞
+
+ )}
+
+ );
+}
+
+function Objective({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({ plant: h.plantProgress, defuse: h.defuseProgress, hint: h.actionHint, site: h.inBombsite }));
+ return (
+
+ {s.hint &&
{s.hint}
}
+ {s.plant !== null && (
+
+ )}
+ {s.defuse !== null && (
+
+ )}
+
+ );
+}
+
+function Spectator({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({
+ spectating: h.spectating,
+ deathCam: h.deathCam,
+ name: h.viewName,
+ team: h.viewTeam,
+ canTakeover: h.canTakeover,
+ controllingBot: h.controllingBot,
+ playerTeam: h.playerTeam,
+ }));
+ if (s.controllingBot && !s.spectating) {
+ return 正在操控:{s.controllingBot}(BOT)
;
+ }
+ if (!s.spectating) return null;
+ if (s.deathCam) {
+ return (
+
+
你阵亡了
+
即将切换到存活队友视角(左键跳过)
+
+ );
+ }
+ return (
+
+
+ 正在观察:{s.name || '—'}
+
+
+ 鼠标左/右键或 ←/→ 切换队友
+ {s.canTakeover ? ' · 按 E 接管该队友(BOT)' : s.team !== s.playerTeam ? ' · 队友已全部阵亡' : ''}
+
+
+ );
+}
+
+function Fps({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({ fps: h.fps, show: h.showFps }));
+ if (!s.show) return null;
+ return {s.fps} FPS
;
+}
+
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/MainMenu.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/MainMenu.tsx
new file mode 100644
index 0000000..61d30da
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/MainMenu.tsx
@@ -0,0 +1,131 @@
+import { useState } from 'react';
+import type { Difficulty, MatchSettings, Team } from '../game/core/types';
+import { ControlsHelp } from './ControlsHelp';
+
+interface Props {
+ initial: MatchSettings;
+ onStart: (s: MatchSettings) => void;
+}
+
+function Segmented(props: { value: T; options: { value: T; label: string; hint?: string }[]; onChange: (v: T) => void; tone?: (v: T) => string }) {
+ return (
+
+ {props.options.map((o) => (
+
+ ))}
+
+ );
+}
+
+export function MainMenu({ initial, onStart }: Props) {
+ const [s, setS] = useState(initial);
+ const set = (k: K, v: MatchSettings[K]) => setS((p) => ({ ...p, [k]: v }));
+
+ return (
+
+
+
+
+
+
+
+ 阵营
+
+ value={s.playerTeam}
+ onChange={(v) => set('playerTeam', v)}
+ tone={(v) => (v === 'CT' ? 'ct' : 't')}
+ options={[
+ { value: 'CT', label: '反恐精英 CT', hint: '防守 A/B 点,拆除炸弹' },
+ { value: 'T', label: '恐怖分子 T', hint: '进攻并安放 C4' },
+ ]}
+ />
+ 开局
+
+ value={s.firstRound}
+ onChange={(v) => set('firstRound', v)}
+ options={[
+ { value: 'pistol', label: '手枪局开局(标准)' },
+ { value: 'rifle', label: '直接长枪局' },
+ ]}
+ />
+ 手枪局护甲
+
+ value={s.pistolArmor}
+ onChange={(v) => set('pistolArmor', v)}
+ options={[
+ { value: 'none', label: '无护甲' },
+ { value: 'kevlar', label: '防弹衣(无头盔)' },
+ ]}
+ />
+ AI 难度
+
+ value={s.difficulty}
+ onChange={(v) => set('difficulty', v)}
+ options={[
+ { value: 'easy', label: '简单' },
+ { value: 'normal', label: '普通' },
+ { value: 'hard', label: '困难' },
+ ]}
+ />
+ 胜利条件
+
+ value={s.roundsToWin}
+ onChange={(v) => set('roundsToWin', v)}
+ options={[
+ { value: 5, label: '先赢 5 局' },
+ { value: 8, label: '先赢 8 局' },
+ { value: 13, label: '先赢 13 局' },
+ ]}
+ />
+
+
+
+
+
+
+
点击开始后进入游戏,单击画面锁定鼠标 · ESC 暂停
+
+
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Minimap.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Minimap.tsx
new file mode 100644
index 0000000..79b875a
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Minimap.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useMemo, useRef } from 'react';
+import type { GameEngine } from '../game/engine/GameEngine';
+import { renderRadarBase } from './radar';
+
+const SIZE = 236;
+
+/**
+ * Always-on radar: full Dust2 top-down outline, live markers for self (arrow), teammates,
+ * spotted enemies and the C4. Drawn on its own canvas every animation frame.
+ */
+export function Minimap({ engine }: { engine: GameEngine }) {
+ const canvas = useRef(null);
+ const base = useMemo(() => renderRadarBase(engine.world.level), [engine]);
+
+ useEffect(() => {
+ let raf = 0;
+ const draw = () => {
+ raf = requestAnimationFrame(draw);
+ const c = canvas.current;
+ if (!c) return;
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
+ if (c.width !== SIZE * dpr) {
+ c.width = SIZE * dpr;
+ c.height = SIZE * dpr;
+ }
+ const ctx = c.getContext('2d')!;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, SIZE, SIZE);
+ const b = engine.world.level.def.bounds;
+ const mw = b.x1 - b.x0;
+ const mh = b.z1 - b.z0;
+ const scale = (SIZE - 8) / Math.max(mw, mh);
+ const ox = (SIZE - mw * scale) / 2;
+ const oy = (SIZE - mh * scale) / 2;
+ ctx.imageSmoothingEnabled = true;
+ ctx.drawImage(base, 0, 0, base.width, base.height, ox, oy, mw * scale, mh * scale);
+ const P = (x: number, z: number): [number, number] => [ox + (x - b.x0) * scale, oy + (z - b.z0) * scale];
+ const r = engine.radar();
+ const t = performance.now() / 1000;
+
+ // bomb
+ if (r.bomb) {
+ const [x, y] = P(r.bomb.x, r.bomb.z);
+ const planted = r.bomb.state === 'planted';
+ const pulse = planted ? 0.5 + 0.5 * Math.sin(t * 8) : 1;
+ ctx.fillStyle = planted ? `rgba(255,${60 + 80 * pulse},40,1)` : r.bomb.state === 'defused' ? '#3fdc6a' : '#ff8c2a';
+ ctx.strokeStyle = '#111';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.rect(x - 5, y - 4, 10, 8);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = '#111';
+ ctx.font = 'bold 7px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('C4', x, y + 0.5);
+ if (planted) {
+ ctx.strokeStyle = `rgba(255,80,40,${0.6 * (1 - ((t * 1.5) % 1))})`;
+ ctx.beginPath();
+ ctx.arc(x, y, 6 + ((t * 1.5) % 1) * 12, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ }
+
+ for (const a of r.actors) {
+ const [x, y] = P(a.x, a.z);
+ const mate = a.team === r.team;
+ if (!a.alive) {
+ if (!mate) continue;
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(x - 3, y - 3);
+ ctx.lineTo(x + 3, y + 3);
+ ctx.moveTo(x + 3, y - 3);
+ ctx.lineTo(x - 3, y + 3);
+ ctx.stroke();
+ continue;
+ }
+ const color = !mate ? '#ff3b30' : a.team === 'CT' ? '#5aa2ff' : '#f2b233';
+ // facing wedge
+ const fx = -Math.sin(a.yaw);
+ const fz = -Math.cos(a.yaw);
+ if (a.self) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(Math.atan2(fz, fx) + Math.PI / 2);
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = '#000';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.moveTo(0, -7);
+ ctx.lineTo(5, 5);
+ ctx.lineTo(0, 2.5);
+ ctx.lineTo(-5, 5);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ // view cone
+ ctx.fillStyle = 'rgba(255,255,255,0.10)';
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ const ang = Math.atan2(fz, fx);
+ ctx.arc(x, y, 26, ang - 0.6, ang + 0.6);
+ ctx.closePath();
+ ctx.fill();
+ } else {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + fx * 8, y + fz * 8);
+ ctx.stroke();
+ ctx.fillStyle = color;
+ ctx.strokeStyle = a.viewed ? '#ffffff' : '#000';
+ ctx.lineWidth = a.viewed ? 2 : 1.2;
+ ctx.beginPath();
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ if (a.hasBomb) {
+ ctx.fillStyle = '#ff8c2a';
+ ctx.fillRect(x + 3, y - 7, 5, 4);
+ }
+ }
+ }
+ // frame
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(0.5, 0.5, SIZE - 1, SIZE - 1);
+ };
+ raf = requestAnimationFrame(draw);
+ return () => cancelAnimationFrame(raf);
+ }, [engine, base]);
+
+ return ;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Overlays.tsx b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Overlays.tsx
new file mode 100644
index 0000000..421edde
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/Overlays.tsx
@@ -0,0 +1,209 @@
+import { useState } from 'react';
+import { ROUND_END_TEXT, TEAM_NAME, type MatchSettings, type Team } from '../game/core/types';
+import { BUY_KEYS, type GameEngine } from '../game/engine/GameEngine';
+import { useStore } from '../game/engine/Store';
+import { WEAPONS } from '../game/weapons/WeaponDefs';
+import { ControlsHelp } from './ControlsHelp';
+
+const teamClass = (t: Team) => (t === 'CT' ? 'ct' : 't');
+
+export function BuyMenu({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({ open: h.buyOpen, pistol: h.pistolRound, team: h.playerTeam, w: h.slots }));
+ if (!s.open) return null;
+ return (
+
+
+ 购买菜单
+ {s.pistol ? '手枪局:仅可选择手枪' : '自由购买(原型无经济系统)'} · 数字键购买 · B 关闭
+
+
+ {BUY_KEYS.map((b) => {
+ const isArmor = b.item === 'armor';
+ const def = isArmor ? null : WEAPONS[b.item as Exclude
];
+ const disabled = !isArmor && def!.slot === 0 && s.pistol;
+ const owned = !isArmor && s.w.some((x) => x.id === def!.id);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export function Scoreboard({ engine }: { engine: GameEngine }) {
+ const s = useStore(engine.hud, (h) => ({ open: h.scoreboardOpen, rows: h.scoreboard, ct: h.scoreCT, t: h.scoreT, history: h.history }));
+ if (!s.open) return null;
+ const sorted = (team: Team) => s.rows.filter((r) => r.team === team).sort((a, b) => b.kills - a.kills || a.deaths - b.deaths);
+ return (
+
+ {(['CT', 'T'] as Team[]).map((team) => (
+
+
+ {TEAM_NAME[team]}
+ {team === 'CT' ? s.ct : s.t}
+
+
+
+
+ | 玩家 |
+ 击杀 |
+ 死亡 |
+ 爆头 |
+ 伤害 |
+
+
+
+ {sorted(team).map((r) => (
+
+ |
+ {r.name}
+ {r.isHuman ? '(你)' : ' BOT'}
+ {r.isControlled && !r.isHuman ? ' · 操控中' : ''}
+ {r.hasBomb ? ' 💣' : ''}
+ |
+ {r.kills} |
+ {r.deaths} |
+ {r.headshots} |
+ {r.damage} |
+
+ ))}
+
+
+
+ ))}
+
+ {s.history.map((h, i) => (
+
+ {h.reason === 'bomb_exploded' ? '💥' : h.reason === 'bomb_defused' ? '✂' : h.reason === 'time_expired' ? '⏱' : '☠'}
+
+ ))}
+
+
+ );
+}
+
+export function PauseMenu({ engine, onExit }: { engine: GameEngine; onExit: (s?: MatchSettings) => void }) {
+ const [sens, setSens] = useState(engine.currentSettings.sensitivity);
+ const [vol, setVol] = useState(engine.currentSettings.volume);
+ const ready = useStore(engine.hud, (h) => h.ready);
+ const started = useStore(engine.hud, (h) => h.started);
+ const team = useStore(engine.hud, (h) => h.playerTeam);
+ if (!ready) return null;
+ return (
+ e.stopPropagation()}>
+
+
{started ? '已暂停' : `准备就绪 · 你是${team === 'CT' ? '反恐精英(CT)' : '恐怖分子(T)'}`}
+ {!started && (
+
+ {team === 'CT'
+ ? '目标:守住 A / B 炸弹点,消灭全部恐怖分子,或在炸弹爆炸前拆除它(按住 E)。'
+ : '目标:携带 C4 的队友会推进到 A 或 B 点安放炸弹;消灭全部反恐精英或让炸弹爆炸即可获胜。'}
+
+ )}
+
+
+
+
+ );
+}
+
+export function MatchOver({ engine, onExit }: { engine: GameEngine; onExit: (s?: MatchSettings) => void }) {
+ const s = useStore(engine.hud, (h) => ({ winner: h.matchWinner, ct: h.scoreCT, t: h.scoreT, rows: h.scoreboard, team: h.playerTeam }));
+ if (!s.winner) return null;
+ const win = s.winner === s.team;
+ const best = [...s.rows].sort((a, b) => b.kills - a.kills)[0];
+ return (
+
+
+
{win ? '胜利!' : '失败'}
+
+ CT {s.ct} : {s.t} T
+
+
{TEAM_NAME[s.winner]}赢得了比赛
+ {best && (
+
+ MVP:{best.name}({best.kills} 杀 / {best.deaths} 死)
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/radar.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/radar.ts
new file mode 100644
index 0000000..f86d5cb
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/src/ui/radar.ts
@@ -0,0 +1,116 @@
+import type { Level } from '../game/world/Level';
+
+export const RADAR_PX = 4; // pixels per meter in the base image
+
+/**
+ * Pre-renders the Dust2 top-down outline from the level raster: walkable floors shaded by
+ * height, buildings dark, cover boxes outlined, bombsites tinted and callouts labelled.
+ */
+export function renderRadarBase(level: Level): HTMLCanvasElement {
+ const b = level.def.bounds;
+ const W = Math.round((b.x1 - b.x0) * RADAR_PX);
+ const H = Math.round((b.z1 - b.z0) * RADAR_PX);
+ const c = document.createElement('canvas');
+ c.width = W;
+ c.height = H;
+ const ctx = c.getContext('2d')!;
+ ctx.fillStyle = 'rgba(0,0,0,0)';
+ ctx.clearRect(0, 0, W, H);
+
+ const img = ctx.createImageData(W, H);
+ const d = img.data;
+ const nav = level.nav;
+ const res = 0.5;
+ for (let py = 0; py < H; py++) {
+ for (let px = 0; px < W; px++) {
+ const x = b.x0 + (px + 0.5) / RADAR_PX;
+ const z = b.z0 + (py + 0.5) / RADAR_PX;
+ const ci = Math.floor((x - b.x0) / res);
+ const cj = Math.floor((z - b.z0) / res);
+ const idx = cj * level.cols + ci;
+ const fh = level.floorHeight[idx];
+ const o = (py * W + px) * 4;
+ if (Number.isNaN(fh)) {
+ // building: draw only an outline band near floors for a crisp look
+ d[o] = 28;
+ d[o + 1] = 30;
+ d[o + 2] = 34;
+ d[o + 3] = 150;
+ continue;
+ }
+ const walk = nav.walkable[idx] === 1;
+ const shade = 150 + Math.max(-40, Math.min(60, fh * 28));
+ let r = shade * 1.0;
+ let g = shade * 0.93;
+ let bl = shade * 0.78;
+ if (!walk) {
+ // inflated edges / props: slightly darker so cover reads on the radar
+ r *= 0.86;
+ g *= 0.86;
+ bl *= 0.86;
+ }
+ d[o] = r;
+ d[o + 1] = g;
+ d[o + 2] = bl;
+ d[o + 3] = 235;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+
+ // props (crates etc.) as dark boxes
+ ctx.fillStyle = 'rgba(70,52,30,0.85)';
+ for (const box of level.def.boxes) {
+ if (box.kind === 'ceiling' || box.kind === 'lintel' || box.kind === 'beam') continue;
+ ctx.fillRect((box.x0 - b.x0) * RADAR_PX, (box.z0 - b.z0) * RADAR_PX, (box.x1 - box.x0) * RADAR_PX, (box.z1 - box.z0) * RADAR_PX);
+ }
+ // covered areas (tunnels) hatched darker
+ ctx.fillStyle = 'rgba(40,40,50,0.28)';
+ for (const box of level.def.boxes) {
+ if (box.kind !== 'ceiling') continue;
+ ctx.fillRect((box.x0 - b.x0) * RADAR_PX, (box.z0 - b.z0) * RADAR_PX, (box.x1 - box.x0) * RADAR_PX, (box.z1 - box.z0) * RADAR_PX);
+ }
+ // doors
+ ctx.strokeStyle = 'rgba(60,140,160,0.95)';
+ ctx.lineWidth = 2;
+ for (const dl of level.def.doors) {
+ ctx.beginPath();
+ ctx.moveTo((dl.hx - b.x0) * RADAR_PX, (dl.hz - b.z0) * RADAR_PX);
+ ctx.lineTo((dl.hx + Math.cos(dl.angle) * dl.length - b.x0) * RADAR_PX, (dl.hz + Math.sin(dl.angle) * dl.length - b.z0) * RADAR_PX);
+ ctx.stroke();
+ }
+ // bombsites
+ for (const s of level.def.bombsites) {
+ ctx.fillStyle = 'rgba(220,60,50,0.16)';
+ ctx.strokeStyle = 'rgba(230,80,70,0.55)';
+ ctx.lineWidth = 2;
+ const x = (s.x0 - b.x0) * RADAR_PX;
+ const y = (s.z0 - b.z0) * RADAR_PX;
+ ctx.fillRect(x, y, (s.x1 - s.x0) * RADAR_PX, (s.z1 - s.z0) * RADAR_PX);
+ ctx.setLineDash([6, 4]);
+ ctx.strokeRect(x, y, (s.x1 - s.x0) * RADAR_PX, (s.z1 - s.z0) * RADAR_PX);
+ ctx.setLineDash([]);
+ }
+ // labels
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ for (const l of level.def.labels) {
+ const x = (l.x - b.x0) * RADAR_PX;
+ const y = (l.z - b.z0) * RADAR_PX;
+ if (l.big) {
+ ctx.font = `900 ${10 * RADAR_PX}px "Segoe UI", Arial, sans-serif`;
+ ctx.fillStyle = 'rgba(255,90,70,0.9)';
+ ctx.strokeStyle = 'rgba(0,0,0,0.6)';
+ ctx.lineWidth = 4;
+ ctx.strokeText(l.text, x, y);
+ ctx.fillText(l.text, x, y);
+ } else {
+ ctx.font = `700 ${2.6 * RADAR_PX}px "Microsoft YaHei", "PingFang SC", sans-serif`;
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = 'rgba(0,0,0,0.65)';
+ ctx.strokeText(l.text, x, y);
+ ctx.fillStyle = 'rgba(255,255,255,0.92)';
+ ctx.fillText(l.text, x, y);
+ }
+ }
+ return c;
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/tsconfig.json b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/tsconfig.json
new file mode 100644
index 0000000..54d765a
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/outputs/claude-opus-5-5/claude-code-max/cs-dust2/vite.config.ts b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/vite.config.ts
new file mode 100644
index 0000000..8632d9f
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/cs-dust2/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ },
+ build: {
+ target: 'es2020',
+ chunkSizeWarningLimit: 2000,
+ },
+});
diff --git a/outputs/claude-opus-5-5/claude-code-max/mythos-craft.html b/outputs/claude-opus-5-5/claude-code-max/mythos-craft.html
new file mode 100644
index 0000000..3c312b7
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/mythos-craft.html
@@ -0,0 +1,5112 @@
+
+
+
+
+
+神域方块 · MythCraft —— 诸神的方块世界
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
E 物品栏 · C 神话图鉴 · M 地图 · F 飞行 · N 切换昼夜 · H 帮助
+
+
+
神域方块
+
诸神在此相遇!
+
MYTHCRAFT · 诸神的方块世界
+
+
+
加载中…
+
+
+
+
游戏暂停
+
+
+
+
+
+
+
+
创造模式物品栏
+
+
+
点击方块放入当前快捷栏格子;悬停时按 1-9 放入对应格子
+
+
+
+
+
世界地图
+
+
滚轮缩放 · 拖动平移 · 双击传送到该位置 · M / Esc 关闭
+
+
+
+
操作说明
+
+
移动WASD
+
视角鼠标
+
跳跃 / 飞行上升空格
+
疾跑 / 飞行下降Shift 或双击 W
+
开关飞行F 或双击 空格
+
破坏方块鼠标左键(按住连续)
+
放置方块鼠标右键
+
选取方块鼠标中键
+
切换物品1-9 / 滚轮
+
物品栏E
+
神话图鉴(可传送)C
+
世界地图M
+
切换昼夜时段N
+
命令行T 或 /
+
隐藏界面 / 截图 / 调试F1 / F2 / F3
+
暂停菜单Esc
+
+
提示:出生于昆仑神坛,向北走上彩虹桥可抵达云端天宫;屏幕顶部的罗盘标出了所有神话地标的方向与距离。
+ 常用命令:/tp 地标名(如 /tp 世界树)、/time night、/fly、/give 金块、/rd 12。夜晚可以看到极光与发光的神殿。
+
+
+
+
+
+
diff --git a/outputs/claude-opus-5-5/claude-code-max/pelican-cycling.svg b/outputs/claude-opus-5-5/claude-code-max/pelican-cycling.svg
new file mode 100644
index 0000000..51ec806
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/pelican-cycling.svg
@@ -0,0 +1,146 @@
+
\ No newline at end of file
diff --git a/outputs/claude-opus-5-5/claude-code-max/skeleton-watch.html b/outputs/claude-opus-5-5/claude-code-max/skeleton-watch.html
new file mode 100644
index 0000000..55790a9
--- /dev/null
+++ b/outputs/claude-opus-5-5/claude-code-max/skeleton-watch.html
@@ -0,0 +1,2378 @@
+
+
+
+
+
+Squelette Céleste · 3D 镂空机械表
+
+
+
+
+
+
+
+
+
+
Aetheris
+
Squelette Céleste
+
CALIBRE AE·1618 · 手动上链全镂空腕表
+
+
+
+拖动 旋转腕表 · 滚轮 缩放 · 悬停 识别零件 · 单击 查看详情 · 双击 聚焦
快捷键 E 爆炸视图 · L 夜光 · S 慢动作 · W 上链 · T 标签 · 1–4 视角
+
+
+
+
+
+
+
+
+
AETHERIS
+
Squelette Céleste
+
+
正在载入 THREE.JS
+
+
+
+
+
+
+