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 ( +
+ engine?.resume()} /> + {!engine && !error && ( +
+
+
正在生成 Dust2 地图、角色与音效…
+
+ )} + {error && ( +
+
启动失败
+
{error}
+ +
+ )} + {engine && } +
+ ); +} 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.hp} +
+
+
+
+
+ {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 && ( +
+
正在安放 C4…
+
+
+
+
+ )} + {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 ( +
+
+
+
+
+ DUST + II +
+
5v5 爆破模式 · 浏览器 3D 原型
+
React 18 + TypeScript + three.js · 地图 / 角色 / 武器 / 音效 全部程序化生成
+
+ +
+
+

阵营

+ + 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 局' }, + ]} + /> +
+ +
+

玩家名称

+ set('playerName', e.target.value)} /> +

+ 鼠标灵敏度 {s.sensitivity.toFixed(2)} +

+ set('sensitivity', Number(e.target.value))} /> +

+ 音量 {Math.round(s.volume * 100)}% +

+ set('volume', Number(e.target.value))} /> +

画质

+ + value={s.quality} + onChange={(v) => set('quality', v)} + options={[ + { value: 'high', label: '高(实时阴影)' }, + { value: 'low', label: '流畅(无阴影)' }, + ]} + /> + + +
+
+ + +
点击开始后进入游戏,单击画面锁定鼠标 · 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 点安放炸弹;消灭全部反恐精英或让炸弹爆炸即可获胜。'} +

+ )} + +
+
+

+ 鼠标灵敏度 {sens.toFixed(2)} +

+ { + const v = Number(e.target.value); + setSens(v); + engine.setSensitivity(v); + }} + /> +

+ 音量 {Math.round(vol * 100)}% +

+ { + const v = Number(e.target.value); + setVol(v); + engine.setVolume(v); + }} + /> + +
+ +
+
+
+ ); +} + +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 —— 诸神的方块世界 + + + + + +
+ +
诸神在此相遇!
+
MYTHCRAFT · 诸神的方块世界
+ +
+
加载中…
+
单文件 HTML5 + WebGL2 · 柏林噪声无限地形 · 17 处神话地标 · 支持存档(浏览器本地)
+
+ + + + + + + + + + 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 @@ + +海边骑自行车的鹈鹕 · A pelican cycling along the shore + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Pelican + + + + + + \ 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
+
+
+ + + + +