From 83e99a7ef8b99f8dd893c50b2bfc3a8e8f09bf2e Mon Sep 17 00:00:00 2001
From: Evilom <33251918+Evilom@users.noreply.github.com>
Date: Mon, 11 May 2026 17:39:31 +0800
Subject: [PATCH] =?UTF-8?q?chore:=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.claude/settings.local.json | 8 +
.gitignore | 24 +
CLAUDE.md | 280 +++
CONTRIBUTING.md | 70 +
docs/ARCHITECTURE.md | 145 ++
docs/GDD.md | 158 ++
docs/adr/001-entity-component-pattern.md | 33 +
docs/adr/002-2d-topdown-rendering.md | 33 +
docs/adr/003-json-mod-system.md | 34 +
docs/adr/004-json-only-mod-runtime.md | 27 +
index.html | 39 +
package-lock.json | 1935 +++++++++++++++++
package.json | 27 +
public/data/mods/base-scripts-mod.json | 87 +
public/data/mods/example-patrol-mod.json | 16 +
public/data/mods/example-quest-mod.json | 106 +
public/data/mods/example-spell-mod.json | 16 +
public/data/mods/example-weapons-mod.json | 115 +
...le-quest-mod.json EgameOES-WEBpublicdatamods | 115 +
public/favicon.svg | 1 +
public/icons.svg | 24 +
src/core/EntityManager.test.ts | 66 +
src/core/EntityManager.ts | 102 +
src/core/EventBus.test.ts | 83 +
src/core/EventBus.ts | 50 +
src/core/GameEvents.ts | 127 ++
src/core/GameManager.ts | 82 +
src/data/DataRegistry.test.ts | 64 +
src/data/DataRegistry.ts | 1568 +++++++++++++
src/data/alchemy/ingredients.json | 148 ++
src/data/alchemy/potions.json | 100 +
src/data/crafting/cooking.json | 83 +
src/data/crafting/smithing.json | 177 ++
src/data/dialogue/trees.json | 196 ++
src/data/enemies/enemies.json | 256 +++
src/data/game-config.json | 41 +
src/data/items/armor.json | 352 +++
src/data/items/enchantments.json | 116 +
src/data/items/items.json | 275 +++
src/data/items/soul-gems.json | 10 +
src/data/items/weapons.json | 158 ++
src/data/mods/example-quest-mod.json | 106 +
src/data/mods/example-weapons-mod.json | 115 +
src/data/quests/quests.json | 183 ++
src/data/races/races.json | 95 +
src/data/skills/perks.json | 186 ++
src/data/skills/skills.json | 28 +
src/data/skills/vampire-perks.json | 57 +
src/data/skills/werewolf-perks.json | 57 +
src/data/spells/shouts.json | 44 +
src/data/spells/spells.json | 225 ++
src/data/transforms.json | 44 +
src/data/vampire-stages.json | 63 +
src/data/world/standing-stones.json | 82 +
src/data/world/zones.json | 210 ++
src/main.ts | 57 +
src/maps/MapManager.ts | 276 +++
src/mods/ModAPI.ts | 90 +
src/mods/ModLoader.test.ts | 96 +
src/mods/ModLoader.ts | 163 ++
src/mods/ModManager.ts | 129 ++
src/mods/ModResolver.ts | 135 ++
src/mods/ModScriptEngine.ts | 156 ++
src/mods/ModTypes.ts | 99 +
src/mods/ModValidator.ts | 279 +++
src/mods/ScriptContext.ts | 540 +++++
src/mods/VirtualFS.ts | 106 +
src/save/SaveManager.ts | 110 +
src/scenes/GameScene.ts | 618 ++++++
src/systems/AISystem.ts | 118 +
src/systems/AlchemySystem.ts | 239 ++
src/systems/CombatSystem.test.ts | 81 +
src/systems/CombatSystem.ts | 289 +++
src/systems/ContainerSystem.ts | 175 ++
src/systems/CookingSystem.ts | 157 ++
src/systems/CorpseSystem.ts | 173 ++
src/systems/DayNightSystem.ts | 141 ++
src/systems/DialogueSystem.ts | 249 +++
src/systems/EnchantingSystem.ts | 229 ++
src/systems/GroundItemSystem.ts | 212 ++
src/systems/InventorySystem.test.ts | 92 +
src/systems/InventorySystem.ts | 261 +++
src/systems/LegendarySystem.test.ts | 115 +
src/systems/LegendarySystem.ts | 94 +
src/systems/LevelingSystem.ts | 101 +
src/systems/MagicSystem.ts | 371 ++++
src/systems/MovementSystem.ts | 95 +
src/systems/ProximitySystem.ts | 134 ++
src/systems/QuestSystem.ts | 455 ++++
src/systems/RegenSystem.ts | 83 +
src/systems/ScriptSystem.test.ts | 149 ++
src/systems/ScriptSystem.ts | 103 +
src/systems/SmithingSystem.ts | 200 ++
src/systems/StatusEffectSystem.ts | 152 ++
src/systems/TransformationSystem.test.ts | 152 ++
src/systems/TransformationSystem.ts | 270 +++
src/systems/VampireSystem.test.ts | 106 +
src/systems/VampireSystem.ts | 252 +++
src/ui/UIManager.ts | 467 ++++
src/ui/components/CharacterCreationUI.ts | 307 +++
src/ui/components/CombatUI.ts | 172 ++
src/ui/components/CraftingUI.ts | 364 ++++
src/ui/components/DialogueUI.ts | 178 ++
src/ui/components/ModManagerUI.ts | 248 +++
src/ui/components/SkillTreeUI.ts | 230 ++
src/ui/components/WorldMapUI.ts | 313 +++
src/ui/theme.ts | 295 +++
tsconfig.json | 27 +
vitest.config.ts | 13 +
109 files changed, 19558 insertions(+)
create mode 100644 .claude/settings.local.json
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 CONTRIBUTING.md
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/GDD.md
create mode 100644 docs/adr/001-entity-component-pattern.md
create mode 100644 docs/adr/002-2d-topdown-rendering.md
create mode 100644 docs/adr/003-json-mod-system.md
create mode 100644 docs/adr/004-json-only-mod-runtime.md
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 public/data/mods/base-scripts-mod.json
create mode 100644 public/data/mods/example-patrol-mod.json
create mode 100644 public/data/mods/example-quest-mod.json
create mode 100644 public/data/mods/example-spell-mod.json
create mode 100644 public/data/mods/example-weapons-mod.json
create mode 100644 public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods
create mode 100644 public/favicon.svg
create mode 100644 public/icons.svg
create mode 100644 src/core/EntityManager.test.ts
create mode 100644 src/core/EntityManager.ts
create mode 100644 src/core/EventBus.test.ts
create mode 100644 src/core/EventBus.ts
create mode 100644 src/core/GameEvents.ts
create mode 100644 src/core/GameManager.ts
create mode 100644 src/data/DataRegistry.test.ts
create mode 100644 src/data/DataRegistry.ts
create mode 100644 src/data/alchemy/ingredients.json
create mode 100644 src/data/alchemy/potions.json
create mode 100644 src/data/crafting/cooking.json
create mode 100644 src/data/crafting/smithing.json
create mode 100644 src/data/dialogue/trees.json
create mode 100644 src/data/enemies/enemies.json
create mode 100644 src/data/game-config.json
create mode 100644 src/data/items/armor.json
create mode 100644 src/data/items/enchantments.json
create mode 100644 src/data/items/items.json
create mode 100644 src/data/items/soul-gems.json
create mode 100644 src/data/items/weapons.json
create mode 100644 src/data/mods/example-quest-mod.json
create mode 100644 src/data/mods/example-weapons-mod.json
create mode 100644 src/data/quests/quests.json
create mode 100644 src/data/races/races.json
create mode 100644 src/data/skills/perks.json
create mode 100644 src/data/skills/skills.json
create mode 100644 src/data/skills/vampire-perks.json
create mode 100644 src/data/skills/werewolf-perks.json
create mode 100644 src/data/spells/shouts.json
create mode 100644 src/data/spells/spells.json
create mode 100644 src/data/transforms.json
create mode 100644 src/data/vampire-stages.json
create mode 100644 src/data/world/standing-stones.json
create mode 100644 src/data/world/zones.json
create mode 100644 src/main.ts
create mode 100644 src/maps/MapManager.ts
create mode 100644 src/mods/ModAPI.ts
create mode 100644 src/mods/ModLoader.test.ts
create mode 100644 src/mods/ModLoader.ts
create mode 100644 src/mods/ModManager.ts
create mode 100644 src/mods/ModResolver.ts
create mode 100644 src/mods/ModScriptEngine.ts
create mode 100644 src/mods/ModTypes.ts
create mode 100644 src/mods/ModValidator.ts
create mode 100644 src/mods/ScriptContext.ts
create mode 100644 src/mods/VirtualFS.ts
create mode 100644 src/save/SaveManager.ts
create mode 100644 src/scenes/GameScene.ts
create mode 100644 src/systems/AISystem.ts
create mode 100644 src/systems/AlchemySystem.ts
create mode 100644 src/systems/CombatSystem.test.ts
create mode 100644 src/systems/CombatSystem.ts
create mode 100644 src/systems/ContainerSystem.ts
create mode 100644 src/systems/CookingSystem.ts
create mode 100644 src/systems/CorpseSystem.ts
create mode 100644 src/systems/DayNightSystem.ts
create mode 100644 src/systems/DialogueSystem.ts
create mode 100644 src/systems/EnchantingSystem.ts
create mode 100644 src/systems/GroundItemSystem.ts
create mode 100644 src/systems/InventorySystem.test.ts
create mode 100644 src/systems/InventorySystem.ts
create mode 100644 src/systems/LegendarySystem.test.ts
create mode 100644 src/systems/LegendarySystem.ts
create mode 100644 src/systems/LevelingSystem.ts
create mode 100644 src/systems/MagicSystem.ts
create mode 100644 src/systems/MovementSystem.ts
create mode 100644 src/systems/ProximitySystem.ts
create mode 100644 src/systems/QuestSystem.ts
create mode 100644 src/systems/RegenSystem.ts
create mode 100644 src/systems/ScriptSystem.test.ts
create mode 100644 src/systems/ScriptSystem.ts
create mode 100644 src/systems/SmithingSystem.ts
create mode 100644 src/systems/StatusEffectSystem.ts
create mode 100644 src/systems/TransformationSystem.test.ts
create mode 100644 src/systems/TransformationSystem.ts
create mode 100644 src/systems/VampireSystem.test.ts
create mode 100644 src/systems/VampireSystem.ts
create mode 100644 src/ui/UIManager.ts
create mode 100644 src/ui/components/CharacterCreationUI.ts
create mode 100644 src/ui/components/CombatUI.ts
create mode 100644 src/ui/components/CraftingUI.ts
create mode 100644 src/ui/components/DialogueUI.ts
create mode 100644 src/ui/components/ModManagerUI.ts
create mode 100644 src/ui/components/SkillTreeUI.ts
create mode 100644 src/ui/components/WorldMapUI.ts
create mode 100644 src/ui/theme.ts
create mode 100644 tsconfig.json
create mode 100644 vitest.config.ts
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..cac7ccd
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npx tsc *)",
+ "Bash(npx vitest *)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..88e1715
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,280 @@
+# OES-WEB - 上古卷轴 Web 版
+
+## Overview
+用 AI 工具开发的 Web 版《上古卷轴》风格 RPG 游戏。2D 俯视角,极简像素/色块美术,重点是系统完整性和 Mod 生态。
+当前阶段: Phase 3 完成,进入 Phase 4 内容扩展。
+
+## Tech Stack
+- Language: TypeScript 5.x (strict mode)
+- Framework: Phaser 3.8x (2D game framework)
+- Build: Vite 6
+- Testing: Vitest
+- Persistence: Dexie.js (IndexedDB) + fflate 压缩
+- Audio: Howler.js
+- UI: HTML/CSS overlay on Phaser canvas
+
+## Commands
+- `npm run dev` -- 启动开发服务器 (localhost:5173, 热重载)
+- `npm run build` -- 生产构建到 dist/
+- `npm run test` -- 运行测试
+- `npx tsc --noEmit` -- TypeScript 类型检查
+
+## Project Structure
+```
+src/
+├── core/ # GameManager, EventBus, EntityManager (核心单例)
+├── components/ # 数据组件 (Health, Combat, Skills...)
+├── systems/ # 游戏系统 (Combat, Magic, Quest...)
+├── scenes/ # Phaser 场景 (GameScene, MenuScene...)
+├── entities/ # 实体工厂 (Player, NPC, Enemy...)
+├── ai/ # AI 控制器和行为树
+├── ui/ # HTML/CSS UI 组件
+│ └── components/ # HUD, Inventory, DialogueUI...
+├── data/ # JSON 游戏数据 (物品/法术/任务/NPC)
+├── mechanics/ # 计算公式 (伤害/技能/炼金...)
+├── save/ # 存档系统 (Dexie.js)
+├── maps/ # 地图管理
+├── mods/ # Mod 系统 (ModLoader, ModManager)
+└── utils/ # 工具函数
+```
+
+## Architecture
+- **Entity-Component 模式**: 实体是 ID,组件是数据,系统操作组件
+- **EventBus 事件总线**: 系统间解耦通信,不直接调用
+- **GameManager 单例**: 初始化所有系统,管理主循环
+- **DataRegistry 数据注册表**: 所有游戏数据通过 DataRegistry 加载,支持 Mod 覆盖
+- **系统执行顺序**: Input → AI → Physics → Combat → Magic → Stealth → StatusEffect → Inventory → Corpse → ItemInteraction → Quest → Dialogue → Animation → Spawn → ModHook → Save
+
+## Data Architecture (全部可 Mod 覆盖)
+所有游戏数据通过 `DataRegistry` 统一加载和管理。Mod 可覆盖任何数据类型:
+- **DataRegistry**: 中央数据注册表,加载 JSON 文件,监听 `mod:dataResolved` 事件
+- **数据类型**: items, weapons, armor, enemies, quests, races, skills, perks, standingStones, spells, recipes
+- **地图数据**: 由 MapManager 从 `zones.json` 加载,支持 Mod 覆盖
+- **UI 组件**: CharacterCreationUI/SkillTreeUI 从 DataRegistry 读取数据,不再直接导入 JSON
+
+## Conventions
+- TypeScript strict mode,不用 `any`,用 `unknown` 然后窄化
+- 组件必须有 `type` 字段作为标识
+- 所有时间值用毫秒,delta-time 从游戏时钟获取
+- 实体位置用浮点数,渲染时四舍五入
+- 文件命名: 组件 PascalCase,工具 camelCase,资源 kebab-case
+- 测试文件: `*.test.ts` 或 `*.spec.ts`
+
+## Do Not
+- 不要修改 `src/core/` 下的核心模块除非运行完整测试
+- 不要添加外部依赖未经讨论
+- 不要硬编码屏幕尺寸
+- 不要直接 fetch 资源,通过 AssetManager
+- 不要在游戏循环中产生不必要的对象分配
+- 不要跳过 TypeScript 类型检查直接提交
+
+## Development Progress
+
+### Phase 0: 项目脚手架 ✅
+- Vite + TypeScript + Phaser 3 配置
+- GameManager, EventBus, EntityManager 核心模块
+- 基础游戏场景和 UI 覆盖层
+- IndexedDB 存档系统骨架
+
+### Phase 1: 核心角色系统 ✅
+- 10 个可玩种族 (Nord, Dunmer, Altmer, Argonian, Khajiit, Breton, Imperial, Redguard, Orc, Bosmer)
+- 角色创建界面 (选种族、命名)
+- 生命/魔力/耐力三大属性系统
+- 18 项技能和 XP 追踪
+- 升级系统 (每 10 级技能 = 1 角色等级)
+- **18 棵完整天赋树** (251 个天赋)
+- 种族能力和被动
+- 13 个站立之石
+- 存档/读档角色数据
+
+### Phase 2: 基础战斗系统 ✅
+- 实时动作战斗 (鼠标左键攻击)
+- 单手/双手武器系统
+- 盾牌格挡 (右键按住)
+- 强力攻击 (Shift)
+- 暴击系统 (10% 基础 + 潜行加成)
+- 5 种敌人类型 (强盗/狼/骷髅/熊/蜘蛛) + 7 种新敌人 (尸鬼/尸鬼亡灵/洞穴熊/冰霜蜘蛛/强盗逃犯/强盗暴徒/死灵法师)
+- 敌人 AI (追逐/攻击)
+- 玩家死亡和复活
+- 战斗音效和视觉反馈
+- 状态效果系统
+- 回血/回魔/回耐力系统
+
+### Phase 3: 探索与世界 ✅
+- 地图系统 (7 个区域)
+ - 白漫城 (城市)
+ - 白漫城外 (平原)
+ - 荒瀑古坟 (地牢)
+ - 溪木镇 (村庄)
+ - 暗光洞穴 (地牢)
+ - 古代遗迹 (地牢)
+ - 天际省荒野 (世界地图)
+- 门和区域过渡
+- 宝箱容器系统
+- 地面物品拾取
+- 快速旅行
+- 世界地图 UI
+- 昼夜循环系统
+- 物品交互 (悬停提示/使用/装备)
+- 收藏/快捷栏系统
+
+### Phase 4: NPC 与对话 ✅ (基础)
+- NPC 实体和闲置行为
+- 分支对话系统
+- 商人买卖界面
+- 每个城市的 NPC
+- Speech 技能检定
+- 随从招募系统
+
+### Phase 5: 扩展战斗与魔法 ✅ (基础)
+- 5 大魔法学派基础法术
+- 法术施放 UI
+- 潜行攻击倍率
+- 完整潜行/检测系统
+- 龙吼系统
+- 尸体系统 (状态机/搜刮/复生)
+
+### Phase 6: 制作系统 ✅ (基础)
+- 炼金系统 (材料效果/组合/药水/毒药)
+- 附魔系统 (分解/附魔/灵魂石)
+- 锻造系统 (锻造台/砂轮)
+- 烹饪系统
+
+### Phase 7: 任务系统 ✅ (基础)
+- 任务状态机 (开始/目标/完成/失败)
+- 任务日志 UI
+- 主线任务 (3 个)
+- 公会任务线 (战士/盗贼)
+- 魔神任务 (1 个)
+- 辐射任务 (3 个)
+
+### Phase 8: 高级系统 🔄
+- **狼人变形** ✅:
+ - TransformationSystem: form 组件管理变形状态
+ - T 键切换狼人/吸血鬼领主形态
+ - 形态加成: 生命+100, 耐力+50, 护甲+10, 移速+60, 爪击伤害 20
+ - 变形时替换武器为形态武器,修改精灵外观
+ - 形态有时限 (120s) + 冷却 (30s)
+ - 狼人专属天赋树 (6 个天赋: 野兽之力/厚皮毛/疾跑/恐惧嚎叫/野性恢复/月圆之夜)
+- **吸血鬼系统** ✅:
+ - VampireSystem: 4 阶段感染进度
+ - 白天惩罚 (阳光伤害) + 夜间增益 (属性加成)
+ - 吸血回复机制
+ - 吸血鬼专属天赋树 (6 个天赋: 暗夜视觉/冰霜亲和/生命虹吸/暗夜潜行/血魔法/远古血脉)
+- **传奇技能** ✅:
+ - LegendarySystem: 技能 100 后可传奇重置
+ - 归零 + 获得 1 天赋点 + 传奇次数 +1
+ - 每次传奇该技能获得 +10% XP 加成
+- 阵营声望 (数据已定义)
+- 结婚系统 (待实现)
+
+### Phase 8.5: Mod 系统 ✅
+- JSON 数据覆盖加载器
+- 加载优先级系统
+- 插件清单格式 (manifest.json)
+- 事件钩子 API
+- Mod 管理器 UI
+- 依赖检查和冲突检测
+- 示例 Mod (武器/任务)
+- **实体脚本系统** (与原版上古卷轴一致):
+ - 脚本定义: properties (持久化变量) + handlers (JS 代码字符串)
+ - 生命周期事件: OnLoad, OnUpdate, OnHit, OnDeath, OnActivate, OnUnload, OnEquip, OnUse, OnZoneEnter, OnZoneLeave
+ - 沙箱化上下文 API: entity, inventory, combat, magic, dialogue, quest, effects, prop, time, entities, events, data, log
+ - ScriptSystem 集成到游戏循环,自动调用 OnUpdate 和事件转发
+ - 实体通过 `data.script` 字段挂载脚本
+
+### Phase 9: 内容与打磨 🔄
+- 30+ 种敌人 (已完成)
+- 7 个区域 (已完成)
+- 18 棵天赋树 (已完成)
+- 11 个任务 (已完成)
+- 昼夜循环 (已完成)
+- 待完成: 更多地图、更多任务、UI 打磨、音频集成
+
+### Phase 10: 最终集成 ⏳
+- 端到端测试
+- 平衡调整
+- 构建优化
+
+## Game Content Summary
+- **种族**: 10 个
+- **敌人**: 12 种 (强盗/狼/骷髅/尸鬼/尸鬼亡灵/熊/洞穴熊/蜘蛛/冰霜蜘蛛/强盗逃犯/强盗暴徒/死灵法师)
+- **区域**: 7 个 (白漫城/白漫城外/荒瀑古坟/溪木镇/暗光洞穴/古代遗迹/天际省荒野)
+- **天赋树**: 20 棵 (18 技能 + 狼人 + 吸血鬼, 263 个天赋)
+- **任务**: 11 个 (3 主线/2 战士公会/1 盗贼公会/1 魔神/3 辐射/1 采药)
+- **武器**: 15 种 (铁/钢各 tier)
+- **护甲**: 27 件 (铁/钢/皮革/精灵/兽人/乌木/魔族/龙)
+- **物品**: 28 种 (消耗品/材料/杂物)
+- **法术**: 多种 (毁灭/恢复/召唤/变化)
+- **站立之石**: 13 个
+- **变形形态**: 2 种 (狼人/吸血鬼领主)
+- **高级系统**: 狼人变形 + 吸血鬼感染 + 传奇技能
+
+## Mod 生态
+- Mod 格式: JSON 数据文件 + manifest.json 清单
+- 加载方式: 深度合并,后加载覆盖先加载
+- 事件钩子: GameEvents.on('event:name', callback)
+- Mod 管理器: 启用/禁用/排序/冲突检测
+- **全部数据可覆盖**: 所有游戏数据 (物品/武器/护甲/敌人/任务/种族/技能/天赋/站立之石/法术/配方/地图) 均通过 DataRegistry 或 MapManager 加载,Mod 可覆盖任何数据
+
+## Mod 脚本系统
+Mod 可为游戏实体附加自定义脚本逻辑,与原版上古卷轴的脚本系统一致。
+
+### 脚本定义格式 (Mod JSON)
+```json
+{
+ "manifest": { "id": "my-mod", "name": "我的 Mod", "version": "1.0.0" },
+ "scripts": {
+ "guard_patrol": {
+ "properties": { "alertLevel": 0, "homeX": 0 },
+ "handlers": {
+ "OnLoad": "ctx.log('守卫上线');",
+ "OnHit": "ctx.prop('alertLevel', ctx.prop('alertLevel') + 1);",
+ "OnDeath": "ctx.log('守卫阵亡');"
+ }
+ }
+ }
+}
+```
+
+### 实体挂载
+Zone 数据中的实体通过 `data.script` 字段挂载脚本:
+```json
+{ "type": "npc", "data": { "name": "守卫", "script": "my-mod:guard_patrol" } }
+```
+
+### 生命周期事件
+| 事件 | 触发时机 | 参数 |
+|------|---------|------|
+| `OnLoad` | 实体创建时 | 无 |
+| `OnUpdate` | 每帧 | `delta` (ms) |
+| `OnHit` | 被攻击时 | `{ attacker, damage, isCritical }` |
+| `OnDeath` | 死亡时 | `{ killer }` |
+| `OnActivate` | 玩家交互时 | `{ player }` |
+| `OnUnload` | 实体销毁时 | 无 |
+| `OnEquip` | 被装备时 | `{ item, slot }` |
+| `OnUse` | 被使用时 | `{ item }` |
+| `OnZoneEnter` | 区域加载时 | `{ zoneId }` |
+
+### 沙箱化上下文 API (`ctx`)
+脚本执行时获得 `ctx` 对象,包含:
+- `entity` — 读写生命/魔力/耐力/位置/技能/等级
+- `inventory` — 增删查物品/金币
+- `combat` — 伤害/治疗/击杀
+- `magic` — 施法/学习/查询法术
+- `dialogue` — 启动对话树 (待实现)
+- `quest` — 任务操作 (待实现)
+- `effects` — 应用/移除状态效果
+- `prop` — 持久化自定义变量
+- `time` — 游戏时间查询
+- `entities` — 查询周围实体
+- `events` — 自定义事件监听/触发
+- `data` — 只读查询游戏数据
+- `log` — 调试日志
+
+### 关键文件
+- `src/mods/ModTypes.ts` — ScriptDefinition, ScriptInstance 类型
+- `src/mods/ScriptContext.ts` — 沙箱化 API 实现
+- `src/mods/ModScriptEngine.ts` — 脚本编译和实例管理
+- `src/systems/ScriptSystem.ts` — 游戏循环集成
+- `src/mods/ModLoader.ts` — Mod 加载时注册脚本定义
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..df2e94e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,70 @@
+# OES-WEB 开发流程
+
+## 开发环境搭建
+
+1. 克隆仓库
+2. 运行 `npm install`
+3. 运行 `npm run dev` 启动开发服务器
+4. 打开 http://localhost:5173
+
+## 分支策略
+
+- `main` -- 生产就绪代码
+- `develop` -- 功能集成分支
+- `feature/*` -- 单个功能分支
+- `fix/*` -- Bug 修复分支
+
+分支命名: `feature/short-description` 或 `fix/issue-number-description`
+
+## 提交规范
+
+使用 Conventional Commits:
+- `feat: 添加新敌人类型`
+- `fix: 修复对角线移动碰撞检测`
+- `docs: 更新 GDD 新增能力系统`
+- `refactor: 提取物理计算到独立模块`
+- `test: 添加存档系统集成测试`
+
+## 代码审查清单
+
+- [ ] 所有测试通过 (`npm test`)
+- [ ] TypeScript 类型检查通过 (`npx tsc --noEmit`)
+- [ ] 没有 console.log 留在生产代码中
+- [ ] 新功能有对应测试
+- [ ] 性能: 游戏循环中无不必要对象分配
+- [ ] 组件有 `type` 字段
+
+## 文件命名
+
+- 组件: PascalCase (`PlayerEntity.ts`)
+- 工具: camelCase (`collisionDetection.ts`)
+- 资源: kebab-case (`enemy-sprite-sheet.png`)
+- 测试: `*.test.ts` 或 `*.spec.ts`
+
+## 测试标准
+
+- 所有游戏逻辑需要单元测试 (物理、技能计算、炼金组合)
+- 存档/读档需要集成测试
+- 视觉/音频功能需要手动测试
+- 合并前运行完整测试套件
+
+## 开发工作流 (AI 辅助)
+
+### 功能开发
+1. 在 GDD 中描述功能
+2. 创建 feature 分支
+3. 让 Claude Code 探索代码库并提出方案
+4. 审批后实现并添加测试
+5. 审查并迭代
+
+### Bug 修复
+1. 描述症状
+2. Claude Code 搜索相关代码
+3. 提出修复方案和根因分析
+4. 验证后提交
+
+### 重构
+1. 陈述目标 ("让碰撞系统更易测试")
+2. Claude Code 分析当前结构
+3. 提出方案后再修改
+4. 审批后执行
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..93e5a79
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,145 @@
+# OES-WEB 架构文档
+
+## 系统架构图
+
+```
+┌─────────────────────────────────────────────────────┐
+│ UI Layer (HTML/CSS) │
+│ HUD │ Inventory │ Dialogue │ Skills │ Map │ Mods │
+└──────────────────────┬──────────────────────────────┘
+ │ EventBus
+┌──────────────────────┴──────────────────────────────┐
+│ Game Logic Layer (TypeScript) │
+│ Systems: Combat │ Magic │ Quest │ Alchemy │ ... │
+│ Components: Health │ Skills │ Inventory │ ... │
+│ Entities: Player │ NPC │ Enemy │ Corpse │ Item │
+└──────────────────────┬──────────────────────────────┘
+ │
+┌──────────────────────┴──────────────────────────────┐
+│ Core Engine Layer (Phaser 3) │
+│ Rendering │ Physics │ Input │ Audio │ Scene Mgmt │
+└──────────────────────┬──────────────────────────────┘
+ │
+┌──────────────────────┴──────────────────────────────┐
+│ Data Layer (JSON + IndexedDB) │
+│ Base Data │ DataRegistry │ Save Data │ Mod Data │
+└─────────────────────────────────────────────────────┘
+```
+
+## 核心模块
+
+### GameManager (单例)
+- 初始化 Phaser 游戏实例
+- 注册和管理系统
+- 管理主循环 tick 顺序
+
+### EventBus (单例)
+- 发布/订阅事件系统
+- 系统间解耦通信
+- 所有交互通过事件,不直接调用
+
+### EntityManager (单例)
+- 实体生命周期管理
+- 组件存储和查询
+- 实体工厂方法
+
+## 系统执行顺序
+
+每帧按以下顺序执行:
+
+```
+1. InputSystem ← 读取玩家输入
+2. AIControlSystem ← NPC/敌人 AI 决策
+3. PhysicsSystem ← 移动、碰撞检测
+4. CombatSystem ← 伤害计算、命中检测
+5. MagicSystem ← 法术施放、效果
+6. StealthSystem ← 检测等级、潜行倍率
+7. StatusEffectSystem ← Buff/Debuff/毒药
+8. InventorySystem ← 重量、物品管理
+9. CorpseSystem ← 尸体状态、搜刮
+10. ItemInteractionSystem ← 物品拾取、容器
+11. QuestSystem ← 任务状态更新
+12. DialogueSystem ← NPC 对话
+13. AnimationSystem ← 精灵动画
+14. SpawnSystem ← 随机遭遇、刷新
+15. ModHookSystem ← Mod 事件钩子
+16. SaveSystem ← 自动存档
+```
+
+## 数据流
+
+```
+Player Input
+ ↓
+EventBus.emit('player:attack', { target, damage })
+ ↓
+CombatSystem 处理事件
+ ↓
+EventBus.emit('entity:damaged', { entity, amount })
+ ↓
+HealthSystem 更新生命值
+ ↓
+EventBus.emit('entity:died', { entity })
+ ↓
+CorpseSystem 生成尸体
+LootSystem 生成掉落
+QuestSystem 检查任务目标
+```
+
+## 组件设计
+
+所有组件必须有 `type` 字段:
+
+```typescript
+interface Component {
+ type: string;
+ [key: string]: any;
+}
+
+// 示例
+{ type: 'health', current: 100, max: 100 }
+{ type: 'position', x: 100, y: 200 }
+{ type: 'combat', attackPower: 10, defense: 5 }
+```
+
+## Mod 系统架构
+
+```
+Base Game Data (src/data/*.json)
+ ↓
+ModValidator (manifest + data schema guard)
+ ↓
+ModResolver (dependency order + priority merge + conflict report)
+ ↓
+Resolved Mod Data
+ ↓
+DataRegistry rebuilds runtime snapshot
+ ↓
+Game Systems read DataRegistry
+```
+
+### Mod v1 边界
+
+- 当前只支持 JSON 数据 Mod: `manifest + data`
+- 支持数据域: `items / enemies / npcs / quests / recipes / spells / skills / perks / zones`
+- 禁止执行玩家导入脚本;旧脚本入口只保留为禁用兼容桩
+- 加载顺序: 依赖先于依赖方,同层按 `priority` 从小到大合并,后合并者覆盖前者
+- 冲突: 多个启用 Mod 修改同一 `domain.id` 时记录冲突,最终以后合并者为准
+- 运行时数据入口: 新系统应通过 `DataRegistry` 读取物品、敌人、掉落、任务等内容,不再在系统内硬编码内容表
+
+## 存档结构
+
+```json
+{
+ "version": 1,
+ "timestamp": 1234567890,
+ "playTime": 3600,
+ "character": { "name", "race", "level", "skills", "perks" },
+ "inventory": { "items", "gold", "equipped" },
+ "worldState": { "discoveredLocations", "clearedDungeons" },
+ "quests": { "active", "completed", "failed" },
+ "factions": { "factionId": "reputation" },
+ "npcs": { "npcId": "state" },
+ "mapStates": { "mapId": "entities" }
+}
+```
diff --git a/docs/GDD.md b/docs/GDD.md
new file mode 100644
index 0000000..3b9b94b
--- /dev/null
+++ b/docs/GDD.md
@@ -0,0 +1,158 @@
+# OES-WEB 游戏设计文档 (GDD)
+
+> 上古卷轴风格 Web RPG - 用 AI 工具开发,系统完整性优先,Mod 生态为核心
+
+---
+
+## 1. 核心定位
+
+- **类型**: 2D 俯视角开放世界 RPG
+- **平台**: Web 浏览器 (Chrome/Firefox/Edge)
+- **风格**: 上古卷轴 (Elder Scrolls) 系统复刻
+- **美术**: 极简像素/色块 (后期可美化)
+- **特色**: 完整 RPG 系统 + Mod 生态
+
+## 2. 核心循环
+
+```
+探索世界 → 遭遇敌人/发现地点 → 战斗/对话/制作 → 获得奖励 → 角色成长 → 探索更远
+```
+
+## 3. 系统清单
+
+### 3.1 角色系统
+- [x] 10 个可玩种族 (Nord/Dunmer/Altmer/Argonian/Khajiit/Breton/Imperial/Redguard/Orc/Bosmer)
+- [ ] 3 大属性: 生命/魔力/耐力
+- [ ] 18 项技能 (战斗6/魔法6/潜行6)
+- [ ] ~251 个天赋 (Perk), 18 棵天赋树
+- [ ] 升级系统: 每 10 级技能 = 1 角色等级
+- [ ] 传奇技能系统
+
+### 3.2 战斗系统
+- [ ] 实时动作战斗 (俯视角)
+- [ ] 单手武器: 剑/锤/斧/匕首
+- [ ] 双手武器: 大剑/战锤/战斧
+- [ ] 盾牌格挡和猛击
+- [ ] 双持武器
+- [ ] 强力攻击 (消耗耐力)
+- [ ] 弓箭远程战斗
+- [ ] 潜行攻击倍率 (匕首最高 15x)
+- [ ] 检测系统: 隐藏/警觉/发现/搜索
+
+### 3.3 魔法系统
+- [ ] 5 大魔法学派: 毁灭/恢复/幻术/召唤/变化
+- [ ] 龙吼系统: 20 个龙吼, 每个 3 个字
+- [ ] 魔力消耗和施法
+
+### 3.4 炼金系统
+- [ ] 40+ 种材料, 每种 4 个可发现效果
+- [ ] 2-3 种材料组合制作药水/毒药
+- [ ] 毒药涂抹在武器上
+
+### 3.5 附魔系统
+- [ ] 分解物品学习附魔
+- [ ] 灵魂石 (6 级: 微小→大→黑色)
+- [ ] 武器/护甲附魔
+
+### 3.6 锻造系统
+- [ ] 锻造台制作武器护甲
+- [ ] 砂轮/工作台强化
+- [ ] 材料等级: 铁→钢→矮人→兽人→乌木→魔族→龙
+
+### 3.7 任务系统
+- [ ] 主线任务 (~20 个)
+- [ ] 4 个公会任务线 (战士/盗贼/黑暗兄弟会/法师学院)
+- [ ] 15 个魔神任务
+- [ ] 辐射任务 (程序生成)
+
+### 3.8 NPC 系统
+- [ ] 分支对话树, 技能检定
+- [ ] 60+ 商人, 50+ 随从
+- [ ] 结婚系统
+- [ ] 阵营声望
+
+### 3.9 世界探索
+- [ ] 8 个主要城市
+- [ ] 200+ 地牢
+- [ ] 随机遭遇
+- [ ] 快速旅行
+
+### 3.10 背包/装备
+- [ ] 重量系统 (基础负重 300)
+- [ ] 8+ 装备槽位
+- [ ] 等级化掉落
+
+### 3.11 经济系统
+- [ ] 金币货币 (塞普汀)
+- [ ] 商人金池和补货
+
+### 3.12 站立之石
+- [ ] 13 个站立之石, 每次激活一个
+
+### 3.13 狼人/吸血鬼
+- [ ] 狼人变形 + 天赋树
+- [ ] 吸血鬼阶段 + 喂食机制
+
+### 3.14 尸体系统
+- [ ] 状态机: 活着→尸体→已搜刮→灰烬/销毁
+- [ ] 搜刮容器, "搜索 [名字]" 提示
+- [ ] 复生法术梯度 (25级→100级)
+- [ ] 狼人/吸血鬼喂食
+
+### 3.15 物品交互系统
+- [ ] 地面物品拾取, 悬停提示
+- [ ] 容器系统 (宝箱/桶/瓮)
+- [ ] 开锁小游戏 (5 级难度)
+- [ ] 重量/负重
+- [ ] 收藏/快捷栏 (F 收藏, 1-9 快捷)
+- [ ] 偷窃/赏金系统
+- [ ] 书籍阅读
+- [ ] 烹饪系统
+
+### 3.16 Mod 系统 (核心)
+- [x] JSON 数据覆盖, 深度合并
+- [x] 加载优先级系统
+- [ ] 插件清单 (manifest.json)
+- [ ] 事件钩子 API
+- [ ] Mod 管理器 UI
+- [x] 依赖检查和冲突检测
+- [x] 基础 Schema 验证
+- [ ] Mod 导入/导出
+
+## 4. 操控方式
+
+| 按键 | 功能 |
+|------|------|
+| WASD / 方向键 | 移动 |
+| E | 交互/攻击 |
+| F | 收藏物品 |
+| 1-9 | 快捷栏 |
+| Tab/Esc | 暂停菜单 |
+| I | 背包 |
+| M | 地图 |
+| J | 任务日志 |
+
+## 5. 技术约束
+
+- 60fps 目标 (中端硬件)
+- 最大 100 个活跃实体
+- 存档压缩后 < 500KB
+- 初始加载 < 5MB
+- 现代浏览器 (Chrome/Firefox/Edge/Safari)
+
+## 6. 开发阶段
+
+| 阶段 | 内容 | 状态 |
+|------|------|------|
+| Phase 0 | 项目脚手架 | ✅ 完成 |
+| Phase 1 | 核心角色系统 | 🔄 进行中 |
+| Phase 2 | 基础战斗 | ⏳ 待开始 |
+| Phase 3 | 探索与世界 | ⏳ |
+| Phase 4 | NPC 与对话 | ⏳ |
+| Phase 5 | 扩展战斗与魔法 | ⏳ |
+| Phase 6 | 制作系统 | ⏳ |
+| Phase 7 | 任务系统 | ⏳ |
+| Phase 8 | 高级系统 | ⏳ |
+| Phase 8.5 | Mod 系统 | ⏳ |
+| Phase 9 | 内容与打磨 | ⏳ |
+| Phase 10 | 最终集成 | ⏳ |
diff --git a/docs/adr/001-entity-component-pattern.md b/docs/adr/001-entity-component-pattern.md
new file mode 100644
index 0000000..1eddbb5
--- /dev/null
+++ b/docs/adr/001-entity-component-pattern.md
@@ -0,0 +1,33 @@
+# ADR-001: 使用 Entity-Component 模式
+
+## Status
+Accepted
+
+## Date
+2026-05-10
+
+## Context
+需要一个管理游戏对象的架构,要求:
+- 支持 50+ 个并发实体
+- 轻松添加新实体类型,无需修改核心系统
+- 高效查询特定组件组合的实体
+- 解耦物理、渲染和 AI 逻辑
+
+## Decision
+使用 Entity-Component (EC) 模式,不用完整 ECS 框架。
+
+- 实体是唯一 ID (字符串)
+- 组件是纯数据对象,必须有 `type` 字段
+- 系统遍历组件数组操作匹配的实体
+- EventBus 处理系统间通信
+
+选择此方案而非:
+- 继承层次 (太僵化,钻石问题)
+- 完整 ECS 库 (增加依赖,复杂度超出需求)
+- 扁平对象数组 (大数据量缓存不友好)
+
+## Consequences
+- 添加新实体类型只需定义组件 (无需修改类)
+- 系统可以独立开发和测试
+- 需要自己编写组件存储和查询逻辑
+- 实体数量在 200+ 时可能需要优化
diff --git a/docs/adr/002-2d-topdown-rendering.md b/docs/adr/002-2d-topdown-rendering.md
new file mode 100644
index 0000000..19c507a
--- /dev/null
+++ b/docs/adr/002-2d-topdown-rendering.md
@@ -0,0 +1,33 @@
+# ADR-002: 使用 2D 俯视角渲染
+
+## Status
+Accepted
+
+## Date
+2026-05-10
+
+## Context
+需要选择渲染方式,要求:
+- 浏览器原生支持
+- AI 工具可以生成素材
+- 开发速度快
+- 能展示所有 RPG 系统
+
+## Decision
+使用 2D 俯视角 (类经典塞尔达/早期最终幻想)。
+
+- Phaser 3 + Arcade Physics
+- 32x32 像素 tile 地图
+- 极简色块/简单像素美术
+- HTML/CSS 覆盖层处理 UI
+
+选择此方案而非:
+- 3D WebGL (开发周期太长,AI 难以生成 3D 模型)
+- 2D 横版 (不适合开放世界探索)
+- 纯 HTML UI (失去游戏世界沉浸感)
+
+## Consequences
+- 开发速度快,可以专注系统完整性
+- AI 可以生成 2D 像素美术
+- 性能良好,中端硬件可达 60fps
+- 视觉表现简单,后期可美化
diff --git a/docs/adr/003-json-mod-system.md b/docs/adr/003-json-mod-system.md
new file mode 100644
index 0000000..57184b0
--- /dev/null
+++ b/docs/adr/003-json-mod-system.md
@@ -0,0 +1,34 @@
+# ADR-003: 使用 JSON 数据的 Mod 系统
+
+## Status
+Accepted
+
+## Date
+2026-05-10
+
+## Context
+需要实现 Mod 生态系统,要求:
+- 玩家可以制作和分享 Mod
+- Mod 可以添加新内容和修改现有内容
+- 加载顺序可控制
+- 冲突可以检测和解决
+
+## Decision
+使用 JSON 数据文件作为 Mod 格式。
+
+- Mod 格式: JSON 数据 + manifest.json 清单
+- 加载方式: 深度合并,后加载覆盖先加载
+- 优先级: 数字越小越先加载
+- 事件钩子: GameEvents.on() 让 Mod 响应游戏事件
+- 验证: JSON Schema 确保数据结构合规
+
+选择此方案而非:
+- 二进制格式 (不可读,难以调试)
+- 脚本语言 (安全风险,性能问题)
+- 文件系统覆盖 (浏览器限制)
+
+## Consequences
+- Mod 格式简单易懂,玩家容易上手
+- JSON 可以被任何文本编辑器修改
+- 深度合并可能产生意外覆盖,需要冲突检测
+- 运行时加载性能良好
diff --git a/docs/adr/004-json-only-mod-runtime.md b/docs/adr/004-json-only-mod-runtime.md
new file mode 100644
index 0000000..1ceca65
--- /dev/null
+++ b/docs/adr/004-json-only-mod-runtime.md
@@ -0,0 +1,27 @@
+# ADR-004: Mod v1 采用 JSON-only 数据运行时
+
+## Status
+Accepted
+
+## Date
+2026-05-10
+
+## Context
+OES-WEB 的目标是让 Mod 成为核心能力,但浏览器内执行玩家导入的脚本会带来注入、数据窃取、无限循环和调试成本风险。当前阶段更需要先把基础内容扩展、覆盖、加载顺序、冲突报告和游戏系统接入跑稳。
+
+## Decision
+Mod v1 只支持 JSON 数据 Mod,不执行任意 JavaScript。
+
+- Mod 包结构: `{ manifest, data }`
+- `manifest` 声明 id/name/version/priority/dependencies
+- `data` 支持 items/enemies/npcs/quests/recipes/spells/skills/perks/zones
+- `ModValidator` 校验清单、数据域、内容 id 和危险 key
+- `ModResolver` 按依赖和优先级合并启用 Mod,并报告冲突
+- `DataRegistry` 使用 Base Data + Resolved Mod Data 重建运行时数据快照
+- 旧脚本入口保留为禁用桩,任何执行请求都会失败并发出 `script:error`
+
+## Consequences
+- 武器、敌人、掉落、任务和地图可以先通过 JSON 稳定扩展
+- 安全边界清晰,避免 `new Function`/eval 类执行入口进入主包
+- 脚本 Mod 暂时不能实现复杂行为,后续如需要应单独设计 Worker 沙箱和权限 API
+- 系统必须逐步改为只读 `DataRegistry`,减少硬编码内容
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8d58d71
--- /dev/null
+++ b/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ OES-WEB - Elder Scrolls Web
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..5f64668
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1935 @@
+{
+ "name": "oes-web",
+ "version": "0.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "oes-web",
+ "version": "0.0.0",
+ "dependencies": {
+ "dexie": "^4.4.2",
+ "fflate": "^0.8.2",
+ "howler": "^2.2.4",
+ "lodash-es": "^4.18.1",
+ "phaser": "^4.1.0"
+ },
+ "devDependencies": {
+ "@types/lodash-es": "^4.17.12",
+ "typescript": "~6.0.2",
+ "vite": "^8.0.10",
+ "vitest": "^4.1.5"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.128.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
+ "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
+ "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
+ "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
+ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
+ "dev": true
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
+ "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
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "dev": true,
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "4.1.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "dev": true,
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true,
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dexie": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
+ "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "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,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
+ },
+ "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,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/howler": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
+ "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ]
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true
+ },
+ "node_modules/phaser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.1.0.tgz",
+ "integrity": "sha512-ZXv5Bhyg2BqJGAAxNI2xvmzGXW9q+TwUG1RLri5ZDBYGGtcma6aWUO/eJ7EbozeqRd5fKdpo4ycNMQt+Bi5iYg==",
+ "dependencies": {
+ "eventemitter3": "^5.0.4"
+ }
+ },
+ "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
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "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"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
+ "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
+ "dev": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.128.0",
+ "@rolldown/pluginutils": "1.0.0-rc.18"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.11",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+ "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
+ "dev": true,
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.0-rc.18",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "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/vitest": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.5",
+ "@vitest/browser-preview": "4.1.5",
+ "@vitest/browser-webdriverio": "4.1.5",
+ "@vitest/coverage-istanbul": "4.1.5",
+ "@vitest/coverage-v8": "4.1.5",
+ "@vitest/ui": "4.1.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ },
+ "dependencies": {
+ "@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "@tybys/wasm-util": "^0.10.1"
+ }
+ },
+ "@oxc-project/types": {
+ "version": "0.128.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
+ "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
+ "dev": true
+ },
+ "@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
+ "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
+ "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ }
+ },
+ "@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
+ "dev": true,
+ "optional": true
+ },
+ "@rolldown/pluginutils": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
+ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
+ "dev": true
+ },
+ "@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true
+ },
+ "@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "requires": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
+ "@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
+ },
+ "@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true
+ },
+ "@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "dev": true,
+ "requires": {
+ "@types/lodash": "*"
+ }
+ },
+ "@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "dev": true,
+ "requires": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ }
+ },
+ "@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "dev": true,
+ "requires": {
+ "@vitest/spy": "4.1.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ }
+ },
+ "@vitest/pretty-format": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "dev": true,
+ "requires": {
+ "tinyrainbow": "^3.1.0"
+ }
+ },
+ "@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "dev": true,
+ "requires": {
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
+ }
+ },
+ "@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "requires": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ }
+ },
+ "@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true
+ },
+ "@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "requires": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ }
+ },
+ "assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true
+ },
+ "chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true
+ },
+ "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
+ },
+ "detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true
+ },
+ "dexie": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
+ "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="
+ },
+ "es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true
+ },
+ "estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "requires": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
+ },
+ "expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true
+ },
+ "fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "requires": {}
+ },
+ "fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
+ },
+ "fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "optional": true
+ },
+ "howler": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
+ "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
+ },
+ "lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "requires": {
+ "detect-libc": "^2.0.3",
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "dev": true,
+ "optional": true
+ },
+ "lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "dev": true,
+ "optional": true
+ },
+ "lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
+ },
+ "magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "requires": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true
+ },
+ "obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true
+ },
+ "pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true
+ },
+ "phaser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.1.0.tgz",
+ "integrity": "sha512-ZXv5Bhyg2BqJGAAxNI2xvmzGXW9q+TwUG1RLri5ZDBYGGtcma6aWUO/eJ7EbozeqRd5fKdpo4ycNMQt+Bi5iYg==",
+ "requires": {
+ "eventemitter3": "^5.0.4"
+ }
+ },
+ "picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "dev": true,
+ "requires": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "rolldown": {
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
+ "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
+ "dev": true,
+ "requires": {
+ "@oxc-project/types": "=0.128.0",
+ "@rolldown/binding-android-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18",
+ "@rolldown/pluginutils": "1.0.0-rc.18"
+ }
+ },
+ "siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
+ "source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true
+ },
+ "stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true
+ },
+ "tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "tinyexec": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
+ "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
+ "dev": true
+ },
+ "tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "requires": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ }
+ },
+ "tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true
+ },
+ "tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "optional": true
+ },
+ "typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true
+ },
+ "vite": {
+ "version": "8.0.11",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+ "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
+ "dev": true,
+ "requires": {
+ "fsevents": "~2.3.3",
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.0-rc.18",
+ "tinyglobby": "^0.2.16"
+ }
+ },
+ "vitest": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "requires": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ }
+ },
+ "why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "requires": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..cb7cf58
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "oes-web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/lodash-es": "^4.17.12",
+ "typescript": "~6.0.2",
+ "vite": "^8.0.10",
+ "vitest": "^4.1.5"
+ },
+ "dependencies": {
+ "dexie": "^4.4.2",
+ "fflate": "^0.8.2",
+ "howler": "^2.2.4",
+ "lodash-es": "^4.18.1",
+ "phaser": "^4.1.0"
+ }
+}
diff --git a/public/data/mods/base-scripts-mod.json b/public/data/mods/base-scripts-mod.json
new file mode 100644
index 0000000..4746195
--- /dev/null
+++ b/public/data/mods/base-scripts-mod.json
@@ -0,0 +1,87 @@
+{
+ "manifest": {
+ "id": "base-scripts",
+ "name": "基础实体脚本",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "为游戏实体提供默认行为脚本:守卫巡逻、狼群AI、亡灵守卫、蜘蛛毒液等",
+ "priority": 50,
+ "dependencies": []
+ },
+ "data": {},
+ "scripts": {
+ "guard_patrol": {
+ "properties": {
+ "homeX": 0,
+ "homeY": 0,
+ "alertLevel": 0,
+ "patrolAngle": 0,
+ "idleTimer": 0,
+ "state": "idle"
+ },
+ "handlers": {
+ "OnLoad": "var pos = ctx.entity.getPosition(); ctx.prop('homeX', pos.x); ctx.prop('homeY', pos.y); ctx.log('守卫部署完毕,位置: ' + pos.x + ',' + pos.y);",
+ "OnUpdate": "if (ctx.prop('state') === 'idle') { var t = ctx.prop('idleTimer') + delta; ctx.prop('idleTimer', t); if (t > 3000) { ctx.prop('state', 'patrol'); ctx.prop('idleTimer', 0); } } else if (ctx.prop('state') === 'patrol') { var a = ctx.prop('patrolAngle') + 0.02; ctx.prop('patrolAngle', a); var hx = ctx.prop('homeX'); var hy = ctx.prop('homeY'); var tx = hx + Math.cos(a) * 48; var ty = hy + Math.sin(a) * 48; ctx.entity.setPosition(tx, ty); var t2 = ctx.prop('idleTimer') + delta; ctx.prop('idleTimer', t2); if (t2 > 4000) { ctx.prop('state', 'idle'); ctx.prop('idleTimer', 0); } }",
+ "OnHit": "var al = ctx.prop('alertLevel') + 1; ctx.prop('alertLevel', al); ctx.prop('state', 'idle'); ctx.prop('idleTimer', 0); ctx.log('守卫受到攻击! 警戒等级: ' + al); if (al >= 5) { ctx.effects.apply({ id: 'guard_rage', type: 'buff', attribute: 'damage', magnitude: 10, durationMs: 10000 }); ctx.log('守卫进入狂暴状态!'); }",
+ "OnDeath": "ctx.log('守卫阵亡'); ctx.events.emit('guard:down', { x: ctx.entity.getPosition().x, y: ctx.entity.getPosition().y });"
+ }
+ },
+ "pack_wolf": {
+ "properties": {
+ "homeX": 0,
+ "homeY": 0,
+ "packSize": 2,
+ "aggroRange": 120,
+ "fleeHealthPct": 0.2,
+ "huntTarget": null
+ },
+ "handlers": {
+ "OnLoad": "var pos = ctx.entity.getPosition(); ctx.prop('homeX', pos.x); ctx.prop('homeY', pos.y); ctx.log('狼已生成,巢穴: ' + pos.x + ',' + pos.y);",
+ "OnUpdate": "var hp = ctx.entity.getHealth(); var maxHp = 100; if (hp / maxHp < ctx.prop('fleeHealthPct')) { var hx = ctx.prop('homeX'); var hy = ctx.prop('homeY'); var pos = ctx.entity.getPosition(); var dx = pos.x - hx; var dy = pos.y - hy; var dist = Math.sqrt(dx * dx + dy * dy); if (dist > 10) { var nx = dx / dist; var ny = dy / dist; ctx.entity.setPosition(pos.x + nx * 2, pos.y + ny * 2); } }",
+ "OnHit": "var hp = ctx.entity.getHealth(); var maxHp = 100; ctx.log('狼被攻击! 生命: ' + hp + '/' + maxHp); if (hp / maxHp < ctx.prop('fleeHealthPct')) { ctx.log('狼因伤势过重逃跑'); } else { ctx.events.emit('wolf:aggro', { x: ctx.entity.getPosition().x, y: ctx.entity.getPosition().y }); }",
+ "OnDeath": "ctx.log('狼被击杀'); ctx.events.emit('wolf:killed', { x: ctx.entity.getPosition().x, y: ctx.entity.getPosition().y });"
+ }
+ },
+ "draugr_guard": {
+ "properties": {
+ "dormant": true,
+ "wakeRange": 80,
+ "homeX": 0,
+ "homeY": 0,
+ "deathCount": 0
+ },
+ "handlers": {
+ "OnLoad": "var pos = ctx.entity.getPosition(); ctx.prop('homeX', pos.x); ctx.prop('homeY', pos.y); ctx.log('亡灵守卫沉睡中...');",
+ "OnUpdate": "if (ctx.prop('dormant')) { var pos = ctx.entity.getPosition(); var nearby = ctx.entities.getNearby(ctx.prop('wakeRange')); for (var i = 0; i < nearby.length; i++) { if (nearby[i].type === 'player') { ctx.prop('dormant', false); ctx.log('亡灵守卫苏醒!'); ctx.effects.apply({ id: 'draugr_awaken', type: 'buff', attribute: 'damage', magnitude: 5, durationMs: 30000 }); break; } } }",
+ "OnHit": "if (ctx.prop('dormant')) { ctx.prop('dormant', false); ctx.log('亡灵守卫被惊醒!'); }",
+ "OnDeath": "var dc = ctx.prop('deathCount') + 1; ctx.prop('deathCount', dc); ctx.log('亡灵倒下 (第 ' + dc + ' 次)'); if (dc < 3) { ctx.prop('dormant', true); ctx.entity.setHealth(ctx.entity.getHealth() + 50); ctx.log('亡灵再次站起!'); } else { ctx.log('亡灵最终被消灭'); }"
+ }
+ },
+ "frost_spider": {
+ "properties": {
+ "homeX": 0,
+ "homeY": 0,
+ "webCooldown": 0,
+ "poisonStacks": 0
+ },
+ "handlers": {
+ "OnLoad": "var pos = ctx.entity.getPosition(); ctx.prop('homeX', pos.x); ctx.prop('homeY', pos.y); ctx.log('冰霜蜘蛛在巢穴待命');",
+ "OnHit": "var stacks = ctx.prop('poisonStacks') + 1; ctx.prop('poisonStacks', stacks); if (stacks >= 3) { ctx.log('蜘蛛毒液叠满3层! 施加冰冻效果'); ctx.effects.apply({ id: 'frost_venom', type: 'debuff', attribute: 'speed', magnitude: -0.3, durationMs: 5000, source: 'frost_spider' }); ctx.effects.apply({ id: 'frost_dot', type: 'debuff', attribute: 'health', magnitude: -3, durationMs: 5000, source: 'frost_spider' }); ctx.prop('poisonStacks', 0); } else { ctx.log('蜘蛛毒液层数: ' + stacks); }",
+ "OnUpdate": "var cd = ctx.prop('webCooldown'); if (cd > 0) { ctx.prop('webCooldown', cd - delta); }",
+ "OnDeath": "ctx.log('冰霜蜘蛛被击杀'); ctx.prop('poisonStacks', 0);"
+ }
+ },
+ "town_elder": {
+ "properties": {
+ "greeted": false,
+ "hintGiven": false
+ },
+ "handlers": {
+ "OnLoad": "ctx.log('镇长就位');",
+ "OnZoneEnter": "if (!ctx.prop('greeted')) { ctx.prop('greeted', true); ctx.log('镇长: 欢迎来到这片土地。'); }",
+ "OnActivate": "if (!ctx.prop('hintGiven')) { ctx.prop('hintGiven', true); ctx.log('镇长: 北方的古坟里藏着宝物,但要小心亡灵。'); ctx.events.emit('elder:hint', { hint: '北方古坟有宝藏' }); } else { ctx.log('镇长: 祝你旅途平安。'); }",
+ "OnDeath": "ctx.log('镇长遇害! 村庄失去领导者'); ctx.events.emit('elder:killed', {});"
+ }
+ }
+ }
+}
diff --git a/public/data/mods/example-patrol-mod.json b/public/data/mods/example-patrol-mod.json
new file mode 100644
index 0000000..2463531
--- /dev/null
+++ b/public/data/mods/example-patrol-mod.json
@@ -0,0 +1,16 @@
+{
+ "manifest": {
+ "id": "example-patrol",
+ "name": "敌人巡逻系统",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "让敌人在闲置时巡逻,增加游戏真实感",
+ "priority": 200,
+ "dependencies": [],
+ "scripts": ["patrol.js"]
+ },
+ "data": {},
+ "scripts": {
+ "patrol.js": "log('Patrol mod loaded!');\n\nconst patrolData = new Map();\nconst PATROL_SPEED = 40;\nconst PATROL_RANGE = 100;\nconst IDLE_TIME = 2000;\n\ngame.on('entity:created', (data) => {\n const entity = data.entity;\n if (entity.type === 'enemy') {\n const pos = entity.position;\n if (pos) {\n patrolData.set(entity.id, {\n originX: pos.x,\n originY: pos.y,\n targetX: pos.x,\n targetY: pos.y,\n state: 'idle',\n idleTimer: 0,\n waitTime: utils.random(1000, 3000)\n });\n }\n }\n});\n\ngame.on('entity:destroyed', (data) => {\n patrolData.delete(data.entity.id);\n});\n\ngame.on('game:update', (data) => {\n const delta = data.delta;\n \n for (const [entityId, patrol] of patrolData) {\n const entity = game.getEntity(entityId);\n if (!entity || !entity.position) continue;\n \n const ai = entity.getComponent('ai');\n if (ai && ai.state === 'chase') continue;\n \n if (patrol.state === 'idle') {\n patrol.idleTimer += delta;\n if (patrol.idleTimer >= patrol.waitTime) {\n patrol.state = 'patrol';\n patrol.waitTime = utils.random(1500, 4000);\n const angle = utils.random(0, Math.PI * 2);\n const dist = utils.random(30, PATROL_RANGE);\n patrol.targetX = patrol.originX + Math.cos(angle) * dist;\n patrol.targetY = patrol.originY + Math.sin(angle) * dist;\n }\n } else if (patrol.state === 'patrol') {\n const dx = patrol.targetX - entity.position.x;\n const dy = patrol.targetY - entity.position.y;\n const dist = Math.sqrt(dx * dx + dy * dy);\n \n if (dist < 5) {\n patrol.state = 'idle';\n patrol.idleTimer = 0;\n } else {\n const nx = dx / dist;\n const ny = dy / dist;\n entity.position = {\n x: entity.position.x + nx * PATROL_SPEED * (delta / 1000),\n y: entity.position.y + ny * PATROL_SPEED * (delta / 1000)\n };\n entity.faceToward(patrol.targetX, patrol.targetY);\n }\n }\n }\n});\n\nlog('Patrol behavior registered for all enemies');"
+ }
+}
diff --git a/public/data/mods/example-quest-mod.json b/public/data/mods/example-quest-mod.json
new file mode 100644
index 0000000..1617261
--- /dev/null
+++ b/public/data/mods/example-quest-mod.json
@@ -0,0 +1,106 @@
+{
+ "manifest": {
+ "id": "example-quest",
+ "name": "示例任务包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加一个新任务线到游戏中",
+ "priority": 200,
+ "dependencies": []
+ },
+ "data": {
+ "quests": {
+ "lost_sword": {
+ "id": "lost_sword",
+ "name": "失落的圣剑",
+ "type": "side",
+ "description": "一位铁匠的祖传圣剑在附近的洞穴中丢失了",
+ "objectives": [
+ { "id": "talk_to_blacksmith", "description": "与铁匠对话", "type": "talk", "target": "blacksmith_01" },
+ { "id": "find_cave", "description": "找到洞穴", "type": "explore", "target": "cave_01" },
+ { "id": "kill_bandits", "description": "消灭洞穴中的强盗", "type": "kill", "target": "bandit", "count": 5 },
+ { "id": "find_sword", "description": "找到失落的圣剑", "type": "collect", "target": "legendary_sword", "count": 1 },
+ { "id": "return_sword", "description": "将圣剑归还给铁匠", "type": "talk", "target": "blacksmith_01" }
+ ],
+ "rewards": {
+ "gold": 500,
+ "items": ["legendary_sword"],
+ "xp": 200,
+ "faction": "companions",
+ "factionRep": 10
+ },
+ "prerequisites": [],
+ "levelRequired": 5
+ },
+ "mysterious_artifact": {
+ "id": "mysterious_artifact",
+ "name": "神秘文物",
+ "type": "daedric",
+ "description": "一件古老的文物在废弃的神殿中被发现",
+ "objectives": [
+ { "id": "investigate_rumors", "description": "调查传言", "type": "talk", "target": "innkeeper_01" },
+ { "id": "find_temple", "description": "找到废弃神殿", "type": "explore", "target": "temple_01" },
+ { "id": "solve_puzzle", "description": "解开神殿谜题", "type": "interact", "target": "puzzle_01" },
+ { "id": "defeat_guardian", "description": "击败守护者", "type": "kill", "target": "temple_guardian", "count": 1 },
+ { "id": "take_artifact", "description": "取走文物", "type": "collect", "target": "mysterious_artifact", "count": 1 },
+ { "id": "choose_fate", "description": "决定文物的命运", "type": "choice", "options": ["keep", "destroy", "give_to_mage"] }
+ ],
+ "rewards": {
+ "gold": 1000,
+ "items": ["mysterious_artifact"],
+ "xp": 500
+ },
+ "prerequisites": ["lost_sword"],
+ "levelRequired": 15
+ }
+ },
+ "items": {
+ "legendary_sword": {
+ "id": "legendary_sword",
+ "name": "铁匠的祖传圣剑",
+ "type": "weapon",
+ "subtype": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 15,
+ "speed": 1.0,
+ "weight": 10,
+ "value": 200,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "smite",
+ "magnitude": 5,
+ "duration": 0
+ },
+ "keywords": ["metal", "slashing", "unique"],
+ "description": "铁匠家族世代相传的宝剑",
+ "questItem": true
+ },
+ "mysterious_artifact": {
+ "id": "mysterious_artifact",
+ "name": "神秘文物",
+ "type": "misc",
+ "subtype": "artifact",
+ "weight": 5,
+ "value": 0,
+ "keywords": ["unique", "quest", "daedric"],
+ "description": "一件来自远古的神秘物品,散发着不祥的气息",
+ "questItem": true
+ }
+ },
+ "npcs": {
+ "blacksmith_01": {
+ "id": "blacksmith_01",
+ "name": "铁匠哈蒙",
+ "race": "nord",
+ "level": 10,
+ "faction": "blacksmiths",
+ "dialogue": {
+ "greeting": "欢迎来到我的铁匠铺。你需要什么?",
+ "quest_start": "你来得正好!我的祖传圣剑在附近的洞穴被强盗抢走了。如果你能帮我找回来,我一定会重重报答你!",
+ "quest_complete": "太感谢了!这把剑对我们家族意义重大。这是你的报酬,还有,请随时回来打造装备。"
+ }
+ }
+ }
+ }
+}
diff --git a/public/data/mods/example-spell-mod.json b/public/data/mods/example-spell-mod.json
new file mode 100644
index 0000000..3e5481d
--- /dev/null
+++ b/public/data/mods/example-spell-mod.json
@@ -0,0 +1,16 @@
+{
+ "manifest": {
+ "id": "example-spells",
+ "name": "自定义法术包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加自定义法术,展示脚本化法术系统",
+ "priority": 300,
+ "dependencies": [],
+ "scripts": ["spells.js"]
+ },
+ "data": {},
+ "scripts": {
+ "spells.js": "log('Custom spells mod loaded!');\n\nmod.registerSpell('chain_lightning', {\n name: '连锁闪电',\n school: 'destruction',\n cost: 30,\n magnitude: 25,\n range: 200,\n description: '发射闪电,可在敌人间弹跳'\n});\n\nmod.registerSpell('blood_heal', {\n name: '血疗术',\n school: 'restoration',\n cost: 20,\n magnitude: 40,\n description: '消耗自身生命值来治疗目标'\n});\n\nmod.registerSpell('shadow_clone', {\n name: '暗影分身',\n school: 'illusion',\n cost: 35,\n duration: 15000,\n description: '创造一个分身吸引敌人注意'\n});\n\ngame.on('spell:cast', (data) => {\n const { caster, spellId, target } = data;\n \n if (spellId === 'chain_lightning') {\n const nearbyEnemies = game.getNearbyEntities(\n caster.position.x,\n caster.position.y,\n 200\n );\n \n let chainTarget = target;\n let chainCount = 0;\n const maxChains = 3;\n const hitEntities = new Set();\n \n if (chainTarget) {\n chainTarget.damage(25, 'shock');\n chainTarget.say('⚡', 1000);\n hitEntities.add(chainTarget.id);\n chainCount++;\n \n while (chainCount < maxChains) {\n let closest = null;\n let closestDist = 150;\n \n for (const enemy of nearbyEnemies) {\n if (hitEntities.has(enemy.id)) continue;\n const dist = chainTarget.distanceTo(enemy);\n if (dist < closestDist) {\n closest = enemy;\n closestDist = dist;\n }\n }\n \n if (closest) {\n closest.damage(20, 'shock');\n closest.say('⚡', 1000);\n hitEntities.add(closest.id);\n chainTarget = closest;\n chainCount++;\n } else {\n break;\n }\n }\n }\n \n return true;\n }\n \n if (spellId === 'blood_heal') {\n const casterHealth = caster.health;\n if (casterHealth && casterHealth.current > 20) {\n caster.damage(20, 'unresistable');\n \n if (target) {\n target.heal(40);\n target.say('+40 HP', 2000);\n } else {\n caster.heal(40);\n caster.say('+40 HP', 2000);\n }\n return true;\n } else {\n caster.say('生命值不足!', 2000);\n return false;\n }\n }\n \n if (spellId === 'shadow_clone') {\n if (!caster.position) return false;\n \n const clone = game.createEntity('npc');\n clone.position = {\n x: caster.position.x + 50,\n y: caster.position.y\n };\n clone.addComponent({\n type: 'npcData',\n name: '暗影分身',\n dialogue: 'clone'\n });\n clone.addComponent({\n type: 'health',\n current: 1,\n max: 1\n });\n clone.addComponent({\n type: 'clone',\n owner: caster.id,\n duration: 15000,\n spawnTime: Date.now()\n });\n \n clone.say('分身出现!', 2000);\n \n game.once('game:update', () => {\n const enemies = game.getNearbyEntities(\n clone.position.x,\n clone.position.y,\n 150\n );\n for (const enemy of enemies) {\n const ai = enemy.getComponent('ai');\n if (ai) {\n ai.state = 'chase';\n }\n }\n });\n \n return true;\n }\n \n return false;\n});\n\ngame.on('game:update', (data) => {\n const clones = game.getEntitiesByType('npc');\n for (const clone of clones) {\n const cloneData = clone.getComponent('clone');\n if (cloneData) {\n const elapsed = Date.now() - cloneData.spawnTime;\n if (elapsed >= cloneData.duration) {\n clone.say('分身消失...', 1000);\n setTimeout(() => {\n game.destroyEntity(clone.id);\n }, 1000);\n }\n }\n }\n});\n\nlog('Custom spells registered: chain_lightning, blood_heal, shadow_clone');"
+ }
+}
diff --git a/public/data/mods/example-weapons-mod.json b/public/data/mods/example-weapons-mod.json
new file mode 100644
index 0000000..d5fde4f
--- /dev/null
+++ b/public/data/mods/example-weapons-mod.json
@@ -0,0 +1,115 @@
+{
+ "manifest": {
+ "id": "example-weapons",
+ "name": "示例武器包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加 5 把新武器到游戏中",
+ "priority": 100,
+ "dependencies": []
+ },
+ "data": {
+ "items": {
+ "flame_sword": {
+ "id": "flame_sword",
+ "name": "烈焰之剑",
+ "type": "weapon",
+ "subtype": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 12,
+ "speed": 1.0,
+ "weight": 12,
+ "value": 150,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "fire_damage",
+ "magnitude": 10,
+ "duration": 0
+ },
+ "keywords": ["metal", "slashing", "fire"],
+ "description": "一把燃烧着永恒火焰的魔法剑"
+ },
+ "frost_staff": {
+ "id": "frost_staff",
+ "name": "冰霜法杖",
+ "type": "weapon",
+ "subtype": "staff",
+ "material": "crystal",
+ "tier": 3,
+ "damage": 8,
+ "speed": 0.8,
+ "weight": 8,
+ "value": 300,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "frost_damage",
+ "magnitude": 15,
+ "duration": 0
+ },
+ "keywords": ["magic", "frost", "staff"],
+ "description": "散发寒气的水晶法杖"
+ },
+ "shadow_dagger": {
+ "id": "shadow_dagger",
+ "name": "暗影匕首",
+ "type": "weapon",
+ "subtype": "dagger",
+ "material": "ebony",
+ "tier": 5,
+ "damage": 15,
+ "speed": 1.5,
+ "weight": 3,
+ "value": 500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "silent_damage",
+ "magnitude": 20,
+ "duration": 0
+ },
+ "keywords": ["metal", "piercing", "shadow"],
+ "description": "来自暗影界的匕首,攻击无声"
+ },
+ "thunder_hammer": {
+ "id": "thunder_hammer",
+ "name": "雷霆战锤",
+ "type": "weapon",
+ "subtype": "warhammer",
+ "material": "daedric",
+ "tier": 6,
+ "damage": 30,
+ "speed": 0.6,
+ "weight": 25,
+ "value": 1000,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "shock_damage",
+ "magnitude": 25,
+ "duration": 0
+ },
+ "keywords": ["metal", "blunt", "lightning"],
+ "description": "蕴含雷电之力的魔族战锤"
+ },
+ "dragon_bow": {
+ "id": "dragon_bow",
+ "name": "龙骨弓",
+ "type": "weapon",
+ "subtype": "bow",
+ "material": "dragon",
+ "tier": 7,
+ "damage": 22,
+ "speed": 0.7,
+ "weight": 14,
+ "value": 1500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "absorb_health",
+ "magnitude": 5,
+ "duration": 0
+ },
+ "keywords": ["ranged", "dragon", "piercing"],
+ "description": "用龙骨打造的强大弓"
+ }
+ }
+ }
+}
diff --git a/public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods b/public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods
new file mode 100644
index 0000000..d5fde4f
--- /dev/null
+++ b/public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods
@@ -0,0 +1,115 @@
+{
+ "manifest": {
+ "id": "example-weapons",
+ "name": "示例武器包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加 5 把新武器到游戏中",
+ "priority": 100,
+ "dependencies": []
+ },
+ "data": {
+ "items": {
+ "flame_sword": {
+ "id": "flame_sword",
+ "name": "烈焰之剑",
+ "type": "weapon",
+ "subtype": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 12,
+ "speed": 1.0,
+ "weight": 12,
+ "value": 150,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "fire_damage",
+ "magnitude": 10,
+ "duration": 0
+ },
+ "keywords": ["metal", "slashing", "fire"],
+ "description": "一把燃烧着永恒火焰的魔法剑"
+ },
+ "frost_staff": {
+ "id": "frost_staff",
+ "name": "冰霜法杖",
+ "type": "weapon",
+ "subtype": "staff",
+ "material": "crystal",
+ "tier": 3,
+ "damage": 8,
+ "speed": 0.8,
+ "weight": 8,
+ "value": 300,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "frost_damage",
+ "magnitude": 15,
+ "duration": 0
+ },
+ "keywords": ["magic", "frost", "staff"],
+ "description": "散发寒气的水晶法杖"
+ },
+ "shadow_dagger": {
+ "id": "shadow_dagger",
+ "name": "暗影匕首",
+ "type": "weapon",
+ "subtype": "dagger",
+ "material": "ebony",
+ "tier": 5,
+ "damage": 15,
+ "speed": 1.5,
+ "weight": 3,
+ "value": 500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "silent_damage",
+ "magnitude": 20,
+ "duration": 0
+ },
+ "keywords": ["metal", "piercing", "shadow"],
+ "description": "来自暗影界的匕首,攻击无声"
+ },
+ "thunder_hammer": {
+ "id": "thunder_hammer",
+ "name": "雷霆战锤",
+ "type": "weapon",
+ "subtype": "warhammer",
+ "material": "daedric",
+ "tier": 6,
+ "damage": 30,
+ "speed": 0.6,
+ "weight": 25,
+ "value": 1000,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "shock_damage",
+ "magnitude": 25,
+ "duration": 0
+ },
+ "keywords": ["metal", "blunt", "lightning"],
+ "description": "蕴含雷电之力的魔族战锤"
+ },
+ "dragon_bow": {
+ "id": "dragon_bow",
+ "name": "龙骨弓",
+ "type": "weapon",
+ "subtype": "bow",
+ "material": "dragon",
+ "tier": 7,
+ "damage": 22,
+ "speed": 0.7,
+ "weight": 14,
+ "value": 1500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "absorb_health",
+ "magnitude": 5,
+ "duration": 0
+ },
+ "keywords": ["ranged", "dragon", "piercing"],
+ "description": "用龙骨打造的强大弓"
+ }
+ }
+ }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons.svg b/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/src/core/EntityManager.test.ts b/src/core/EntityManager.test.ts
new file mode 100644
index 0000000..abfa105
--- /dev/null
+++ b/src/core/EntityManager.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest';
+import { EntityManager } from './EntityManager';
+
+describe('EntityManager', () => {
+ it('should create entities', () => {
+ const manager = new EntityManager();
+ const entity = manager.createEntity('player');
+
+ expect(entity).toBeDefined();
+ expect(entity.id).toBeDefined();
+ expect(entity.type).toBe('player');
+ });
+
+ it('should add and get components', () => {
+ const manager = new EntityManager();
+ const entity = manager.createEntity('player');
+
+ manager.addComponent(entity.id, { type: 'health', current: 100, max: 100 });
+
+ const health = manager.getComponent<{ current: number; max: number }>(entity.id, 'health');
+ expect(health).toBeDefined();
+ expect(health?.current).toBe(100);
+ expect(health?.max).toBe(100);
+ });
+
+ it('should remove components', () => {
+ const manager = new EntityManager();
+ const entity = manager.createEntity('player');
+
+ manager.addComponent(entity.id, { type: 'health', current: 100, max: 100 });
+ manager.removeComponent(entity.id, 'health');
+
+ const health = manager.getComponent(entity.id, 'health');
+ expect(health).toBeUndefined();
+ });
+
+ it('should destroy entities', () => {
+ const manager = new EntityManager();
+ const entity = manager.createEntity('enemy');
+
+ manager.addComponent(entity.id, { type: 'health', current: 50, max: 50 });
+ manager.destroyEntity(entity.id);
+
+ const retrieved = manager.getEntity(entity.id);
+ expect(retrieved).toBeUndefined();
+ });
+
+ it('should get entities by type', () => {
+ const manager = new EntityManager();
+
+ manager.createEntity('player');
+ manager.createEntity('enemy');
+ manager.createEntity('enemy');
+
+ const enemies = manager.getEntitiesByType('enemy');
+ expect(enemies).toHaveLength(2);
+ });
+
+ it('should return undefined for non-existent components', () => {
+ const manager = new EntityManager();
+ const entity = manager.createEntity('player');
+
+ const health = manager.getComponent(entity.id, 'nonexistent');
+ expect(health).toBeUndefined();
+ });
+});
diff --git a/src/core/EntityManager.ts b/src/core/EntityManager.ts
new file mode 100644
index 0000000..b79dadf
--- /dev/null
+++ b/src/core/EntityManager.ts
@@ -0,0 +1,102 @@
+import { eventBus } from './EventBus';
+
+export type EntityType = 'player' | 'npc' | 'enemy' | 'item' | 'projectile' | 'trigger' | 'corpse';
+
+export interface Component {
+ type: string;
+ [key: string]: any;
+}
+
+export interface Entity {
+ id: string;
+ type: EntityType;
+ components: Map;
+ active: boolean;
+ sprite?: Phaser.GameObjects.GameObject;
+}
+
+export class EntityManager {
+ private static instance: EntityManager;
+ private entities: Map = new Map();
+ private nextId: number = 0;
+
+ static getInstance(): EntityManager {
+ if (!EntityManager.instance) {
+ EntityManager.instance = new EntityManager();
+ }
+ return EntityManager.instance;
+ }
+
+ createEntity(type: EntityType): Entity {
+ const id = `${type}_${this.nextId++}`;
+ const entity: Entity = {
+ id,
+ type,
+ components: new Map(),
+ active: true,
+ };
+ this.entities.set(id, entity);
+ eventBus.emit('entity:created', { entity });
+ return entity;
+ }
+
+ destroyEntity(id: string): void {
+ const entity = this.entities.get(id);
+ if (entity) {
+ entity.active = false;
+ this.entities.delete(id);
+ eventBus.emit('entity:destroyed', { entity });
+ }
+ }
+
+ getEntity(id: string): Entity | undefined {
+ return this.entities.get(id);
+ }
+
+ getEntitiesByType(type: EntityType): Entity[] {
+ return Array.from(this.entities.values()).filter(
+ (e) => e.type === type && e.active
+ );
+ }
+
+ getAllEntities(): Entity[] {
+ return Array.from(this.entities.values()).filter((e) => e.active);
+ }
+
+ addComponent(entityId: string, component: Component): void {
+ const entity = this.entities.get(entityId);
+ if (entity) {
+ entity.components.set(component.type, component);
+ eventBus.emit('entity:componentAdded', { entityId, component });
+ }
+ }
+
+ removeComponent(entityId: string, componentType: string): void {
+ const entity = this.entities.get(entityId);
+ if (entity) {
+ entity.components.delete(componentType);
+ eventBus.emit('entity:componentRemoved', { entityId, componentType });
+ }
+ }
+
+ getComponent(entityId: string, componentType: string): T | undefined {
+ return this.entities.get(entityId)?.components.get(componentType) as T;
+ }
+
+ hasComponent(entityId: string, componentType: string): boolean {
+ return this.entities.get(entityId)?.components.has(componentType) ?? false;
+ }
+
+ getEntitiesWithComponent(componentType: string): Entity[] {
+ return Array.from(this.entities.values()).filter(
+ (e) => e.active && e.components.has(componentType)
+ );
+ }
+
+ clear(): void {
+ this.entities.clear();
+ this.nextId = 0;
+ }
+}
+
+export const entityManager = EntityManager.getInstance();
diff --git a/src/core/EventBus.test.ts b/src/core/EventBus.test.ts
new file mode 100644
index 0000000..0414a2b
--- /dev/null
+++ b/src/core/EventBus.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi } from 'vitest';
+import { EventBus } from './EventBus';
+
+describe('EventBus', () => {
+ it('should emit and receive events', () => {
+ const bus = new EventBus();
+ const callback = vi.fn();
+
+ bus.on('test:event', callback);
+ bus.emit('test:event', { data: 'hello' });
+
+ expect(callback).toHaveBeenCalledWith({ data: 'hello' });
+ });
+
+ it('should support multiple listeners', () => {
+ const bus = new EventBus();
+ const callback1 = vi.fn();
+ const callback2 = vi.fn();
+
+ bus.on('test:event', callback1);
+ bus.on('test:event', callback2);
+ bus.emit('test:event', {});
+
+ expect(callback1).toHaveBeenCalledOnce();
+ expect(callback2).toHaveBeenCalledOnce();
+ });
+
+ it('should unsubscribe listeners', () => {
+ const bus = new EventBus();
+ const callback = vi.fn();
+
+ const unsubscribe = bus.on('test:event', callback);
+ bus.emit('test:event', {});
+ expect(callback).toHaveBeenCalledOnce();
+
+ unsubscribe();
+ bus.emit('test:event', {});
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+ it('should support once listeners', () => {
+ const bus = new EventBus();
+ const callback = vi.fn();
+
+ bus.once('test:event', callback);
+ bus.emit('test:event', {});
+ bus.emit('test:event', {});
+
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+ it('should remove all listeners for a specific event', () => {
+ const bus = new EventBus();
+ const callback1 = vi.fn();
+ const callback2 = vi.fn();
+
+ bus.on('event1', callback1);
+ bus.on('event2', callback2);
+ bus.removeAllListeners('event1');
+
+ bus.emit('event1', {});
+ bus.emit('event2', {});
+
+ expect(callback1).not.toHaveBeenCalled();
+ expect(callback2).toHaveBeenCalledOnce();
+ });
+
+ it('should remove all listeners when no event specified', () => {
+ const bus = new EventBus();
+ const callback1 = vi.fn();
+ const callback2 = vi.fn();
+
+ bus.on('event1', callback1);
+ bus.on('event2', callback2);
+ bus.removeAllListeners();
+
+ bus.emit('event1', {});
+ bus.emit('event2', {});
+
+ expect(callback1).not.toHaveBeenCalled();
+ expect(callback2).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts
new file mode 100644
index 0000000..f2a129d
--- /dev/null
+++ b/src/core/EventBus.ts
@@ -0,0 +1,50 @@
+export type EventCallback = (data: T) => void;
+
+export class EventBus {
+ private static instance: EventBus;
+ private listeners: Map> = new Map();
+
+ static getInstance(): EventBus {
+ if (!EventBus.instance) {
+ EventBus.instance = new EventBus();
+ }
+ return EventBus.instance;
+ }
+
+ on(event: string, callback: EventCallback): () => void {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, new Set());
+ }
+ this.listeners.get(event)!.add(callback);
+
+ return () => this.off(event, callback);
+ }
+
+ off(event: string, callback: EventCallback): void {
+ this.listeners.get(event)?.delete(callback);
+ }
+
+ emit(event: string, data?: T): void {
+ this.listeners.get(event)?.forEach((callback) => {
+ callback(data);
+ });
+ }
+
+ once(event: string, callback: EventCallback): () => void {
+ const wrapper: EventCallback = (data) => {
+ callback(data);
+ this.off(event, wrapper);
+ };
+ return this.on(event, wrapper);
+ }
+
+ removeAllListeners(event?: string): void {
+ if (event) {
+ this.listeners.delete(event);
+ } else {
+ this.listeners.clear();
+ }
+ }
+}
+
+export const eventBus = EventBus.getInstance();
diff --git a/src/core/GameEvents.ts b/src/core/GameEvents.ts
new file mode 100644
index 0000000..48e1527
--- /dev/null
+++ b/src/core/GameEvents.ts
@@ -0,0 +1,127 @@
+import type { Entity } from './EntityManager';
+
+// ============================================================
+// GameEvents - 统一事件契约
+// 所有事件 payload 类型在此定义,系统间通过事件通信
+// ============================================================
+
+export interface GameEvents {
+ // === Game Lifecycle ===
+ 'game:initialized': {};
+ 'game:destroyed': {};
+ 'game:update': { delta: number; time: number };
+ 'game:zoneChanged': { zoneId: string; zone: any };
+ 'game:saved': { id: string; name: string };
+ 'game:loaded': { id: string };
+ 'game:saveDeleted': { id: string };
+ 'game:saveUpdated': { id: string };
+
+ // === Entity ===
+ 'entity:created': { entity: Entity };
+ 'entity:destroyed': { entity: Entity };
+ 'entity:damaged': { entity: Entity; amount: number; type?: string };
+ 'entity:killed': { entity: Entity; killer?: Entity };
+ 'entity:say': { entity: Entity; message: string; duration: number };
+ 'entity:componentAdded': { entityId: string; component: any };
+ 'entity:componentRemoved': { entityId: string; componentType: string };
+
+ // === Combat ===
+ 'combat:attack': { attacker: Entity; target: Entity; isPowerAttack?: boolean };
+ 'combat:beforeAttack': { attacker: Entity; target: Entity; damage: number; isPowerAttack: boolean };
+ 'combat:afterAttack': { attacker: Entity; target: Entity; damage: number; isPowerAttack: boolean };
+ 'combat:magicDamage': { caster: Entity; target: Entity; damage: number; school: string };
+
+ // === Player ===
+ 'player:created': { entity: Entity };
+ 'player:levelUp': { entityId: string; newLevel: number; healthBonus: number; magickaBonus: number; staminaBonus: number };
+
+ // === Skill ===
+ 'skill:improved': { entityId: string; skill: string; amount: number };
+ 'skill:updated': { entityId: string; skill: string; newValue: number };
+ 'skill:legendary': { entityId: string; skill: string };
+
+ // === Inventory & Items ===
+ 'inventory:updated': { entity: Entity };
+ 'equipment:changed': { entity: Entity; item: any; slot: string };
+ 'item:pickup': { entity: Entity; itemId: string; quantity: number };
+ 'item:drop': { entity: Entity; itemId: string; quantity: number };
+ 'item:dropWorld': { entity: Entity; itemId: string; quantity: number };
+ 'item:use': { entity: Entity; itemId: string };
+ 'item:used': { entity: Entity; item: any };
+ 'item:spawnedGround': { entity: Entity; itemId: string; quantity: number };
+
+ // === Quest ===
+ 'quest:started': { questId: string; player: Entity };
+ 'quest:activated': { quest: any; player: Entity };
+ 'quest:completed': { quest: any; player: Entity };
+ 'quest:failed': { quest: any };
+
+ // === Dialogue ===
+ 'dialogue:started': { npc: Entity; player: Entity };
+ 'dialogue:line': { speaker: string; text: string; options: any[]; npc: any };
+ 'dialogue:ended': { npc: Entity };
+
+ // === Magic ===
+ 'spell:cast': { caster: Entity; spell: any; target?: Entity };
+ 'spell:learned': { entity: Entity; spellId: string };
+ 'shout:used': { caster: Entity; shout: any };
+
+ // === Status Effects ===
+ 'statusEffect:applied': { entity: Entity; effect: string; duration: number; magnitude: number };
+ 'statusEffect:removed': { entity: Entity; effect: string };
+
+ // === Corpse ===
+ 'corpse:created': { entity: Entity; loot: any[] };
+ 'corpse:searched': { entity: Entity; searcher: Entity; loot: any[] };
+ 'corpse:decayed': { entity: Entity; state: string };
+
+ // === Container ===
+ 'container:created': { entity: Entity };
+ 'container:open': { entity: Entity; opener: Entity };
+ 'container:opened': { entity: Entity; opener: Entity; loot: any[] };
+ 'container:locked': { entity: Entity; opener: Entity; lockLevel: number };
+ 'container:empty': { entity: Entity; opener: Entity };
+ 'container:looted': { entity: Entity; looter: Entity };
+ 'container:unlocked': { entity: Entity };
+
+ // === Crafting ===
+ 'alchemy:brewed': { player: Entity; potion: any; recipe: any };
+ 'smithing:crafted': { entity: Entity; recipe: any; item: any };
+ 'smithing:improved': { entity: Entity; itemId: string; item: any };
+ 'enchantment:learned': { entity: Entity; enchantmentId: string };
+ 'enchantment:disenchanted': { entity: Entity; itemId: string; enchantment: any };
+ 'enchantment:applied': { entity: Entity; itemId: string; enchantment: any; magnitude: number };
+ 'soulGem:filled': { entity: Entity; soulGem: any; soulLevel: number };
+ 'cooking:cooked': { entity: Entity; recipe: any; item: any };
+
+ // === World ===
+ 'zone:entered': { zoneId: string; zone: any };
+ 'zone:loaded': { zone: any };
+ 'world:fastTravel': { locationId: string };
+
+ // === Faction ===
+ 'faction:changeRep': { player: Entity; faction: string; amount: number };
+
+ // === Perk ===
+ 'perk:unlocked': { entityId: string; perkId: string };
+
+ // === Mod ===
+ 'mod:loaded': { modId: string };
+ 'mod:unloaded': { modId: string };
+ 'mod:enabled': { modId: string };
+ 'mod:disabled': { modId: string };
+ 'mod:dataResolved': { data: any };
+ 'mod:itemRegistered': { modId: string; itemId: string; data: any };
+ 'mod:spellRegistered': { modId: string; spellId: string; data: any };
+ 'mod:enemyRegistered': { modId: string; enemyId: string; data: any };
+ 'mod:npcRegistered': { modId: string; npcId: string; data: any };
+ 'mod:questRegistered': { modId: string; questId: string; data: any };
+ 'mod:recipeRegistered': { modId: string; recipeId: string; data: any };
+ 'mod:zoneRegistered': { modId: string; zoneId: string; data: any };
+
+ // === Script ===
+ 'script:error': { modId: string; scriptId?: string; filePath?: string; error: any };
+
+ // === System ===
+ 'system:registered': { name: string };
+}
diff --git a/src/core/GameManager.ts b/src/core/GameManager.ts
new file mode 100644
index 0000000..0abf4fa
--- /dev/null
+++ b/src/core/GameManager.ts
@@ -0,0 +1,82 @@
+import Phaser from 'phaser';
+import { eventBus } from './EventBus';
+
+export interface GameConfig {
+ width: number;
+ height: number;
+ parent: string | HTMLElement;
+}
+
+export class GameManager {
+ private static instance: GameManager;
+ private game: Phaser.Game | null = null;
+ private systems: Map = new Map();
+ private isRunning: boolean = false;
+
+ static getInstance(): GameManager {
+ if (!GameManager.instance) {
+ GameManager.instance = new GameManager();
+ }
+ return GameManager.instance;
+ }
+
+ init(config: GameConfig, scenes: Phaser.Types.Scenes.SceneType[]): void {
+ this.game = new Phaser.Game({
+ type: Phaser.AUTO,
+ width: config.width,
+ height: config.height,
+ parent: config.parent,
+ pixelArt: true,
+ scale: {
+ mode: Phaser.Scale.FIT,
+ autoCenter: Phaser.Scale.CENTER_BOTH,
+ },
+ physics: {
+ default: 'arcade',
+ arcade: {
+ gravity: { x: 0, y: 0 },
+ debug: false,
+ },
+ },
+ scene: scenes,
+ });
+
+ this.isRunning = true;
+ eventBus.emit('game:initialized');
+ }
+
+ getGame(): Phaser.Game | null {
+ return this.game;
+ }
+
+ registerSystem(name: string, system: any): void {
+ this.systems.set(name, system);
+ eventBus.emit('system:registered', { name });
+ }
+
+ getSystem(name: string): T | undefined {
+ return this.systems.get(name) as T;
+ }
+
+ update(delta: number): void {
+ this.systems.forEach((system) => {
+ if (system.update) {
+ system.update(delta);
+ }
+ });
+ }
+
+ destroy(): void {
+ this.game?.destroy(true);
+ this.game = null;
+ this.systems.clear();
+ this.isRunning = false;
+ eventBus.emit('game:destroyed');
+ }
+
+ getIsRunning(): boolean {
+ return this.isRunning;
+ }
+}
+
+export const gameManager = GameManager.getInstance();
diff --git a/src/data/DataRegistry.test.ts b/src/data/DataRegistry.test.ts
new file mode 100644
index 0000000..89405d5
--- /dev/null
+++ b/src/data/DataRegistry.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { dataRegistry } from './DataRegistry';
+import { modLoader } from '../mods/ModLoader';
+
+describe('DataRegistry', () => {
+ beforeEach(() => {
+ modLoader.clearForTests();
+ dataRegistry.resetForTests();
+ });
+
+ it('normalizes base weapon data into inventory-ready items', () => {
+ const ironSword = dataRegistry.getItem('iron_sword');
+
+ expect(ironSword?.type).toBe('weapon');
+ expect(ironSword?.subtype).toBe('one_handed_sword');
+ expect(ironSword?.effects).toContainEqual({ type: 'damage', magnitude: 10 });
+ });
+
+ it('applies resolved mod data over base data', async () => {
+ await modLoader.loadMod(
+ { id: 'wolf_rebalance', name: 'Wolf Rebalance', version: '1.0.0' },
+ {
+ enemies: {
+ wolf: {
+ name: 'Dire Wolf',
+ health: 90,
+ damage: 14,
+ color: '#abcdef',
+ },
+ },
+ }
+ );
+
+ const wolf = dataRegistry.getEnemy('wolf');
+
+ expect(wolf?.name).toBe('Dire Wolf');
+ expect(wolf?.health).toBe(90);
+ expect(wolf?.color).toBe(0xabcdef);
+ });
+
+ it('adds new mod items to the registry', async () => {
+ await modLoader.loadMod(
+ { id: 'new_items', name: 'New Items', version: '1.0.0' },
+ {
+ items: {
+ flame_sword: {
+ name: 'Flame Sword',
+ type: 'weapon',
+ subtype: 'one_handed_sword',
+ damage: 18,
+ speed: 1,
+ weight: 9,
+ value: 150,
+ },
+ },
+ }
+ );
+
+ const flameSword = dataRegistry.getItem('flame_sword');
+
+ expect(flameSword?.type).toBe('weapon');
+ expect(flameSword?.effects).toContainEqual({ type: 'damage', magnitude: 18 });
+ });
+});
diff --git a/src/data/DataRegistry.ts b/src/data/DataRegistry.ts
new file mode 100644
index 0000000..22792c9
--- /dev/null
+++ b/src/data/DataRegistry.ts
@@ -0,0 +1,1568 @@
+import { eventBus } from '../core/EventBus';
+import { modLoader } from '../mods/ModLoader';
+import itemsJson from './items/items.json';
+import weaponsJson from './items/weapons.json';
+import armorJson from './items/armor.json';
+import enemiesJson from './enemies/enemies.json';
+import questsJson from './quests/quests.json';
+import racesJson from './races/races.json';
+import skillsJson from './skills/skills.json';
+import perksJson from './skills/perks.json';
+import werewolfPerksJson from './skills/werewolf-perks.json';
+import vampirePerksJson from './skills/vampire-perks.json';
+import standingStonesJson from './world/standing-stones.json';
+import spellsJson from './spells/spells.json';
+import shoutsJson from './spells/shouts.json';
+import enchantmentsJson from './items/enchantments.json';
+import soulGemsJson from './items/soul-gems.json';
+import ingredientsJson from './alchemy/ingredients.json';
+import potionsJson from './alchemy/potions.json';
+import smithingJson from './crafting/smithing.json';
+import cookingJson from './crafting/cooking.json';
+import transformsJson from './transforms.json';
+import vampireStagesJson from './vampire-stages.json';
+import dialogueJson from './dialogue/trees.json';
+import gameConfigJson from './game-config.json';
+
+export interface DataEffect {
+ type: string;
+ magnitude: number;
+ duration?: number;
+ attribute?: string;
+}
+
+export interface ItemData {
+ id: string;
+ name: string;
+ type: string;
+ subtype?: string;
+ material?: string;
+ tier?: number;
+ damage?: number;
+ speed?: number;
+ weight: number;
+ value: number;
+ description?: string;
+ effects: DataEffect[];
+ enchantment?: unknown;
+ keywords?: string[];
+}
+
+export interface EnemyLootTable {
+ gold?: { min: number; max: number };
+ items?: { id: string; chance: number; quantity?: number }[];
+}
+
+export interface EnemyData {
+ id: string;
+ name: string;
+ level: number;
+ health: number;
+ stamina: number;
+ damage: number;
+ armor: number;
+ detectionRange: number;
+ attackRange: number;
+ attackSpeed: number;
+ size: number;
+ color: number;
+ loot?: EnemyLootTable;
+}
+
+export interface SpellData {
+ id: string;
+ name: string;
+ school: string;
+ cost: number;
+ magnitude: number;
+ duration?: number;
+ range?: number;
+ description?: string;
+}
+
+export interface RecipeData {
+ id: string;
+ name: string;
+ ingredients: string[];
+ result: {
+ id: string;
+ name: string;
+ type: string;
+ effects: DataEffect[];
+ weight: number;
+ value: number;
+ };
+}
+
+export interface QuestData {
+ id: string;
+ name: string;
+ description: string;
+ type: 'main' | 'side' | 'guild' | 'daedric' | 'radiant';
+ level: number;
+ prerequisites: string[];
+ objectives: { id: string; description: string; type: string; target?: string; quantity?: number; count?: number }[];
+ rewards: { gold?: number; items?: { id: string; quantity: number }[]; xp?: number; faction?: string; factionRep?: number };
+}
+
+export interface RaceData {
+ id: string;
+ name: string;
+ description: string;
+ bonuses: Record;
+ baseStats: { health: number; magicka: number; stamina: number };
+ power: { id: string; name: string; description: string; cooldown: number };
+ passive: { id: string; name: string; description: string; value: number; type: string };
+}
+
+export interface SkillData {
+ id: string;
+ name: string;
+ description: string;
+ category: 'combat' | 'magic' | 'stealth';
+}
+
+export interface PerkData {
+ id: string;
+ name: string;
+ description: string;
+ rank: number;
+ maxRank: number;
+ requires?: string[];
+ requiresPerkPoints?: number;
+ skill: string;
+ skillLevel: number;
+}
+
+export interface PerkTreeData {
+ id: string;
+ name: string;
+ perks: PerkData[];
+}
+
+export interface StandingStoneData {
+ id: string;
+ name: string;
+ description: string;
+ effect: { type: string; category?: string; value: number };
+}
+
+export interface ArmorData {
+ id: string;
+ name: string;
+ type: 'armor';
+ subtype: string;
+ tier: string;
+ armor: number;
+ weight: number;
+ value: number;
+ material: string;
+ description?: string;
+}
+
+export interface ShoutData {
+ id: string;
+ name: string;
+ words: string[];
+ wordCount: number;
+ cooldown: number;
+ effects: { type: string; magnitude: number; duration?: number }[];
+}
+
+export interface FullSpellData {
+ id: string;
+ name: string;
+ school: string;
+ type: 'self' | 'target' | 'area' | 'ranged';
+ magickaCost: number;
+ magnitude: number;
+ duration: number;
+ cooldown: number;
+ level: number;
+ description: string;
+ effects: { type: string; attribute?: string; magnitude: number; duration?: number }[];
+}
+
+export interface EnchantmentData {
+ id: string;
+ name: string;
+ type: 'weapon' | 'armor' | 'jewelry';
+ effects: { type: string; magnitude: number; duration?: number }[];
+ magnitude: number;
+ duration: number;
+}
+
+export interface SoulGemData {
+ id: string;
+ name: string;
+ size: 'petty' | 'lesser' | 'common' | 'greater' | 'grand' | 'black';
+ capacity: number;
+ filled: boolean;
+ soulLevel?: number;
+}
+
+export interface SmithingRecipeData {
+ id: string;
+ name: string;
+ type: 'weapon' | 'armor' | 'shield' | 'material';
+ tier: string;
+ materials: { id: string; quantity: number }[];
+ result: { id: string; name: string; type: string; damage?: number; armor?: number; weight: number; value: number };
+ skillRequired: number;
+}
+
+export interface SmithingMaterialData {
+ tier: string;
+ level: number;
+}
+
+export interface SmithingStationData {
+ type: string;
+ name: string;
+ availableRecipes: string[];
+}
+
+export interface CookingRecipeData {
+ id: string;
+ name: string;
+ ingredients: string[];
+ result: {
+ id: string;
+ name: string;
+ type: 'food' | 'drink';
+ effects: { type: string; magnitude: number; duration?: number }[];
+ weight: number;
+ value: number;
+ };
+}
+
+export interface DialogueTreeData {
+ id: string;
+ npcId: string;
+ lines: Record;
+ startLineId: string;
+}
+
+export interface TransformData {
+ id: string;
+ name: string;
+ healthBonus: number;
+ staminaBonus: number;
+ damageBonus: number;
+ armorBonus: number;
+ speedBonus: number;
+ durationMs: number;
+ cooldownMs: number;
+ weaponId: string;
+ weaponDamage: number;
+ weaponSpeed: number;
+ suppressMagicka: boolean;
+ effects: { id: string; attribute: string; magnitude: number; durationMs: number }[];
+}
+
+export interface AlchemyIngredientData {
+ id: string;
+ name: string;
+ weight: number;
+ value: number;
+ effects: { type: string; magnitude: number; duration: number }[];
+ harvestNode?: string;
+}
+
+export interface VampireStageData {
+ stage: number;
+ name: string;
+ frostResist: number;
+ fireResist: number;
+ sunDamage: number;
+ nightBonuses: { id: string; attribute: string; magnitude: number }[];
+ infectionThresholdMs: number;
+}
+
+export interface GameConfigData {
+ combat: {
+ baseDamage: number;
+ skillBonus: number;
+ powerAttackMultiplier: number;
+ critBaseChance: number;
+ critPerSneakSkill: number;
+ armorDivisor: number;
+ damageVariance: [number, number];
+ blockDamageMultiplier: number;
+ blockBaseChance: number;
+ blockSkillBonus: number;
+ blockStaminaThreshold: number;
+ blockStaminaPenalty: number;
+ blockStaminaCost: number;
+ attackRanges: { melee: number; unarmed: number };
+ staminaCosts: { powerAttack: number; normal: number };
+ weaponDamageThresholds: { twoHanded: number; powerAttack: number };
+ skillImprovementAmounts: { attack: number; armor: number };
+ };
+ leveling: {
+ defaultSkillLevel: number;
+ maxSkillLevel: number;
+ levelDivisor: number;
+ healthPerLevel: number;
+ magickaPerLevel: number;
+ staminaPerLevel: number;
+ xpBase: number;
+ xpScale: number;
+ legendaryThreshold: number;
+ };
+ regen: {
+ delayMs: number;
+ healthPerSecond: number;
+ magickaPerSecond: number;
+ staminaPerSecond: number;
+ restorationBonusPerSkill: number;
+ };
+}
+
+type UnknownRecord = Record;
+
+export class DataRegistry {
+ private static instance: DataRegistry;
+ private items: Map = new Map();
+ private enemies: Map = new Map();
+ private spells: Map = new Map();
+ private recipes: Map = new Map();
+ private quests: Map = new Map();
+ private races: Map = new Map();
+ private skills: Map = new Map();
+ private perkTrees: Map = new Map();
+ private standingStones: Map = new Map();
+ private armor: Map = new Map();
+ private shouts: Map = new Map();
+ private fullSpells: Map = new Map();
+ private enchantments: Map = new Map();
+ private soulGems: Map = new Map();
+ private smithingRecipes: Map = new Map();
+ private smithingMaterials: Map = new Map();
+ private smithingStations: Map = new Map();
+ private cookingRecipes: Map = new Map();
+ private alchemyIngredients: Map = new Map();
+ private dialogueTrees: Map = new Map();
+ private transforms: Map = new Map();
+ private vampireStages: Map = new Map();
+ private gameConfig: GameConfigData | null = null;
+ private loaded = false;
+
+ static getInstance(): DataRegistry {
+ if (!DataRegistry.instance) {
+ DataRegistry.instance = new DataRegistry();
+ }
+ return DataRegistry.instance;
+ }
+
+ constructor() {
+ this.resetToBaseData();
+ eventBus.on('mod:dataResolved', () => {
+ this.reloadFromMods();
+ });
+ }
+
+ async loadAll(): Promise {
+ this.resetToBaseData();
+ this.applyModData(modLoader.getResolvedData());
+ this.loaded = true;
+ }
+
+ resetForTests(): void {
+ this.resetToBaseData();
+ this.loaded = true;
+ }
+
+ private resetToBaseData(): void {
+ this.items.clear();
+ this.enemies.clear();
+ this.spells.clear();
+ this.recipes.clear();
+ this.quests.clear();
+ this.races.clear();
+ this.skills.clear();
+ this.perkTrees.clear();
+ this.standingStones.clear();
+ this.armor.clear();
+ this.shouts.clear();
+ this.fullSpells.clear();
+ this.enchantments.clear();
+ this.soulGems.clear();
+ this.smithingRecipes.clear();
+ this.smithingMaterials.clear();
+ this.smithingStations.clear();
+ this.cookingRecipes.clear();
+ this.dialogueTrees.clear();
+ this.transforms.clear();
+ this.vampireStages.clear();
+ this.gameConfig = null;
+
+ this.loadItemRecord(extractRecord(itemsJson, 'items'), 'item');
+ this.loadItemRecord(extractRecord(weaponsJson, 'weapons'), 'weapon');
+ this.loadArmorRecord(extractRecord(armorJson, 'armor'));
+ this.loadEnemyRecord(extractRecord(enemiesJson, 'enemies'));
+ this.loadQuestRecord(extractRecord(questsJson, 'quests'));
+ this.loadRaceRecord(extractArray(racesJson, 'races'));
+ this.loadSkillRecord(extractRecord(skillsJson, 'skills'));
+ this.loadPerkTreeRecord(extractRecord(perksJson, 'perkTrees'));
+ this.loadPerkTreeRecord(extractRecord(werewolfPerksJson, 'perkTrees'));
+ this.loadPerkTreeRecord(extractRecord(vampirePerksJson, 'perkTrees'));
+ this.loadStandingStoneRecord(extractArray(standingStonesJson, 'standingStones'));
+
+ this.loadShoutRecord(extractRecord(shoutsJson, 'shouts'));
+ this.loadFullSpellRecord(extractRecord(spellsJson, 'spells'));
+ this.loadEnchantmentRecord(extractRecord(enchantmentsJson, 'enchantments'));
+ this.loadSoulGemRecord(extractRecord(soulGemsJson, 'soulGems'));
+ this.loadAlchemyIngredientRecord(extractRecord(ingredientsJson, 'ingredients'));
+ this.loadRecipeRecord(extractRecord(potionsJson, 'recipes'));
+ this.loadSmithingMaterialRecord(extractRecord(smithingJson, 'smithing.materials'));
+ this.loadSmithingStationRecord(extractRecord(smithingJson, 'smithing.stations'));
+ this.loadSmithingRecipeRecord(extractRecord(smithingJson, 'smithing.recipes'));
+ this.loadCookingRecipeRecord(extractRecord(cookingJson, 'cooking.recipes'));
+ this.loadDialogueRecord(extractRecord(dialogueJson, 'dialogue'));
+ this.loadTransformRecord(extractRecord(transformsJson, 'transforms'));
+ this.loadVampireStageRecord(extractRecord(vampireStagesJson, 'vampireStages'));
+ this.loadGameConfig(extractRecord(gameConfigJson, 'gameConfig'));
+ }
+
+ private reloadFromMods(): void {
+ this.resetToBaseData();
+ this.applyModData(modLoader.getResolvedData());
+ }
+
+ private applyModData(data: { [key: string]: unknown }): void {
+ this.loadItemRecord(asRecord(data.items), 'item');
+ this.loadArmorRecord(asRecord(data.armor));
+ this.loadEnemyRecord(asRecord(data.enemies));
+ this.loadSpellRecord(asRecord(data.spells));
+ this.loadRecipeRecord(asRecord(data.recipes));
+ this.loadQuestRecord(asRecord(data.quests));
+ this.loadRaceRecord(asArray(data.races));
+ this.loadSkillRecord(asRecord(data.skills));
+ this.loadPerkTreeRecord(asRecord(data.perkTrees));
+ this.loadStandingStoneRecord(asArray(data.standingStones));
+ this.loadShoutRecord(asRecord(data.shouts));
+ this.loadFullSpellRecord(asRecord(data.fullSpells));
+ this.loadEnchantmentRecord(asRecord(data.enchantments));
+ this.loadSoulGemRecord(asRecord(data.soulGems));
+ this.loadAlchemyIngredientRecord(asRecord(data.alchemyIngredients));
+ this.loadSmithingRecipeRecord(asRecord(data.smithingRecipes));
+ this.loadCookingRecipeRecord(asRecord(data.cookingRecipes));
+ this.loadDialogueRecord(asRecord(data.dialogue));
+ this.loadTransformRecord(asRecord(data.transforms));
+ this.loadVampireStageRecord(asRecord(data.vampireStages));
+ if (isRecord(data.gameConfig)) {
+ this.loadGameConfig(asRecord(data.gameConfig));
+ }
+ }
+
+ private loadItemRecord(record: UnknownRecord, source: 'item' | 'weapon'): void {
+ for (const [id, value] of Object.entries(record)) {
+ const item = normalizeItem(id, value, source);
+ if (item) {
+ this.items.set(item.id, item);
+ }
+ }
+ }
+
+ private loadEnemyRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ const enemy = normalizeEnemy(id, value);
+ if (enemy) {
+ this.enemies.set(enemy.id, enemy);
+ }
+ }
+ }
+
+ private loadSpellRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const spell = normalizeSpell(id, value);
+ if (spell) {
+ this.spells.set(spell.id, spell);
+ }
+ }
+ }
+
+ private loadRecipeRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const recipe = normalizeRecipe(id, value);
+ if (recipe) {
+ this.recipes.set(recipe.id, recipe);
+ }
+ }
+ }
+
+ private loadQuestRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const quest = normalizeQuest(id, value);
+ if (quest) {
+ this.quests.set(quest.id, quest);
+ }
+ }
+ }
+
+ private loadRaceRecord(races: unknown[]): void {
+ for (const value of races) {
+ if (!isRecord(value)) continue;
+ const race = normalizeRace(value);
+ if (race) {
+ this.races.set(race.id, race);
+ }
+ }
+ }
+
+ private loadSkillRecord(record: UnknownRecord): void {
+ const categories = ['combat', 'magic', 'stealth'];
+ for (const category of categories) {
+ const skills = record[category];
+ if (!Array.isArray(skills)) continue;
+ for (const value of skills) {
+ if (!isRecord(value)) continue;
+ const skill = normalizeSkill(value, category as SkillData['category']);
+ if (skill) {
+ this.skills.set(skill.id, skill);
+ }
+ }
+ }
+ }
+
+ private loadPerkTreeRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const tree = normalizePerkTree(id, value);
+ if (tree) {
+ this.perkTrees.set(tree.id, tree);
+ }
+ }
+ }
+
+ private loadStandingStoneRecord(stones: unknown[]): void {
+ for (const value of stones) {
+ if (!isRecord(value)) continue;
+ const stone = normalizeStandingStone(value);
+ if (stone) {
+ this.standingStones.set(stone.id, stone);
+ }
+ }
+ }
+
+ private loadArmorRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const armorPiece = normalizeArmor(id, value);
+ if (armorPiece) {
+ this.armor.set(armorPiece.id, armorPiece);
+ }
+ }
+ }
+
+ private loadShoutRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const shout = normalizeShout(id, value);
+ if (shout) {
+ this.shouts.set(shout.id, shout);
+ }
+ }
+ }
+
+ private loadFullSpellRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const spell = normalizeFullSpell(id, value);
+ if (spell) {
+ this.fullSpells.set(spell.id, spell);
+ }
+ }
+ }
+
+ private loadEnchantmentRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const enchant = normalizeEnchantment(id, value);
+ if (enchant) {
+ this.enchantments.set(enchant.id, enchant);
+ }
+ }
+ }
+
+ private loadSoulGemRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const gem = normalizeSoulGem(id, value);
+ if (gem) {
+ this.soulGems.set(gem.id, gem);
+ }
+ }
+ }
+
+ private loadAlchemyIngredientRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const ing = normalizeAlchemyIngredient(id, value);
+ if (ing) {
+ this.alchemyIngredients.set(ing.id, ing);
+ }
+ }
+ }
+
+ private loadSmithingMaterialRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const mat = normalizeSmithingMaterial(id, value);
+ if (mat) {
+ this.smithingMaterials.set(mat.tier, mat);
+ }
+ }
+ }
+
+ private loadSmithingStationRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const station = normalizeSmithingStation(id, value);
+ if (station) {
+ this.smithingStations.set(id, station);
+ }
+ }
+ }
+
+ private loadSmithingRecipeRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const recipe = normalizeSmithingRecipe(id, value);
+ if (recipe) {
+ this.smithingRecipes.set(recipe.id, recipe);
+ }
+ }
+ }
+
+ private loadCookingRecipeRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const recipe = normalizeCookingRecipe(id, value);
+ if (recipe) {
+ this.cookingRecipes.set(recipe.id, recipe);
+ }
+ }
+ }
+
+ private loadDialogueRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const tree = normalizeDialogueTree(id, value);
+ if (tree) {
+ this.dialogueTrees.set(tree.id, tree);
+ }
+ }
+ }
+
+ private loadTransformRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const transform = normalizeTransform(id, value);
+ if (transform) {
+ this.transforms.set(transform.id, transform);
+ }
+ }
+ }
+
+ private loadVampireStageRecord(record: UnknownRecord): void {
+ for (const [id, value] of Object.entries(record)) {
+ if (!isRecord(value)) continue;
+ const stage = normalizeVampireStage(id, value);
+ if (stage) {
+ this.vampireStages.set(String(stage.stage), stage);
+ }
+ }
+ }
+
+ private loadGameConfig(record: UnknownRecord): void {
+ if (isRecord(record.combat) && isRecord(record.leveling) && isRecord(record.regen)) {
+ this.gameConfig = {
+ combat: {
+ baseDamage: readNumber(record.combat, 'baseDamage', 10),
+ skillBonus: readNumber(record.combat, 'skillBonus', 0.5),
+ powerAttackMultiplier: readNumber(record.combat, 'powerAttackMultiplier', 1.5),
+ critBaseChance: readNumber(record.combat, 'critBaseChance', 0.10),
+ critPerSneakSkill: readNumber(record.combat, 'critPerSneakSkill', 0.005),
+ armorDivisor: readNumber(record.combat, 'armorDivisor', 100),
+ damageVariance: [0.9, 1.1],
+ blockDamageMultiplier: readNumber(record.combat, 'blockDamageMultiplier', 0.2),
+ blockBaseChance: readNumber(record.combat, 'blockBaseChance', 0.3),
+ blockSkillBonus: readNumber(record.combat, 'blockSkillBonus', 0.5),
+ blockStaminaThreshold: readNumber(record.combat, 'blockStaminaThreshold', 10),
+ blockStaminaPenalty: readNumber(record.combat, 'blockStaminaPenalty', -0.2),
+ blockStaminaCost: readNumber(record.combat, 'blockStaminaCost', 10),
+ attackRanges: { melee: 60, unarmed: 45 },
+ staminaCosts: { powerAttack: 25, normal: 5 },
+ weaponDamageThresholds: { twoHanded: 6, powerAttack: 15 },
+ skillImprovementAmounts: { attack: 0.5, armor: 0.3 },
+ },
+ leveling: {
+ defaultSkillLevel: readNumber(record.leveling, 'defaultSkillLevel', 15),
+ maxSkillLevel: readNumber(record.leveling, 'maxSkillLevel', 100),
+ levelDivisor: readNumber(record.leveling, 'levelDivisor', 10),
+ healthPerLevel: readNumber(record.leveling, 'healthPerLevel', 10),
+ magickaPerLevel: readNumber(record.leveling, 'magickaPerLevel', 5),
+ staminaPerLevel: readNumber(record.leveling, 'staminaPerLevel', 5),
+ xpBase: readNumber(record.leveling, 'xpBase', 100),
+ xpScale: readNumber(record.leveling, 'xpScale', 1.1),
+ legendaryThreshold: readNumber(record.leveling, 'legendaryThreshold', 50),
+ },
+ regen: {
+ delayMs: readNumber(record.regen, 'delayMs', 3000),
+ healthPerSecond: readNumber(record.regen, 'healthPerSecond', 0.5),
+ magickaPerSecond: readNumber(record.regen, 'magickaPerSecond', 3),
+ staminaPerSecond: readNumber(record.regen, 'staminaPerSecond', 5),
+ restorationBonusPerSkill: readNumber(record.regen, 'restorationBonusPerSkill', 0.02),
+ },
+ };
+ }
+ }
+
+ getItem(id: string): ItemData | undefined {
+ return this.items.get(id);
+ }
+
+ getAllItems(): ItemData[] {
+ return Array.from(this.items.values());
+ }
+
+ getItemsByType(type: string): ItemData[] {
+ return this.getAllItems().filter((item) => item.type === type);
+ }
+
+ getEnemy(id: string): EnemyData | undefined {
+ return this.enemies.get(id);
+ }
+
+ getAllEnemies(): EnemyData[] {
+ return Array.from(this.enemies.values());
+ }
+
+ getSpell(id: string): SpellData | undefined {
+ return this.spells.get(id);
+ }
+
+ getAllSpells(): SpellData[] {
+ return Array.from(this.spells.values());
+ }
+
+ getSpellsBySchool(school: string): SpellData[] {
+ return this.getAllSpells().filter((spell) => spell.school === school);
+ }
+
+ getRecipe(id: string): RecipeData | undefined {
+ return this.recipes.get(id);
+ }
+
+ getAllRecipes(): RecipeData[] {
+ return Array.from(this.recipes.values());
+ }
+
+ getRecipesByType(type: string): RecipeData[] {
+ return this.getAllRecipes().filter((recipe) => recipe.result.type === type);
+ }
+
+ getQuest(id: string): QuestData | undefined {
+ return this.quests.get(id);
+ }
+
+ getAllQuests(): QuestData[] {
+ return Array.from(this.quests.values());
+ }
+
+ getQuestsByType(type: QuestData['type']): QuestData[] {
+ return this.getAllQuests().filter((quest) => quest.type === type);
+ }
+
+ getRace(id: string): RaceData | undefined {
+ return this.races.get(id);
+ }
+
+ getAllRaces(): RaceData[] {
+ return Array.from(this.races.values());
+ }
+
+ getSkill(id: string): SkillData | undefined {
+ return this.skills.get(id);
+ }
+
+ getAllSkills(): SkillData[] {
+ return Array.from(this.skills.values());
+ }
+
+ getSkillsByCategory(category: SkillData['category']): SkillData[] {
+ return this.getAllSkills().filter((skill) => skill.category === category);
+ }
+
+ getPerkTree(id: string): PerkTreeData | undefined {
+ return this.perkTrees.get(id);
+ }
+
+ getAllPerkTrees(): PerkTreeData[] {
+ return Array.from(this.perkTrees.values());
+ }
+
+ getStandingStone(id: string): StandingStoneData | undefined {
+ return this.standingStones.get(id);
+ }
+
+ getAllStandingStones(): StandingStoneData[] {
+ return Array.from(this.standingStones.values());
+ }
+
+ getArmor(id: string): ArmorData | undefined {
+ return this.armor.get(id);
+ }
+
+ getAllArmor(): ArmorData[] {
+ return Array.from(this.armor.values());
+ }
+
+ getArmorByTier(tier: string): ArmorData[] {
+ return this.getAllArmor().filter((a) => a.tier === tier);
+ }
+
+ getShout(id: string): ShoutData | undefined {
+ return this.shouts.get(id);
+ }
+
+ getAllShouts(): ShoutData[] {
+ return Array.from(this.shouts.values());
+ }
+
+ getFullSpell(id: string): FullSpellData | undefined {
+ return this.fullSpells.get(id);
+ }
+
+ getAllFullSpells(): FullSpellData[] {
+ return Array.from(this.fullSpells.values());
+ }
+
+ getEnchantment(id: string): EnchantmentData | undefined {
+ return this.enchantments.get(id);
+ }
+
+ getAllEnchantments(): EnchantmentData[] {
+ return Array.from(this.enchantments.values());
+ }
+
+ getEnchantmentsByType(type: string): EnchantmentData[] {
+ return this.getAllEnchantments().filter((e) => e.type === type);
+ }
+
+ getSoulGem(id: string): SoulGemData | undefined {
+ return this.soulGems.get(id);
+ }
+
+ getAllSoulGems(): SoulGemData[] {
+ return Array.from(this.soulGems.values());
+ }
+
+ getAlchemyIngredient(id: string): AlchemyIngredientData | undefined {
+ return this.alchemyIngredients.get(id);
+ }
+
+ getAllAlchemyIngredients(): AlchemyIngredientData[] {
+ return Array.from(this.alchemyIngredients.values());
+ }
+
+ getSmithingRecipe(id: string): SmithingRecipeData | undefined {
+ return this.smithingRecipes.get(id);
+ }
+
+ getAllSmithingRecipes(): SmithingRecipeData[] {
+ return Array.from(this.smithingRecipes.values());
+ }
+
+ getSmithingMaterial(tier: string): SmithingMaterialData | undefined {
+ return this.smithingMaterials.get(tier);
+ }
+
+ getAllSmithingMaterials(): SmithingMaterialData[] {
+ return Array.from(this.smithingMaterials.values());
+ }
+
+ getSmithingStation(type: string): SmithingStationData | undefined {
+ return this.smithingStations.get(type);
+ }
+
+ getAllSmithingStations(): SmithingStationData[] {
+ return Array.from(this.smithingStations.values());
+ }
+
+ getCookingRecipe(id: string): CookingRecipeData | undefined {
+ return this.cookingRecipes.get(id);
+ }
+
+ getAllCookingRecipes(): CookingRecipeData[] {
+ return Array.from(this.cookingRecipes.values());
+ }
+
+ getDialogueTree(id: string): DialogueTreeData | undefined {
+ return this.dialogueTrees.get(id);
+ }
+
+ getAllDialogueTrees(): DialogueTreeData[] {
+ return Array.from(this.dialogueTrees.values());
+ }
+
+ getTransform(id: string): TransformData | undefined {
+ return this.transforms.get(id);
+ }
+
+ getAllTransforms(): TransformData[] {
+ return Array.from(this.transforms.values());
+ }
+
+ getVampireStage(stage: number): VampireStageData | undefined {
+ return this.vampireStages.get(String(stage));
+ }
+
+ getAllVampireStages(): VampireStageData[] {
+ return Array.from(this.vampireStages.values());
+ }
+
+ getGameConfig(): GameConfigData {
+ return this.gameConfig ?? {
+ combat: {
+ baseDamage: 10, skillBonus: 0.5, powerAttackMultiplier: 1.5,
+ critBaseChance: 0.10, critPerSneakSkill: 0.005, armorDivisor: 100,
+ damageVariance: [0.9, 1.1], blockDamageMultiplier: 0.2, blockBaseChance: 0.3,
+ blockSkillBonus: 0.5, blockStaminaThreshold: 10, blockStaminaPenalty: -0.2,
+ blockStaminaCost: 10, attackRanges: { melee: 60, unarmed: 45 },
+ staminaCosts: { powerAttack: 25, normal: 5 },
+ weaponDamageThresholds: { twoHanded: 6, powerAttack: 15 },
+ skillImprovementAmounts: { attack: 0.5, armor: 0.3 },
+ },
+ leveling: {
+ defaultSkillLevel: 15, maxSkillLevel: 100, levelDivisor: 10,
+ healthPerLevel: 10, magickaPerLevel: 5, staminaPerLevel: 5,
+ xpBase: 100, xpScale: 1.1, legendaryThreshold: 50,
+ },
+ regen: {
+ delayMs: 3000, healthPerSecond: 0.5, magickaPerSecond: 3,
+ staminaPerSecond: 5, restorationBonusPerSkill: 0.02,
+ },
+ };
+ }
+
+ isLoaded(): boolean {
+ return this.loaded;
+ }
+}
+
+function normalizeItem(id: string, value: unknown, source: 'item' | 'weapon'): ItemData | null {
+ if (!isRecord(value)) return null;
+
+ const rawType = readString(value, 'type', source === 'weapon' ? 'weapon' : 'misc');
+ const isWeapon = source === 'weapon' || rawType === 'weapon' || typeof value.damage === 'number';
+ const effects = normalizeEffects(value);
+ const damage = readOptionalNumber(value, 'damage');
+ const speed = readOptionalNumber(value, 'speed');
+
+ if (damage !== undefined && !effects.some((effect) => effect.type === 'damage')) {
+ effects.push({ type: 'damage', magnitude: damage });
+ }
+
+ if (speed !== undefined && !effects.some((effect) => effect.type === 'speed')) {
+ effects.push({ type: 'speed', magnitude: speed });
+ }
+
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ type: isWeapon ? 'weapon' : rawType,
+ subtype: isWeapon ? readString(value, 'subtype', rawType) : readOptionalString(value, 'subtype'),
+ material: readOptionalString(value, 'material'),
+ tier: readOptionalNumber(value, 'tier'),
+ damage,
+ speed,
+ weight: readNumber(value, 'weight', 0),
+ value: readNumber(value, 'value', 0),
+ description: readOptionalString(value, 'description'),
+ effects,
+ enchantment: value.enchantment,
+ keywords: Array.isArray(value.keywords) ? value.keywords.filter((keyword): keyword is string => typeof keyword === 'string') : undefined,
+ };
+}
+
+function normalizeEnemy(id: string, value: unknown): EnemyData | null {
+ if (!isRecord(value)) return null;
+
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ level: readNumber(value, 'level', 1),
+ health: readNumber(value, 'health', 50),
+ stamina: readNumber(value, 'stamina', 50),
+ damage: readNumber(value, 'damage', 8),
+ armor: readNumber(value, 'armor', 0),
+ detectionRange: readNumber(value, 'detectionRange', 150),
+ attackRange: readNumber(value, 'attackRange', 45),
+ attackSpeed: readNumber(value, 'attackSpeed', 1),
+ size: readNumber(value, 'size', 24),
+ color: parseColor(value.color, 0xff0000),
+ loot: normalizeLoot(value.loot),
+ };
+}
+
+function normalizeSpell(id: string, value: UnknownRecord): SpellData | null {
+ const school = readOptionalString(value, 'school');
+ if (!school) return null;
+
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ school,
+ cost: readNumber(value, 'cost', readNumber(value, 'magickaCost', 0)),
+ magnitude: readNumber(value, 'magnitude', 0),
+ duration: readOptionalNumber(value, 'duration'),
+ range: readOptionalNumber(value, 'range'),
+ description: readOptionalString(value, 'description'),
+ };
+}
+
+function normalizeRecipe(id: string, value: UnknownRecord): RecipeData | null {
+ const ingredients = Array.isArray(value.ingredients)
+ ? value.ingredients.filter((ingredient): ingredient is string => typeof ingredient === 'string')
+ : [];
+ if (!isRecord(value.result)) return null;
+
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ ingredients,
+ result: {
+ id: readString(value.result, 'id', `${id}_result`),
+ name: readString(value.result, 'name', `${id} result`),
+ type: readString(value.result, 'type', 'misc'),
+ effects: normalizeEffects(value.result),
+ weight: readNumber(value.result, 'weight', 0),
+ value: readNumber(value.result, 'value', 0),
+ },
+ };
+}
+
+function normalizeQuest(id: string, value: UnknownRecord): QuestData | null {
+ const objectives = Array.isArray(value.objectives)
+ ? value.objectives.filter(isRecord).map((objective, index) => ({
+ id: readString(objective, 'id', `${id}_objective_${index}`),
+ description: readString(objective, 'description', ''),
+ type: readString(objective, 'type', 'interact'),
+ target: readOptionalString(objective, 'target'),
+ quantity: readOptionalNumber(objective, 'quantity'),
+ count: readOptionalNumber(objective, 'count'),
+ }))
+ : [];
+
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ description: readString(value, 'description', ''),
+ type: readQuestType(value.type),
+ level: readNumber(value, 'level', readNumber(value, 'levelRequired', 1)),
+ prerequisites: Array.isArray(value.prerequisites)
+ ? value.prerequisites.filter((prerequisite): prerequisite is string => typeof prerequisite === 'string')
+ : [],
+ objectives,
+ rewards: normalizeQuestRewards(value.rewards),
+ };
+}
+
+function normalizeQuestRewards(value: unknown): QuestData['rewards'] {
+ if (!isRecord(value)) return {};
+ const rawItems = value.items;
+ const items = Array.isArray(rawItems)
+ ? rawItems.flatMap((item) => {
+ if (typeof item === 'string') return [{ id: item, quantity: 1 }];
+ if (isRecord(item)) return [{ id: readString(item, 'id', ''), quantity: readNumber(item, 'quantity', 1) }];
+ return [];
+ }).filter((item) => item.id)
+ : undefined;
+
+ return {
+ gold: readOptionalNumber(value, 'gold'),
+ xp: readOptionalNumber(value, 'xp'),
+ faction: readOptionalString(value, 'faction'),
+ factionRep: readOptionalNumber(value, 'factionRep'),
+ items,
+ };
+}
+
+function normalizeLoot(value: unknown): EnemyLootTable | undefined {
+ if (!isRecord(value)) return undefined;
+ const gold = isRecord(value.gold)
+ ? { min: readNumber(value.gold, 'min', 0), max: readNumber(value.gold, 'max', 0) }
+ : undefined;
+ const items = Array.isArray(value.items)
+ ? value.items.filter(isRecord).map((item) => ({
+ id: readString(item, 'id', ''),
+ chance: readNumber(item, 'chance', 1),
+ quantity: readNumber(item, 'quantity', 1),
+ })).filter((item) => item.id)
+ : undefined;
+
+ return { gold, items };
+}
+
+function normalizeEffects(value: UnknownRecord): DataEffect[] {
+ const effects: DataEffect[] = [];
+ const rawEffects = value.effects;
+ const rawEffect = value.effect;
+
+ if (Array.isArray(rawEffects)) {
+ effects.push(...rawEffects.filter(isRecord).map(normalizeEffect).filter((effect): effect is DataEffect => effect !== null));
+ } else if (isRecord(rawEffects)) {
+ const effect = normalizeEffect(rawEffects);
+ if (effect) effects.push(effect);
+ }
+
+ if (isRecord(rawEffect)) {
+ const effect = normalizeEffect(rawEffect);
+ if (effect) effects.push(effect);
+ }
+
+ return effects;
+}
+
+function normalizeEffect(value: UnknownRecord): DataEffect | null {
+ const type = readOptionalString(value, 'type');
+ if (!type) return null;
+
+ return {
+ type,
+ magnitude: readNumber(value, 'magnitude', 0),
+ duration: readOptionalNumber(value, 'duration'),
+ attribute: readOptionalString(value, 'attribute'),
+ };
+}
+
+function extractRecord(value: unknown, key: string): UnknownRecord {
+ if (!isRecord(value)) return {};
+ const parts = key.split('.');
+ let current: unknown = value;
+ for (const part of parts) {
+ if (!isRecord(current)) return {};
+ current = current[part];
+ }
+ return asRecord(current);
+}
+
+function asRecord(value: unknown): UnknownRecord {
+ return isRecord(value) ? value : {};
+}
+
+function isRecord(value: unknown): value is UnknownRecord {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function readString(record: UnknownRecord, key: string, fallback: string): string {
+ const value = record[key];
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
+}
+
+function readOptionalString(record: UnknownRecord, key: string): string | undefined {
+ const value = record[key];
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
+}
+
+function readNumber(record: UnknownRecord, key: string, fallback: number): number {
+ const value = record[key];
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
+}
+
+function readOptionalNumber(record: UnknownRecord, key: string): number | undefined {
+ const value = record[key];
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+
+function parseColor(value: unknown, fallback: number): number {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string') {
+ const normalized = value.startsWith('#') ? value.slice(1) : value;
+ const parsed = Number.parseInt(normalized, 16);
+ return Number.isFinite(parsed) ? parsed : fallback;
+ }
+ return fallback;
+}
+
+function readQuestType(value: unknown): QuestData['type'] {
+ if (value === 'main' || value === 'side' || value === 'guild' || value === 'daedric' || value === 'radiant') {
+ return value;
+ }
+ return 'side';
+}
+
+function extractArray(value: unknown, key: string): unknown[] {
+ if (!isRecord(value)) return [];
+ const child = value[key];
+ return Array.isArray(child) ? child : [];
+}
+
+function asArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+function normalizeRace(value: UnknownRecord): RaceData | null {
+ const id = readString(value, 'id', '');
+ if (!id) return null;
+ const baseStats = isRecord(value.baseStats) ? value.baseStats : {};
+ const power = isRecord(value.power) ? value.power : {};
+ const passive = isRecord(value.passive) ? value.passive : {};
+ const bonuses: Record = {};
+ if (isRecord(value.bonuses)) {
+ for (const [k, v] of Object.entries(value.bonuses)) {
+ if (typeof v === 'number') bonuses[k] = v;
+ }
+ }
+
+ return {
+ id,
+ name: readString(value, 'name', id),
+ description: readString(value, 'description', ''),
+ bonuses,
+ baseStats: {
+ health: readNumber(baseStats, 'health', 100),
+ magicka: readNumber(baseStats, 'magicka', 50),
+ stamina: readNumber(baseStats, 'stamina', 100),
+ },
+ power: {
+ id: readString(power, 'id', ''),
+ name: readString(power, 'name', ''),
+ description: readString(power, 'description', ''),
+ cooldown: readNumber(power, 'cooldown', 60),
+ },
+ passive: {
+ id: readString(passive, 'id', ''),
+ name: readString(passive, 'name', ''),
+ description: readString(passive, 'description', ''),
+ value: readNumber(passive, 'value', 0),
+ type: readString(passive, 'type', ''),
+ },
+ };
+}
+
+function normalizeSkill(value: UnknownRecord, category: SkillData['category']): SkillData | null {
+ const id = readString(value, 'id', '');
+ if (!id) return null;
+ return {
+ id,
+ name: readString(value, 'name', id),
+ description: readString(value, 'description', ''),
+ category,
+ };
+}
+
+function normalizePerkTree(id: string, value: UnknownRecord): PerkTreeData | null {
+ const rawPerks = value.perks;
+ const perks: PerkData[] = Array.isArray(rawPerks)
+ ? rawPerks.filter(isRecord).map((p) => ({
+ id: readString(p, 'id', ''),
+ name: readString(p, 'name', ''),
+ description: readString(p, 'description', ''),
+ rank: readNumber(p, 'rank', 1),
+ maxRank: readNumber(p, 'maxRank', 1),
+ requires: Array.isArray(p.requires) ? p.requires.filter((r): r is string => typeof r === 'string') : undefined,
+ requiresPerkPoints: readOptionalNumber(p, 'requiresPerkPoints'),
+ skill: readString(p, 'skill', id),
+ skillLevel: readNumber(p, 'skillLevel', 0),
+ })).filter((p) => p.id)
+ : [];
+
+ return {
+ id,
+ name: readString(value, 'name', id),
+ perks,
+ };
+}
+
+function normalizeStandingStone(value: UnknownRecord): StandingStoneData | null {
+ const id = readString(value, 'id', '');
+ if (!id) return null;
+ const effect = isRecord(value.effect) ? value.effect : {};
+ return {
+ id,
+ name: readString(value, 'name', id),
+ description: readString(value, 'description', ''),
+ effect: {
+ type: readString(effect, 'type', ''),
+ category: readOptionalString(effect, 'category'),
+ value: readNumber(effect, 'value', 0),
+ },
+ };
+}
+
+function normalizeArmor(id: string, value: UnknownRecord): ArmorData | null {
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ type: 'armor',
+ subtype: readString(value, 'subtype', 'chest'),
+ tier: readString(value, 'tier', 'iron'),
+ armor: readNumber(value, 'armor', 0),
+ weight: readNumber(value, 'weight', 0),
+ value: readNumber(value, 'value', 0),
+ material: readString(value, 'material', ''),
+ description: readOptionalString(value, 'description'),
+ };
+}
+
+function normalizeShout(id: string, value: UnknownRecord): ShoutData | null {
+ const words = Array.isArray(value.words)
+ ? value.words.filter((w): w is string => typeof w === 'string')
+ : [];
+ if (!words.length) return null;
+ const effects = Array.isArray(value.effects)
+ ? value.effects.filter(isRecord).map((e) => ({
+ type: readString(e, 'type', ''),
+ magnitude: readNumber(e, 'magnitude', 0),
+ duration: readOptionalNumber(e, 'duration'),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ words,
+ wordCount: readNumber(value, 'wordCount', words.length),
+ cooldown: readNumber(value, 'cooldown', 10000),
+ effects,
+ };
+}
+
+function normalizeFullSpell(id: string, value: UnknownRecord): FullSpellData | null {
+ const school = readOptionalString(value, 'school');
+ if (!school) return null;
+ const effects = Array.isArray(value.effects)
+ ? value.effects.filter(isRecord).map((e) => ({
+ type: readString(e, 'type', ''),
+ attribute: readOptionalString(e, 'attribute'),
+ magnitude: readNumber(e, 'magnitude', 0),
+ duration: readOptionalNumber(e, 'duration'),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ school,
+ type: readString(value, 'type', 'target') as FullSpellData['type'],
+ magickaCost: readNumber(value, 'magickaCost', 0),
+ magnitude: readNumber(value, 'magnitude', 0),
+ duration: readNumber(value, 'duration', 0),
+ cooldown: readNumber(value, 'cooldown', 500),
+ level: readNumber(value, 'level', 1),
+ description: readString(value, 'description', ''),
+ effects,
+ };
+}
+
+function normalizeEnchantment(id: string, value: UnknownRecord): EnchantmentData | null {
+ const effects = Array.isArray(value.effects)
+ ? value.effects.filter(isRecord).map((e) => ({
+ type: readString(e, 'type', ''),
+ magnitude: readNumber(e, 'magnitude', 0),
+ duration: readOptionalNumber(e, 'duration'),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ type: readString(value, 'type', 'weapon') as EnchantmentData['type'],
+ effects,
+ magnitude: readNumber(value, 'magnitude', 0),
+ duration: readNumber(value, 'duration', 0),
+ };
+}
+
+function normalizeSoulGem(id: string, value: UnknownRecord): SoulGemData | null {
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ size: readString(value, 'size', 'petty') as SoulGemData['size'],
+ capacity: readNumber(value, 'capacity', 25),
+ filled: typeof value.filled === 'boolean' ? value.filled : false,
+ soulLevel: readOptionalNumber(value, 'soulLevel'),
+ };
+}
+
+function normalizeAlchemyIngredient(id: string, value: UnknownRecord): AlchemyIngredientData | null {
+ const effects = Array.isArray(value.effects)
+ ? value.effects.filter(isRecord).map((e) => ({
+ type: readString(e, 'type', ''),
+ magnitude: readNumber(e, 'magnitude', 0),
+ duration: readNumber(e, 'duration', 0),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ weight: readNumber(value, 'weight', 0.1),
+ value: readNumber(value, 'value', 0),
+ effects,
+ harvestNode: readOptionalString(value, 'harvestNode'),
+ };
+}
+
+function normalizeSmithingMaterial(id: string, value: UnknownRecord): SmithingMaterialData | null {
+ return {
+ tier: readString(value, 'tier', id),
+ level: readNumber(value, 'level', 1),
+ };
+}
+
+function normalizeSmithingStation(id: string, value: UnknownRecord): SmithingStationData | null {
+ const recipes = Array.isArray(value.availableRecipes)
+ ? value.availableRecipes.filter((r): r is string => typeof r === 'string')
+ : [];
+ return {
+ type: readString(value, 'type', id),
+ name: readString(value, 'name', id),
+ availableRecipes: recipes,
+ };
+}
+
+function normalizeSmithingRecipe(id: string, value: UnknownRecord): SmithingRecipeData | null {
+ const materials = Array.isArray(value.materials)
+ ? value.materials.filter(isRecord).map((m) => ({
+ id: readString(m, 'id', ''),
+ quantity: readNumber(m, 'quantity', 1),
+ })).filter((m) => m.id)
+ : [];
+ const result = isRecord(value.result) ? value.result : {};
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ type: readString(value, 'type', 'weapon') as SmithingRecipeData['type'],
+ tier: readString(value, 'tier', 'iron'),
+ materials,
+ result: {
+ id: readString(result, 'id', id),
+ name: readString(result, 'name', id),
+ type: readString(result, 'type', 'misc'),
+ damage: readOptionalNumber(result, 'damage'),
+ armor: readOptionalNumber(result, 'armor'),
+ weight: readNumber(result, 'weight', 0),
+ value: readNumber(result, 'value', 0),
+ },
+ skillRequired: readNumber(value, 'skillRequired', 0),
+ };
+}
+
+function normalizeCookingRecipe(id: string, value: UnknownRecord): CookingRecipeData | null {
+ const ingredients = Array.isArray(value.ingredients)
+ ? value.ingredients.filter((i): i is string => typeof i === 'string')
+ : [];
+ const result = isRecord(value.result) ? value.result : {};
+ const effects = Array.isArray(result.effects)
+ ? result.effects.filter(isRecord).map((e) => ({
+ type: readString(e, 'type', ''),
+ magnitude: readNumber(e, 'magnitude', 0),
+ duration: readOptionalNumber(e, 'duration'),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ ingredients,
+ result: {
+ id: readString(result, 'id', id),
+ name: readString(result, 'name', id),
+ type: readString(result, 'type', 'food') as CookingRecipeData['result']['type'],
+ effects,
+ weight: readNumber(result, 'weight', 0.5),
+ value: readNumber(result, 'value', 0),
+ },
+ };
+}
+
+function normalizeDialogueTree(id: string, value: UnknownRecord): DialogueTreeData | null {
+ const lines: DialogueTreeData['lines'] = {};
+ if (isRecord(value.lines)) {
+ for (const [lineId, lineValue] of Object.entries(value.lines)) {
+ if (!isRecord(lineValue)) continue;
+ const options = Array.isArray(lineValue.options)
+ ? lineValue.options.filter(isRecord).map((opt) => ({
+ id: readString(opt, 'id', ''),
+ text: readString(opt, 'text', ''),
+ nextLineId: readString(opt, 'nextLineId', ''),
+ conditions: Array.isArray(opt.conditions) ? opt.conditions : undefined,
+ effects: Array.isArray(opt.effects) ? opt.effects : undefined,
+ skillCheck: isRecord(opt.skillCheck) ? opt.skillCheck : undefined,
+ }))
+ : undefined;
+ lines[lineId] = {
+ id: readString(lineValue, 'id', lineId),
+ speaker: readString(lineValue, 'speaker', ''),
+ text: readString(lineValue, 'text', ''),
+ options,
+ conditions: Array.isArray(lineValue.conditions) ? lineValue.conditions : undefined,
+ effects: Array.isArray(lineValue.effects) ? lineValue.effects : undefined,
+ };
+ }
+ }
+ return {
+ id: readString(value, 'id', id),
+ npcId: readString(value, 'npcId', id),
+ lines,
+ startLineId: readString(value, 'startLineId', 'start'),
+ };
+}
+
+function normalizeTransform(id: string, value: UnknownRecord): TransformData | null {
+ const effects = Array.isArray(value.effects)
+ ? value.effects.filter(isRecord).map((e) => ({
+ id: readString(e, 'id', ''),
+ attribute: readString(e, 'attribute', ''),
+ magnitude: readNumber(e, 'magnitude', 0),
+ durationMs: readNumber(e, 'durationMs', 0),
+ }))
+ : [];
+ return {
+ id: readString(value, 'id', id),
+ name: readString(value, 'name', id),
+ healthBonus: readNumber(value, 'healthBonus', 0),
+ staminaBonus: readNumber(value, 'staminaBonus', 0),
+ damageBonus: readNumber(value, 'damageBonus', 0),
+ armorBonus: readNumber(value, 'armorBonus', 0),
+ speedBonus: readNumber(value, 'speedBonus', 0),
+ durationMs: readNumber(value, 'durationMs', 60000),
+ cooldownMs: readNumber(value, 'cooldownMs', 30000),
+ weaponId: readString(value, 'weaponId', ''),
+ weaponDamage: readNumber(value, 'weaponDamage', 10),
+ weaponSpeed: readNumber(value, 'weaponSpeed', 1.0),
+ suppressMagicka: typeof value.suppressMagicka === 'boolean' ? value.suppressMagicka : false,
+ effects,
+ };
+}
+
+function normalizeVampireStage(id: string, value: UnknownRecord): VampireStageData | null {
+ const nightBonuses = Array.isArray(value.nightBonuses)
+ ? value.nightBonuses.filter(isRecord).map((b) => ({
+ id: readString(b, 'id', ''),
+ attribute: readString(b, 'attribute', ''),
+ magnitude: readNumber(b, 'magnitude', 0),
+ }))
+ : [];
+ return {
+ stage: readNumber(value, 'stage', Number.parseInt(id, 10) || 0),
+ name: readString(value, 'name', id),
+ frostResist: readNumber(value, 'frostResist', 0),
+ fireResist: readNumber(value, 'fireResist', 0),
+ sunDamage: readNumber(value, 'sunDamage', 0),
+ nightBonuses,
+ infectionThresholdMs: readNumber(value, 'infectionThresholdMs', 0),
+ };
+}
+
+export const dataRegistry = DataRegistry.getInstance();
diff --git a/src/data/alchemy/ingredients.json b/src/data/alchemy/ingredients.json
new file mode 100644
index 0000000..2e225b3
--- /dev/null
+++ b/src/data/alchemy/ingredients.json
@@ -0,0 +1,148 @@
+{
+ "ingredients": {
+ "blue_mountain_flower": {
+ "id": "blue_mountain_flower",
+ "name": "蓝山花",
+ "weight": 0.1,
+ "value": 8,
+ "effects": [
+ { "type": "restore_health", "magnitude": 10, "duration": 0 },
+ { "type": "restore_magicka", "magnitude": 5, "duration": 0 },
+ { "type": "fortify_health", "magnitude": 10, "duration": 300000 }
+ ],
+ "harvestNode": "flower"
+ },
+ "deathbell": {
+ "id": "deathbell",
+ "name": "死亡铃",
+ "weight": 0.1,
+ "value": 10,
+ "effects": [
+ { "type": "damage_health", "magnitude": 15, "duration": 0 },
+ { "type": "slow", "magnitude": 0.5, "duration": 10000 },
+ { "type": "damage_stamina", "magnitude": 10, "duration": 0 }
+ ],
+ "harvestNode": "flower"
+ },
+ "blue_butterfly_wing": {
+ "id": "blue_butterfly_wing",
+ "name": "蓝蝴蝶翅膀",
+ "weight": 0.1,
+ "value": 5,
+ "effects": [
+ { "type": "restore_magicka", "magnitude": 8, "duration": 0 },
+ { "type": "fortify_magicka", "magnitude": 8, "duration": 300000 },
+ { "type": "damage_stamina", "magnitude": 5, "duration": 0 }
+ ],
+ "harvestNode": "insect"
+ },
+ "chaurus_egg": {
+ "id": "chaurus_egg",
+ "name": "查鲁斯蛋",
+ "weight": 0.3,
+ "value": 12,
+ "effects": [
+ { "type": "restore_magicka", "magnitude": 12, "duration": 0 },
+ { "type": "fortify_stamina", "magnitude": 10, "duration": 300000 },
+ { "type": "damage_health", "magnitude": 8, "duration": 20000 }
+ ],
+ "harvestNode": "insect"
+ },
+ "dragons_tongue": {
+ "id": "dragons_tongue",
+ "name": "龙舌",
+ "weight": 0.1,
+ "value": 10,
+ "effects": [
+ { "type": "fortify_health", "magnitude": 15, "duration": 300000 },
+ { "type": "fortify_stamina", "magnitude": 10, "duration": 300000 },
+ { "type": "fire_resist", "magnitude": 25, "duration": 300000 }
+ ],
+ "harvestNode": "flower"
+ },
+ "impstool": {
+ "id": "impstool",
+ "name": "小鬼凳",
+ "weight": 0.1,
+ "value": 5,
+ "effects": [
+ { "type": "damage_health", "magnitude": 10, "duration": 10000 },
+ { "type": "slow", "magnitude": 0.5, "duration": 10000 },
+ { "type": "restore_health", "magnitude": 5, "duration": 0 }
+ ],
+ "harvestNode": "mushroom"
+ },
+ "nordic_barnacle": {
+ "id": "nordic_barnacle",
+ "name": "诺德藤壶",
+ "weight": 0.3,
+ "value": 10,
+ "effects": [
+ { "type": "restore_health", "magnitude": 15, "duration": 0 },
+ { "type": "waterbreathing", "magnitude": 1, "duration": 300000 },
+ { "type": "detect_life", "magnitude": 20, "duration": 60000 }
+ ],
+ "harvestNode": "underwater"
+ },
+ "philters": {
+ "id": "philters",
+ "name": "过滤器",
+ "weight": 0.2,
+ "value": 15,
+ "effects": [
+ { "type": "restore_magicka", "magnitude": 15, "duration": 0 },
+ { "type": "fortify_health", "magnitude": 20, "duration": 300000 },
+ { "type": "regenerate_health", "magnitude": 0.5, "duration": 300000 }
+ ],
+ "harvestNode": "mushroom"
+ },
+ "river_betty": {
+ "id": "river_betty",
+ "name": "河鲈",
+ "weight": 0.1,
+ "value": 8,
+ "effects": [
+ { "type": "restore_magicka", "magnitude": 10, "duration": 0 },
+ { "type": "fortify_health", "magnitude": 10, "duration": 300000 },
+ { "type": "damage_stamina", "magnitude": 15, "duration": 0 }
+ ],
+ "harvestNode": "fish"
+ },
+ "sabre_cat_tooth": {
+ "id": "sabre_cat_tooth",
+ "name": "剑齿虎牙",
+ "weight": 0.1,
+ "value": 12,
+ "effects": [
+ { "type": "restore_stamina", "magnitude": 15, "duration": 0 },
+ { "type": "fortify_health", "magnitude": 15, "duration": 300000 },
+ { "type": "restore_health", "magnitude": 5, "duration": 0 }
+ ],
+ "harvestNode": "animal"
+ },
+ "tundra_cotton": {
+ "id": "tundra_cotton",
+ "name": "苔原棉花",
+ "weight": 0.1,
+ "value": 6,
+ "effects": [
+ { "type": "fortify_magicka", "magnitude": 12, "duration": 300000 },
+ { "type": "fortify_stamina", "magnitude": 8, "duration": 300000 },
+ { "type": "restore_stamina", "magnitude": 5, "duration": 0 }
+ ],
+ "harvestNode": "plant"
+ },
+ "wheat": {
+ "id": "wheat",
+ "name": "小麦",
+ "weight": 0.1,
+ "value": 5,
+ "effects": [
+ { "type": "restore_health", "magnitude": 8, "duration": 0 },
+ { "type": "fortify_health", "magnitude": 25, "duration": 300000 },
+ { "type": "regenerate_health", "magnitude": 0.5, "duration": 600000 }
+ ],
+ "harvestNode": "plant"
+ }
+ }
+}
diff --git a/src/data/alchemy/potions.json b/src/data/alchemy/potions.json
new file mode 100644
index 0000000..fefa9f6
--- /dev/null
+++ b/src/data/alchemy/potions.json
@@ -0,0 +1,100 @@
+{
+ "recipes": {
+ "health_potion": {
+ "id": "health_potion",
+ "name": "生命药水",
+ "ingredients": ["blue_mountain_flower", "wheat"],
+ "result": {
+ "id": "health_potion",
+ "name": "生命药水",
+ "type": "potion",
+ "effects": [{ "type": "restore_health", "magnitude": 50, "duration": 0 }],
+ "value": 25
+ }
+ },
+ "magicka_potion": {
+ "id": "magicka_potion",
+ "name": "魔力药水",
+ "ingredients": ["blue_butterfly_wing", "tundra_cotton"],
+ "result": {
+ "id": "magicka_potion",
+ "name": "魔力药水",
+ "type": "potion",
+ "effects": [{ "type": "restore_magicka", "magnitude": 50, "duration": 0 }],
+ "value": 30
+ }
+ },
+ "stamina_potion": {
+ "id": "stamina_potion",
+ "name": "耐力药水",
+ "ingredients": ["sabre_cat_tooth", "tundra_cotton"],
+ "result": {
+ "id": "stamina_potion",
+ "name": "耐力药水",
+ "type": "potion",
+ "effects": [{ "type": "restore_stamina", "magnitude": 50, "duration": 0 }],
+ "value": 20
+ }
+ },
+ "poison_damage": {
+ "id": "poison_damage",
+ "name": "伤害毒药",
+ "ingredients": ["deathbell", "impstool"],
+ "result": {
+ "id": "poison_damage",
+ "name": "伤害毒药",
+ "type": "poison",
+ "effects": [{ "type": "damage_health", "magnitude": 30, "duration": 0 }],
+ "value": 40
+ }
+ },
+ "poison_slow": {
+ "id": "poison_slow",
+ "name": "减速毒药",
+ "ingredients": ["deathbell", "river_betty"],
+ "result": {
+ "id": "poison_slow",
+ "name": "减速毒药",
+ "type": "poison",
+ "effects": [{ "type": "slow", "magnitude": 0.5, "duration": 15000 }],
+ "value": 35
+ }
+ },
+ "invisibility_potion": {
+ "id": "invisibility_potion",
+ "name": "隐身药水",
+ "ingredients": ["nordic_barnacle", "chaurus_egg"],
+ "result": {
+ "id": "invisibility_potion",
+ "name": "隐身药水",
+ "type": "potion",
+ "effects": [{ "type": "invisibility", "magnitude": 1, "duration": 30000 }],
+ "value": 150
+ }
+ },
+ "fire_resist_potion": {
+ "id": "fire_resist_potion",
+ "name": "火焰抗性药水",
+ "ingredients": ["dragons_tongue", "blue_mountain_flower"],
+ "result": {
+ "id": "fire_resist_potion",
+ "name": "火焰抗性药水",
+ "type": "potion",
+ "effects": [{ "type": "fire_resist", "magnitude": 50, "duration": 300000 }],
+ "value": 60
+ }
+ },
+ "regenerate_potion": {
+ "id": "regenerate_potion",
+ "name": "再生药水",
+ "ingredients": ["philters", "wheat"],
+ "result": {
+ "id": "regenerate_potion",
+ "name": "再生药水",
+ "type": "potion",
+ "effects": [{ "type": "regenerate_health", "magnitude": 1, "duration": 600000 }],
+ "value": 80
+ }
+ }
+ }
+}
diff --git a/src/data/crafting/cooking.json b/src/data/crafting/cooking.json
new file mode 100644
index 0000000..38ef500
--- /dev/null
+++ b/src/data/crafting/cooking.json
@@ -0,0 +1,83 @@
+{
+ "cooking": {
+ "stations": [
+ { "x": 100, "y": 100, "zone": "whiterun" },
+ { "x": 200, "y": 150, "zone": "whiterun" },
+ { "x": 300, "y": 200, "zone": "riverwood" }
+ ],
+ "recipes": {
+ "cooked_beef": {
+ "id": "cooked_beef",
+ "name": "熟牛肉",
+ "ingredients": ["raw_beef", "salt_pile"],
+ "result": { "id": "cooked_beef", "name": "熟牛肉", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 20 }], "weight": 0.5, "value": 10 }
+ },
+ "cooked_chicken": {
+ "id": "cooked_chicken",
+ "name": "熟鸡肉",
+ "ingredients": ["raw_chicken", "salt_pile"],
+ "result": { "id": "cooked_chicken", "name": "熟鸡肉", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 15 }], "weight": 0.3, "value": 8 }
+ },
+ "cooked_mutton": {
+ "id": "cooked_mutton",
+ "name": "熟羊肉",
+ "ingredients": ["raw_mutton", "salt_pile"],
+ "result": { "id": "cooked_mutton", "name": "熟羊肉", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 18 }], "weight": 0.4, "value": 9 }
+ },
+ "cooked_pork": {
+ "id": "cooked_pork",
+ "name": "熟猪肉",
+ "ingredients": ["raw_pork", "salt_pile"],
+ "result": { "id": "cooked_pork", "name": "熟猪肉", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 22 }], "weight": 0.5, "value": 12 }
+ },
+ "vegetable_soup": {
+ "id": "vegetable_soup",
+ "name": "蔬菜汤",
+ "ingredients": ["potato", "carrot", "leek"],
+ "result": { "id": "vegetable_soup", "name": "蔬菜汤", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 25 }, { "type": "restore_stamina", "magnitude": 10 }], "weight": 0.5, "value": 15 }
+ },
+ "beef_stew": {
+ "id": "beef_stew",
+ "name": "牛肉炖菜",
+ "ingredients": ["raw_beef", "potato", "carrot", "salt_pile"],
+ "result": { "id": "beef_stew", "name": "牛肉炖菜", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 40 }, { "type": "fortify_health", "magnitude": 20, "duration": 300000 }], "weight": 1, "value": 25 }
+ },
+ "tomato_soup": {
+ "id": "tomato_soup",
+ "name": "番茄汤",
+ "ingredients": ["tomato", "leek", "salt_pile"],
+ "result": { "id": "tomato_soup", "name": "番茄汤", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 30 }, { "type": "restore_stamina", "magnitude": 15 }], "weight": 0.5, "value": 18 }
+ },
+ "apple_cabbage_stew": {
+ "id": "apple_cabbage_stew",
+ "name": "苹果卷心菜炖菜",
+ "ingredients": ["apple", "cabbage", "salt_pile"],
+ "result": { "id": "apple_cabbage_stew", "name": "苹果卷心菜炖菜", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 28 }], "weight": 0.5, "value": 14 }
+ },
+ "bread": {
+ "id": "bread",
+ "name": "面包",
+ "ingredients": ["flour", "salt_pile"],
+ "result": { "id": "bread", "name": "面包", "type": "food", "effects": [{ "type": "restore_health", "magnitude": 10 }], "weight": 0.2, "value": 5 }
+ },
+ "beer": {
+ "id": "beer",
+ "name": "啤酒",
+ "ingredients": ["wheat", "jazbay_grapes"],
+ "result": { "id": "beer", "name": "啤酒", "type": "drink", "effects": [{ "type": "restore_stamina", "magnitude": 20 }], "weight": 0.5, "value": 8 }
+ },
+ "wine": {
+ "id": "wine",
+ "name": "葡萄酒",
+ "ingredients": ["jazbay_grapes", "snowberries"],
+ "result": { "id": "wine", "name": "葡萄酒", "type": "drink", "effects": [{ "type": "restore_stamina", "magnitude": 25 }, { "type": "fortify_stamina", "magnitude": 15, "duration": 300000 }], "weight": 0.5, "value": 15 }
+ },
+ "mead": {
+ "id": "mead",
+ "name": "蜂蜜酒",
+ "ingredients": ["honey", "wheat"],
+ "result": { "id": "mead", "name": "蜂蜜酒", "type": "drink", "effects": [{ "type": "restore_stamina", "magnitude": 30 }], "weight": 0.5, "value": 12 }
+ }
+ }
+ }
+}
diff --git a/src/data/crafting/smithing.json b/src/data/crafting/smithing.json
new file mode 100644
index 0000000..ef3f1d8
--- /dev/null
+++ b/src/data/crafting/smithing.json
@@ -0,0 +1,177 @@
+{
+ "smithing": {
+ "materials": {
+ "iron": { "tier": "iron", "level": 1 },
+ "steel": { "tier": "steel", "level": 2 },
+ "corundum": { "tier": "corundum", "level": 3 },
+ "orichalcum": { "tier": "orichalcum", "level": 4 },
+ "moonstone": { "tier": "moonstone", "level": 5 },
+ "ebony": { "tier": "ebony", "level": 6 },
+ "daedric": { "tier": "daedric", "level": 7 },
+ "dragon": { "tier": "dragon", "level": 8 }
+ },
+ "stations": {
+ "forge": {
+ "type": "forge",
+ "name": "锻造炉",
+ "availableRecipes": ["iron_sword", "iron_waraxe", "iron_mace", "iron_dagger", "iron_greatsword", "iron_warhammer", "iron_shield", "iron_helmet", "iron_chestplate", "iron_gauntlets", "iron_boots", "steel_sword", "steel_greatsword", "steel_shield", "hunting_bow", "iron_arrow"]
+ },
+ "workbench": {
+ "type": "workbench",
+ "name": "工作台",
+ "availableRecipes": ["iron_helmet", "iron_chestplate", "iron_gauntlets", "iron_boots", "iron_shield", "steel_shield"]
+ },
+ "grindstone": {
+ "type": "grindstone",
+ "name": "砂轮",
+ "availableRecipes": ["iron_sword", "iron_waraxe", "iron_mace", "iron_dagger", "iron_greatsword", "iron_warhammer", "steel_sword", "steel_greatsword"]
+ }
+ },
+ "recipes": {
+ "iron_sword": {
+ "id": "iron_sword",
+ "name": "铁剑",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 2 }, { "id": "leather_strips", "quantity": 1 }],
+ "result": { "id": "iron_sword", "name": "铁剑", "type": "one_handed_sword", "damage": 10, "weight": 10, "value": 50 },
+ "skillRequired": 0
+ },
+ "iron_waraxe": {
+ "id": "iron_waraxe",
+ "name": "铁战斧",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 2 }, { "id": "leather_strips", "quantity": 1 }],
+ "result": { "id": "iron_waraxe", "name": "铁战斧", "type": "one_handed_axe", "damage": 9, "weight": 12, "value": 45 },
+ "skillRequired": 0
+ },
+ "iron_mace": {
+ "id": "iron_mace",
+ "name": "铁钉锤",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 3 }],
+ "result": { "id": "iron_mace", "name": "铁钉锤", "type": "one_handed_mace", "damage": 11, "weight": 14, "value": 55 },
+ "skillRequired": 0
+ },
+ "iron_dagger": {
+ "id": "iron_dagger",
+ "name": "铁匕首",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 1 }, { "id": "leather_strips", "quantity": 1 }],
+ "result": { "id": "iron_dagger", "name": "铁匕首", "type": "dagger", "damage": 6, "weight": 3, "value": 25 },
+ "skillRequired": 0
+ },
+ "iron_greatsword": {
+ "id": "iron_greatsword",
+ "name": "铁制大剑",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 4 }, { "id": "leather_strips", "quantity": 2 }],
+ "result": { "id": "iron_greatsword", "name": "铁制大剑", "type": "two_handed_sword", "damage": 18, "weight": 20, "value": 100 },
+ "skillRequired": 20
+ },
+ "iron_warhammer": {
+ "id": "iron_warhammer",
+ "name": "铁战锤",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 5 }],
+ "result": { "id": "iron_warhammer", "name": "铁战锤", "type": "two_handed_mace", "damage": 20, "weight": 25, "value": 120 },
+ "skillRequired": 20
+ },
+ "iron_shield": {
+ "id": "iron_shield",
+ "name": "铁盾",
+ "type": "shield",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 4 }, { "id": "leather", "quantity": 1 }],
+ "result": { "id": "iron_shield", "name": "铁盾", "type": "shield", "armor": 20, "weight": 12, "value": 60 },
+ "skillRequired": 0
+ },
+ "iron_helmet": {
+ "id": "iron_helmet",
+ "name": "铁头盔",
+ "type": "armor",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 2 }],
+ "result": { "id": "iron_helmet", "name": "铁头盔", "type": "head", "armor": 15, "weight": 5, "value": 40 },
+ "skillRequired": 0
+ },
+ "iron_chestplate": {
+ "id": "iron_chestplate",
+ "name": "铁胸甲",
+ "type": "armor",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 4 }],
+ "result": { "id": "iron_chestplate", "name": "铁胸甲", "type": "chest", "armor": 25, "weight": 15, "value": 80 },
+ "skillRequired": 0
+ },
+ "iron_gauntlets": {
+ "id": "iron_gauntlets",
+ "name": "铁护手",
+ "type": "armor",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 2 }],
+ "result": { "id": "iron_gauntlets", "name": "铁护手", "type": "hands", "armor": 10, "weight": 4, "value": 30 },
+ "skillRequired": 0
+ },
+ "iron_boots": {
+ "id": "iron_boots",
+ "name": "铁靴",
+ "type": "armor",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 2 }],
+ "result": { "id": "iron_boots", "name": "铁靴", "type": "feet", "armor": 10, "weight": 4, "value": 30 },
+ "skillRequired": 0
+ },
+ "steel_sword": {
+ "id": "steel_sword",
+ "name": "钢剑",
+ "type": "weapon",
+ "tier": "steel",
+ "materials": [{ "id": "steel_ingot", "quantity": 2 }, { "id": "leather_strips", "quantity": 1 }],
+ "result": { "id": "steel_sword", "name": "钢剑", "type": "one_handed_sword", "damage": 14, "weight": 11, "value": 120 },
+ "skillRequired": 20
+ },
+ "steel_greatsword": {
+ "id": "steel_greatsword",
+ "name": "钢制大剑",
+ "type": "weapon",
+ "tier": "steel",
+ "materials": [{ "id": "steel_ingot", "quantity": 4 }, { "id": "leather_strips", "quantity": 2 }],
+ "result": { "id": "steel_greatsword", "name": "钢制大剑", "type": "two_handed_sword", "damage": 24, "weight": 22, "value": 250 },
+ "skillRequired": 30
+ },
+ "steel_shield": {
+ "id": "steel_shield",
+ "name": "钢盾",
+ "type": "shield",
+ "tier": "steel",
+ "materials": [{ "id": "steel_ingot", "quantity": 4 }, { "id": "leather", "quantity": 1 }],
+ "result": { "id": "steel_shield", "name": "钢盾", "type": "shield", "armor": 30, "weight": 13, "value": 150 },
+ "skillRequired": 20
+ },
+ "hunting_bow": {
+ "id": "hunting_bow",
+ "name": "猎弓",
+ "type": "weapon",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 1 }, { "id": "leather", "quantity": 2 }],
+ "result": { "id": "hunting_bow", "name": "猎弓", "type": "bow", "damage": 8, "weight": 6, "value": 50 },
+ "skillRequired": 10
+ },
+ "iron_arrow": {
+ "id": "iron_arrow",
+ "name": "铁箭",
+ "type": "material",
+ "tier": "iron",
+ "materials": [{ "id": "iron_ingot", "quantity": 1 }],
+ "result": { "id": "iron_arrow", "name": "铁箭", "type": "ammo", "damage": 8, "weight": 0.1, "value": 5 },
+ "skillRequired": 0
+ }
+ }
+ }
+}
diff --git a/src/data/dialogue/trees.json b/src/data/dialogue/trees.json
new file mode 100644
index 0000000..25f7614
--- /dev/null
+++ b/src/data/dialogue/trees.json
@@ -0,0 +1,196 @@
+{
+ "dialogue": {
+ "blacksmith_01": {
+ "id": "blacksmith_01",
+ "npcId": "blacksmith_01",
+ "lines": {
+ "start": {
+ "id": "start",
+ "speaker": "铁匠哈蒙",
+ "text": "欢迎来到我的铁匠铺。你需要什么?",
+ "options": [
+ { "id": "shop", "text": "我想看看你的货物", "nextLineId": "shop" },
+ { "id": "forge", "text": "你能帮我打造装备吗?", "nextLineId": "forge" },
+ { "id": "quest", "text": "有什么我能帮忙的吗?", "nextLineId": "quest", "conditions": [{ "type": "level", "value": 5 }] },
+ { "id": "bye", "text": "再见", "nextLineId": "end" }
+ ]
+ },
+ "shop": {
+ "id": "shop",
+ "speaker": "铁匠哈蒙",
+ "text": "这是我最好的货物。铁制和钢制武器,还有一些护甲。",
+ "options": [
+ { "id": "buy_weapons", "text": "看看武器", "nextLineId": "weapons" },
+ { "id": "buy_armor", "text": "看看护甲", "nextLineId": "armor" },
+ { "id": "back", "text": "返回", "nextLineId": "start" }
+ ]
+ },
+ "weapons": {
+ "id": "weapons",
+ "speaker": "铁匠哈蒙",
+ "text": "铁剑 50 金币,钢剑 120 金币。都是好货!",
+ "options": [
+ { "id": "buy_iron_sword", "text": "买铁剑 (50金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 50 }, { "type": "giveItem", "value": { "id": "iron_sword", "quantity": 1 } }] },
+ { "id": "buy_steel_sword", "text": "买钢剑 (120金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 120 }, { "type": "giveItem", "value": { "id": "steel_sword", "quantity": 1 } }] },
+ { "id": "back", "text": "返回", "nextLineId": "shop" }
+ ]
+ },
+ "armor": {
+ "id": "armor",
+ "speaker": "铁匠哈蒙",
+ "text": "铁甲 100 金币,钢甲 250 金币。保证结实!",
+ "options": [
+ { "id": "buy_iron_armor", "text": "买铁甲 (100金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 100 }, { "type": "giveItem", "value": { "id": "iron_armor", "quantity": 1 } }] },
+ { "id": "back", "text": "返回", "nextLineId": "shop" }
+ ]
+ },
+ "bought": {
+ "id": "bought",
+ "speaker": "铁匠哈蒙",
+ "text": "成交!好装备要配好主人。还需要什么?",
+ "options": [
+ { "id": "back", "text": "看看其他东西", "nextLineId": "shop" },
+ { "id": "bye", "text": "谢谢,再见", "nextLineId": "end" }
+ ]
+ },
+ "forge": {
+ "id": "forge",
+ "speaker": "铁匠哈蒙",
+ "text": "我可以帮你打造装备,但需要材料。带铁锭和皮革来,我就能给你做把好剑。",
+ "options": [
+ { "id": "back", "text": "我去找材料", "nextLineId": "end" }
+ ]
+ },
+ "quest": {
+ "id": "quest",
+ "speaker": "铁匠哈蒙",
+ "text": "你来得正好!我的祖传圣剑在附近的洞穴被强盗抢走了。如果你能帮我找回来,我一定会重重报答你!",
+ "options": [
+ { "id": "accept_quest", "text": "我帮你找回来", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "lost_sword" }] },
+ { "id": "decline_quest", "text": "我现在没空", "nextLineId": "quest_declined" }
+ ]
+ },
+ "quest_accepted": {
+ "id": "quest_accepted",
+ "speaker": "铁匠哈蒙",
+ "text": "太感谢了!那些强盗在附近的洞穴里。小心点,他们人多。找到圣剑后带回来给我!",
+ "options": [
+ { "id": "bye", "text": "我会小心的", "nextLineId": "end" }
+ ]
+ },
+ "quest_declined": {
+ "id": "quest_declined",
+ "speaker": "铁匠哈蒙",
+ "text": "好吧,如果你改变主意了,随时来找我。",
+ "options": [
+ { "id": "bye", "text": "再见", "nextLineId": "end" }
+ ]
+ },
+ "end": {
+ "id": "end",
+ "speaker": "铁匠哈蒙",
+ "text": "保重!"
+ }
+ },
+ "startLineId": "start"
+ },
+ "guard_01": {
+ "id": "guard_01",
+ "npcId": "guard_01",
+ "lines": {
+ "start": {
+ "id": "start",
+ "speaker": "守卫",
+ "text": "欢迎来到雪漫城。保持和平,市民。",
+ "options": [
+ { "id": "about_city", "text": "关于这座城市", "nextLineId": "about_city" },
+ { "id": "about_guilds", "text": "有什么公会吗?", "nextLineId": "guilds" },
+ { "id": "bye", "text": "再见", "nextLineId": "end" }
+ ]
+ },
+ "about_city": {
+ "id": "about_city",
+ "speaker": "守卫",
+ "text": "雪漫城是天际省的首府。这里有铁匠铺、酒馆,还有领主大厅。北边有个洞穴,小心点。",
+ "options": [
+ { "id": "back", "text": "谢谢", "nextLineId": "start" }
+ ]
+ },
+ "guilds": {
+ "id": "guilds",
+ "speaker": "守卫",
+ "text": "战士公会在城里,盗贼公会在裂谷城,法师学院在冬堡。黑暗兄弟会...嗯,你不会想和他们打交道的。",
+ "options": [
+ { "id": "back", "text": "明白了", "nextLineId": "start" }
+ ]
+ },
+ "end": {
+ "id": "end",
+ "speaker": "守卫",
+ "text": "小心点。"
+ }
+ },
+ "startLineId": "start"
+ },
+ "merchant_01": {
+ "id": "merchant_01",
+ "npcId": "merchant_01",
+ "lines": {
+ "start": {
+ "id": "start",
+ "speaker": "商人",
+ "text": "看看我的货物!药水、材料、杂物,应有尽有!",
+ "options": [
+ { "id": "buy_potions", "text": "看看药水", "nextLineId": "potions" },
+ { "id": "buy_materials", "text": "看看材料", "nextLineId": "materials" },
+ { "id": "sell", "text": "我想卖东西", "nextLineId": "sell" },
+ { "id": "bye", "text": "再见", "nextLineId": "end" }
+ ]
+ },
+ "potions": {
+ "id": "potions",
+ "speaker": "商人",
+ "text": "生命药水 25 金币,魔力药水 30 金币,耐力药水 20 金币。",
+ "options": [
+ { "id": "buy_health", "text": "买生命药水 (25金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 25 }, { "type": "giveItem", "value": { "id": "health_potion", "quantity": 1 } }] },
+ { "id": "buy_magicka", "text": "买魔力药水 (30金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 30 }, { "type": "giveItem", "value": { "id": "magicka_potion", "quantity": 1 } }] },
+ { "id": "back", "text": "返回", "nextLineId": "start" }
+ ]
+ },
+ "materials": {
+ "id": "materials",
+ "speaker": "商人",
+ "text": "铁矿石 10 金币,铁锭 15 金币,皮革 10 金币。",
+ "options": [
+ { "id": "buy_iron_ore", "text": "买铁矿石 (10金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 10 }, { "type": "giveItem", "value": { "id": "iron_ore", "quantity": 1 } }] },
+ { "id": "buy_iron_ingot", "text": "买铁锭 (15金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 15 }, { "type": "giveItem", "value": { "id": "iron_ingot", "quantity": 1 } }] },
+ { "id": "back", "text": "返回", "nextLineId": "start" }
+ ]
+ },
+ "sell": {
+ "id": "sell",
+ "speaker": "商人",
+ "text": "你想卖什么?把东西给我看看。",
+ "options": [
+ { "id": "back", "text": "我再想想", "nextLineId": "start" }
+ ]
+ },
+ "bought": {
+ "id": "bought",
+ "speaker": "商人",
+ "text": "好交易!还需要什么?",
+ "options": [
+ { "id": "back", "text": "看看其他东西", "nextLineId": "start" },
+ { "id": "bye", "text": "谢谢,再见", "nextLineId": "end" }
+ ]
+ },
+ "end": {
+ "id": "end",
+ "speaker": "商人",
+ "text": "下次再来!"
+ }
+ },
+ "startLineId": "start"
+ }
+ }
+}
diff --git a/src/data/enemies/enemies.json b/src/data/enemies/enemies.json
new file mode 100644
index 0000000..5198280
--- /dev/null
+++ b/src/data/enemies/enemies.json
@@ -0,0 +1,256 @@
+{
+ "enemies": {
+ "bandit": {
+ "id": "bandit",
+ "name": "强盗",
+ "level": 5,
+ "health": 60,
+ "stamina": 40,
+ "damage": 8,
+ "armor": 10,
+ "detectionRange": 150,
+ "attackRange": 45,
+ "attackSpeed": 1.0,
+ "loot": {
+ "gold": { "min": 10, "max": 30 },
+ "items": [
+ { "id": "iron_sword", "chance": 0.2 },
+ { "id": "health_potion", "chance": 0.3 }
+ ]
+ },
+ "color": "#cc3333",
+ "size": 24
+ },
+ "wolf": {
+ "id": "wolf",
+ "name": "狼",
+ "level": 3,
+ "health": 40,
+ "stamina": 60,
+ "damage": 6,
+ "armor": 5,
+ "detectionRange": 200,
+ "attackRange": 35,
+ "attackSpeed": 1.5,
+ "loot": {
+ "gold": { "min": 0, "max": 5 },
+ "items": [
+ { "id": "wolf_pelt", "chance": 0.5 }
+ ]
+ },
+ "color": "#888888",
+ "size": 20
+ },
+ "skeleton": {
+ "id": "skeleton",
+ "name": "骷髅",
+ "level": 8,
+ "health": 70,
+ "stamina": 30,
+ "damage": 12,
+ "armor": 15,
+ "detectionRange": 120,
+ "attackRange": 45,
+ "attackSpeed": 0.8,
+ "loot": {
+ "gold": { "min": 5, "max": 15 },
+ "items": [
+ { "id": "bone", "chance": 0.8 },
+ { "id": "iron_arrow", "chance": 0.3 }
+ ]
+ },
+ "color": "#ccccaa",
+ "size": 24
+ },
+ "draugr": {
+ "id": "draugr",
+ "name": "尸鬼",
+ "level": 10,
+ "health": 100,
+ "stamina": 40,
+ "damage": 15,
+ "armor": 18,
+ "detectionRange": 140,
+ "attackRange": 45,
+ "attackSpeed": 0.9,
+ "loot": {
+ "gold": { "min": 10, "max": 25 },
+ "items": [
+ { "id": "iron_sword", "chance": 0.15 },
+ { "id": "health_potion", "chance": 0.2 }
+ ]
+ },
+ "color": "#556644",
+ "size": 26
+ },
+ "draugr_wight": {
+ "id": "draugr_wight",
+ "name": "尸鬼亡灵",
+ "level": 16,
+ "health": 180,
+ "stamina": 50,
+ "damage": 22,
+ "armor": 25,
+ "detectionRange": 160,
+ "attackRange": 50,
+ "attackSpeed": 1.0,
+ "loot": {
+ "gold": { "min": 20, "max": 50 },
+ "items": [
+ { "id": "steel_sword", "chance": 0.1 },
+ { "id": "health_potion", "chance": 0.3 }
+ ]
+ },
+ "color": "#445533",
+ "size": 28
+ },
+ "bear": {
+ "id": "bear",
+ "name": "熊",
+ "level": 10,
+ "health": 120,
+ "stamina": 50,
+ "damage": 18,
+ "armor": 20,
+ "detectionRange": 180,
+ "attackRange": 50,
+ "attackSpeed": 0.6,
+ "loot": {
+ "gold": { "min": 0, "max": 0 },
+ "items": [
+ { "id": "bear_pelt", "chance": 0.7 },
+ { "id": "bear_claw", "chance": 0.4 }
+ ]
+ },
+ "color": "#8B4513",
+ "size": 32
+ },
+ "cave_bear": {
+ "id": "cave_bear",
+ "name": "洞穴熊",
+ "level": 14,
+ "health": 160,
+ "stamina": 55,
+ "damage": 22,
+ "armor": 25,
+ "detectionRange": 170,
+ "attackRange": 50,
+ "attackSpeed": 0.65,
+ "loot": {
+ "gold": { "min": 0, "max": 10 },
+ "items": [
+ { "id": "bear_pelt", "chance": 0.8 },
+ { "id": "bear_claw", "chance": 0.5 }
+ ]
+ },
+ "color": "#6B3410",
+ "size": 34
+ },
+ "spider": {
+ "id": "spider",
+ "name": "蜘蛛",
+ "level": 6,
+ "health": 50,
+ "stamina": 45,
+ "damage": 10,
+ "armor": 8,
+ "detectionRange": 160,
+ "attackRange": 40,
+ "attackSpeed": 1.2,
+ "loot": {
+ "gold": { "min": 0, "max": 10 },
+ "items": [
+ { "id": "spider_silk", "chance": 0.6 },
+ { "id": "poison_sac", "chance": 0.3 }
+ ]
+ },
+ "color": "#440044",
+ "size": 22
+ },
+ "frostbite_spider": {
+ "id": "frostbite_spider",
+ "name": "冰霜蜘蛛",
+ "level": 12,
+ "health": 80,
+ "stamina": 50,
+ "damage": 14,
+ "armor": 12,
+ "detectionRange": 170,
+ "attackRange": 42,
+ "attackSpeed": 1.3,
+ "loot": {
+ "gold": { "min": 5, "max": 15 },
+ "items": [
+ { "id": "spider_silk", "chance": 0.7 },
+ { "id": "poison_sac", "chance": 0.4 }
+ ]
+ },
+ "color": "#2244aa",
+ "size": 26
+ },
+ "bandit_outlaw": {
+ "id": "bandit_outlaw",
+ "name": "强盗逃犯",
+ "level": 8,
+ "health": 80,
+ "stamina": 45,
+ "damage": 12,
+ "armor": 15,
+ "detectionRange": 150,
+ "attackRange": 45,
+ "attackSpeed": 1.1,
+ "loot": {
+ "gold": { "min": 15, "max": 40 },
+ "items": [
+ { "id": "steel_sword", "chance": 0.1 },
+ { "id": "health_potion", "chance": 0.35 }
+ ]
+ },
+ "color": "#aa2222",
+ "size": 26
+ },
+ "bandit_thug": {
+ "id": "bandit_thug",
+ "name": "强盗暴徒",
+ "level": 12,
+ "health": 110,
+ "stamina": 50,
+ "damage": 16,
+ "armor": 20,
+ "detectionRange": 150,
+ "attackRange": 50,
+ "attackSpeed": 0.85,
+ "loot": {
+ "gold": { "min": 25, "max": 60 },
+ "items": [
+ { "id": "steel_greatsword", "chance": 0.08 },
+ { "id": "health_potion", "chance": 0.4 }
+ ]
+ },
+ "color": "#992222",
+ "size": 28
+ },
+ "necromancer": {
+ "id": "necromancer",
+ "name": "死灵法师",
+ "level": 14,
+ "health": 60,
+ "magicka": 120,
+ "stamina": 30,
+ "damage": 8,
+ "armor": 5,
+ "detectionRange": 180,
+ "attackRange": 120,
+ "attackSpeed": 0.7,
+ "loot": {
+ "gold": { "min": 20, "max": 50 },
+ "items": [
+ { "id": "magicka_potion", "chance": 0.4 },
+ { "id": "soul_gem", "chance": 0.2 }
+ ]
+ },
+ "color": "#6622aa",
+ "size": 24
+ }
+ }
+}
diff --git a/src/data/game-config.json b/src/data/game-config.json
new file mode 100644
index 0000000..935f430
--- /dev/null
+++ b/src/data/game-config.json
@@ -0,0 +1,41 @@
+{
+ "gameConfig": {
+ "combat": {
+ "baseDamage": 10,
+ "skillBonus": 0.5,
+ "powerAttackMultiplier": 1.5,
+ "critBaseChance": 0.10,
+ "critPerSneakSkill": 0.005,
+ "armorDivisor": 100,
+ "damageVariance": [0.9, 1.1],
+ "blockDamageMultiplier": 0.2,
+ "blockBaseChance": 0.3,
+ "blockSkillBonus": 0.5,
+ "blockStaminaThreshold": 10,
+ "blockStaminaPenalty": -0.2,
+ "blockStaminaCost": 10,
+ "attackRanges": { "melee": 60, "unarmed": 45 },
+ "staminaCosts": { "powerAttack": 25, "normal": 5 },
+ "weaponDamageThresholds": { "twoHanded": 6, "powerAttack": 15 },
+ "skillImprovementAmounts": { "attack": 0.5, "armor": 0.3 }
+ },
+ "leveling": {
+ "defaultSkillLevel": 15,
+ "maxSkillLevel": 100,
+ "levelDivisor": 10,
+ "healthPerLevel": 10,
+ "magickaPerLevel": 5,
+ "staminaPerLevel": 5,
+ "xpBase": 100,
+ "xpScale": 1.1,
+ "legendaryThreshold": 50
+ },
+ "regen": {
+ "delayMs": 3000,
+ "healthPerSecond": 0.5,
+ "magickaPerSecond": 3,
+ "staminaPerSecond": 5,
+ "restorationBonusPerSkill": 0.02
+ }
+ }
+}
diff --git a/src/data/items/armor.json b/src/data/items/armor.json
new file mode 100644
index 0000000..211e5c6
--- /dev/null
+++ b/src/data/items/armor.json
@@ -0,0 +1,352 @@
+{
+ "armor": {
+ "iron_helmet": {
+ "id": "iron_helmet",
+ "name": "铁盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "iron",
+ "armor": 8,
+ "weight": 5,
+ "value": 60,
+ "material": "iron_ingot",
+ "description": "简单的铁制头盔"
+ },
+ "iron_chestplate": {
+ "id": "iron_chestplate",
+ "name": "铁胸甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "iron",
+ "armor": 15,
+ "weight": 15,
+ "value": 120,
+ "material": "iron_ingot",
+ "description": "坚固的铁制胸甲"
+ },
+ "iron_gauntlets": {
+ "id": "iron_gauntlets",
+ "name": "铁护手",
+ "type": "armor",
+ "subtype": "gauntlets",
+ "tier": "iron",
+ "armor": 6,
+ "weight": 4,
+ "value": 50,
+ "material": "iron_ingot",
+ "description": "铁制护手"
+ },
+ "iron_boots": {
+ "id": "iron_boots",
+ "name": "铁靴",
+ "type": "armor",
+ "subtype": "boots",
+ "tier": "iron",
+ "armor": 8,
+ "weight": 6,
+ "value": 60,
+ "material": "iron_ingot",
+ "description": "沉重的铁靴"
+ },
+ "iron_shield": {
+ "id": "iron_shield",
+ "name": "铁盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "iron",
+ "armor": 10,
+ "weight": 8,
+ "value": 80,
+ "material": "iron_ingot",
+ "description": "标准的铁制盾牌"
+ },
+ "steel_helmet": {
+ "id": "steel_helmet",
+ "name": "钢盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "steel",
+ "armor": 12,
+ "weight": 5,
+ "value": 120,
+ "material": "steel_ingot",
+ "description": "精炼钢制头盔"
+ },
+ "steel_chestplate": {
+ "id": "steel_chestplate",
+ "name": "钢胸甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "steel",
+ "armor": 22,
+ "weight": 18,
+ "value": 240,
+ "material": "steel_ingot",
+ "description": "坚固的钢制胸甲"
+ },
+ "steel_gauntlets": {
+ "id": "steel_gauntlets",
+ "name": "钢护手",
+ "type": "armor",
+ "subtype": "gauntlets",
+ "tier": "steel",
+ "armor": 10,
+ "weight": 4,
+ "value": 100,
+ "material": "steel_ingot",
+ "description": "钢制护手"
+ },
+ "steel_boots": {
+ "id": "steel_boots",
+ "name": "钢靴",
+ "type": "armor",
+ "subtype": "boots",
+ "tier": "steel",
+ "armor": 12,
+ "weight": 7,
+ "value": 120,
+ "material": "steel_ingot",
+ "description": "钢制战靴"
+ },
+ "steel_shield": {
+ "id": "steel_shield",
+ "name": "钢盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "steel",
+ "armor": 16,
+ "weight": 10,
+ "value": 150,
+ "material": "steel_ingot",
+ "description": "精炼钢盾"
+ },
+ "leather_helmet": {
+ "id": "leather_helmet",
+ "name": "皮盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "leather",
+ "armor": 6,
+ "weight": 2,
+ "value": 50,
+ "material": "leather",
+ "description": "轻便的皮革头盔"
+ },
+ "leather_chestpiece": {
+ "id": "leather_chestpiece",
+ "name": "皮甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "leather",
+ "armor": 12,
+ "weight": 6,
+ "value": 100,
+ "material": "leather",
+ "description": "灵活的皮革胸甲"
+ },
+ "leather_bracers": {
+ "id": "leather_bracers",
+ "name": "皮护腕",
+ "type": "armor",
+ "subtype": "gauntlets",
+ "tier": "leather",
+ "armor": 4,
+ "weight": 1,
+ "value": 40,
+ "material": "leather",
+ "description": "皮革护腕"
+ },
+ "leather_boots": {
+ "id": "leather_boots",
+ "name": "皮靴",
+ "type": "armor",
+ "subtype": "boots",
+ "tier": "leather",
+ "armor": 6,
+ "weight": 2,
+ "value": 50,
+ "material": "leather",
+ "description": "轻便的皮革靴子"
+ },
+ "elven_helmet": {
+ "id": "elven_helmet",
+ "name": "精灵头盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "elven",
+ "armor": 15,
+ "weight": 2,
+ "value": 200,
+ "material": "moonstone_ingot",
+ "description": "优雅的精灵头盔"
+ },
+ "elven_armor": {
+ "id": "elven_armor",
+ "name": "精灵甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "elven",
+ "armor": 26,
+ "weight": 8,
+ "value": 400,
+ "material": "moonstone_ingot",
+ "description": "轻盈的精灵护甲"
+ },
+ "elven_shield": {
+ "id": "elven_shield",
+ "name": "精灵盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "elven",
+ "armor": 20,
+ "weight": 4,
+ "value": 250,
+ "material": "moonstone_ingot",
+ "description": "精致的精灵盾"
+ },
+ "orcish_helmet": {
+ "id": "orcish_helmet",
+ "name": "兽人头盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "orcish",
+ "armor": 18,
+ "weight": 8,
+ "value": 300,
+ "material": "orichalcum_ingot",
+ "description": "粗糙但坚固的兽人头盔"
+ },
+ "orcish_armor": {
+ "id": "orcish_armor",
+ "name": "兽人甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "orcish",
+ "armor": 32,
+ "weight": 22,
+ "value": 600,
+ "material": "orichalcum_ingot",
+ "description": "厚重的兽人护甲"
+ },
+ "orcish_shield": {
+ "id": "orcish_shield",
+ "name": "兽人盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "orcish",
+ "armor": 24,
+ "weight": 12,
+ "value": 350,
+ "material": "orichalcum_ingot",
+ "description": "粗犷的兽人盾牌"
+ },
+ "ebony_helmet": {
+ "id": "ebony_helmet",
+ "name": "乌木头盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "ebony",
+ "armor": 22,
+ "weight": 6,
+ "value": 600,
+ "material": "ebony_ingot",
+ "description": "漆黑的乌木头盔"
+ },
+ "ebony_armor": {
+ "id": "ebony_armor",
+ "name": "乌木甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "ebony",
+ "armor": 40,
+ "weight": 20,
+ "value": 1200,
+ "material": "ebony_ingot",
+ "description": "华丽的乌木护甲"
+ },
+ "ebony_shield": {
+ "id": "ebony_shield",
+ "name": "乌木盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "ebony",
+ "armor": 30,
+ "weight": 10,
+ "value": 700,
+ "material": "ebony_ingot",
+ "description": "坚硬的乌木盾"
+ },
+ "daedric_helmet": {
+ "id": "daedric_helmet",
+ "name": "魔族头盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "daedric",
+ "armor": 28,
+ "weight": 8,
+ "value": 1500,
+ "material": "daedric_heart",
+ "description": "来自湮灭的魔族头盔"
+ },
+ "daedric_armor": {
+ "id": "daedric_armor",
+ "name": "魔族甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "daedric",
+ "armor": 50,
+ "weight": 25,
+ "value": 3000,
+ "material": "daedric_heart",
+ "description": "恐怖的魔族护甲"
+ },
+ "daedric_shield": {
+ "id": "daedric_shield",
+ "name": "魔族盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "daedric",
+ "armor": 38,
+ "weight": 12,
+ "value": 1800,
+ "material": "daedric_heart",
+ "description": "邪恶的魔族盾"
+ },
+ "dragon_helmet": {
+ "id": "dragon_helmet",
+ "name": "龙头盔",
+ "type": "armor",
+ "subtype": "helmet",
+ "tier": "dragon",
+ "armor": 32,
+ "weight": 10,
+ "value": 2500,
+ "material": "dragon_bone",
+ "description": "用龙骨打造的头盔"
+ },
+ "dragon_armor": {
+ "id": "dragon_armor",
+ "name": "龙甲",
+ "type": "armor",
+ "subtype": "chest",
+ "tier": "dragon",
+ "armor": 60,
+ "weight": 30,
+ "value": 5000,
+ "material": "dragon_bone",
+ "description": "传说中的龙骨铠甲"
+ },
+ "dragon_shield": {
+ "id": "dragon_shield",
+ "name": "龙盾",
+ "type": "armor",
+ "subtype": "shield",
+ "tier": "dragon",
+ "armor": 45,
+ "weight": 15,
+ "value": 3000,
+ "material": "dragon_bone",
+ "description": "龙骨盾牌"
+ }
+ }
+}
diff --git a/src/data/items/enchantments.json b/src/data/items/enchantments.json
new file mode 100644
index 0000000..8e82d01
--- /dev/null
+++ b/src/data/items/enchantments.json
@@ -0,0 +1,116 @@
+{
+ "enchantments": {
+ "fire_damage": {
+ "id": "fire_damage",
+ "name": "火焰伤害",
+ "type": "weapon",
+ "effects": [{ "type": "fire_damage", "magnitude": 10 }],
+ "magnitude": 10,
+ "duration": 0
+ },
+ "frost_damage": {
+ "id": "frost_damage",
+ "name": "冰霜伤害",
+ "type": "weapon",
+ "effects": [{ "type": "frost_damage", "magnitude": 10 }],
+ "magnitude": 10,
+ "duration": 0
+ },
+ "shock_damage": {
+ "id": "shock_damage",
+ "name": "电击伤害",
+ "type": "weapon",
+ "effects": [{ "type": "shock_damage", "magnitude": 8 }],
+ "magnitude": 8,
+ "duration": 0
+ },
+ "absorb_health": {
+ "id": "absorb_health",
+ "name": "吸收生命",
+ "type": "weapon",
+ "effects": [{ "type": "absorb_health", "magnitude": 5 }],
+ "magnitude": 5,
+ "duration": 0
+ },
+ "absorb_magicka": {
+ "id": "absorb_magicka",
+ "name": "吸收魔力",
+ "type": "weapon",
+ "effects": [{ "type": "absorb_magicka", "magnitude": 10 }],
+ "magnitude": 10,
+ "duration": 0
+ },
+ "absorb_stamina": {
+ "id": "absorb_stamina",
+ "name": "吸收耐力",
+ "type": "weapon",
+ "effects": [{ "type": "absorb_stamina", "magnitude": 10 }],
+ "magnitude": 10,
+ "duration": 0
+ },
+ "silent_damage": {
+ "id": "silent_damage",
+ "name": "无声伤害",
+ "type": "weapon",
+ "effects": [{ "type": "damage_health", "magnitude": 20 }, { "type": "silence", "magnitude": 1, "duration": 30000 }],
+ "magnitude": 20,
+ "duration": 0
+ },
+ "smite": {
+ "id": "smite",
+ "name": "圣光打击",
+ "type": "weapon",
+ "effects": [{ "type": "damage_health", "magnitude": 15 }],
+ "magnitude": 15,
+ "duration": 0
+ },
+ "fortify_health_armor": {
+ "id": "fortify_health_armor",
+ "name": "强化生命",
+ "type": "armor",
+ "effects": [{ "type": "fortify_health", "magnitude": 20 }],
+ "magnitude": 20,
+ "duration": 0
+ },
+ "fortify_magicka_armor": {
+ "id": "fortify_magicka_armor",
+ "name": "强化魔力",
+ "type": "armor",
+ "effects": [{ "type": "fortify_magicka", "magnitude": 20 }],
+ "magnitude": 20,
+ "duration": 0
+ },
+ "fortify_stamina_armor": {
+ "id": "fortify_stamina_armor",
+ "name": "强化耐力",
+ "type": "armor",
+ "effects": [{ "type": "fortify_stamina", "magnitude": 20 }],
+ "magnitude": 20,
+ "duration": 0
+ },
+ "regenerate_health_armor": {
+ "id": "regenerate_health_armor",
+ "name": "生命再生",
+ "type": "armor",
+ "effects": [{ "type": "regenerate_health", "magnitude": 0.5 }],
+ "magnitude": 0.5,
+ "duration": 0
+ },
+ "muffle_boots": {
+ "id": "muffle_boots",
+ "name": "消音",
+ "type": "armor",
+ "effects": [{ "type": "muffle", "magnitude": 1 }],
+ "magnitude": 1,
+ "duration": 0
+ },
+ "fortify_carry_weight": {
+ "id": "fortify_carry_weight",
+ "name": "强化负重",
+ "type": "jewelry",
+ "effects": [{ "type": "fortify_stamina", "magnitude": 30 }],
+ "magnitude": 30,
+ "duration": 0
+ }
+ }
+}
diff --git a/src/data/items/items.json b/src/data/items/items.json
new file mode 100644
index 0000000..cf2bee0
--- /dev/null
+++ b/src/data/items/items.json
@@ -0,0 +1,275 @@
+{
+ "items": {
+ "health_potion": {
+ "id": "health_potion",
+ "name": "生命药水",
+ "type": "consumable",
+ "subtype": "potion",
+ "weight": 0.5,
+ "value": 25,
+ "effect": {
+ "type": "restore_health",
+ "magnitude": 50
+ },
+ "description": "恢复 50 点生命值"
+ },
+ "magicka_potion": {
+ "id": "magicka_potion",
+ "name": "魔力药水",
+ "type": "consumable",
+ "subtype": "potion",
+ "weight": 0.5,
+ "value": 30,
+ "effect": {
+ "type": "restore_magicka",
+ "magnitude": 50
+ },
+ "description": "恢复 50 点魔力"
+ },
+ "stamina_potion": {
+ "id": "stamina_potion",
+ "name": "耐力药水",
+ "type": "consumable",
+ "subtype": "potion",
+ "weight": 0.5,
+ "value": 20,
+ "effect": {
+ "type": "restore_stamina",
+ "magnitude": 50
+ },
+ "description": "恢复 50 点耐力"
+ },
+ "bread": {
+ "id": "bread",
+ "name": "面包",
+ "type": "consumable",
+ "subtype": "food",
+ "weight": 0.2,
+ "value": 5,
+ "effect": {
+ "type": "restore_health",
+ "magnitude": 10
+ },
+ "description": "普通的面包,恢复少量生命"
+ },
+ "cheese_wheel": {
+ "id": "cheese_wheel",
+ "name": "奶酪轮",
+ "type": "consumable",
+ "subtype": "food",
+ "weight": 1,
+ "value": 15,
+ "effect": {
+ "type": "restore_health",
+ "magnitude": 20
+ },
+ "description": "美味的奶酪"
+ },
+ "iron_ore": {
+ "id": "iron_ore",
+ "name": "铁矿石",
+ "type": "material",
+ "subtype": "ore",
+ "weight": 5,
+ "value": 10,
+ "description": "未加工的铁矿石"
+ },
+ "iron_ingot": {
+ "id": "iron_ingot",
+ "name": "铁锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 4,
+ "value": 15,
+ "description": "精炼的铁锭"
+ },
+ "steel_ingot": {
+ "id": "steel_ingot",
+ "name": "钢锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 5,
+ "value": 30,
+ "description": "精炼的钢锭"
+ },
+ "leather": {
+ "id": "leather",
+ "name": "皮革",
+ "type": "material",
+ "subtype": "leather",
+ "weight": 2,
+ "value": 10,
+ "description": "动物皮革"
+ },
+ "leather_strips": {
+ "id": "leather_strips",
+ "name": "皮带",
+ "type": "material",
+ "subtype": "leather",
+ "weight": 0.5,
+ "value": 5,
+ "description": "用于锻造的皮带"
+ },
+ "corundum_ingot": {
+ "id": "corundum_ingot",
+ "name": "珊瑚锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 6,
+ "value": 40,
+ "description": "精炼的珊瑚锭"
+ },
+ "orichalcum_ingot": {
+ "id": "orichalcum_ingot",
+ "name": "山铜锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 6,
+ "value": 50,
+ "description": "精炼的山铜锭"
+ },
+ "moonstone_ingot": {
+ "id": "moonstone_ingot",
+ "name": "月石锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 6,
+ "value": 60,
+ "description": "精炼的月石锭"
+ },
+ "ebony_ingot": {
+ "id": "ebony_ingot",
+ "name": "乌木锭",
+ "type": "material",
+ "subtype": "ingot",
+ "weight": 7,
+ "value": 100,
+ "description": "珍贵的乌木锭"
+ },
+ "daedric_heart": {
+ "id": "daedric_heart",
+ "name": "魔族心脏",
+ "type": "material",
+ "subtype": "special",
+ "weight": 3,
+ "value": 500,
+ "description": "来自湮灭的魔族心脏"
+ },
+ "dragon_bone": {
+ "id": "dragon_bone",
+ "name": "龙骨",
+ "type": "material",
+ "subtype": "special",
+ "weight": 15,
+ "value": 300,
+ "description": "坚固的龙骨"
+ },
+ "dragon_scale": {
+ "id": "dragon_scale",
+ "name": "龙鳞",
+ "type": "material",
+ "subtype": "special",
+ "weight": 10,
+ "value": 250,
+ "description": "坚韧的龙鳞"
+ },
+ "lockpick": {
+ "id": "lockpick",
+ "name": "开锁器",
+ "type": "misc",
+ "subtype": "tool",
+ "weight": 0.1,
+ "value": 5,
+ "description": "用于开锁的工具"
+ },
+ "gold_coin": {
+ "id": "gold_coin",
+ "name": "金币",
+ "type": "currency",
+ "weight": 0,
+ "value": 1,
+ "description": "塞普汀金币"
+ },
+ "soul_gem": {
+ "id": "soul_gem",
+ "name": "灵魂石",
+ "type": "material",
+ "subtype": "gem",
+ "weight": 0.5,
+ "value": 40,
+ "description": "可以容纳灵魂的空灵魂石"
+ },
+ "filled_soul_gem": {
+ "id": "filled_soul_gem",
+ "name": "填充灵魂石",
+ "type": "material",
+ "subtype": "gem",
+ "weight": 0.5,
+ "value": 80,
+ "description": "装满灵魂的灵魂石"
+ },
+ "iron_arrow": {
+ "id": "iron_arrow",
+ "name": "铁箭",
+ "type": "ammo",
+ "subtype": "arrow",
+ "weight": 0.1,
+ "value": 1,
+ "description": "普通的铁箭"
+ },
+ "bone": {
+ "id": "bone",
+ "name": "骨头",
+ "type": "misc",
+ "subtype": "misc",
+ "weight": 1,
+ "value": 2,
+ "description": "一副骨架"
+ },
+ "wolf_pelt": {
+ "id": "wolf_pelt",
+ "name": "狼皮",
+ "type": "material",
+ "subtype": "leather",
+ "weight": 2,
+ "value": 8,
+ "description": "柔软的狼皮"
+ },
+ "bear_pelt": {
+ "id": "bear_pelt",
+ "name": "熊皮",
+ "type": "material",
+ "subtype": "leather",
+ "weight": 4,
+ "value": 15,
+ "description": "厚实的熊皮"
+ },
+ "bear_claw": {
+ "id": "bear_claw",
+ "name": "熊爪",
+ "type": "misc",
+ "subtype": "misc",
+ "weight": 0.5,
+ "value": 5,
+ "description": "锋利的熊爪"
+ },
+ "spider_silk": {
+ "id": "spider_silk",
+ "name": "蜘蛛丝",
+ "type": "material",
+ "subtype": "leather",
+ "weight": 0.1,
+ "value": 10,
+ "description": "坚韧的蜘蛛丝"
+ },
+ "poison_sac": {
+ "id": "poison_sac",
+ "name": "毒囊",
+ "type": "misc",
+ "subtype": "misc",
+ "weight": 0.3,
+ "value": 8,
+ "description": "含有剧毒的毒囊"
+ }
+ }
+}
diff --git a/src/data/items/soul-gems.json b/src/data/items/soul-gems.json
new file mode 100644
index 0000000..91fbb6f
--- /dev/null
+++ b/src/data/items/soul-gems.json
@@ -0,0 +1,10 @@
+{
+ "soulGems": {
+ "petty_soul_gem": { "id": "petty_soul_gem", "name": "微型灵魂石", "size": "petty", "capacity": 25, "filled": false },
+ "lesser_soul_gem": { "id": "lesser_soul_gem", "name": "次级灵魂石", "size": "lesser", "capacity": 50, "filled": false },
+ "common_soul_gem": { "id": "common_soul_gem", "name": "普通灵魂石", "size": "common", "capacity": 100, "filled": false },
+ "greater_soul_gem": { "id": "greater_soul_gem", "name": "高级灵魂石", "size": "greater", "capacity": 200, "filled": false },
+ "grand_soul_gem": { "id": "grand_soul_gem", "name": "大型灵魂石", "size": "grand", "capacity": 400, "filled": false },
+ "black_soul_gem": { "id": "black_soul_gem", "name": "黑色灵魂石", "size": "black", "capacity": 400, "filled": false }
+ }
+}
diff --git a/src/data/items/weapons.json b/src/data/items/weapons.json
new file mode 100644
index 0000000..c862217
--- /dev/null
+++ b/src/data/items/weapons.json
@@ -0,0 +1,158 @@
+{
+ "weapons": {
+ "fists": {
+ "id": "fists",
+ "name": "拳头",
+ "type": "unarmed",
+ "damage": 4,
+ "speed": 1.4,
+ "weight": 0,
+ "value": 0,
+ "description": "空手战斗"
+ },
+ "iron_sword": {
+ "id": "iron_sword",
+ "name": "铁剑",
+ "type": "one_handed_sword",
+ "material": "iron",
+ "tier": 1,
+ "damage": 10,
+ "speed": 1.0,
+ "weight": 10,
+ "value": 50,
+ "description": "普通的铁制剑"
+ },
+ "iron_waraxe": {
+ "id": "iron_waraxe",
+ "name": "铁战斧",
+ "type": "one_handed_axe",
+ "material": "iron",
+ "tier": 1,
+ "damage": 9,
+ "speed": 1.1,
+ "weight": 12,
+ "value": 45,
+ "description": "铁制战斧"
+ },
+ "iron_mace": {
+ "id": "iron_mace",
+ "name": "铁钉锤",
+ "type": "one_handed_mace",
+ "material": "iron",
+ "tier": 1,
+ "damage": 11,
+ "speed": 0.9,
+ "weight": 14,
+ "value": 55,
+ "description": "沉重的铁钉锤"
+ },
+ "iron_dagger": {
+ "id": "iron_dagger",
+ "name": "铁匕首",
+ "type": "dagger",
+ "material": "iron",
+ "tier": 1,
+ "damage": 6,
+ "speed": 1.6,
+ "weight": 3,
+ "value": 25,
+ "description": "小巧的铁匕首"
+ },
+ "steel_sword": {
+ "id": "steel_sword",
+ "name": "钢剑",
+ "type": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 14,
+ "speed": 1.0,
+ "weight": 11,
+ "value": 120,
+ "description": "精钢打造的长剑"
+ },
+ "steel_waraxe": {
+ "id": "steel_waraxe",
+ "name": "钢战斧",
+ "type": "one_handed_axe",
+ "material": "steel",
+ "tier": 2,
+ "damage": 13,
+ "speed": 1.1,
+ "weight": 13,
+ "value": 110,
+ "description": "钢制战斧"
+ },
+ "steel_mace": {
+ "id": "steel_mace",
+ "name": "钢钉锤",
+ "type": "one_handed_mace",
+ "material": "steel",
+ "tier": 2,
+ "damage": 15,
+ "speed": 0.9,
+ "weight": 15,
+ "value": 130,
+ "description": "坚固的钢钉锤"
+ },
+ "iron_greatsword": {
+ "id": "iron_greatsword",
+ "name": "铁制大剑",
+ "type": "two_handed_sword",
+ "material": "iron",
+ "tier": 1,
+ "damage": 18,
+ "speed": 0.7,
+ "weight": 20,
+ "value": 100,
+ "description": "沉重的铁制大剑"
+ },
+ "steel_greatsword": {
+ "id": "steel_greatsword",
+ "name": "钢制大剑",
+ "type": "two_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 24,
+ "speed": 0.7,
+ "weight": 22,
+ "value": 250,
+ "description": "精钢大剑"
+ },
+ "iron_warhammer": {
+ "id": "iron_warhammer",
+ "name": "铁战锤",
+ "type": "two_handed_mace",
+ "material": "iron",
+ "tier": 1,
+ "damage": 20,
+ "speed": 0.6,
+ "weight": 25,
+ "value": 120,
+ "description": "巨大的铁战锤"
+ },
+ "hunting_bow": {
+ "id": "hunting_bow",
+ "name": "猎弓",
+ "type": "bow",
+ "material": "wood",
+ "tier": 1,
+ "damage": 8,
+ "speed": 0.9,
+ "weight": 6,
+ "value": 50,
+ "description": "简单的猎弓"
+ },
+ "long_bow": {
+ "id": "long_bow",
+ "name": "长弓",
+ "type": "bow",
+ "material": "wood",
+ "tier": 2,
+ "damage": 12,
+ "speed": 0.8,
+ "weight": 8,
+ "value": 120,
+ "description": "射程更远的长弓"
+ }
+ }
+}
diff --git a/src/data/mods/example-quest-mod.json b/src/data/mods/example-quest-mod.json
new file mode 100644
index 0000000..1617261
--- /dev/null
+++ b/src/data/mods/example-quest-mod.json
@@ -0,0 +1,106 @@
+{
+ "manifest": {
+ "id": "example-quest",
+ "name": "示例任务包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加一个新任务线到游戏中",
+ "priority": 200,
+ "dependencies": []
+ },
+ "data": {
+ "quests": {
+ "lost_sword": {
+ "id": "lost_sword",
+ "name": "失落的圣剑",
+ "type": "side",
+ "description": "一位铁匠的祖传圣剑在附近的洞穴中丢失了",
+ "objectives": [
+ { "id": "talk_to_blacksmith", "description": "与铁匠对话", "type": "talk", "target": "blacksmith_01" },
+ { "id": "find_cave", "description": "找到洞穴", "type": "explore", "target": "cave_01" },
+ { "id": "kill_bandits", "description": "消灭洞穴中的强盗", "type": "kill", "target": "bandit", "count": 5 },
+ { "id": "find_sword", "description": "找到失落的圣剑", "type": "collect", "target": "legendary_sword", "count": 1 },
+ { "id": "return_sword", "description": "将圣剑归还给铁匠", "type": "talk", "target": "blacksmith_01" }
+ ],
+ "rewards": {
+ "gold": 500,
+ "items": ["legendary_sword"],
+ "xp": 200,
+ "faction": "companions",
+ "factionRep": 10
+ },
+ "prerequisites": [],
+ "levelRequired": 5
+ },
+ "mysterious_artifact": {
+ "id": "mysterious_artifact",
+ "name": "神秘文物",
+ "type": "daedric",
+ "description": "一件古老的文物在废弃的神殿中被发现",
+ "objectives": [
+ { "id": "investigate_rumors", "description": "调查传言", "type": "talk", "target": "innkeeper_01" },
+ { "id": "find_temple", "description": "找到废弃神殿", "type": "explore", "target": "temple_01" },
+ { "id": "solve_puzzle", "description": "解开神殿谜题", "type": "interact", "target": "puzzle_01" },
+ { "id": "defeat_guardian", "description": "击败守护者", "type": "kill", "target": "temple_guardian", "count": 1 },
+ { "id": "take_artifact", "description": "取走文物", "type": "collect", "target": "mysterious_artifact", "count": 1 },
+ { "id": "choose_fate", "description": "决定文物的命运", "type": "choice", "options": ["keep", "destroy", "give_to_mage"] }
+ ],
+ "rewards": {
+ "gold": 1000,
+ "items": ["mysterious_artifact"],
+ "xp": 500
+ },
+ "prerequisites": ["lost_sword"],
+ "levelRequired": 15
+ }
+ },
+ "items": {
+ "legendary_sword": {
+ "id": "legendary_sword",
+ "name": "铁匠的祖传圣剑",
+ "type": "weapon",
+ "subtype": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 15,
+ "speed": 1.0,
+ "weight": 10,
+ "value": 200,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "smite",
+ "magnitude": 5,
+ "duration": 0
+ },
+ "keywords": ["metal", "slashing", "unique"],
+ "description": "铁匠家族世代相传的宝剑",
+ "questItem": true
+ },
+ "mysterious_artifact": {
+ "id": "mysterious_artifact",
+ "name": "神秘文物",
+ "type": "misc",
+ "subtype": "artifact",
+ "weight": 5,
+ "value": 0,
+ "keywords": ["unique", "quest", "daedric"],
+ "description": "一件来自远古的神秘物品,散发着不祥的气息",
+ "questItem": true
+ }
+ },
+ "npcs": {
+ "blacksmith_01": {
+ "id": "blacksmith_01",
+ "name": "铁匠哈蒙",
+ "race": "nord",
+ "level": 10,
+ "faction": "blacksmiths",
+ "dialogue": {
+ "greeting": "欢迎来到我的铁匠铺。你需要什么?",
+ "quest_start": "你来得正好!我的祖传圣剑在附近的洞穴被强盗抢走了。如果你能帮我找回来,我一定会重重报答你!",
+ "quest_complete": "太感谢了!这把剑对我们家族意义重大。这是你的报酬,还有,请随时回来打造装备。"
+ }
+ }
+ }
+ }
+}
diff --git a/src/data/mods/example-weapons-mod.json b/src/data/mods/example-weapons-mod.json
new file mode 100644
index 0000000..d5fde4f
--- /dev/null
+++ b/src/data/mods/example-weapons-mod.json
@@ -0,0 +1,115 @@
+{
+ "manifest": {
+ "id": "example-weapons",
+ "name": "示例武器包",
+ "version": "1.0.0",
+ "author": "OES-WEB",
+ "description": "添加 5 把新武器到游戏中",
+ "priority": 100,
+ "dependencies": []
+ },
+ "data": {
+ "items": {
+ "flame_sword": {
+ "id": "flame_sword",
+ "name": "烈焰之剑",
+ "type": "weapon",
+ "subtype": "one_handed_sword",
+ "material": "steel",
+ "tier": 2,
+ "damage": 12,
+ "speed": 1.0,
+ "weight": 12,
+ "value": 150,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "fire_damage",
+ "magnitude": 10,
+ "duration": 0
+ },
+ "keywords": ["metal", "slashing", "fire"],
+ "description": "一把燃烧着永恒火焰的魔法剑"
+ },
+ "frost_staff": {
+ "id": "frost_staff",
+ "name": "冰霜法杖",
+ "type": "weapon",
+ "subtype": "staff",
+ "material": "crystal",
+ "tier": 3,
+ "damage": 8,
+ "speed": 0.8,
+ "weight": 8,
+ "value": 300,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "frost_damage",
+ "magnitude": 15,
+ "duration": 0
+ },
+ "keywords": ["magic", "frost", "staff"],
+ "description": "散发寒气的水晶法杖"
+ },
+ "shadow_dagger": {
+ "id": "shadow_dagger",
+ "name": "暗影匕首",
+ "type": "weapon",
+ "subtype": "dagger",
+ "material": "ebony",
+ "tier": 5,
+ "damage": 15,
+ "speed": 1.5,
+ "weight": 3,
+ "value": 500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "silent_damage",
+ "magnitude": 20,
+ "duration": 0
+ },
+ "keywords": ["metal", "piercing", "shadow"],
+ "description": "来自暗影界的匕首,攻击无声"
+ },
+ "thunder_hammer": {
+ "id": "thunder_hammer",
+ "name": "雷霆战锤",
+ "type": "weapon",
+ "subtype": "warhammer",
+ "material": "daedric",
+ "tier": 6,
+ "damage": 30,
+ "speed": 0.6,
+ "weight": 25,
+ "value": 1000,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "shock_damage",
+ "magnitude": 25,
+ "duration": 0
+ },
+ "keywords": ["metal", "blunt", "lightning"],
+ "description": "蕴含雷电之力的魔族战锤"
+ },
+ "dragon_bow": {
+ "id": "dragon_bow",
+ "name": "龙骨弓",
+ "type": "weapon",
+ "subtype": "bow",
+ "material": "dragon",
+ "tier": 7,
+ "damage": 22,
+ "speed": 0.7,
+ "weight": 14,
+ "value": 1500,
+ "enchantmentSlots": 1,
+ "enchantment": {
+ "type": "absorb_health",
+ "magnitude": 5,
+ "duration": 0
+ },
+ "keywords": ["ranged", "dragon", "piercing"],
+ "description": "用龙骨打造的强大弓"
+ }
+ }
+ }
+}
diff --git a/src/data/quests/quests.json b/src/data/quests/quests.json
new file mode 100644
index 0000000..6020490
--- /dev/null
+++ b/src/data/quests/quests.json
@@ -0,0 +1,183 @@
+{
+ "quests": {
+ "main_01_unbound": {
+ "id": "main_01_unbound",
+ "name": "无束缚",
+ "type": "main",
+ "level": 1,
+ "description": "你被帝国军队俘虏,押送到洛克尔的处刑台。当处刑即将开始时,一只龙突然出现...",
+ "objectives": [
+ { "id": "escape", "description": "逃离处刑现场", "type": "reach", "target": "riverwood", "completed": false },
+ { "id": "talk_elder", "description": "与溪木镇长交谈", "type": "talk", "target": "riverwood_elder", "completed": false },
+ { "id": "find_weapon", "description": "在附近寻找武器", "type": "collect", "target": "iron_sword", "quantity": 1, "completed": false }
+ ],
+ "rewards": {
+ "gold": 100,
+ "xp": 200,
+ "items": [{ "id": "iron_sword", "quantity": 1 }]
+ }
+ },
+ "main_02_bleakfalls": {
+ "id": "main_02_bleakfalls",
+ "name": "荒瀑古坟",
+ "type": "main",
+ "level": 3,
+ "description": "溪木镇的铁匠告诉你,他的黄金龙爪被偷了,盗贼可能藏在荒瀑古坟中。",
+ "prerequisites": ["main_01_unbound"],
+ "objectives": [
+ { "id": "enter_barrow", "description": "进入荒瀑古坟", "type": "reach", "target": "bleakfalls_barrow", "completed": false },
+ { "id": "kill_skeletons", "description": "消灭骷髅守卫", "type": "kill", "target": "skeleton", "quantity": 3, "current": 0, "completed": false },
+ { "id": "find_claw", "description": "找到黄金龙爪", "type": "collect", "target": "golden_claw", "quantity": 1, "completed": false },
+ { "id": "return_claw", "description": "将龙爪归还给铁匠", "type": "talk", "target": "riverwood_blacksmith", "completed": false }
+ ],
+ "rewards": {
+ "gold": 300,
+ "xp": 400,
+ "items": [{ "id": "steel_sword", "quantity": 1 }],
+ "faction": { "companions": 10 }
+ }
+ },
+ "main_03_dragonsreach": {
+ "id": "main_03_dragonsreach",
+ "name": "龙啸府",
+ "type": "main",
+ "level": 5,
+ "description": "白漫城的领主需要你帮助调查龙的出现。前往龙啸府与领主交谈。",
+ "prerequisites": ["main_02_bleakfalls"],
+ "objectives": [
+ { "id": "talk_jarls", "description": "与白漫城领主交谈", "type": "talk", "target": "whiterun_jarls", "completed": false },
+ { "id": "kill_dragons", "description": "消灭出现的龙", "type": "kill", "target": "dragon", "quantity": 1, "current": 0, "completed": false },
+ { "id": "report_back", "description": "向领主报告", "type": "talk", "target": "whiterun_jarls", "completed": false }
+ ],
+ "rewards": {
+ "gold": 500,
+ "xp": 600,
+ "items": [{ "id": "steel_chestplate", "quantity": 1 }],
+ "faction": { "whiterun": 20 }
+ }
+ },
+ "companion_01_proving": {
+ "id": "companion_01_proving",
+ "name": "证明自己",
+ "type": "guild",
+ "guild": "companions",
+ "level": 3,
+ "description": "战士公会的成员需要你证明自己的实力。前往暗光洞穴消灭蜘蛛。",
+ "objectives": [
+ { "id": "enter_cave", "description": "进入暗光洞穴", "type": "reach", "target": "darklight_cave", "completed": false },
+ { "id": "kill_spiders", "description": "消灭蜘蛛", "type": "kill", "target": "spider", "quantity": 3, "current": 0, "completed": false },
+ { "id": "return_report", "description": "向战士公会报告", "type": "talk", "target": "companion_leader", "completed": false }
+ ],
+ "rewards": {
+ "gold": 200,
+ "xp": 300,
+ "items": [{ "id": "steel_sword", "quantity": 1 }],
+ "faction": { "companions": 15 }
+ }
+ },
+ "companion_02_greymane": {
+ "id": "companion_02_greymane",
+ "name": "灰鬃之怒",
+ "type": "guild",
+ "guild": "companions",
+ "level": 5,
+ "description": "灰鬃家族与风暴斗篷之间有冲突,你需要帮助解决。",
+ "prerequisites": ["companion_01_proving"],
+ "objectives": [
+ { "id": "talk_greymane", "description": "与灰鬃族长交谈", "type": "talk", "target": "greymane_leader", "completed": false },
+ { "id": "investigate", "description": "调查冲突原因", "type": "reach", "target": "whiterun", "completed": false },
+ { "id": "resolve", "description": "解决冲突", "type": "talk", "target": "greymane_leader", "completed": false }
+ ],
+ "rewards": {
+ "gold": 400,
+ "xp": 500,
+ "items": [{ "id": "steel_chestplate", "quantity": 1 }],
+ "faction": { "companions": 20, "whiterun": 10 }
+ }
+ },
+ "thief_01_pickpocket": {
+ "id": "thief_01_pickpocket",
+ "name": "小试牛刀",
+ "type": "guild",
+ "guild": "thieves_guild",
+ "level": 2,
+ "description": "盗贼公会需要你证明自己的偷窃技巧。从溪木镇的商人那里偷取一封信。",
+ "objectives": [
+ { "id": "steal_letter", "description": "从溪木商人偷取信件", "type": "steal", "target": "stolen_letter", "quantity": 1, "completed": false },
+ { "id": "deliver_letter", "description": "将信件交给盗贼公会", "type": "talk", "target": "thief_leader", "completed": false }
+ ],
+ "rewards": {
+ "gold": 150,
+ "xp": 200,
+ "items": [{ "id": "lockpick", "quantity": 10 }],
+ "faction": { "thieves_guild": 10 }
+ }
+ },
+ "daedric_01_mehrunes": {
+ "id": "daedric_01_mehrunes",
+ "name": "梅法拉的剃刀",
+ "type": "daedric",
+ "level": 10,
+ "description": "你在古代遗迹中发现了一把神秘的匕首,据说与魔神梅法拉有关...",
+ "objectives": [
+ { "id": "enter_ruins", "description": "进入古代遗迹", "type": "reach", "target": "ancient_ruins", "completed": false },
+ { "id": "find_dagger", "description": "找到梅法拉的剃刀", "type": "collect", "target": "azorias_dagger", "quantity": 1, "completed": false },
+ { "id": "defeat_guardian", "description": "击败守护者", "type": "kill", "target": "draugr_wight", "quantity": 1, "current": 0, "completed": false },
+ { "id": "choose", "description": "做出选择:保留或摧毁", "type": "interact", "target": "azorias_dagger", "completed": false }
+ ],
+ "rewards": {
+ "gold": 800,
+ "xp": 1000,
+ "items": [{ "id": "azorias_dagger", "quantity": 1 }]
+ }
+ },
+ "misc_herb_gathering": {
+ "id": "misc_herb_gathering",
+ "name": "采药",
+ "type": "misc",
+ "level": 1,
+ "description": "溪木镇的炼金师需要你收集一些草药。",
+ "objectives": [
+ { "id": "collect_flowers", "description": "收集蓝色山花", "type": "collect", "target": "blue_mountain_flower", "quantity": 5, "current": 0, "completed": false },
+ { "id": "deliver_herbs", "description": "将草药交给炼金师", "type": "talk", "target": "riverwood_alchemist", "completed": false }
+ ],
+ "rewards": {
+ "gold": 50,
+ "xp": 100,
+ "items": [{ "id": "health_potion", "quantity": 3 }]
+ }
+ },
+ "misc_wolf_pelts": {
+ "id": "misc_wolf_pelts",
+ "name": "狼皮收购",
+ "type": "misc",
+ "level": 1,
+ "description": "商人需要狼皮来做衣服。",
+ "objectives": [
+ { "id": "kill_wolves", "description": "猎杀狼", "type": "kill", "target": "wolf", "quantity": 3, "current": 0, "completed": false },
+ { "id": "collect_pelts", "description": "收集狼皮", "type": "collect", "target": "wolf_pelt", "quantity": 3, "current": 0, "completed": false },
+ { "id": "sell_pelts", "description": "将狼皮卖给商人", "type": "talk", "target": "merchant_01", "completed": false }
+ ],
+ "rewards": {
+ "gold": 100,
+ "xp": 150
+ }
+ },
+ "misc_bear_problem": {
+ "id": "misc_bear_problem",
+ "name": "熊患",
+ "type": "misc",
+ "level": 5,
+ "description": "雪漫城外的农场被熊骚扰,需要有人解决这个问题。",
+ "objectives": [
+ { "id": "kill_bears", "description": "消灭骚扰农场的熊", "type": "kill", "target": "bear", "quantity": 2, "current": 0, "completed": false },
+ { "id": "report_back", "description": "向农场主报告", "type": "talk", "target": "whiterun_farmer", "completed": false }
+ ],
+ "rewards": {
+ "gold": 200,
+ "xp": 250,
+ "items": [{ "id": "bear_pelt", "quantity": 2 }]
+ }
+ }
+ }
+}
diff --git a/src/data/races/races.json b/src/data/races/races.json
new file mode 100644
index 0000000..40df39b
--- /dev/null
+++ b/src/data/races/races.json
@@ -0,0 +1,95 @@
+{
+ "races": [
+ {
+ "id": "nord",
+ "name": "Nord (诺德人)",
+ "description": "天际省的原住民,强壮的战士种族",
+ "bonuses": { "twoHanded": 10, "heavyArmor": 5, "block": 5, "smithing": 5, "speech": 5 },
+ "baseStats": { "health": 120, "magicka": 40, "stamina": 100 },
+ "power": { "id": "battle_cry", "name": "战吼", "description": "恐惧附近的敌人", "cooldown": 60 },
+ "passive": { "id": "frost_resistance", "name": "冰霜抗性", "description": "50% 冰霜抗性", "value": 0.5, "type": "frost_resistance" }
+ },
+ {
+ "id": "dunmer",
+ "name": "Dunmer (暗精灵)",
+ "description": "来自晨风的神秘精灵,擅长毁灭魔法",
+ "bonuses": { "destruction": 10, "lightArmor": 5, "alteration": 5, "illusion": 5, "alchemy": 5 },
+ "baseStats": { "health": 80, "magicka": 80, "stamina": 80 },
+ "power": { "id": "ancestors_wrath", "name": "祖先之怒", "description": "火焰斗篷伤害附近敌人", "cooldown": 60 },
+ "passive": { "id": "fire_frost_resistance", "name": "火焰冰霜抗性", "description": "50% 火焰和冰霜抗性", "value": 0.5, "type": "elemental_resistance" }
+ },
+ {
+ "id": "altmer",
+ "name": "Altmer (高精灵)",
+ "description": "来自夏暮岛的高等精灵,魔法天赋极高",
+ "bonuses": { "enchanting": 10, "destruction": 5, "conjuration": 5, "illusion": 5, "alteration": 5 },
+ "baseStats": { "health": 70, "magicka": 100, "stamina": 70 },
+ "power": { "id": "highborn", "name": "高等精灵血脉", "description": "60秒内+50魔力", "cooldown": 60 },
+ "passive": { "id": "extra_magicka", "name": "额外魔力", "description": "+50 最大魔力", "value": 50, "type": "max_magicka" }
+ },
+ {
+ "id": "argonian",
+ "name": "Argonian (亚龙人)",
+ "description": "来自黑沼泽的爬行种族,擅长潜行和开锁",
+ "bonuses": { "lockpicking": 10, "sneak": 5, "lightArmor": 5, "restoration": 5, "alteration": 5 },
+ "baseStats": { "health": 90, "magicka": 60, "stamina": 90 },
+ "power": { "id": "histskin", "name": "先祖之皮", "description": "60秒内10倍生命恢复", "cooldown": 60 },
+ "passive": { "id": "waterbreathing", "name": "水下呼吸", "description": "可以在水下呼吸", "value": 1, "type": "waterbreathing" },
+ "passive2": { "id": "disease_resistance", "name": "疾病抗性", "description": "50% 疾病抗性", "value": 0.5, "type": "disease_resistance" }
+ },
+ {
+ "id": "khajiit",
+ "name": "Khajiit (猫人)",
+ "description": "来自艾斯维尔的猫形种族,天生潜行大师",
+ "bonuses": { "sneak": 10, "pickpocket": 5, "lockpicking": 5, "archery": 5, "alchemy": 5 },
+ "baseStats": { "health": 85, "magicka": 60, "stamina": 95 },
+ "power": { "id": "claws", "name": "利爪", "description": "徒手伤害+15", "cooldown": 0 },
+ "passive": { "id": "night_eye", "name": "夜视", "description": "黑暗中看得更清楚", "value": 1, "type": "night_eye" }
+ },
+ {
+ "id": "breton",
+ "name": "Breton (布莱顿人)",
+ "description": "来自高岩的人类精灵混血,魔法抗性极强",
+ "bonuses": { "conjuration": 10, "alteration": 5, "illusion": 5, "restoration": 5, "enchanting": 5 },
+ "baseStats": { "health": 80, "magicka": 90, "stamina": 70 },
+ "power": { "id": "dragonskin", "name": "龙皮", "description": "60秒内50%法术吸收", "cooldown": 60 },
+ "passive": { "id": "magic_resistance", "name": "魔法抗性", "description": "25% 魔法抗性", "value": 0.25, "type": "magic_resistance" }
+ },
+ {
+ "id": "imperial",
+ "name": "Imperial (帝国人)",
+ "description": "来自西罗帝尔的人类,擅长领导和交易",
+ "bonuses": { "restoration": 10, "heavyArmor": 5, "oneHanded": 5, "speech": 5, "smithing": 5 },
+ "baseStats": { "health": 100, "magicka": 60, "stamina": 80 },
+ "power": { "id": "voice_of_emperor", "name": "帝王之声", "description": "平静附近的NPC", "cooldown": 60 },
+ "passive": { "id": "gold_bonus", "name": "金币加成", "description": "宝箱中多10%金币", "value": 0.1, "type": "gold_bonus" }
+ },
+ {
+ "id": "redguard",
+ "name": "Redguard (红卫兵)",
+ "description": "来自锤镇的战士种族,剑术精湛",
+ "bonuses": { "oneHanded": 10, "archery": 5, "block": 5, "heavyArmor": 5, "smithing": 5 },
+ "baseStats": { "health": 100, "magicka": 50, "stamina": 90 },
+ "power": { "id": "adrenaline_rush", "name": "肾上腺素激增", "description": "60秒内10倍耐力恢复", "cooldown": 60 },
+ "passive": { "id": "poison_resistance", "name": "毒素抗性", "description": "50% 毒素抗性", "value": 0.5, "type": "poison_resistance" }
+ },
+ {
+ "id": "orc",
+ "name": "Orc (兽人)",
+ "description": "来自沃古尔的强壮战士,狂暴时威力惊人",
+ "bonuses": { "heavyArmor": 10, "oneHanded": 5, "twoHanded": 5, "block": 5, "smithing": 5 },
+ "baseStats": { "health": 110, "magicka": 40, "stamina": 90 },
+ "power": { "id": "berserker_rage", "name": "狂战士之怒", "description": "60秒内半伤双倍输出", "cooldown": 60 },
+ "passive": null
+ },
+ {
+ "id": "bosmer",
+ "name": "Bosmer (木精灵)",
+ "description": "来自艾尔默森林的精灵,弓箭和潜行大师",
+ "bonuses": { "archery": 10, "sneak": 5, "lightArmor": 5, "alchemy": 5, "pickpocket": 5 },
+ "baseStats": { "health": 80, "magicka": 60, "stamina": 90 },
+ "power": { "id": "command_animal", "name": "命令动物", "description": "控制附近的动物", "cooldown": 60 },
+ "passive": { "id": "poison_disease_resistance", "name": "毒素疾病抗性", "description": "50% 毒素和疾病抗性", "value": 0.5, "type": "poison_disease_resistance" }
+ }
+ ]
+}
diff --git a/src/data/skills/perks.json b/src/data/skills/perks.json
new file mode 100644
index 0000000..26f0116
--- /dev/null
+++ b/src/data/skills/perks.json
@@ -0,0 +1,186 @@
+{
+ "perkTrees": {
+ "oneHanded": {
+ "name": "单手武器",
+ "perks": [
+ { "id": "oneHanded1", "name": "单手武器训练 I", "description": "单手武器伤害+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "oneHanded2", "name": "战斗愤怒", "description": "双持攻击速度+25%", "requires": ["oneHanded1"], "rank": 1, "maxRank": 1 },
+ { "id": "oneHanded3", "name": "虎之撕裂", "description": "单手武器流血伤害", "requires": ["oneHanded1"], "rank": 1, "maxRank": 1 },
+ { "id": "oneHanded4", "name": "猛击", "description": "强力攻击伤害+50%", "requires": ["oneHanded2"], "rank": 1, "maxRank": 1 },
+ { "id": "oneHanded5", "name": "致命一击", "description": "暴击伤害+50%", "requires": ["oneHanded3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "destruction": {
+ "name": "毁灭",
+ "perks": [
+ { "id": "destruction1", "name": "毁灭训练 I", "description": "毁灭法术伤害+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "destration2", "name": "冲击波", "description": "双持施法击退敌人", "requires": ["destruction1"], "rank": 1, "maxRank": 1 },
+ { "id": "destruction3", "name": "火焰大师", "description": "火焰伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1 },
+ { "id": "destruction4", "name": "冰霜大师", "description": "冰霜伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1 },
+ { "id": "destruction5", "name": "闪电大师", "description": "闪电伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "sneak": {
+ "name": "潜行",
+ "perks": [
+ { "id": "sneak1", "name": "潜行训练 I", "description": "被发现几率-20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "sneak2", "name": "静步", "description": "移动噪音-50%", "requires": ["sneak1"], "rank": 1, "maxRank": 1 },
+ { "id": "sneak3", "name": "轻脚", "description": "不触发压力板", "requires": ["sneak1"], "rank": 1, "maxRank": 1 },
+ { "id": "sneak4", "name": "暗影战士", "description": "蹲下短暂隐身", "requires": ["sneak2"], "rank": 1, "maxRank": 1 },
+ { "id": "sneak5", "name": "刺客之刃", "description": "匕首潜行攻击x15", "requires": ["sneak3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "restoration": {
+ "name": "恢复",
+ "perks": [
+ { "id": "restoration1", "name": "恢复训练 I", "description": "恢复法术效果+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "restoration2", "name": "快速施法", "description": "恢复法术施法速度+25%", "requires": ["restoration1"], "rank": 1, "maxRank": 1 },
+ { "id": "restoration3", "name": "反击", "description": "格挡时恢复魔力", "requires": ["restoration1"], "rank": 1, "maxRank": 1 },
+ { "id": "restoration4", "name": "亡灵杀手", "description": "对亡灵伤害+50%", "requires": ["restoration2"], "rank": 1, "maxRank": 1 },
+ { "id": "restoration5", "name": "符文大师", "description": "符文持续时间+50%", "requires": ["restoration3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "smithing": {
+ "name": "锻造",
+ "perks": [
+ { "id": "smithing1", "name": "钢铁锻造", "description": "可以锻造钢铁装备", "requires": [], "rank": 1, "maxRank": 1 },
+ { "id": "smithing2", "name": "矮人锻造", "description": "可以锻造矮人装备", "requires": ["smithing1"], "rank": 1, "maxRank": 1 },
+ { "id": "smithing3", "name": "兽人锻造", "description": "可以锻造兽人装备", "requires": ["smithing2"], "rank": 1, "maxRank": 1 },
+ { "id": "smithing4", "name": "乌木锻造", "description": "可以锻造乌木装备", "requires": ["smithing3"], "rank": 1, "maxRank": 1 },
+ { "id": "smithing5", "name": "魔族锻造", "description": "可以锻造魔族装备", "requires": ["smithing4"], "rank": 1, "maxRank": 1 },
+ { "id": "smithing6", "name": "龙锻造", "description": "可以锻造龙装备", "requires": ["smithing5"], "rank": 1, "maxRank": 1 },
+ { "id": "smithingArcane", "name": "奥术锻造", "description": "可以强化附魔装备", "requires": ["smithing1"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "twoHanded": {
+ "name": "双手武器",
+ "perks": [
+ { "id": "twoHanded1", "name": "双手武器训练 I", "description": "双手武器伤害+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "twoHanded2", "name": "横扫千军", "description": "双手武器攻击范围+25%", "requires": ["twoHanded1"], "rank": 1, "maxRank": 1 },
+ { "id": "twoHanded3", "name": "破甲重击", "description": "无视30%护甲", "requires": ["twoHanded1"], "rank": 1, "maxRank": 1 },
+ { "id": "twoHanded4", "name": "震地猛击", "description": "强力攻击击倒敌人", "requires": ["twoHanded2"], "rank": 1, "maxRank": 1 },
+ { "id": "twoHanded5", "name": "处决者", "description": "低生命敌人即死", "requires": ["twoHanded3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "archery": {
+ "name": "弓术",
+ "perks": [
+ { "id": "archery1", "name": "弓术训练 I", "description": "弓箭伤害+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "archery2", "name": "精准射击", "description": "弓箭暴击率+15%", "requires": ["archery1"], "rank": 1, "maxRank": 1 },
+ { "id": "archery3", "name": "快速拉弦", "description": "射速+25%", "requires": ["archery1"], "rank": 1, "maxRank": 1 },
+ { "id": "archery4", "name": "鹰眼", "description": "瞄准时减速时间延长", "requires": ["archery2"], "rank": 1, "maxRank": 1 },
+ { "id": "archery5", "name": "致命射击", "description": "远程暴击伤害x3", "requires": ["archery3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "block": {
+ "name": "格挡",
+ "perks": [
+ { "id": "block1", "name": "格挡训练 I", "description": "格挡减伤+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "block2", "name": "盾牌猛击", "description": "盾牌攻击击退敌人", "requires": ["block1"], "rank": 1, "maxRank": 1 },
+ { "id": "block3", "name": "不动如山", "description": "格挡不消耗耐力", "requires": ["block1"], "rank": 1, "maxRank": 1 },
+ { "id": "block4", "name": "反射投射", "description": "格挡反弹弓箭", "requires": ["block2"], "rank": 1, "maxRank": 1 },
+ { "id": "block5", "name": "完美格挡", "description": "精确时机格挡免伤", "requires": ["block3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "heavyArmor": {
+ "name": "重甲",
+ "perks": [
+ { "id": "heavyArmor1", "name": "重甲训练 I", "description": "重甲防御+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "heavyArmor2", "name": "钢铁之肤", "description": "重甲不减速", "requires": ["heavyArmor1"], "rank": 1, "maxRank": 1 },
+ { "id": "heavyArmor3", "name": "坚韧", "description": "重甲损坏率-50%", "requires": ["heavyArmor1"], "rank": 1, "maxRank": 1 },
+ { "id": "heavyArmor4", "name": "铁壁", "description": "受到攻击时反弹伤害", "requires": ["heavyArmor2"], "rank": 1, "maxRank": 1 },
+ { "id": "heavyArmor5", "name": "不屈", "description": "濒死时短暂无敌", "requires": ["heavyArmor3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "lightArmor": {
+ "name": "轻甲",
+ "perks": [
+ { "id": "lightArmor1", "name": "轻甲训练 I", "description": "轻甲防御+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "lightArmor2", "name": "灵活身法", "description": "轻甲增加移动速度", "requires": ["lightArmor1"], "rank": 1, "maxRank": 1 },
+ { "id": "lightArmor3", "name": "闪避", "description": "10%几率完全闪避攻击", "requires": ["lightArmor1"], "rank": 1, "maxRank": 1 },
+ { "id": "lightArmor4", "name": "疾风步", "description": "被攻击后短暂加速", "requires": ["lightArmor2"], "rank": 1, "maxRank": 1 },
+ { "id": "lightArmor5", "name": "影舞者", "description": "闪避后下一次攻击暴击", "requires": ["lightArmor3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "conjuration": {
+ "name": "召唤",
+ "perks": [
+ { "id": "conjuration1", "name": "召唤训练 I", "description": "召唤生物持续时间+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "conjuration2", "name": "强化召唤", "description": "召唤生物伤害+50%", "requires": ["conjuration1"], "rank": 1, "maxRank": 1 },
+ { "id": "conjuration3", "name": "灵魂捕获", "description": "击杀自动填充灵魂石", "requires": ["conjuration1"], "rank": 1, "maxRank": 1 },
+ { "id": "conjuration4", "name": "亡灵大师", "description": "可以召唤更强亡灵", "requires": ["conjuration2"], "rank": 1, "maxRank": 1 },
+ { "id": "conjuration5", "name": "湮灭之门", "description": "同时召唤两个生物", "requires": ["conjuration3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "illusion": {
+ "name": "幻术",
+ "perks": [
+ { "id": "illusion1", "name": "幻术训练 I", "description": "幻术法术范围+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "illusion2", "name": "无声施法", "description": "施法不被发现", "requires": ["illusion1"], "rank": 1, "maxRank": 1 },
+ { "id": "illusion3", "name": "恐惧之触", "description": "恐惧法术持续时间+50%", "requires": ["illusion1"], "rank": 1, "maxRank": 1 },
+ { "id": "illusion4", "name": "魅惑大师", "description": "可以魅惑更强敌人", "requires": ["illusion2"], "rank": 1, "maxRank": 1 },
+ { "id": "illusion5", "name": "镜像", "description": "创建分身吸引敌人", "requires": ["illusion3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "alteration": {
+ "name": "变化",
+ "perks": [
+ { "id": "alteration1", "name": "变化训练 I", "description": "变化法术持续时间+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "alteration2", "name": "石肤术", "description": "护甲术效果+50%", "requires": ["alteration1"], "rank": 1, "maxRank": 1 },
+ { "id": "alteration3", "name": "能量吸收", "description": "被攻击时吸收魔力", "requires": ["alteration1"], "rank": 1, "maxRank": 1 },
+ { "id": "alteration4", "name": "念力大师", "description": "可以推动更重物体", "requires": ["alteration2"], "rank": 1, "maxRank": 1 },
+ { "id": "alteration5", "name": "绝对防御", "description": "短暂完全免疫伤害", "requires": ["alteration3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "enchanting": {
+ "name": "附魔",
+ "perks": [
+ { "id": "enchanting1", "name": "附魔训练 I", "description": "附魔效果+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "enchanting2", "name": "双重附魔", "description": "可以附加两个附魔", "requires": ["enchanting1"], "rank": 1, "maxRank": 1 },
+ { "id": "enchanting3", "name": "灵魂榨取", "description": "击杀获得更大灵魂", "requires": ["enchanting1"], "rank": 1, "maxRank": 1 },
+ { "id": "enchanting4", "name": "附魔大师", "description": "附魔消耗-50%", "requires": ["enchanting2"], "rank": 1, "maxRank": 1 },
+ { "id": "enchanting5", "name": "永恒附魔", "description": "附魔效果+100%", "requires": ["enchanting3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "lockpicking": {
+ "name": "开锁",
+ "perks": [
+ { "id": "lockpicking1", "name": "开锁训练 I", "description": "开锁难度降低一级", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "lockpicking2", "name": "巧手", "description": "开锁器不易断裂", "requires": ["lockpicking1"], "rank": 1, "maxRank": 1 },
+ { "id": "lockpicking3", "name": "敏锐", "description": "可以看到锁芯正确位置", "requires": ["lockpicking1"], "rank": 1, "maxRank": 1 },
+ { "id": "lockpicking4", "name": "大师开锁", "description": "可以开大师级锁", "requires": ["lockpicking2"], "rank": 1, "maxRank": 1 },
+ { "id": "lockpicking5", "name": "万能钥匙", "description": "自动开简单锁", "requires": ["lockpicking3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "pickpocket": {
+ "name": "偷窃",
+ "perks": [
+ { "id": "pickpocket1", "name": "偷窃训练 I", "description": "偷窃成功率+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "pickpocket2", "name": "巧手", "description": "可以偷取装备", "requires": ["pickpocket1"], "rank": 1, "maxRank": 1 },
+ { "id": "pickpocket3", "name": "轻触", "description": "偷窃不被发现", "requires": ["pickpocket1"], "rank": 1, "maxRank": 1 },
+ { "id": "pickpocket4", "name": "盗窃大师", "description": "可以偷取贵重物品", "requires": ["pickpocket2"], "rank": 1, "maxRank": 1 },
+ { "id": "pickpocket5", "name": "偷天换日", "description": "可以偷取已装备物品", "requires": ["pickpocket3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "speech": {
+ "name": "口才",
+ "perks": [
+ { "id": "speech1", "name": "口才训练 I", "description": "买卖价格+10%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "speech2", "name": "说服", "description": "解锁说服选项", "requires": ["speech1"], "rank": 1, "maxRank": 1 },
+ { "id": "speech3", "name": "恐吓", "description": "解锁恐吓选项", "requires": ["speech1"], "rank": 1, "maxRank": 1 },
+ { "id": "speech4", "name": "魅力大师", "description": "说服成功率+50%", "requires": ["speech2"], "rank": 1, "maxRank": 1 },
+ { "id": "speech5", "name": "金舌", "description": "所有买卖价格+50%", "requires": ["speech3"], "rank": 1, "maxRank": 1 }
+ ]
+ },
+ "alchemy": {
+ "name": "炼金",
+ "perks": [
+ { "id": "alchemy1", "name": "炼金训练 I", "description": "药水效果+20%", "requires": [], "rank": 1, "maxRank": 5 },
+ { "id": "alchemy2", "name": "炼金大师", "description": "可以发现更多效果", "requires": ["alchemy1"], "rank": 1, "maxRank": 1 },
+ { "id": "alchemy3", "name": "毒药调配", "description": "毒药伤害+50%", "requires": ["alchemy1"], "rank": 1, "maxRank": 1 },
+ { "id": "alchemy4", "name": "倍增效果", "description": "有几率制作双倍药水", "requires": ["alchemy2"], "rank": 1, "maxRank": 1 },
+ { "id": "alchemy5", "name": "炼金宗师", "description": "所有效果+100%", "requires": ["alchemy3"], "rank": 1, "maxRank": 1 }
+ ]
+ }
+ }
+}
diff --git a/src/data/skills/skills.json b/src/data/skills/skills.json
new file mode 100644
index 0000000..c44cdda
--- /dev/null
+++ b/src/data/skills/skills.json
@@ -0,0 +1,28 @@
+{
+ "skills": {
+ "combat": [
+ { "id": "oneHanded", "name": "单手武器", "description": "剑、锤、斧、匕首", "category": "combat" },
+ { "id": "twoHanded", "name": "双手武器", "description": "大剑、战锤、战斧", "category": "combat" },
+ { "id": "archery", "name": "弓箭", "description": "弓和弩", "category": "combat" },
+ { "id": "block", "name": "格挡", "description": "盾牌格挡和猛击", "category": "combat" },
+ { "id": "heavyArmor", "name": "重甲", "description": "铁、钢、矮人等重甲", "category": "combat" },
+ { "id": "smithing", "name": "锻造", "description": "制作和强化武器护甲", "category": "combat" }
+ ],
+ "magic": [
+ { "id": "destruction", "name": "毁灭", "description": "火、冰、雷伤害法术", "category": "magic" },
+ { "id": "conjuration", "name": "召唤", "description": "召唤生物和武器", "category": "magic" },
+ { "id": "illusion", "name": "幻术", "description": "恐惧、狂怒、平静、隐身", "category": "magic" },
+ { "id": "restoration", "name": "恢复", "description": "治疗和防护法术", "category": "magic" },
+ { "id": "alteration", "name": "变化", "description": "护甲、麻痹、传送", "category": "magic" },
+ { "id": "enchanting", "name": "附魔", "description": "给装备添加魔法效果", "category": "magic" }
+ ],
+ "stealth": [
+ { "id": "sneak", "name": "潜行", "description": "隐蔽和潜行攻击", "category": "stealth" },
+ { "id": "lightArmor", "name": "轻甲", "description": "皮革、精灵等轻甲", "category": "stealth" },
+ { "id": "lockpicking", "name": "开锁", "description": "打开锁住的门和箱子", "category": "stealth" },
+ { "id": "pickpocket", "name": "扒窃", "description": "从NPC身上偷东西", "category": "stealth" },
+ { "id": "speech", "name": "口才", "description": "交易、说服、恐吓", "category": "stealth" },
+ { "id": "alchemy", "name": "炼金", "description": "制作药水和毒药", "category": "stealth" }
+ ]
+ }
+}
diff --git a/src/data/skills/vampire-perks.json b/src/data/skills/vampire-perks.json
new file mode 100644
index 0000000..8e1c4c7
--- /dev/null
+++ b/src/data/skills/vampire-perks.json
@@ -0,0 +1,57 @@
+{
+ "perkTrees": {
+ "vampire": {
+ "name": "暗夜血脉",
+ "perks": [
+ {
+ "id": "vampire1",
+ "name": "暗夜视觉 I",
+ "description": "夜间视野范围 +20%",
+ "requires": [],
+ "rank": 1,
+ "maxRank": 3
+ },
+ {
+ "id": "vampire_frost",
+ "name": "冰霜亲和",
+ "description": "冰霜抗性 +15",
+ "requires": ["vampire1"],
+ "rank": 1,
+ "maxRank": 2
+ },
+ {
+ "id": "vampire_siphon",
+ "name": "生命虹吸",
+ "description": "近战攻击偷取 10% 生命",
+ "requires": ["vampire1"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "vampire_night_stalker",
+ "name": "暗夜潜行",
+ "description": "夜间潜行伤害 +25%",
+ "requires": ["vampire_frost"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "vampire_blood_magic",
+ "name": "血魔法",
+ "description": "消耗生命代替魔力施法",
+ "requires": ["vampire_frost"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "vampire_elder",
+ "name": "远古血脉",
+ "description": "所有能力 +20%,白天弱点减半",
+ "requires": ["vampire_night_stalker", "vampire_blood_magic"],
+ "rank": 1,
+ "maxRank": 1
+ }
+ ]
+ }
+ }
+}
diff --git a/src/data/skills/werewolf-perks.json b/src/data/skills/werewolf-perks.json
new file mode 100644
index 0000000..3883ae9
--- /dev/null
+++ b/src/data/skills/werewolf-perks.json
@@ -0,0 +1,57 @@
+{
+ "perkTrees": {
+ "werewolf": {
+ "name": "野性之怒",
+ "perks": [
+ {
+ "id": "werewolf1",
+ "name": "野兽之力 I",
+ "description": "爪击伤害 +5",
+ "requires": [],
+ "rank": 1,
+ "maxRank": 3
+ },
+ {
+ "id": "werewolf_thick_hide",
+ "name": "厚皮毛",
+ "description": "护甲 +10",
+ "requires": ["werewolf1"],
+ "rank": 1,
+ "maxRank": 2
+ },
+ {
+ "id": "werewolf_speed",
+ "name": "疾跑",
+ "description": "移动速度 +20%",
+ "requires": ["werewolf1"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "werewolf_howl",
+ "name": "恐惧嚎叫",
+ "description": "击杀后恐惧周围敌人 5 秒",
+ "requires": ["werewolf_thick_hide"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "werewolf_regen",
+ "name": "野性恢复",
+ "description": "脱战后每秒恢复 5% 生命",
+ "requires": ["werewolf_thick_hide"],
+ "rank": 1,
+ "maxRank": 1
+ },
+ {
+ "id": "werewolf_full_moon",
+ "name": "月圆之夜",
+ "description": "夜间变形无时间限制",
+ "requires": ["werewolf_howl", "werewolf_regen"],
+ "rank": 1,
+ "maxRank": 1
+ }
+ ]
+ }
+ }
+}
diff --git a/src/data/spells/shouts.json b/src/data/spells/shouts.json
new file mode 100644
index 0000000..c89248c
--- /dev/null
+++ b/src/data/spells/shouts.json
@@ -0,0 +1,44 @@
+{
+ "shouts": {
+ "unrelenting_force": {
+ "id": "unrelenting_force",
+ "name": "不屈之力",
+ "words": ["Fus", "Ro", "Dah"],
+ "wordCount": 3,
+ "cooldown": 15000,
+ "effects": [{ "type": "push", "magnitude": 500 }, { "type": "damage", "magnitude": 20 }]
+ },
+ "whirlwind_sprint": {
+ "id": "whirlwind_sprint",
+ "name": "旋风冲刺",
+ "words": ["Wuld", "Nah", "Kest"],
+ "wordCount": 3,
+ "cooldown": 10000,
+ "effects": [{ "type": "push", "magnitude": 800 }]
+ },
+ "slow_time": {
+ "id": "slow_time",
+ "name": "时间减速",
+ "words": ["Tiid", "Klo", "Uln"],
+ "wordCount": 3,
+ "cooldown": 30000,
+ "effects": [{ "type": "time", "magnitude": 0.3, "duration": 10000 }]
+ },
+ "marked_for_death": {
+ "id": "marked_for_death",
+ "name": "死亡标记",
+ "words": ["Krii", "Lun", "Aus"],
+ "wordCount": 3,
+ "cooldown": 20000,
+ "effects": [{ "type": "marked", "magnitude": 1.5, "duration": 60000 }]
+ },
+ "become_ethereal": {
+ "id": "become_ethereal",
+ "name": "虚无化",
+ "words": ["Feim", "Zi", "Gron"],
+ "wordCount": 3,
+ "cooldown": 25000,
+ "effects": [{ "type": "time", "magnitude": 0, "duration": 8000 }]
+ }
+ }
+}
diff --git a/src/data/spells/spells.json b/src/data/spells/spells.json
new file mode 100644
index 0000000..0bbe753
--- /dev/null
+++ b/src/data/spells/spells.json
@@ -0,0 +1,225 @@
+{
+ "spells": {
+ "flames": {
+ "id": "flames",
+ "name": "烈焰",
+ "school": "destruction",
+ "type": "target",
+ "magickaCost": 20,
+ "magnitude": 8,
+ "duration": 0,
+ "cooldown": 500,
+ "level": 1,
+ "description": "发射一团火焰,造成火焰伤害",
+ "effects": [{ "type": "damage", "magnitude": 8 }]
+ },
+ "frostbite": {
+ "id": "frostbite",
+ "name": "冰霜咬",
+ "school": "destruction",
+ "type": "target",
+ "magickaCost": 25,
+ "magnitude": 10,
+ "duration": 0,
+ "cooldown": 500,
+ "level": 1,
+ "description": "发射冰霜,造成冰霜伤害并减速",
+ "effects": [{ "type": "damage", "magnitude": 10 }, { "type": "slow", "magnitude": 0.5, "duration": 3000 }]
+ },
+ "sparks": {
+ "id": "sparks",
+ "name": "电火花",
+ "school": "destruction",
+ "type": "target",
+ "magickaCost": 30,
+ "magnitude": 12,
+ "duration": 0,
+ "cooldown": 500,
+ "level": 1,
+ "description": "发射闪电,造成电击伤害",
+ "effects": [{ "type": "damage", "magnitude": 12 }]
+ },
+ "healing": {
+ "id": "healing",
+ "name": "治疗术",
+ "school": "restoration",
+ "type": "self",
+ "magickaCost": 30,
+ "magnitude": 25,
+ "duration": 0,
+ "cooldown": 1000,
+ "level": 1,
+ "description": "恢复生命值",
+ "effects": [{ "type": "heal", "magnitude": 25 }]
+ },
+ "lesser_ward": {
+ "id": "lesser_ward",
+ "name": "次级结界",
+ "school": "restoration",
+ "type": "self",
+ "magickaCost": 20,
+ "magnitude": 15,
+ "duration": 5000,
+ "cooldown": 2000,
+ "level": 1,
+ "description": "创造一个防护结界",
+ "effects": [{ "type": "fortify", "attribute": "armor", "magnitude": 15, "duration": 5000 }]
+ },
+ "fear": {
+ "id": "fear",
+ "name": "恐惧术",
+ "school": "illusion",
+ "type": "target",
+ "magickaCost": 35,
+ "magnitude": 1,
+ "duration": 10000,
+ "cooldown": 3000,
+ "level": 1,
+ "description": "使目标恐惧逃跑",
+ "effects": [{ "type": "fear", "magnitude": 1, "duration": 10000 }]
+ },
+ "calm": {
+ "id": "calm",
+ "name": "平静术",
+ "school": "illusion",
+ "type": "target",
+ "magickaCost": 40,
+ "magnitude": 1,
+ "duration": 15000,
+ "cooldown": 3000,
+ "level": 1,
+ "description": "使目标平静下来",
+ "effects": [{ "type": "calm", "magnitude": 1, "duration": 15000 }]
+ },
+ "conjure_familiar": {
+ "id": "conjure_familiar",
+ "name": "召唤灵体",
+ "school": "conjuration",
+ "type": "self",
+ "magickaCost": 50,
+ "magnitude": 1,
+ "duration": 60000,
+ "cooldown": 10000,
+ "level": 1,
+ "description": "召唤一个灵体狼协助战斗",
+ "effects": [{ "type": "conjure", "magnitude": 1, "duration": 60000 }]
+ },
+ "bound_sword": {
+ "id": "bound_sword",
+ "name": "束缚之剑",
+ "school": "conjuration",
+ "type": "self",
+ "magickaCost": 40,
+ "magnitude": 15,
+ "duration": 120000,
+ "cooldown": 5000,
+ "level": 1,
+ "description": "召唤一把束缚之剑",
+ "effects": [{ "type": "bound", "magnitude": 15, "duration": 120000 }]
+ },
+ "oakflesh": {
+ "id": "oakflesh",
+ "name": "橡皮术",
+ "school": "alteration",
+ "type": "self",
+ "magickaCost": 35,
+ "magnitude": 20,
+ "duration": 60000,
+ "cooldown": 3000,
+ "level": 1,
+ "description": "增加护甲值",
+ "effects": [{ "type": "fortify", "attribute": "armor", "magnitude": 20, "duration": 60000 }]
+ },
+ "transmute_iron": {
+ "id": "transmute_iron",
+ "name": "转化铁矿",
+ "school": "alteration",
+ "type": "self",
+ "magickaCost": 45,
+ "magnitude": 1,
+ "duration": 0,
+ "cooldown": 2000,
+ "level": 1,
+ "description": "将铁矿石转化为银矿石",
+ "effects": [{ "type": "transmute", "magnitude": 1 }]
+ },
+ "fireball": {
+ "id": "fireball",
+ "name": "火球术",
+ "school": "destruction",
+ "type": "area",
+ "magickaCost": 50,
+ "magnitude": 25,
+ "duration": 0,
+ "cooldown": 1500,
+ "level": 5,
+ "description": "发射一个火球,造成范围伤害",
+ "effects": [{ "type": "damage", "magnitude": 25 }]
+ },
+ "ice_storm": {
+ "id": "ice_storm",
+ "name": "冰风暴",
+ "school": "destruction",
+ "type": "area",
+ "magickaCost": 55,
+ "magnitude": 20,
+ "duration": 3000,
+ "cooldown": 2000,
+ "level": 5,
+ "description": "召唤一场冰风暴",
+ "effects": [{ "type": "damage", "magnitude": 20 }, { "type": "slow", "magnitude": 0.3, "duration": 3000 }]
+ },
+ "greater_ward": {
+ "id": "greater_ward",
+ "name": "高级结界",
+ "school": "restoration",
+ "type": "self",
+ "magickaCost": 40,
+ "magnitude": 30,
+ "duration": 5000,
+ "cooldown": 2000,
+ "level": 5,
+ "description": "创造一个强力防护结界",
+ "effects": [{ "type": "fortify", "attribute": "armor", "magnitude": 30, "duration": 5000 }]
+ },
+ "frenzy": {
+ "id": "frenzy",
+ "name": "狂暴术",
+ "school": "illusion",
+ "type": "target",
+ "magickaCost": 50,
+ "magnitude": 1,
+ "duration": 20000,
+ "cooldown": 5000,
+ "level": 5,
+ "description": "使目标狂暴攻击附近的人",
+ "effects": [{ "type": "frenzy", "magnitude": 1, "duration": 20000 }]
+ },
+ "conjure_dremora": {
+ "id": "conjure_dremora",
+ "name": "召唤魔人",
+ "school": "conjuration",
+ "type": "self",
+ "magickaCost": 80,
+ "magnitude": 2,
+ "duration": 60000,
+ "cooldown": 15000,
+ "level": 10,
+ "description": "召唤一个魔人战士",
+ "effects": [{ "type": "conjure", "magnitude": 2, "duration": 60000 }]
+ },
+ "stoneflesh": {
+ "id": "stoneflesh",
+ "name": "石化术",
+ "school": "alteration",
+ "type": "self",
+ "magickaCost": 60,
+ "magnitude": 40,
+ "duration": 60000,
+ "cooldown": 3000,
+ "level": 10,
+ "description": "大幅增加护甲值",
+ "effects": [{ "type": "fortify", "attribute": "armor", "magnitude": 40, "duration": 60000 }]
+ }
+ }
+}
diff --git a/src/data/transforms.json b/src/data/transforms.json
new file mode 100644
index 0000000..c2f9e14
--- /dev/null
+++ b/src/data/transforms.json
@@ -0,0 +1,44 @@
+{
+ "transforms": {
+ "werewolf": {
+ "id": "werewolf",
+ "name": "狼人形态",
+ "healthBonus": 100,
+ "staminaBonus": 50,
+ "damageBonus": 15,
+ "armorBonus": 10,
+ "speedBonus": 60,
+ "durationMs": 120000,
+ "cooldownMs": 30000,
+ "weaponId": "werewolf_claws",
+ "weaponDamage": 20,
+ "weaponSpeed": 1.2,
+ "suppressMagicka": true,
+ "effects": [
+ { "id": "werewolf_form_health", "attribute": "health_max", "magnitude": 100, "durationMs": 120000 },
+ { "id": "werewolf_form_stamina", "attribute": "stamina_max", "magnitude": 50, "durationMs": 120000 },
+ { "id": "werewolf_form_speed", "attribute": "speed", "magnitude": 60, "durationMs": 120000 }
+ ]
+ },
+ "vampire_lord": {
+ "id": "vampire_lord",
+ "name": "吸血鬼领主",
+ "healthBonus": 80,
+ "staminaBonus": 30,
+ "damageBonus": 12,
+ "armorBonus": 5,
+ "speedBonus": 30,
+ "durationMs": 90000,
+ "cooldownMs": 60000,
+ "weaponId": "vampire_claws",
+ "weaponDamage": 16,
+ "weaponSpeed": 1.3,
+ "suppressMagicka": false,
+ "effects": [
+ { "id": "vampire_lord_health", "attribute": "health_max", "magnitude": 80, "durationMs": 90000 },
+ { "id": "vampire_lord_stamina", "attribute": "stamina_max", "magnitude": 30, "durationMs": 90000 },
+ { "id": "vampire_lord_speed", "attribute": "speed", "magnitude": 30, "durationMs": 90000 }
+ ]
+ }
+ }
+}
diff --git a/src/data/vampire-stages.json b/src/data/vampire-stages.json
new file mode 100644
index 0000000..a608148
--- /dev/null
+++ b/src/data/vampire-stages.json
@@ -0,0 +1,63 @@
+{
+ "vampireStages": {
+ "0": {
+ "stage": 0,
+ "name": "未感染",
+ "frostResist": 0,
+ "fireResist": 0,
+ "sunDamage": 0,
+ "nightBonuses": [],
+ "infectionThresholdMs": 0
+ },
+ "1": {
+ "stage": 1,
+ "name": "初期感染",
+ "frostResist": 10,
+ "fireResist": 0,
+ "sunDamage": 0,
+ "nightBonuses": [
+ { "id": "vampire_night_frost1", "attribute": "armor", "magnitude": 5 }
+ ],
+ "infectionThresholdMs": 86400000
+ },
+ "2": {
+ "stage": 2,
+ "name": "中期感染",
+ "frostResist": 20,
+ "fireResist": -10,
+ "sunDamage": 0,
+ "nightBonuses": [
+ { "id": "vampire_night_frost2", "attribute": "armor", "magnitude": 10 },
+ { "id": "vampire_night_damage", "attribute": "damage", "magnitude": 5 }
+ ],
+ "infectionThresholdMs": 172800000
+ },
+ "3": {
+ "stage": 3,
+ "name": "深度感染",
+ "frostResist": 30,
+ "fireResist": -25,
+ "sunDamage": 1,
+ "nightBonuses": [
+ { "id": "vampire_night_frost3", "attribute": "armor", "magnitude": 15 },
+ { "id": "vampire_night_damage2", "attribute": "damage", "magnitude": 10 },
+ { "id": "vampire_night_speed", "attribute": "speed", "magnitude": 20 }
+ ],
+ "infectionThresholdMs": 259200000
+ },
+ "4": {
+ "stage": 4,
+ "name": "完全感染",
+ "frostResist": 40,
+ "fireResist": -50,
+ "sunDamage": 3,
+ "nightBonuses": [
+ { "id": "vampire_night_frost4", "attribute": "armor", "magnitude": 20 },
+ { "id": "vampire_night_damage3", "attribute": "damage", "magnitude": 15 },
+ { "id": "vampire_night_speed2", "attribute": "speed", "magnitude": 30 },
+ { "id": "vampire_night_health", "attribute": "health_max", "magnitude": 50 }
+ ],
+ "infectionThresholdMs": 999999999999
+ }
+ }
+}
diff --git a/src/data/world/standing-stones.json b/src/data/world/standing-stones.json
new file mode 100644
index 0000000..be0d0b8
--- /dev/null
+++ b/src/data/world/standing-stones.json
@@ -0,0 +1,82 @@
+{
+ "standingStones": [
+ {
+ "id": "warrior",
+ "name": "战士之石",
+ "description": "所有战斗技能提升速度+20%",
+ "effect": { "type": "skillXPBonus", "category": "combat", "value": 0.2 }
+ },
+ {
+ "id": "mage",
+ "name": "法师之石",
+ "description": "所有魔法技能提升速度+20%",
+ "effect": { "type": "skillXPBonus", "category": "magic", "value": 0.2 }
+ },
+ {
+ "id": "thief",
+ "name": "盗贼之石",
+ "description": "所有潜行技能提升速度+20%",
+ "effect": { "type": "skillXPBonus", "category": "stealth", "value": 0.2 }
+ },
+ {
+ "id": "lover",
+ "name": "恋人之石",
+ "description": "所有技能提升速度+15%",
+ "effect": { "type": "skillXPBonus", "category": "all", "value": 0.15 }
+ },
+ {
+ "id": "steed",
+ "name": "骏马之石",
+ "description": "+100 负重,护甲无重量",
+ "effect": { "type": "carryWeight", "value": 100 }
+ },
+ {
+ "id": "lord",
+ "name": "领主之石",
+ "description": "+50 生命,+25% 魔法抗性",
+ "effect": { "type": "healthAndMR", "health": 50, "magicResistance": 0.25 }
+ },
+ {
+ "id": "lady",
+ "name": "女士之石",
+ "description": "生命和耐力恢复速度+25%",
+ "effect": { "type": "regenBonus", "health": 0.25, "stamina": 0.25 }
+ },
+ {
+ "id": "tower",
+ "name": "高塔之石",
+ "description": "每天一次自动开锁 (大师级以下)",
+ "effect": { "type": "autoUnlock", "maxLevel": "expert", "cooldown": 86400 }
+ },
+ {
+ "id": "apprentice",
+ "name": "学徒之石",
+ "description": "魔力恢复速度+100%,但魔法抗性-100%",
+ "effect": { "type": "magickaRegenBonus", "value": 1.0, "penalty": { "magicResistance": -1.0 } }
+ },
+ {
+ "id": "atronach",
+ "name": "魔agogue之石",
+ "description": "+50 魔力,50% 法术吸收,但魔力恢复-50%",
+ "effect": { "type": "atronach", "magicka": 50, "spellAbsorption": 0.5, "penalty": { "magickaRegen": -0.5 } }
+ },
+ {
+ "id": "shadow",
+ "name": "暗影之石",
+ "description": "每天一次隐身60秒",
+ "effect": { "type": "dailyPower", "spell": "invisibility", "duration": 60, "cooldown": 86400 }
+ },
+ {
+ "id": "serpent",
+ "name": "蛇之石",
+ "description": "每天一次麻痹目标10秒+50毒素伤害",
+ "effect": { "type": "dailyPower", "spell": "paralyze", "duration": 10, "damage": 50, "cooldown": 86400 }
+ },
+ {
+ "id": "ritual",
+ "name": "仪式之石",
+ "description": "每天一次复活附近所有尸体 (200秒)",
+ "effect": { "type": "dailyPower", "spell": "massReanimate", "duration": 200, "cooldown": 86400 }
+ }
+ ]
+}
diff --git a/src/data/world/zones.json b/src/data/world/zones.json
new file mode 100644
index 0000000..122eb21
--- /dev/null
+++ b/src/data/world/zones.json
@@ -0,0 +1,210 @@
+{
+ "zones": {
+ "whiterun": {
+ "id": "whiterun",
+ "name": "雪漫城",
+ "description": "天际省的首府,一座繁华的城市",
+ "width": 25,
+ "height": 20,
+ "tileSize": 32,
+ "baseTile": 2,
+ "borderTile": 6,
+ "structures": [
+ { "type": "floor", "tile": 5, "x": 3, "y": 3, "w": 6, "h": 4 },
+ { "type": "floor", "tile": 5, "x": 10, "y": 8, "w": 7, "h": 5 },
+ { "type": "floor", "tile": 5, "x": 18, "y": 14, "w": 5, "h": 4 }
+ ],
+ "doors": [
+ { "x": 12, "y": 0, "targetZone": "whiterun_exterior", "targetX": 12, "targetY": 28 }
+ ],
+ "entities": [
+ { "type": "npc", "id": "guard_01", "x": 12, "y": 3, "data": { "name": "守卫", "dialogue": "欢迎来到雪漫城", "script": "base-scripts:guard_patrol" } },
+ { "type": "npc", "id": "merchant_01", "x": 5, "y": 4, "data": { "name": "商人", "dialogue": "看看我的货物", "shop": true } },
+ { "type": "npc", "id": "blacksmith_01", "x": 5, "y": 5, "data": { "name": "铁匠", "dialogue": "需要打造什么?", "forge": true } }
+ ],
+ "chests": [
+ { "id": "chest_01", "x": 4, "y": 4, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }], "locked": false, "lockLevel": 0 }
+ ],
+ "spawnPoint": { "x": 12, "y": 10 }
+ },
+ "whiterun_exterior": {
+ "id": "whiterun_exterior",
+ "name": "雪漫城外",
+ "description": "雪漫城周围的平原",
+ "width": 30,
+ "height": 30,
+ "tileSize": 32,
+ "baseTile": 1,
+ "borderTile": 13,
+ "structures": [
+ { "type": "door", "tile": 7, "x": 10, "y": 26, "w": 5, "h": 3 },
+ { "type": "wall", "tile": 0, "x": 18, "y": 5, "w": 5, "h": 4 }
+ ],
+ "procedural": {
+ "treeChance": 0.1,
+ "bushChance": 0.05,
+ "treeTile": 14,
+ "bushTile": 15
+ },
+ "doors": [
+ { "x": 12, "y": 29, "targetZone": "whiterun", "targetX": 12, "targetY": 2 },
+ { "x": 20, "y": 6, "targetZone": "bleakfalls_barrow", "targetX": 1, "targetY": 10 },
+ { "x": 2, "y": 15, "targetZone": "riverwood", "targetX": 14, "targetY": 18 },
+ { "x": 25, "y": 20, "targetZone": "darklight_cave", "targetX": 1, "targetY": 10 },
+ { "x": 5, "y": 5, "targetZone": "ancient_ruins", "targetX": 1, "targetY": 15 }
+ ],
+ "entities": [
+ { "type": "enemy", "id": "bandit_01", "x": 5, "y": 10, "data": { "type": "bandit" } },
+ { "type": "enemy", "id": "wolf_01", "x": 20, "y": 15, "data": { "type": "wolf", "script": "base-scripts:pack_wolf" } },
+ { "type": "enemy", "id": "wolf_02", "x": 22, "y": 16, "data": { "type": "wolf" } }
+ ],
+ "chests": [],
+ "spawnPoint": { "x": 12, "y": 25 }
+ },
+ "bleakfalls_barrow": {
+ "id": "bleakfalls_barrow",
+ "name": "荒瀑古坟",
+ "description": "一座古老的诺德遗迹,据说藏有珍贵的宝物",
+ "width": 20,
+ "height": 20,
+ "tileSize": 32,
+ "baseTile": 3,
+ "borderTile": 6,
+ "structures": [
+ { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 4, "h": 4 },
+ { "type": "wall", "tile": 0, "x": 12, "y": 12, "w": 4, "h": 4 }
+ ],
+ "doors": [
+ { "x": 0, "y": 10, "targetZone": "whiterun_exterior", "targetX": 19, "targetY": 7 }
+ ],
+ "entities": [
+ { "type": "enemy", "id": "skeleton_01", "x": 8, "y": 6, "data": { "type": "skeleton" } },
+ { "type": "enemy", "id": "skeleton_02", "x": 14, "y": 13, "data": { "type": "skeleton" } },
+ { "type": "enemy", "id": "draugr_01", "x": 10, "y": 10, "data": { "type": "draugr", "script": "base-scripts:draugr_guard" } }
+ ],
+ "chests": [
+ { "id": "barrow_chest_01", "x": 7, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "gold", "amount": 100 }], "locked": true, "lockLevel": 2 },
+ { "id": "barrow_chest_02", "x": 14, "y": 14, "loot": [{ "type": "item", "id": "iron_ingot", "quantity": 5 }], "locked": false, "lockLevel": 0 }
+ ],
+ "spawnPoint": { "x": 2, "y": 10 }
+ },
+ "riverwood": {
+ "id": "riverwood",
+ "name": "溪木镇",
+ "description": "一个宁静的河边小镇,以伐木业为生",
+ "width": 20,
+ "height": 20,
+ "tileSize": 32,
+ "baseTile": 1,
+ "borderTile": 14,
+ "structures": [
+ { "type": "floor", "tile": 5, "x": 5, "y": 3, "w": 4, "h": 4 },
+ { "type": "floor", "tile": 5, "x": 10, "y": 8, "w": 4, "h": 4 },
+ { "type": "floor", "tile": 5, "x": 15, "y": 4, "w": 4, "h": 4 },
+ { "type": "water", "tile": 4, "x": 2, "y": 14, "w": 3, "h": 3 }
+ ],
+ "doors": [
+ { "x": 14, "y": 19, "targetZone": "whiterun_exterior", "targetX": 3, "targetY": 16 }
+ ],
+ "entities": [
+ { "type": "npc", "id": "riverwood_merchant", "x": 6, "y": 4, "data": { "name": "溪木商人", "dialogue": "欢迎来到溪木镇", "shop": true } },
+ { "type": "npc", "id": "riverwood_blacksmith", "x": 11, "y": 9, "data": { "name": "溪木铁匠", "dialogue": "需要打造什么?", "forge": true } },
+ { "type": "npc", "id": "riverwood_elder", "x": 16, "y": 5, "data": { "name": "镇长", "dialogue": "溪木镇是个好地方", "script": "base-scripts:town_elder" } }
+ ],
+ "chests": [
+ { "id": "riverwood_chest_01", "x": 6, "y": 5, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }, { "type": "gold", "amount": 50 }], "locked": false, "lockLevel": 0 }
+ ],
+ "spawnPoint": { "x": 10, "y": 15 }
+ },
+ "darklight_cave": {
+ "id": "darklight_cave",
+ "name": "暗光洞穴",
+ "description": "一个阴暗的洞穴,据说有蜘蛛出没",
+ "width": 25,
+ "height": 20,
+ "tileSize": 32,
+ "baseTile": 3,
+ "borderTile": 6,
+ "structures": [
+ { "type": "wall", "tile": 0, "x": 8, "y": 5, "w": 5, "h": 5 },
+ { "type": "wall", "tile": 0, "x": 15, "y": 10, "w": 6, "h": 6 },
+ { "type": "water", "tile": 4, "x": 3, "y": 12, "w": 4, "h": 5 }
+ ],
+ "doors": [
+ { "x": 0, "y": 10, "targetZone": "whiterun_exterior", "targetX": 24, "targetY": 20 }
+ ],
+ "entities": [
+ { "type": "enemy", "id": "spider_01", "x": 10, "y": 7, "data": { "type": "spider" } },
+ { "type": "enemy", "id": "spider_02", "x": 18, "y": 12, "data": { "type": "frostbite_spider", "script": "base-scripts:frost_spider" } },
+ { "type": "enemy", "id": "bandit_01", "x": 5, "y": 8, "data": { "type": "bandit" } }
+ ],
+ "chests": [
+ { "id": "cave_chest_01", "x": 18, "y": 13, "loot": [{ "type": "item", "id": "spider_silk", "quantity": 3 }, { "type": "gold", "amount": 40 }], "locked": false, "lockLevel": 0 },
+ { "id": "cave_chest_02", "x": 10, "y": 6, "loot": [{ "type": "item", "id": "health_potion", "quantity": 1 }], "locked": true, "lockLevel": 1 }
+ ],
+ "spawnPoint": { "x": 2, "y": 10 }
+ },
+ "ancient_ruins": {
+ "id": "ancient_ruins",
+ "name": "古代遗迹",
+ "description": "一座被遗忘的古代遗迹,守护着强大的宝物",
+ "width": 25,
+ "height": 25,
+ "tileSize": 32,
+ "baseTile": 3,
+ "borderTile": 6,
+ "structures": [
+ { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 5, "h": 5 },
+ { "type": "wall", "tile": 0, "x": 15, "y": 5, "w": 5, "h": 5 },
+ { "type": "wall", "tile": 0, "x": 5, "y": 15, "w": 5, "h": 5 },
+ { "type": "wall", "tile": 0, "x": 15, "y": 15, "w": 5, "h": 5 },
+ { "type": "campfire", "tile": 9, "x": 12, "y": 12, "w": 1, "h": 1 }
+ ],
+ "doors": [
+ { "x": 0, "y": 15, "targetZone": "whiterun_exterior", "targetX": 6, "targetY": 6 }
+ ],
+ "entities": [
+ { "type": "enemy", "id": "draugr_ruins_01", "x": 7, "y": 7, "data": { "type": "draugr" } },
+ { "type": "enemy", "id": "draugr_ruins_02", "x": 17, "y": 7, "data": { "type": "draugr" } },
+ { "type": "enemy", "id": "draugr_ruins_03", "x": 7, "y": 17, "data": { "type": "draugr_wight" } },
+ { "type": "enemy", "id": "skeleton_ruins_01", "x": 12, "y": 8, "data": { "type": "skeleton" } },
+ { "type": "enemy", "id": "skeleton_ruins_02", "x": 12, "y": 16, "data": { "type": "skeleton" } }
+ ],
+ "chests": [
+ { "id": "ruins_chest_01", "x": 12, "y": 12, "loot": [{ "type": "item", "id": "steel_greatsword", "quantity": 1 }, { "type": "gold", "amount": 200 }], "locked": true, "lockLevel": 3 },
+ { "id": "ruins_chest_02", "x": 7, "y": 8, "loot": [{ "type": "item", "id": "moonstone_ingot", "quantity": 2 }], "locked": false, "lockLevel": 0 },
+ { "id": "ruins_chest_03", "x": 17, "y": 18, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 1 }, { "type": "gold", "amount": 80 }], "locked": true, "lockLevel": 2 }
+ ],
+ "spawnPoint": { "x": 2, "y": 15 }
+ },
+ "skyrim_overworld": {
+ "id": "skyrim_overworld",
+ "name": "天际省 · 荒野",
+ "description": "天际省的广阔荒野,充满危险与机遇",
+ "width": 40,
+ "height": 40,
+ "tileSize": 32,
+ "baseTile": 1,
+ "borderTile": 13,
+ "structures": [
+ { "type": "stone", "tile": 2, "x": 18, "y": 18, "w": 5, "h": 5 },
+ { "type": "water", "tile": 4, "x": 5, "y": 10, "w": 4, "h": 5 }
+ ],
+ "procedural": {
+ "treeChance": 0.08,
+ "bushChance": 0.04,
+ "treeTile": 14,
+ "bushTile": 15
+ },
+ "doors": [],
+ "entities": [
+ { "type": "enemy", "id": "wolf_over_01", "x": 10, "y": 10, "data": { "type": "wolf" } },
+ { "type": "enemy", "id": "wolf_over_02", "x": 12, "y": 11, "data": { "type": "wolf" } },
+ { "type": "enemy", "id": "bear_over_01", "x": 30, "y": 25, "data": { "type": "bear" } },
+ { "type": "enemy", "id": "bandit_over_01", "x": 25, "y": 15, "data": { "type": "bandit_outlaw" } }
+ ],
+ "chests": [],
+ "spawnPoint": { "x": 20, "y": 20 }
+ }
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..bf956ee
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,57 @@
+import { gameManager } from './core/GameManager';
+import { GameScene } from './scenes/GameScene';
+import { injectGlobalTheme } from './ui/theme';
+import './ui/UIManager';
+import { modManager } from './mods/ModManager';
+import { dataRegistry } from './data/DataRegistry';
+
+injectGlobalTheme();
+
+async function initGame(): Promise {
+ modManager.loadConfig();
+
+ await loadExampleMods();
+ await dataRegistry.loadAll();
+
+ gameManager.init(
+ {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ parent: 'game-container',
+ },
+ [GameScene]
+ );
+
+ // Handle resize
+ window.addEventListener('resize', () => {
+ const game = gameManager.getGame();
+ if (game) {
+ game.scale.resize(window.innerWidth, window.innerHeight);
+ }
+ });
+
+ console.log('OES-WEB initialized');
+}
+
+async function loadExampleMods(): Promise {
+ const mods = [
+ '/data/mods/example-weapons-mod.json',
+ '/data/mods/example-quest-mod.json',
+ '/data/mods/base-scripts-mod.json',
+ ];
+
+ for (const url of mods) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) {
+ const mod = await response.json();
+ await modManager.installMod(mod.manifest, mod.data, mod.scripts);
+ console.log(`Loaded mod: ${mod.manifest.id}`);
+ }
+ } catch {
+ console.log(`Mod not found: ${url}, skipping`);
+ }
+ }
+}
+
+initGame();
diff --git a/src/maps/MapManager.ts b/src/maps/MapManager.ts
new file mode 100644
index 0000000..3449089
--- /dev/null
+++ b/src/maps/MapManager.ts
@@ -0,0 +1,276 @@
+import { eventBus } from '../core/EventBus';
+import zonesJson from '../data/world/zones.json';
+
+export interface MapTile {
+ id: number;
+ name: string;
+ walkable: boolean;
+ color: number;
+ type: 'grass' | 'stone' | 'dirt' | 'water' | 'wood' | 'wall' | 'door' | 'chest' | 'campfire' | 'bed';
+}
+
+export interface MapZone {
+ id: string;
+ name: string;
+ description: string;
+ width: number;
+ height: number;
+ tileSize: number;
+ tiles: number[][];
+ entities: MapEntity[];
+ doors: Door[];
+ chests: Chest[];
+ spawnPoint: { x: number; y: number };
+}
+
+export interface MapEntity {
+ type: 'npc' | 'enemy' | 'item';
+ id: string;
+ x: number;
+ y: number;
+ data: any;
+}
+
+export interface Door {
+ x: number;
+ y: number;
+ targetZone: string;
+ targetX: number;
+ targetY: number;
+}
+
+export interface Chest {
+ id: string;
+ x: number;
+ y: number;
+ loot: any[];
+ locked: boolean;
+ lockLevel: number;
+}
+
+interface RawZoneData {
+ id: string;
+ name: string;
+ description: string;
+ width: number;
+ height: number;
+ tileSize: number;
+ baseTile: number;
+ borderTile: number;
+ structures?: { type: string; tile: number; x: number; y: number; w: number; h: number }[];
+ procedural?: { treeChance: number; bushChance: number; treeTile: number; bushTile: number };
+ doors?: Door[];
+ entities?: MapEntity[];
+ chests?: Chest[];
+ spawnPoint?: { x: number; y: number };
+}
+
+function isRawZoneData(value: unknown): value is RawZoneData {
+ if (!isRecord(value)) return false;
+ return typeof value.id === 'string' && typeof value.width === 'number' && typeof value.height === 'number' && typeof value.baseTile === 'number';
+}
+
+export class MapManager {
+ private static instance: MapManager;
+ private zones: Map = new Map();
+ private currentZone: MapZone | null = null;
+ private tileTypes: Map = new Map();
+
+ static getInstance(): MapManager {
+ if (!MapManager.instance) {
+ MapManager.instance = new MapManager();
+ }
+ return MapManager.instance;
+ }
+
+ constructor() {
+ this.initializeTileTypes();
+ this.initializeZones();
+ eventBus.on('mod:dataResolved', (data: { data: unknown }) => {
+ this.applyModZones(data.data);
+ });
+ }
+
+ private initializeTileTypes(): void {
+ const tiles: MapTile[] = [
+ { id: 0, name: '空', walkable: false, color: 0x000000, type: 'wall' },
+ { id: 1, name: '草地', walkable: true, color: 0x2d5a27, type: 'grass' },
+ { id: 2, name: '石路', walkable: true, color: 0x888888, type: 'stone' },
+ { id: 3, name: '泥土', walkable: true, color: 0x8b4513, type: 'dirt' },
+ { id: 4, name: '水', walkable: false, color: 0x3366aa, type: 'water' },
+ { id: 5, name: '木地板', walkable: true, color: 0x996633, type: 'wood' },
+ { id: 6, name: '墙壁', walkable: false, color: 0x555555, type: 'wall' },
+ { id: 7, name: '门', walkable: true, color: 0xaa8844, type: 'door' },
+ { id: 8, name: '宝箱', walkable: true, color: 0xffaa00, type: 'chest' },
+ { id: 9, name: '营火', walkable: true, color: 0xff4400, type: 'campfire' },
+ { id: 10, name: '床', walkable: true, color: 0x664422, type: 'bed' },
+ { id: 11, name: '沙地', walkable: true, color: 0xc2b280, type: 'dirt' },
+ { id: 12, name: '雪地', walkable: true, color: 0xeeeeff, type: 'grass' },
+ { id: 13, name: '岩石', walkable: false, color: 0x666666, type: 'stone' },
+ { id: 14, name: '树', walkable: false, color: 0x1a4a1a, type: 'wall' },
+ { id: 15, name: '灌木', walkable: false, color: 0x225522, type: 'wall' },
+ ];
+
+ tiles.forEach((tile) => {
+ this.tileTypes.set(tile.id, tile);
+ });
+ }
+
+ private initializeZones(): void {
+ this.zones.clear();
+ const zonesRecord = (zonesJson as { zones: Record }).zones;
+ for (const [id, rawZone] of Object.entries(zonesRecord)) {
+ if (isMapZone(rawZone)) {
+ this.zones.set(id, rawZone);
+ } else if (isRawZoneData(rawZone)) {
+ const zone = this.buildZoneFromData(rawZone);
+ if (zone) this.zones.set(id, zone);
+ }
+ }
+ }
+
+ private buildZoneFromData(data: RawZoneData): MapZone | null {
+ const { width, height, tileSize, baseTile, borderTile } = data;
+ const tiles: number[][] = Array.from({ length: height }, () => Array(width).fill(baseTile) as number[]);
+
+ // Apply border
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (y === 0 || y === height - 1 || x === 0 || x === width - 1) {
+ tiles[y]![x] = borderTile;
+ }
+ }
+ }
+
+ // Apply structures
+ if (data.structures) {
+ for (const s of data.structures) {
+ for (let dy = 0; dy < s.h; dy++) {
+ for (let dx = 0; dx < s.w; dx++) {
+ const tx = s.x + dx;
+ const ty = s.y + dy;
+ if (tx >= 0 && tx < width && ty >= 0 && ty < height) {
+ tiles[ty]![tx] = s.tile;
+ }
+ }
+ }
+ }
+ }
+
+ // Apply procedural decoration
+ if (data.procedural) {
+ const p = data.procedural;
+ for (let y = 1; y < height - 1; y++) {
+ for (let x = 1; x < width - 1; x++) {
+ if (tiles[y]![x] === baseTile) {
+ const r = Math.random();
+ if (r < p.treeChance) {
+ tiles[y]![x] = p.treeTile;
+ } else if (r < p.treeChance + p.bushChance) {
+ tiles[y]![x] = p.bushTile;
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ id: data.id,
+ name: data.name,
+ description: data.description,
+ width,
+ height,
+ tileSize,
+ tiles,
+ entities: (data.entities || []) as MapEntity[],
+ doors: (data.doors || []) as Door[],
+ chests: (data.chests || []) as Chest[],
+ spawnPoint: data.spawnPoint || { x: Math.floor(width / 2), y: Math.floor(height / 2) },
+ };
+ }
+
+ private applyModZones(data: unknown): void {
+ this.initializeZones();
+ if (!isRecord(data) || !isRecord(data.zones)) return;
+
+ for (const zone of Object.values(data.zones)) {
+ if (isMapZone(zone)) {
+ this.registerZone(zone);
+ }
+ }
+ }
+
+ loadZone(zoneId: string): MapZone | null {
+ const zone = this.zones.get(zoneId);
+ if (!zone) return null;
+
+ this.currentZone = zone;
+ eventBus.emit('zone:loaded', { zone });
+ return zone;
+ }
+
+ getCurrentZone(): MapZone | null {
+ return this.currentZone;
+ }
+
+ getZone(zoneId: string): MapZone | undefined {
+ return this.zones.get(zoneId);
+ }
+
+ getTile(tileId: number): MapTile | undefined {
+ return this.tileTypes.get(tileId);
+ }
+
+ isWalkable(x: number, y: number): boolean {
+ if (!this.currentZone) return false;
+
+ const tileId = this.currentZone.tiles[y]?.[x];
+ if (tileId === undefined) return false;
+
+ const tile = this.tileTypes.get(tileId);
+ return tile?.walkable || false;
+ }
+
+ getDoorAt(x: number, y: number): Door | undefined {
+ if (!this.currentZone) return undefined;
+ return this.currentZone.doors.find((d) => d.x === x && d.y === y);
+ }
+
+ getChestAt(x: number, y: number): Chest | undefined {
+ if (!this.currentZone) return undefined;
+ return this.currentZone.chests.find((c) => c.x === x && c.y === y);
+ }
+
+ getAllZones(): MapZone[] {
+ return Array.from(this.zones.values());
+ }
+
+ registerZone(zone: MapZone): void {
+ this.zones.set(zone.id, zone);
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isMapZone(value: unknown): value is MapZone {
+ if (!isRecord(value)) return false;
+ return (
+ typeof value.id === 'string' &&
+ typeof value.name === 'string' &&
+ typeof value.description === 'string' &&
+ typeof value.width === 'number' &&
+ typeof value.height === 'number' &&
+ typeof value.tileSize === 'number' &&
+ Array.isArray(value.tiles) &&
+ Array.isArray(value.entities) &&
+ Array.isArray(value.doors) &&
+ Array.isArray(value.chests) &&
+ isRecord(value.spawnPoint) &&
+ typeof value.spawnPoint.x === 'number' &&
+ typeof value.spawnPoint.y === 'number'
+ );
+}
+
+export const mapManager = MapManager.getInstance();
diff --git a/src/mods/ModAPI.ts b/src/mods/ModAPI.ts
new file mode 100644
index 0000000..f071530
--- /dev/null
+++ b/src/mods/ModAPI.ts
@@ -0,0 +1,90 @@
+import { eventBus } from '../core/EventBus';
+
+export type ModEventCallback = (data: any) => void | Promise;
+
+export class ModAPI {
+ private static instance: ModAPI;
+ private hooks: Map> = new Map();
+
+ static getInstance(): ModAPI {
+ if (!ModAPI.instance) {
+ ModAPI.instance = new ModAPI();
+ }
+ return ModAPI.instance;
+ }
+
+ on(modId: string, event: string, callback: ModEventCallback): () => void {
+ if (!this.hooks.has(event)) {
+ this.hooks.set(event, new Map());
+ }
+ this.hooks.get(event)!.set(modId, callback);
+
+ const unsubscribe = eventBus.on(event, async (data) => {
+ const modCallback = this.hooks.get(event)?.get(modId);
+ if (modCallback) {
+ await modCallback(data);
+ }
+ });
+
+ return unsubscribe;
+ }
+
+ off(modId: string, event: string): void {
+ this.hooks.get(event)?.delete(modId);
+ }
+
+ offAll(modId: string): void {
+ this.hooks.forEach((callbacks) => {
+ callbacks.delete(modId);
+ });
+ }
+
+ emit(event: string, data?: any): void {
+ eventBus.emit(event, data);
+ }
+
+ getRegisteredHooks(): string[] {
+ return Array.from(this.hooks.keys());
+ }
+
+ getModHooks(modId: string): string[] {
+ const hooks: string[] = [];
+ this.hooks.forEach((callbacks, event) => {
+ if (callbacks.has(modId)) {
+ hooks.push(event);
+ }
+ });
+ return hooks;
+ }
+}
+
+export const modAPI = ModAPI.getInstance();
+
+export const GAME_EVENTS = {
+ PLAYER_CREATED: 'player:created',
+ PLAYER_LEVEL_UP: 'player:levelUp',
+ PLAYER_DAMAGED: 'player:damaged',
+ PLAYER_DIED: 'player:died',
+ ENTITY_CREATED: 'entity:created',
+ ENTITY_DESTROYED: 'entity:destroyed',
+ ENTITY_DAMAGED: 'entity:damaged',
+ ENTITY_KILLED: 'entity:killed',
+ ITEM_PICKUP: 'item:pickup',
+ ITEM_USE: 'item:use',
+ ITEM_DROP: 'item:drop',
+ COMBAT_BEFORE_ATTACK: 'combat:beforeAttack',
+ COMBAT_AFTER_ATTACK: 'combat:afterAttack',
+ SKILL_IMPROVED: 'skill:improved',
+ PERK_UNLOCKED: 'perk:unlocked',
+ QUEST_STARTED: 'quest:started',
+ QUEST_COMPLETED: 'quest:completed',
+ QUEST_FAILED: 'quest:failed',
+ DIALOGUE_STARTED: 'dialogue:started',
+ DIALOGUE_CHOSEN: 'dialogue:chosen',
+ CRAFTING_STARTED: 'crafting:started',
+ CRAFTING_COMPLETED: 'crafting:completed',
+ LOOT_GENERATED: 'loot:generated',
+ MOD_LOADED: 'mod:loaded',
+ MOD_UNLOADED: 'mod:unloaded',
+ MOD_DATA_RESOLVED: 'mod:dataResolved',
+};
diff --git a/src/mods/ModLoader.test.ts b/src/mods/ModLoader.test.ts
new file mode 100644
index 0000000..d2eee61
--- /dev/null
+++ b/src/mods/ModLoader.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { modLoader } from './ModLoader';
+
+describe('ModLoader', () => {
+ beforeEach(() => {
+ modLoader.clearForTests();
+ });
+
+ it('loads a valid JSON data mod', async () => {
+ const loaded = await modLoader.loadMod(
+ { id: 'test_items', name: 'Test Items', version: '1.0.0' },
+ {
+ items: {
+ test_sword: {
+ name: 'Test Sword',
+ type: 'weapon',
+ damage: 12,
+ weight: 8,
+ value: 50,
+ },
+ },
+ }
+ );
+
+ expect(loaded).toBe(true);
+ expect(modLoader.getResolvedData().items?.test_sword?.name).toBe('Test Sword');
+ });
+
+ it('orders mods by dependency before priority merge', async () => {
+ await modLoader.loadMod(
+ { id: 'base_weapons', name: 'Base Weapons', version: '1.0.0', priority: 500 },
+ { items: { shared_sword: { name: 'Base Sword', type: 'weapon', damage: 10, weight: 8, value: 50 } } }
+ );
+
+ await modLoader.loadMod(
+ { id: 'rebalance_weapons', name: 'Rebalance Weapons', version: '1.0.0', priority: 100, dependencies: ['base_weapons'] },
+ { items: { shared_sword: { name: 'Rebalanced Sword', damage: 18 } } }
+ );
+
+ expect(modLoader.getResolvedOrder()).toEqual(['base_weapons', 'rebalance_weapons']);
+ expect(modLoader.getResolvedData().items?.shared_sword?.name).toBe('Rebalanced Sword');
+ expect(modLoader.getResolvedData().items?.shared_sword?.damage).toBe(18);
+ });
+
+ it('reports conflicts when enabled mods edit the same entry', async () => {
+ await modLoader.loadMod(
+ { id: 'first_mod', name: 'First', version: '1.0.0', priority: 100 },
+ { enemies: { wolf: { health: 50 } } }
+ );
+ await modLoader.loadMod(
+ { id: 'second_mod', name: 'Second', version: '1.0.0', priority: 200 },
+ { enemies: { wolf: { health: 80 } } }
+ );
+
+ expect(modLoader.detectConflicts()).toEqual([
+ 'first_mod, second_mod modify enemies.wolf; second_mod wins',
+ ]);
+ });
+
+ it('accepts script mods with valid definitions', async () => {
+ const loaded = await modLoader.loadMod(
+ { id: 'script_mod', name: 'Script Mod', version: '1.0.0' },
+ {},
+ {
+ test_script: {
+ properties: { counter: 0 },
+ handlers: {
+ OnLoad: "ctx.log('loaded')",
+ OnUpdate: "ctx.prop('counter', ctx.prop('counter') + 1)",
+ },
+ },
+ }
+ );
+
+ expect(loaded).toBe(true);
+ expect(modLoader.getMod('script_mod')).toBeDefined();
+ });
+
+ it('accepts scripts with unknown handler names as warnings', async () => {
+ const loaded = await modLoader.loadMod(
+ { id: 'warn_script_mod', name: 'Warn Script Mod', version: '1.0.0' },
+ {},
+ {
+ warn_script: {
+ properties: {},
+ handlers: {
+ InvalidEvent: 'ctx.log("nope")',
+ },
+ },
+ }
+ );
+
+ expect(loaded).toBe(true);
+ expect(modLoader.getMod('warn_script_mod')).toBeDefined();
+ });
+});
diff --git a/src/mods/ModLoader.ts b/src/mods/ModLoader.ts
new file mode 100644
index 0000000..61a0339
--- /dev/null
+++ b/src/mods/ModLoader.ts
@@ -0,0 +1,163 @@
+import { eventBus } from '../core/EventBus';
+import { resolveMods } from './ModResolver';
+import { validateModPackage } from './ModValidator';
+import { modScriptEngine } from './ModScriptEngine';
+import type { LoadedMod, ModConflict, ModData, ModIssue, ModManifest, ScriptDefinition } from './ModTypes';
+
+export type { LoadedMod, ModConflict, ModData, ModIssue, ModManifest, ModPackage } from './ModTypes';
+
+export class ModLoader {
+ private static instance: ModLoader;
+ private mods: Map = new Map();
+ private resolvedData: ModData = {};
+ private resolvedOrder: string[] = [];
+ private conflicts: ModConflict[] = [];
+ private lastIssues: ModIssue[] = [];
+
+ static getInstance(): ModLoader {
+ if (!ModLoader.instance) {
+ ModLoader.instance = new ModLoader();
+ }
+ return ModLoader.instance;
+ }
+
+ async loadMod(manifest: ModManifest, data: ModData = {}, scripts?: Record): Promise {
+ const validated = validateModPackage(manifest, data, scripts);
+ if (!validated) {
+ this.lastIssues = [{
+ severity: 'error',
+ code: 'mod_validation_failed',
+ message: 'Mod package failed validation. See console for details.',
+ }];
+ console.error('Invalid mod package', { manifest, data });
+ return false;
+ }
+
+ this.lastIssues = validated.issues;
+
+ if (this.mods.has(validated.manifest.id)) {
+ console.warn(`Mod ${validated.manifest.id} already loaded`);
+ return false;
+ }
+
+ for (const dep of validated.manifest.dependencies) {
+ if (!this.mods.has(dep)) {
+ console.error(`Mod ${validated.manifest.id} requires ${dep} which is not loaded`);
+ return false;
+ }
+ }
+
+ const mod: LoadedMod = {
+ manifest: validated.manifest,
+ data: validated.data,
+ enabled: true,
+ installedAt: Date.now(),
+ warnings: validated.issues.filter((issue) => issue.severity === 'warning'),
+ };
+
+ this.mods.set(validated.manifest.id, mod);
+ this.resolveData();
+
+ if (scripts) {
+ for (const [scriptId, definition] of Object.entries(scripts)) {
+ const fullId = `${manifest.id}:${scriptId}`;
+ modScriptEngine.registerScriptDefinition(fullId, definition);
+ }
+ }
+
+ eventBus.emit('mod:loaded', { modId: validated.manifest.id });
+ return true;
+ }
+
+ unloadMod(modId: string): void {
+ modScriptEngine.clearModScripts(modId);
+ this.mods.delete(modId);
+ this.resolveData();
+ eventBus.emit('mod:unloaded', { modId });
+ }
+
+ enableMod(modId: string): void {
+ const mod = this.mods.get(modId);
+ if (mod) {
+ mod.enabled = true;
+ this.resolveData();
+ eventBus.emit('mod:enabled', { modId });
+ }
+ }
+
+ disableMod(modId: string): void {
+ const mod = this.mods.get(modId);
+ if (mod) {
+ mod.enabled = false;
+ this.resolveData();
+ eventBus.emit('mod:disabled', { modId });
+ }
+ }
+
+ getMod(modId: string): LoadedMod | undefined {
+ return this.mods.get(modId);
+ }
+
+ getAllMods(): LoadedMod[] {
+ return Array.from(this.mods.values());
+ }
+
+ getEnabledMods(): LoadedMod[] {
+ return Array.from(this.mods.values()).filter((m) => m.enabled);
+ }
+
+ setModPriority(modId: string, priority: number): void {
+ const mod = this.mods.get(modId);
+ if (mod) {
+ mod.manifest.priority = priority;
+ this.resolveData();
+ }
+ }
+
+ private resolveData(): void {
+ const result = resolveMods(this.getEnabledMods());
+ this.resolvedData = result.data;
+ this.resolvedOrder = result.order;
+ this.conflicts = result.conflicts;
+
+ eventBus.emit('mod:dataResolved', { data: this.resolvedData });
+ }
+
+ getResolvedData(): ModData {
+ return this.resolvedData;
+ }
+
+ getModData(modId: string): ModData | undefined {
+ return this.mods.get(modId)?.data;
+ }
+
+ detectConflicts(): string[] {
+ return this.conflicts.map((conflict) => {
+ const writers = conflict.modIds.join(', ');
+ return `${writers} modify ${conflict.domain}.${conflict.id}; ${conflict.winnerModId} wins`;
+ });
+ }
+
+ getConflictDetails(): ModConflict[] {
+ return this.conflicts;
+ }
+
+ getResolvedOrder(): string[] {
+ return this.resolvedOrder;
+ }
+
+ getLastIssues(): ModIssue[] {
+ return this.lastIssues;
+ }
+
+ clearForTests(): void {
+ this.mods.clear();
+ this.resolvedData = {};
+ this.resolvedOrder = [];
+ this.conflicts = [];
+ this.lastIssues = [];
+ eventBus.emit('mod:dataResolved', { data: this.resolvedData });
+ }
+}
+
+export const modLoader = ModLoader.getInstance();
diff --git a/src/mods/ModManager.ts b/src/mods/ModManager.ts
new file mode 100644
index 0000000..349fe95
--- /dev/null
+++ b/src/mods/ModManager.ts
@@ -0,0 +1,129 @@
+import { modLoader, type ModManifest, type ModData, type LoadedMod, type ModPackage } from './ModLoader';
+import type { ScriptDefinition } from './ModTypes';
+
+export interface ModConfig {
+ enabledModIds: string[];
+ modOrder: string[];
+}
+
+export class ModManager {
+ private static instance: ModManager;
+ private config: ModConfig = {
+ enabledModIds: [],
+ modOrder: [],
+ };
+
+ static getInstance(): ModManager {
+ if (!ModManager.instance) {
+ ModManager.instance = new ModManager();
+ }
+ return ModManager.instance;
+ }
+
+ async installMod(manifest: ModManifest, data: ModData, scripts?: Record): Promise {
+ const success = await modLoader.loadMod(manifest, data, scripts);
+ if (success) {
+ if (!this.config.enabledModIds.includes(manifest.id)) {
+ this.config.enabledModIds.push(manifest.id);
+ }
+ if (!this.config.modOrder.includes(manifest.id)) {
+ this.config.modOrder.push(manifest.id);
+ }
+ this.saveConfig();
+ }
+ return success;
+ }
+
+ uninstallMod(modId: string): void {
+ modLoader.unloadMod(modId);
+ this.config.enabledModIds = this.config.enabledModIds.filter((id) => id !== modId);
+ this.config.modOrder = this.config.modOrder.filter((id) => id !== modId);
+ this.saveConfig();
+ }
+
+ toggleMod(modId: string): void {
+ const mod = modLoader.getMod(modId);
+ if (!mod) return;
+
+ if (mod.enabled) {
+ modLoader.disableMod(modId);
+ this.config.enabledModIds = this.config.enabledModIds.filter((id) => id !== modId);
+ } else {
+ modLoader.enableMod(modId);
+ this.config.enabledModIds.push(modId);
+ }
+ this.saveConfig();
+ }
+
+ reorderMods(modIds: string[]): void {
+ this.config.modOrder = modIds;
+ modIds.forEach((modId, index) => {
+ modLoader.setModPriority(modId, index * 100);
+ });
+ this.saveConfig();
+ }
+
+ getModList(): LoadedMod[] {
+ return modLoader.getAllMods();
+ }
+
+ getEnabledModList(): LoadedMod[] {
+ return modLoader.getEnabledMods();
+ }
+
+ getConflicts(): string[] {
+ return modLoader.detectConflicts();
+ }
+
+ getModManifest(modId: string): ModManifest | undefined {
+ return modLoader.getMod(modId)?.manifest;
+ }
+
+ async importMod(file: File): Promise {
+ try {
+ const text = await file.text();
+ const modData = JSON.parse(text) as ModPackage;
+
+ if (!modData.manifest || !modData.data) {
+ console.error('Invalid mod format');
+ return false;
+ }
+
+ return await this.installMod(modData.manifest, modData.data, modData.scripts);
+ } catch (error) {
+ console.error('Failed to import mod:', error);
+ return false;
+ }
+ }
+
+ exportMod(modId: string): string | null {
+ const mod = modLoader.getMod(modId);
+ if (!mod) return null;
+
+ return JSON.stringify({
+ manifest: mod.manifest,
+ data: mod.data,
+ }, null, 2);
+ }
+
+ private saveConfig(): void {
+ try {
+ localStorage.setItem('oes-web-mod-config', JSON.stringify(this.config));
+ } catch (error) {
+ console.error('Failed to save mod config:', error);
+ }
+ }
+
+ loadConfig(): void {
+ try {
+ const saved = localStorage.getItem('oes-web-mod-config');
+ if (saved) {
+ this.config = JSON.parse(saved);
+ }
+ } catch (error) {
+ console.error('Failed to load mod config:', error);
+ }
+ }
+}
+
+export const modManager = ModManager.getInstance();
diff --git a/src/mods/ModResolver.ts b/src/mods/ModResolver.ts
new file mode 100644
index 0000000..f0cf93a
--- /dev/null
+++ b/src/mods/ModResolver.ts
@@ -0,0 +1,135 @@
+import {
+ MOD_DATA_DOMAINS,
+ type JsonObject,
+ type ModConflict,
+ type ModData,
+ type ModDataDomain,
+ type LoadedMod,
+ type ModResolveResult,
+} from './ModTypes';
+
+const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+
+export function resolveMods(mods: LoadedMod[]): ModResolveResult {
+ const enabledMods = mods.filter((mod) => mod.enabled);
+ const orderedMods = orderMods(enabledMods);
+ const conflicts = detectConflicts(orderedMods);
+ let data: ModData = {};
+
+ for (const mod of orderedMods) {
+ data = deepMerge(data, mod.data) as ModData;
+ }
+
+ return {
+ data,
+ conflicts,
+ order: orderedMods.map((mod) => mod.manifest.id),
+ };
+}
+
+function orderMods(mods: LoadedMod[]): LoadedMod[] {
+ const byId = new Map(mods.map((mod) => [mod.manifest.id, mod]));
+ const visited = new Set();
+ const visiting = new Set();
+ const ordered: LoadedMod[] = [];
+
+ const visit = (mod: LoadedMod): void => {
+ if (visited.has(mod.manifest.id)) return;
+ if (visiting.has(mod.manifest.id)) return;
+
+ visiting.add(mod.manifest.id);
+ for (const dependencyId of mod.manifest.dependencies) {
+ const dependency = byId.get(dependencyId);
+ if (dependency) {
+ visit(dependency);
+ }
+ }
+ visiting.delete(mod.manifest.id);
+ visited.add(mod.manifest.id);
+ ordered.push(mod);
+ };
+
+ mods
+ .slice()
+ .sort((a, b) => a.manifest.priority - b.manifest.priority || a.manifest.id.localeCompare(b.manifest.id))
+ .forEach(visit);
+
+ return ordered;
+}
+
+function detectConflicts(mods: LoadedMod[]): ModConflict[] {
+ const writers = new Map();
+
+ for (const mod of mods) {
+ for (const domain of MOD_DATA_DOMAINS) {
+ const domainData = mod.data[domain];
+ if (!isJsonObjectRecord(domainData)) continue;
+
+ for (const id of Object.keys(domainData)) {
+ const key = `${domain}:${id}`;
+ const existing = writers.get(key);
+ if (existing) {
+ existing.modIds.push(mod.manifest.id);
+ } else {
+ writers.set(key, { domain, id, modIds: [mod.manifest.id] });
+ }
+ }
+ }
+ }
+
+ return Array.from(writers.values())
+ .filter((writer) => writer.modIds.length > 1)
+ .map((writer) => ({
+ domain: writer.domain,
+ id: writer.id,
+ modIds: writer.modIds,
+ winnerModId: writer.modIds[writer.modIds.length - 1]!,
+ }));
+}
+
+export function deepMerge(target: unknown, source: unknown): unknown {
+ if (!isPlainObject(target) || !isPlainObject(source)) {
+ return cloneJson(source);
+ }
+
+ const result: JsonObject = { ...target };
+
+ for (const [key, value] of Object.entries(source)) {
+ if (UNSAFE_KEYS.has(key)) {
+ continue;
+ }
+
+ const targetValue = result[key];
+ result[key] = isPlainObject(targetValue) && isPlainObject(value)
+ ? deepMerge(targetValue, value) as JsonObject
+ : cloneJson(value);
+ }
+
+ return result;
+}
+
+function cloneJson(value: T): T {
+ if (Array.isArray(value)) {
+ return value.map((item) => cloneJson(item)) as T;
+ }
+
+ if (isPlainObject(value)) {
+ const clone: JsonObject = {};
+ for (const [key, child] of Object.entries(value)) {
+ if (!UNSAFE_KEYS.has(key)) {
+ clone[key] = cloneJson(child);
+ }
+ }
+ return clone as T;
+ }
+
+ return value;
+}
+
+function isPlainObject(value: unknown): value is JsonObject {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isJsonObjectRecord(value: unknown): value is Record {
+ return isPlainObject(value);
+}
diff --git a/src/mods/ModScriptEngine.ts b/src/mods/ModScriptEngine.ts
new file mode 100644
index 0000000..4a5adbf
--- /dev/null
+++ b/src/mods/ModScriptEngine.ts
@@ -0,0 +1,156 @@
+import { eventBus } from '../core/EventBus';
+import { createScriptContext, cleanupScriptEvents } from './ScriptContext';
+import type { ScriptDefinition, ScriptInstance } from './ModTypes';
+
+export class ModScriptEngine {
+ private static instance: ModScriptEngine;
+ private definitions: Map = new Map();
+ private instances: Map = new Map();
+ private compiledHandlers: Map> = new Map();
+
+ static getInstance(): ModScriptEngine {
+ if (!ModScriptEngine.instance) {
+ ModScriptEngine.instance = new ModScriptEngine();
+ }
+ return ModScriptEngine.instance;
+ }
+
+ registerScriptDefinition(scriptId: string, definition: ScriptDefinition): void {
+ this.definitions.set(scriptId, definition);
+ this.compileHandlers(scriptId, definition);
+ }
+
+ private compileHandlers(scriptId: string, definition: ScriptDefinition): void {
+ const handlers = new Map();
+ for (const [event, code] of Object.entries(definition.handlers)) {
+ try {
+ const fn = new Function('ctx', 'eventData', 'delta', `"use strict";\n${code}`);
+ handlers.set(event, fn);
+ } catch (err) {
+ console.error(`[ModScript] Failed to compile handler ${event} for script ${scriptId}:`, err);
+ eventBus.emit('script:error', { modId: scriptId, scriptId: event, error: err });
+ }
+ }
+ this.compiledHandlers.set(scriptId, handlers);
+ }
+
+ instantiateScript(scriptId: string, entityId: string): ScriptInstance | null {
+ const definition = this.definitions.get(scriptId);
+ if (!definition) {
+ console.warn(`[ModScript] Script definition '${scriptId}' not found`);
+ return null;
+ }
+
+ const handlers = this.compiledHandlers.get(scriptId);
+ if (!handlers) {
+ console.warn(`[ModScript] No compiled handlers for '${scriptId}'`);
+ return null;
+ }
+
+ const instanceId = `${scriptId}:${entityId}`;
+ const instance: ScriptInstance = {
+ scriptId,
+ entityId,
+ properties: { ...definition.properties },
+ compiledHandlers: new Map(handlers),
+ eventSubscriptions: [],
+ };
+
+ this.instances.set(instanceId, instance);
+ return instance;
+ }
+
+ executeHandler(entityId: string, scriptId: string, eventName: string, eventData?: unknown, delta?: number): void {
+ const instanceId = `${scriptId}:${entityId}`;
+ const instance = this.instances.get(instanceId);
+ if (!instance) return;
+
+ const handler = instance.compiledHandlers.get(eventName);
+ if (!handler) return;
+
+ const ctx = createScriptContext(entityId, instance);
+ try {
+ handler(ctx, eventData, delta);
+ } catch (err) {
+ console.error(`[ModScript] Error in ${scriptId}.${eventName} on entity ${entityId}:`, err);
+ eventBus.emit('script:error', { modId: scriptId, scriptId: eventName, error: err });
+ }
+ }
+
+ getInstancesForEntity(entityId: string): ScriptInstance[] {
+ const results: ScriptInstance[] = [];
+ for (const [key, instance] of this.instances) {
+ if (key.endsWith(`:${entityId}`)) {
+ results.push(instance);
+ }
+ }
+ return results;
+ }
+
+ getAllInstances(): ScriptInstance[] {
+ return Array.from(this.instances.values());
+ }
+
+ removeInstance(scriptId: string, entityId: string): void {
+ const instanceId = `${scriptId}:${entityId}`;
+ const instance = this.instances.get(instanceId);
+ if (instance) {
+ cleanupScriptEvents(instance);
+ this.instances.delete(instanceId);
+ }
+ }
+
+ removeAllForEntity(entityId: string): void {
+ const toRemove: string[] = [];
+ for (const [key, instance] of this.instances) {
+ if (key.endsWith(`:${entityId}`)) {
+ cleanupScriptEvents(instance);
+ toRemove.push(key);
+ }
+ }
+ for (const key of toRemove) {
+ this.instances.delete(key);
+ }
+ }
+
+ getVariable(entityId: string, scriptId: string, key: string): unknown {
+ const instanceId = `${scriptId}:${entityId}`;
+ return this.instances.get(instanceId)?.properties[key];
+ }
+
+ setVariable(entityId: string, scriptId: string, key: string, value: unknown): void {
+ const instanceId = `${scriptId}:${entityId}`;
+ const instance = this.instances.get(instanceId);
+ if (instance) instance.properties[key] = value;
+ }
+
+ clearModScripts(modId: string): void {
+ const toRemove: string[] = [];
+ for (const [key, instance] of this.instances) {
+ if (instance.scriptId.startsWith(`${modId}:`)) {
+ cleanupScriptEvents(instance);
+ toRemove.push(key);
+ }
+ }
+ for (const key of toRemove) {
+ this.instances.delete(key);
+ }
+ for (const [scriptId] of this.definitions) {
+ if (scriptId.startsWith(`${modId}:`)) {
+ this.definitions.delete(scriptId);
+ this.compiledHandlers.delete(scriptId);
+ }
+ }
+ }
+
+ clearAll(): void {
+ for (const instance of this.instances.values()) {
+ cleanupScriptEvents(instance);
+ }
+ this.instances.clear();
+ this.definitions.clear();
+ this.compiledHandlers.clear();
+ }
+}
+
+export const modScriptEngine = ModScriptEngine.getInstance();
diff --git a/src/mods/ModTypes.ts b/src/mods/ModTypes.ts
new file mode 100644
index 0000000..7b866bd
--- /dev/null
+++ b/src/mods/ModTypes.ts
@@ -0,0 +1,99 @@
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
+export interface JsonObject {
+ [key: string]: JsonValue;
+}
+
+export const MOD_DATA_DOMAINS = [
+ 'items',
+ 'armor',
+ 'enemies',
+ 'races',
+ 'skills',
+ 'perkTrees',
+ 'spells',
+ 'shouts',
+ 'standingStones',
+ 'quests',
+ 'recipes',
+ 'enchantments',
+ 'soulGems',
+ 'smithing',
+ 'cooking',
+ 'dialogue',
+ 'transforms',
+ 'vampireStages',
+ 'gameConfig',
+] as const;
+
+export type ModDataDomain = (typeof MOD_DATA_DOMAINS)[number];
+export type ModDomainRecord = Record;
+
+export type ModData = Partial> & {
+ [key: string]: unknown;
+};
+
+export interface ModManifest {
+ id: string;
+ name: string;
+ version: string;
+ author?: string;
+ description?: string;
+ priority?: number;
+ dependencies?: string[];
+ supportedGameVersion?: string;
+ dataOverrides?: string;
+ scripts?: string[];
+}
+
+export interface ModPackage {
+ manifest: ModManifest;
+ data?: ModData;
+ scripts?: Record;
+}
+
+export interface LoadedMod {
+ manifest: Required> &
+ Omit;
+ data: ModData;
+ enabled: boolean;
+ installedAt: number;
+ warnings: ModIssue[];
+}
+
+export type ModIssueSeverity = 'error' | 'warning';
+
+export interface ModIssue {
+ severity: ModIssueSeverity;
+ code: string;
+ message: string;
+ path?: string;
+}
+
+export interface ModConflict {
+ domain: ModDataDomain;
+ id: string;
+ modIds: string[];
+ winnerModId: string;
+}
+
+export interface ScriptDefinition {
+ properties: Record;
+ handlers: Record;
+}
+
+export type ScriptEventHandler = 'OnLoad' | 'OnUpdate' | 'OnHit' | 'OnDeath' | 'OnActivate' | 'OnUnload' | 'OnEquip' | 'OnUse' | 'OnZoneEnter' | 'OnZoneLeave';
+
+export interface ScriptInstance {
+ scriptId: string;
+ entityId: string;
+ properties: Record;
+ compiledHandlers: Map;
+ eventSubscriptions: Array<{ event: string; unsub: () => void }>;
+}
+
+export interface ModResolveResult {
+ data: ModData;
+ order: string[];
+ conflicts: ModConflict[];
+}
diff --git a/src/mods/ModValidator.ts b/src/mods/ModValidator.ts
new file mode 100644
index 0000000..4f00dbc
--- /dev/null
+++ b/src/mods/ModValidator.ts
@@ -0,0 +1,279 @@
+import { MOD_DATA_DOMAINS, type JsonObject, type ModData, type ModIssue, type ModManifest, type ScriptDefinition } from './ModTypes';
+
+const MOD_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{1,63}$/;
+const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+
+export interface ValidatedMod {
+ manifest: Required> &
+ Omit;
+ data: ModData;
+ issues: ModIssue[];
+}
+
+export function validateModPackage(
+ manifest: unknown,
+ data: unknown,
+ scripts?: Record
+): ValidatedMod | null {
+ const issues: ModIssue[] = [];
+ const normalizedManifest = validateManifest(manifest, issues);
+ const normalizedData = validateData(data, issues);
+
+ if (scripts) {
+ validateScripts(scripts, issues);
+ }
+
+ if (!normalizedManifest || !normalizedData || issues.some((issue) => issue.severity === 'error')) {
+ return null;
+ }
+
+ return {
+ manifest: normalizedManifest,
+ data: normalizedData,
+ issues,
+ };
+}
+
+function validateScripts(scripts: Record, issues: ModIssue[]): void {
+ const VALID_HANDLERS = new Set(['OnLoad', 'OnUpdate', 'OnHit', 'OnDeath', 'OnActivate', 'OnUnload', 'OnEquip', 'OnUse', 'OnZoneEnter', 'OnZoneLeave']);
+
+ for (const [scriptId, definition] of Object.entries(scripts)) {
+ if (!MOD_ID_PATTERN.test(scriptId)) {
+ issues.push({
+ severity: 'error',
+ code: 'script_id_invalid',
+ path: `scripts.${scriptId}`,
+ message: `Script id "${scriptId}" is not valid.`,
+ });
+ continue;
+ }
+
+ if (!isRecord(definition)) {
+ issues.push({
+ severity: 'error',
+ code: 'script_definition_invalid',
+ path: `scripts.${scriptId}`,
+ message: `Script definition must be an object.`,
+ });
+ continue;
+ }
+
+ if (definition.properties !== undefined && !isRecord(definition.properties)) {
+ issues.push({
+ severity: 'error',
+ code: 'script_properties_invalid',
+ path: `scripts.${scriptId}.properties`,
+ message: `Script properties must be an object.`,
+ });
+ }
+
+ if (definition.handlers !== undefined) {
+ if (!isRecord(definition.handlers)) {
+ issues.push({
+ severity: 'error',
+ code: 'script_handlers_invalid',
+ path: `scripts.${scriptId}.handlers`,
+ message: `Script handlers must be an object.`,
+ });
+ } else {
+ for (const [handlerName, code] of Object.entries(definition.handlers)) {
+ if (!VALID_HANDLERS.has(handlerName)) {
+ issues.push({
+ severity: 'warning',
+ code: 'script_handler_unknown',
+ path: `scripts.${scriptId}.handlers.${handlerName}`,
+ message: `Unknown script handler "${handlerName}".`,
+ });
+ }
+ if (typeof code !== 'string') {
+ issues.push({
+ severity: 'error',
+ code: 'script_handler_not_string',
+ path: `scripts.${scriptId}.handlers.${handlerName}`,
+ message: `Script handler code must be a string.`,
+ });
+ }
+ }
+ }
+ }
+ }
+}
+
+function validateManifest(
+ manifest: unknown,
+ issues: ModIssue[]
+): ValidatedMod['manifest'] | null {
+ if (!isRecord(manifest)) {
+ issues.push({
+ severity: 'error',
+ code: 'manifest_invalid',
+ path: 'manifest',
+ message: 'Mod manifest must be an object.',
+ });
+ return null;
+ }
+
+ const id = readString(manifest, 'id');
+ const name = readString(manifest, 'name');
+ const version = readString(manifest, 'version');
+
+ if (!id || !MOD_ID_PATTERN.test(id)) {
+ issues.push({
+ severity: 'error',
+ code: 'manifest_id_invalid',
+ path: 'manifest.id',
+ message: 'Mod id must be lowercase letters, numbers, underscore, or hyphen.',
+ });
+ }
+
+ if (!name) {
+ issues.push({
+ severity: 'error',
+ code: 'manifest_name_missing',
+ path: 'manifest.name',
+ message: 'Mod name is required.',
+ });
+ }
+
+ if (!version) {
+ issues.push({
+ severity: 'error',
+ code: 'manifest_version_missing',
+ path: 'manifest.version',
+ message: 'Mod version is required.',
+ });
+ }
+
+ const rawPriority = manifest.priority;
+ const priority = typeof rawPriority === 'number' && Number.isFinite(rawPriority)
+ ? Math.trunc(rawPriority)
+ : 1000;
+
+ const dependencies = Array.isArray(manifest.dependencies)
+ ? manifest.dependencies.filter((dependency): dependency is string => typeof dependency === 'string')
+ : [];
+
+ if (Array.isArray(manifest.dependencies) && dependencies.length !== manifest.dependencies.length) {
+ issues.push({
+ severity: 'error',
+ code: 'manifest_dependencies_invalid',
+ path: 'manifest.dependencies',
+ message: 'Dependencies must be string mod ids.',
+ });
+ }
+
+ if (!id || !name || !version) {
+ return null;
+ }
+
+ return {
+ ...manifest,
+ id,
+ name,
+ version,
+ priority,
+ dependencies,
+ } as ValidatedMod['manifest'];
+}
+
+function validateData(data: unknown, issues: ModIssue[]): ModData | null {
+ if (data === undefined || data === null) {
+ return {};
+ }
+
+ if (!isRecord(data)) {
+ issues.push({
+ severity: 'error',
+ code: 'data_invalid',
+ path: 'data',
+ message: 'Mod data must be an object.',
+ });
+ return null;
+ }
+
+ const normalized: ModData = {};
+ const knownDomains = new Set(MOD_DATA_DOMAINS);
+
+ for (const [domain, value] of Object.entries(data)) {
+ if (!knownDomains.has(domain)) {
+ issues.push({
+ severity: 'warning',
+ code: 'data_domain_unknown',
+ path: `data.${domain}`,
+ message: `Unknown mod data domain "${domain}" will be ignored by the current engine.`,
+ });
+ normalized[domain] = value;
+ continue;
+ }
+
+ if (!isRecord(value)) {
+ issues.push({
+ severity: 'error',
+ code: 'data_domain_invalid',
+ path: `data.${domain}`,
+ message: `Mod data domain "${domain}" must be an object keyed by id.`,
+ });
+ continue;
+ }
+
+ const domainRecord: Record = {};
+ for (const [entryId, entryValue] of Object.entries(value)) {
+ if (!MOD_ID_PATTERN.test(entryId)) {
+ issues.push({
+ severity: 'error',
+ code: 'data_entry_id_invalid',
+ path: `data.${domain}.${entryId}`,
+ message: `Entry id "${entryId}" is not a valid content id.`,
+ });
+ continue;
+ }
+
+ if (hasUnsafeKey(entryValue)) {
+ issues.push({
+ severity: 'error',
+ code: 'data_entry_unsafe_key',
+ path: `data.${domain}.${entryId}`,
+ message: 'Mod data cannot contain prototype-polluting keys.',
+ });
+ continue;
+ }
+
+ if (!isRecord(entryValue)) {
+ issues.push({
+ severity: 'error',
+ code: 'data_entry_invalid',
+ path: `data.${domain}.${entryId}`,
+ message: 'Mod data entries must be objects.',
+ });
+ continue;
+ }
+
+ domainRecord[entryId] = { ...entryValue, id: readString(entryValue, 'id') || entryId };
+ }
+
+ normalized[domain] = domainRecord;
+ }
+
+ return normalized;
+}
+
+function readString(record: Record, key: string): string | null {
+ const value = record[key];
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function hasUnsafeKey(value: unknown): boolean {
+ if (Array.isArray(value)) {
+ return value.some((item) => hasUnsafeKey(item));
+ }
+
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ return Object.entries(value).some(([key, child]) => UNSAFE_KEYS.has(key) || hasUnsafeKey(child));
+}
diff --git a/src/mods/ScriptContext.ts b/src/mods/ScriptContext.ts
new file mode 100644
index 0000000..3db1c2b
--- /dev/null
+++ b/src/mods/ScriptContext.ts
@@ -0,0 +1,540 @@
+import { entityManager, type Entity } from '../core/EntityManager';
+import { eventBus } from '../core/EventBus';
+import { inventorySystem } from '../systems/InventorySystem';
+import { statusEffectSystem } from '../systems/StatusEffectSystem';
+import { dayNightSystem } from '../systems/DayNightSystem';
+import { dataRegistry } from '../data/DataRegistry';
+import type { ScriptInstance } from './ModTypes';
+
+// Lazy access via globalThis to break circular dependency:
+// DataRegistry → ModLoader → ModScriptEngine → ScriptContext → CombatSystem/MagicSystem/DialogueSystem → DataRegistry
+function getCombatSystem() {
+ return (globalThis as any).__oesCombatSystem;
+}
+function getMagicSystem() {
+ return (globalThis as any).__oesMagicSystem;
+}
+function getQuestSystem() {
+ return (globalThis as any).__oesQuestSystem;
+}
+function getDialogueSystem() {
+ return (globalThis as any).__oesDialogueSystem;
+}
+
+export interface EntityAPI {
+ id: string;
+ getHealth(): number;
+ setHealth(n: number): void;
+ getMagicka(): number;
+ setMagicka(n: number): void;
+ getStamina(): number;
+ setStamina(n: number): void;
+ getPosition(): { x: number; y: number };
+ setPosition(x: number, y: number): void;
+ getSkill(name: string): number;
+ setSkill(name: string, value: number): void;
+ getLevel(): number;
+}
+
+export interface InventoryAPI {
+ addItem(id: string, qty?: number): boolean;
+ removeItem(id: string, qty?: number): boolean;
+ hasItem(id: string, qty?: number): boolean;
+ getItemCount(id: string): number;
+ getGold(): number;
+ addGold(n: number): void;
+ removeGold(n: number): boolean;
+}
+
+export interface CombatAPI {
+ damage(targetId: string, amount: number): void;
+ heal(targetId: string, amount: number): void;
+ kill(targetId: string): void;
+ getAttackRange(): number;
+}
+
+export interface MagicAPI {
+ castSpell(spellId: string, targetId?: string): boolean;
+ learnSpell(spellId: string): boolean;
+ hasSpell(spellId: string): boolean;
+ getSpellsBySchool(school: string): string[];
+}
+
+export interface QuestAPI {
+ start(questId: string): boolean;
+ complete(questId: string): boolean;
+ fail(questId: string): boolean;
+ isActive(questId: string): boolean;
+ isCompleted(questId: string): boolean;
+ getActiveQuests(): string[];
+ setObjective(questId: string, objectiveId: string, count?: number): void;
+ getState(questId: string): { status: string; currentObjective: number; completed: number; total: number } | null;
+}
+
+export interface EffectsAPI {
+ apply(effect: { id: string; type: 'buff' | 'debuff'; attribute: string; magnitude: number; durationMs: number; source?: string }): void;
+ remove(effectId: string): void;
+ has(effectId: string): boolean;
+}
+
+export interface TimeAPI {
+ get(): number;
+ getHour(): number;
+ getMinute(): number;
+ isNight(): boolean;
+ isDawn(): boolean;
+ isDusk(): boolean;
+ getDayCount(): number;
+ getTimeString(): string;
+}
+
+export interface EntitiesAPI {
+ get(id: string): { id: string; type: string; position: { x: number; y: number } | null } | undefined;
+ getByType(type: string): { id: string; type: string; position: { x: number; y: number } | null }[];
+ getNearby(range: number): { id: string; type: string; position: { x: number; y: number } | null; distance: number }[];
+}
+
+export interface EventsAPI {
+ on(event: string, callback: (...args: unknown[]) => void): void;
+ off(event: string): void;
+ emit(event: string, data?: unknown): void;
+}
+
+export interface DataAPI {
+ getItem(id: string): unknown;
+ getAllItems(): unknown[];
+ getEnemy(id: string): unknown;
+ getAllEnemies(): unknown[];
+ getSpell(id: string): unknown;
+ getAllSpells(): unknown[];
+ getRace(id: string): unknown;
+ getAllRaces(): unknown[];
+ getArmor(id: string): unknown;
+ getAllArmor(): unknown[];
+ getQuest(id: string): unknown;
+ getAllQuests(): unknown[];
+ getEnchantment(id: string): unknown;
+ getAllEnchantments(): unknown[];
+ getShout(id: string): unknown;
+ getAllShouts(): unknown[];
+ getStandingStone(id: string): unknown;
+ getAllStandingStones(): unknown[];
+ getRecipe(id: string): unknown;
+ getAllRecipes(): unknown[];
+ getSkill(id: string): unknown;
+ getAllSkills(): unknown[];
+}
+
+export interface ScriptContext {
+ entity: EntityAPI;
+ inventory: InventoryAPI;
+ combat: CombatAPI;
+ magic: MagicAPI;
+ dialogue: { start(treeId: string, npcEntityId: string): void; respond(responseIndex: number): void; close(): void };
+ quest: QuestAPI;
+ effects: EffectsAPI;
+ prop(key: string): unknown;
+ prop(key: string, value: unknown): void;
+ time: TimeAPI;
+ entities: EntitiesAPI;
+ events: EventsAPI;
+ data: DataAPI;
+ log(msg: string): void;
+}
+
+function getEntityById(id: string): Entity | undefined {
+ return entityManager.getEntity(id);
+}
+
+function buildEntityAPI(entityId: string): EntityAPI {
+ return {
+ id: entityId,
+ getHealth(): number {
+ const h = entityManager.getComponent<{ current: number; max: number }>(entityId, 'health');
+ return h ? Math.round(h.current) : 0;
+ },
+ setHealth(n: number): void {
+ const h = entityManager.getComponent<{ current: number; max: number }>(entityId, 'health');
+ if (h) h.current = Math.round(Math.max(0, Math.min(h.max, n)));
+ },
+ getMagicka(): number {
+ const m = entityManager.getComponent<{ current: number; max: number }>(entityId, 'magicka');
+ return m ? Math.round(m.current) : 0;
+ },
+ setMagicka(n: number): void {
+ const m = entityManager.getComponent<{ current: number; max: number }>(entityId, 'magicka');
+ if (m) m.current = Math.round(Math.max(0, Math.min(m.max, n)));
+ },
+ getStamina(): number {
+ const s = entityManager.getComponent<{ current: number; max: number }>(entityId, 'stamina');
+ return s ? Math.round(s.current) : 0;
+ },
+ setStamina(n: number): void {
+ const s = entityManager.getComponent<{ current: number; max: number }>(entityId, 'stamina');
+ if (s) s.current = Math.round(Math.max(0, Math.min(s.max, n)));
+ },
+ getPosition(): { x: number; y: number } {
+ const p = entityManager.getComponent<{ x: number; y: number }>(entityId, 'position');
+ return p ? { x: p.x, y: p.y } : { x: 0, y: 0 };
+ },
+ setPosition(x: number, y: number): void {
+ const p = entityManager.getComponent<{ x: number; y: number }>(entityId, 'position');
+ if (p) { p.x = x; p.y = y; }
+ },
+ getSkill(name: string): number {
+ const s = entityManager.getComponent>(entityId, 'skills');
+ return s ? (s[name] ?? 0) : 0;
+ },
+ setSkill(name: string, value: number): void {
+ const s = entityManager.getComponent>(entityId, 'skills');
+ if (s) s[name] = Math.round(value);
+ },
+ getLevel(): number {
+ const l = entityManager.getComponent<{ level: number }>(entityId, 'level');
+ return l ? l.level : 1;
+ },
+ };
+}
+
+function buildInventoryAPI(entityId: string): InventoryAPI {
+ const entity = getEntityById(entityId);
+ return {
+ addItem(id: string, qty?: number): boolean {
+ if (!entity) return false;
+ return inventorySystem.addItem(entity, id, qty);
+ },
+ removeItem(id: string, qty?: number): boolean {
+ if (!entity) return false;
+ return inventorySystem.removeItem(entity, id, qty);
+ },
+ hasItem(id: string, qty?: number): boolean {
+ if (!entity) return false;
+ return inventorySystem.hasItem(entity, id, qty);
+ },
+ getItemCount(id: string): number {
+ if (!entity) return 0;
+ return inventorySystem.getItemCount(entity, id);
+ },
+ getGold(): number {
+ const inv = entityManager.getComponent<{ gold: number }>(entityId, 'inventory');
+ return inv ? inv.gold : 0;
+ },
+ addGold(n: number): void {
+ const inv = entityManager.getComponent<{ gold: number }>(entityId, 'inventory');
+ if (inv) inv.gold += Math.round(n);
+ },
+ removeGold(n: number): boolean {
+ const inv = entityManager.getComponent<{ gold: number }>(entityId, 'inventory');
+ if (!inv || inv.gold < n) return false;
+ inv.gold -= Math.round(n);
+ return true;
+ },
+ };
+}
+
+function buildCombatAPI(entityId: string): CombatAPI {
+ return {
+ damage(targetId: string, amount: number): void {
+ const target = getEntityById(targetId);
+ if (!target) return;
+ const h = entityManager.getComponent<{ current: number; max: number }>(targetId, 'health');
+ if (!h) return;
+ h.current = Math.round(Math.max(0, h.current - Math.round(amount)));
+ eventBus.emit('combat:afterAttack', {
+ attacker: getEntityById(entityId),
+ target,
+ damage: Math.round(amount),
+ isPowerAttack: false,
+ isBlocked: false,
+ isCritical: false,
+ });
+ if (h.current <= 0) {
+ eventBus.emit('entity:killed', { entity: target, killer: getEntityById(entityId) });
+ }
+ },
+ heal(targetId: string, amount: number): void {
+ const h = entityManager.getComponent<{ current: number; max: number }>(targetId, 'health');
+ if (h) h.current = Math.round(Math.min(h.max, h.current + Math.round(amount)));
+ },
+ kill(targetId: string): void {
+ const target = getEntityById(targetId);
+ if (!target) return;
+ const h = entityManager.getComponent<{ current: number; max: number }>(targetId, 'health');
+ if (h) h.current = 0;
+ eventBus.emit('entity:killed', { entity: target, killer: getEntityById(entityId) });
+ },
+ getAttackRange(): number {
+ const cs = getCombatSystem();
+ return cs ? cs.getAttackRange() : 45;
+ },
+ };
+}
+
+function buildMagicAPI(entityId: string): MagicAPI {
+ return {
+ castSpell(spellId: string, targetId?: string): boolean {
+ const ms = getMagicSystem();
+ if (!ms) return false;
+ const caster = getEntityById(entityId);
+ if (!caster) return false;
+ const target = targetId ? getEntityById(targetId) : undefined;
+ return ms.castSpell(caster, spellId, target);
+ },
+ learnSpell(spellId: string): boolean {
+ const ms = getMagicSystem();
+ if (!ms) return false;
+ const entity = getEntityById(entityId);
+ if (!entity) return false;
+ return ms.learnSpell(entity, spellId);
+ },
+ hasSpell(spellId: string): boolean {
+ const ms = getMagicSystem();
+ if (!ms) return false;
+ const entity = getEntityById(entityId);
+ if (!entity) return false;
+ return ms.hasSpell(entity, spellId);
+ },
+ getSpellsBySchool(school: string): string[] {
+ const ms = getMagicSystem();
+ if (!ms) return [];
+ return ms.getSpellsBySchool(school as 'destruction' | 'restoration' | 'illusion' | 'conjuration' | 'alteration').map((s: { id: string }) => s.id);
+ },
+ };
+}
+
+function buildQuestAPI(entityId: string): QuestAPI {
+ return {
+ start(questId: string): boolean {
+ const qs = getQuestSystem();
+ if (!qs) return false;
+ const player = entityManager.getEntity(entityId);
+ if (!player) return false;
+ return qs.startQuest(questId, player);
+ },
+ complete(questId: string): boolean {
+ const qs = getQuestSystem();
+ if (!qs) return false;
+ return qs.completeQuest(questId);
+ },
+ fail(questId: string): boolean {
+ const qs = getQuestSystem();
+ if (!qs) return false;
+ return qs.failQuest(questId);
+ },
+ isActive(questId: string): boolean {
+ const qs = getQuestSystem();
+ return qs ? qs.isQuestActive(questId) : false;
+ },
+ isCompleted(questId: string): boolean {
+ const qs = getQuestSystem();
+ return qs ? qs.isQuestCompleted(questId) : false;
+ },
+ getActiveQuests(): string[] {
+ const qs = getQuestSystem();
+ return qs ? qs.getActiveQuests().map((q: { id: string }) => q.id) : [];
+ },
+ setObjective(questId: string, objectiveId: string, count?: number): void {
+ const qs = getQuestSystem();
+ if (!qs) return;
+ const quest = qs.getQuest(questId);
+ if (!quest) return;
+ for (const obj of quest.objectives) {
+ if (obj.id === objectiveId && !obj.completed) {
+ if (count !== undefined) {
+ obj.currentCount = (obj.currentCount || 0) + count;
+ if (obj.count && obj.currentCount >= obj.count) {
+ obj.completed = true;
+ }
+ } else {
+ obj.completed = true;
+ }
+ break;
+ }
+ }
+ },
+ getState(questId: string): { status: string; currentObjective: number; completed: number; total: number } | null {
+ const qs = getQuestSystem();
+ if (!qs) return null;
+ const quest = qs.getQuest(questId);
+ if (!quest) return null;
+ const completed = quest.objectives.filter((o: { completed: boolean }) => o.completed).length;
+ return {
+ status: quest.status,
+ currentObjective: quest.currentObjective,
+ completed,
+ total: quest.objectives.length,
+ };
+ },
+ };
+}
+
+function buildEffectsAPI(entityId: string): EffectsAPI {
+ return {
+ apply(effect): void {
+ statusEffectSystem.applyEffect(entityId, {
+ id: effect.id,
+ type: effect.type,
+ attribute: effect.attribute,
+ magnitude: effect.magnitude,
+ remainingMs: effect.durationMs,
+ totalMs: effect.durationMs,
+ source: effect.source ?? 'script',
+ });
+ },
+ remove(effectId: string): void {
+ statusEffectSystem.removeEffect(entityId, effectId);
+ },
+ has(effectId: string): boolean {
+ return statusEffectSystem.hasEffect(entityId, effectId);
+ },
+ };
+}
+
+function buildTimeAPI(): TimeAPI {
+ return {
+ get: () => dayNightSystem.getTime(),
+ getHour: () => dayNightSystem.getHour(),
+ getMinute: () => dayNightSystem.getMinute(),
+ isNight: () => dayNightSystem.isNight(),
+ isDawn: () => dayNightSystem.isDawn(),
+ isDusk: () => dayNightSystem.isDusk(),
+ getDayCount: () => dayNightSystem.getDayCount(),
+ getTimeString: () => dayNightSystem.getTimeString(),
+ };
+}
+
+function buildEntitiesAPI(entityId: string): EntitiesAPI {
+ function toInfo(e: Entity) {
+ const p = entityManager.getComponent<{ x: number; y: number }>(e.id, 'position');
+ return { id: e.id, type: e.type, position: p ? { x: p.x, y: p.y } : null };
+ }
+
+ return {
+ get(id: string) {
+ const e = getEntityById(id);
+ return e ? toInfo(e) : undefined;
+ },
+ getByType(type: string) {
+ return entityManager.getEntitiesByType(type as 'player' | 'npc' | 'enemy' | 'item' | 'projectile' | 'trigger' | 'corpse').map(toInfo);
+ },
+ getNearby(range: number) {
+ const myPos = entityManager.getComponent<{ x: number; y: number }>(entityId, 'position');
+ if (!myPos) return [];
+ const results: { id: string; type: string; position: { x: number; y: number } | null; distance: number }[] = [];
+ for (const e of entityManager.getAllEntities()) {
+ if (e.id === entityId) continue;
+ const p = entityManager.getComponent<{ x: number; y: number }>(e.id, 'position');
+ if (!p) continue;
+ const dx = p.x - myPos.x;
+ const dy = p.y - myPos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= range) {
+ results.push({ id: e.id, type: e.type, position: { x: p.x, y: p.y }, distance: dist });
+ }
+ }
+ return results.sort((a, b) => a.distance - b.distance);
+ },
+ };
+}
+
+function buildDataAPI(): DataAPI {
+ return {
+ getItem: (id: string) => { try { return dataRegistry.getItem(id); } catch { return undefined; } },
+ getAllItems: () => { try { return dataRegistry.getAllItems(); } catch { return []; } },
+ getEnemy: (id: string) => { try { return dataRegistry.getEnemy(id); } catch { return undefined; } },
+ getAllEnemies: () => { try { return dataRegistry.getAllEnemies(); } catch { return []; } },
+ getSpell: (id: string) => { try { return dataRegistry.getSpell(id); } catch { return undefined; } },
+ getAllSpells: () => { try { return dataRegistry.getAllSpells(); } catch { return []; } },
+ getRace: (id: string) => { try { return dataRegistry.getRace(id); } catch { return undefined; } },
+ getAllRaces: () => { try { return dataRegistry.getAllRaces(); } catch { return []; } },
+ getArmor: (id: string) => { try { return dataRegistry.getArmor(id); } catch { return undefined; } },
+ getAllArmor: () => { try { return dataRegistry.getAllArmor(); } catch { return []; } },
+ getQuest: (id: string) => { try { return dataRegistry.getQuest(id); } catch { return undefined; } },
+ getAllQuests: () => { try { return dataRegistry.getAllQuests(); } catch { return []; } },
+ getEnchantment: (id: string) => { try { return dataRegistry.getEnchantment(id); } catch { return undefined; } },
+ getAllEnchantments: () => { try { return dataRegistry.getAllEnchantments(); } catch { return []; } },
+ getShout: (id: string) => { try { return dataRegistry.getShout(id); } catch { return undefined; } },
+ getAllShouts: () => { try { return dataRegistry.getAllShouts(); } catch { return []; } },
+ getStandingStone: (id: string) => { try { return dataRegistry.getStandingStone(id); } catch { return undefined; } },
+ getAllStandingStones: () => { try { return dataRegistry.getAllStandingStones(); } catch { return []; } },
+ getRecipe: (id: string) => { try { return dataRegistry.getRecipe(id); } catch { return undefined; } },
+ getAllRecipes: () => { try { return dataRegistry.getAllRecipes(); } catch { return []; } },
+ getSkill: (id: string) => { try { return dataRegistry.getSkill(id); } catch { return undefined; } },
+ getAllSkills: () => { try { return dataRegistry.getAllSkills(); } catch { return []; } },
+ };
+}
+
+export function createScriptContext(entityId: string, instance: ScriptInstance): ScriptContext {
+ const eventUnsubs: Array<{ event: string; unsub: () => void }> = [];
+
+ return {
+ entity: buildEntityAPI(entityId),
+ inventory: buildInventoryAPI(entityId),
+ combat: buildCombatAPI(entityId),
+ magic: buildMagicAPI(entityId),
+ dialogue: {
+ start(_treeId: string, npcEntityId: string): void {
+ const ds = getDialogueSystem();
+ if (!ds) return;
+ const player = entityManager.getEntity(entityId);
+ const npc = entityManager.getEntity(npcEntityId);
+ if (player && npc) {
+ ds.startDialogue(npc, player);
+ }
+ },
+ respond(responseIndex: number): void {
+ const ds = getDialogueSystem();
+ if (!ds) return;
+ const player = entityManager.getEntity(entityId);
+ if (!player) return;
+ const line = ds.getCurrentLine();
+ if (line?.options?.[responseIndex]) {
+ ds.selectOption(line.options[responseIndex].id, player);
+ }
+ },
+ close(): void {
+ const ds = getDialogueSystem();
+ if (ds) ds.endDialogue();
+ },
+ },
+ quest: buildQuestAPI(entityId),
+ effects: buildEffectsAPI(entityId),
+ prop(key: string, value?: unknown): unknown {
+ if (value === undefined) {
+ return instance.properties[key];
+ }
+ instance.properties[key] = value;
+ return value;
+ },
+ time: buildTimeAPI(),
+ entities: buildEntitiesAPI(entityId),
+ events: {
+ on(event: string, callback: (...args: unknown[]) => void): void {
+ const unsub = eventBus.on(event, callback);
+ eventUnsubs.push({ event, unsub });
+ instance.eventSubscriptions.push({ event, unsub });
+ },
+ off(event: string): void {
+ const idx = eventUnsubs.findIndex((s) => s.event === event);
+ if (idx !== -1) {
+ eventUnsubs[idx]!.unsub();
+ eventUnsubs.splice(idx, 1);
+ }
+ },
+ emit(event: string, data?: unknown): void {
+ eventBus.emit(event, data);
+ },
+ },
+ data: buildDataAPI(),
+ log(msg: string): void {
+ console.log(`[Mod:${instance.scriptId}:${entityId}] ${msg}`);
+ },
+ };
+}
+
+export function cleanupScriptEvents(instance: ScriptInstance): void {
+ for (const sub of instance.eventSubscriptions) {
+ sub.unsub();
+ }
+ instance.eventSubscriptions = [];
+}
diff --git a/src/mods/VirtualFS.ts b/src/mods/VirtualFS.ts
new file mode 100644
index 0000000..d72c675
--- /dev/null
+++ b/src/mods/VirtualFS.ts
@@ -0,0 +1,106 @@
+import { modManager } from './ModManager';
+import type { ModManifest, ModData } from './ModLoader';
+
+export class VirtualFS {
+ private static instance: VirtualFS;
+ private modBasePath: string = '/mods';
+
+ static getInstance(): VirtualFS {
+ if (!VirtualFS.instance) {
+ VirtualFS.instance = new VirtualFS();
+ }
+ return VirtualFS.instance;
+ }
+
+ async loadModFromURL(url: string): Promise {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch mod: ${response.statusText}`);
+ }
+
+ const modPackage = await response.json();
+
+ if (!modPackage.manifest || !modPackage.data) {
+ throw new Error('Invalid mod package format');
+ }
+
+ return await modManager.installMod(modPackage.manifest, modPackage.data);
+ } catch (error) {
+ console.error('Failed to load mod from URL:', error);
+ return false;
+ }
+ }
+
+ async loadModFromDirectory(directoryHandle: FileSystemDirectoryHandle): Promise {
+ try {
+ const manifestFile = await directoryHandle.getFileHandle('manifest.json');
+ const manifestBlob = await manifestFile.getFile();
+ const manifest: ModManifest = JSON.parse(await manifestBlob.text());
+
+ let data: ModData = {};
+
+ const dataFile = await directoryHandle.getFileHandle('data.json').catch(() => null);
+ if (dataFile) {
+ const dataBlob = await dataFile.getFile();
+ data = JSON.parse(await dataBlob.text());
+ }
+
+ return await modManager.installMod(manifest, data);
+ } catch (error) {
+ console.error('Failed to load mod from directory:', error);
+ return false;
+ }
+ }
+
+ async loadModFromFile(file: File): Promise {
+ try {
+ const text = await file.text();
+ const modPackage = JSON.parse(text);
+
+ if (!modPackage.manifest || !modPackage.data) {
+ throw new Error('Invalid mod file format');
+ }
+
+ return await modManager.installMod(modPackage.manifest, modPackage.data);
+ } catch (error) {
+ console.error('Failed to load mod from file:', error);
+ return false;
+ }
+ }
+
+ async saveModToFile(modId: string): Promise {
+ const modJson = modManager.exportMod(modId);
+ if (!modJson) {
+ console.error('Mod not found:', modId);
+ return;
+ }
+
+ const blob = new Blob([modJson], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${modId}.json`;
+ a.click();
+
+ URL.revokeObjectURL(url);
+ }
+
+ async loadAllLocalMods(): Promise {
+ try {
+ const response = await fetch(`${this.modBasePath}/index.json`);
+ if (!response.ok) return;
+
+ const modIndex: { id: string; path: string }[] = await response.json();
+
+ for (const modEntry of modIndex) {
+ await this.loadModFromURL(`${this.modBasePath}/${modEntry.path}`);
+ }
+ } catch (error) {
+ console.log('No local mods found');
+ }
+ }
+}
+
+export const virtualFS = VirtualFS.getInstance();
diff --git a/src/save/SaveManager.ts b/src/save/SaveManager.ts
new file mode 100644
index 0000000..c970e38
--- /dev/null
+++ b/src/save/SaveManager.ts
@@ -0,0 +1,110 @@
+import Dexie, { type Table } from 'dexie';
+import { eventBus } from '../core/EventBus';
+import { modLoader } from '../mods/ModLoader';
+
+export interface SaveData {
+ id?: number;
+ name: string;
+ timestamp: number;
+ playTime: number;
+ character: any;
+ inventory: any;
+ worldState: any;
+ quests: any;
+ factions: any;
+ npcs: any;
+ mapStates: any;
+ mods?: SaveModSnapshot;
+}
+
+export interface SaveModSnapshot {
+ enabledModIds: string[];
+ modOrder: string[];
+}
+
+class SaveDatabase extends Dexie {
+ saves!: Table;
+
+ constructor() {
+ super('OES-WEB-Saves');
+ this.version(1).stores({
+ saves: '++id, name, timestamp',
+ });
+ }
+}
+
+export class SaveManager {
+ private static instance: SaveManager;
+ private db: SaveDatabase;
+
+ static getInstance(): SaveManager {
+ if (!SaveManager.instance) {
+ SaveManager.instance = new SaveManager();
+ }
+ return SaveManager.instance;
+ }
+
+ constructor() {
+ this.db = new SaveDatabase();
+ }
+
+ async saveGame(name: string, data: Omit): Promise {
+ const saveData: SaveData = {
+ name,
+ timestamp: Date.now(),
+ mods: this.captureModSnapshot(),
+ ...data,
+ };
+
+ const id = await this.db.saves.add(saveData);
+ eventBus.emit('game:saved', { id, name });
+ return id!;
+ }
+
+ async loadGame(id: number): Promise {
+ const save = await this.db.saves.get(id);
+ if (save) {
+ eventBus.emit('game:loaded', { id });
+ }
+ return save;
+ }
+
+ async getSaveList(): Promise {
+ return this.db.saves.toArray();
+ }
+
+ async deleteSave(id: number): Promise {
+ await this.db.saves.delete(id);
+ eventBus.emit('game:saveDeleted', { id });
+ }
+
+ async updateSave(id: number, data: Partial): Promise {
+ await this.db.saves.update(id, data);
+ eventBus.emit('game:saveUpdated', { id });
+ }
+
+ private captureModSnapshot(): SaveModSnapshot {
+ const allMods = modLoader.getAllMods();
+ const enabledMods = modLoader.getEnabledMods();
+ return {
+ enabledModIds: enabledMods.map((m) => m.manifest.id),
+ modOrder: allMods
+ .sort((a, b) => a.manifest.priority - b.manifest.priority)
+ .map((m) => m.manifest.id),
+ };
+ }
+
+ getModSnapshotDiff(save: SaveData): { missing: string[]; extra: string[] } | null {
+ if (!save.mods) return null;
+
+ const currentEnabled = modLoader.getEnabledMods().map((m) => m.manifest.id);
+ const saveEnabled = save.mods.enabledModIds;
+
+ const missing = saveEnabled.filter((id) => !currentEnabled.includes(id));
+ const extra = currentEnabled.filter((id) => !saveEnabled.includes(id));
+
+ return { missing, extra };
+ }
+}
+
+export const saveManager = SaveManager.getInstance();
diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts
new file mode 100644
index 0000000..6610e17
--- /dev/null
+++ b/src/scenes/GameScene.ts
@@ -0,0 +1,618 @@
+import Phaser from 'phaser';
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity, type EntityType } from '../core/EntityManager';
+import { inventorySystem } from '../systems/InventorySystem';
+import { corpseSystem } from '../systems/CorpseSystem';
+import { groundItemSystem } from '../systems/GroundItemSystem';
+import { containerSystem } from '../systems/ContainerSystem';
+import { dialogueSystem } from '../systems/DialogueSystem';
+import { magicSystem } from '../systems/MagicSystem';
+import { combatSystem } from '../systems/CombatSystem';
+import { movementSystem } from '../systems/MovementSystem';
+import { aiSystem } from '../systems/AISystem';
+import { proximitySystem } from '../systems/ProximitySystem';
+import { regenSystem } from '../systems/RegenSystem';
+import { statusEffectSystem } from '../systems/StatusEffectSystem';
+import { dayNightSystem } from '../systems/DayNightSystem';
+import { scriptSystem } from '../systems/ScriptSystem';
+import { transformationSystem } from '../systems/TransformationSystem';
+import { vampireSystem } from '../systems/VampireSystem';
+import { dataRegistry } from '../data/DataRegistry';
+import { mapManager, type MapZone } from '../maps/MapManager';
+import { combatUI } from '../ui/components/CombatUI';
+import { uiManager } from '../ui/UIManager';
+import { worldMapUI } from '../ui/components/WorldMapUI';
+import { craftingUI } from '../ui/components/CraftingUI';
+
+export class GameScene extends Phaser.Scene {
+ private player!: Phaser.GameObjects.Rectangle;
+ private playerIndicator!: Phaser.GameObjects.Arc;
+ private playerShadow!: Phaser.GameObjects.Ellipse;
+ private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
+ private wasd!: { W: Phaser.Input.Keyboard.Key; A: Phaser.Input.Keyboard.Key; S: Phaser.Input.Keyboard.Key; D: Phaser.Input.Keyboard.Key };
+ private playerEntity!: Entity;
+ private interactKey!: Phaser.Input.Keyboard.Key;
+ private attackKey!: Phaser.Input.Keyboard.Key;
+ private powerAttackKey!: Phaser.Input.Keyboard.Key;
+ private useKey!: Phaser.Input.Keyboard.Key;
+ private inventoryKey!: Phaser.Input.Keyboard.Key;
+ private mapKey!: Phaser.Input.Keyboard.Key;
+ private shoutKey!: Phaser.Input.Keyboard.Key;
+ private craftingKey!: Phaser.Input.Keyboard.Key;
+ private transformKey!: Phaser.Input.Keyboard.Key;
+ private nearbyEntities = { enemy: null, corpse: null, item: null, container: null, npc: null } as { enemy: Entity | null; corpse: Entity | null; item: Entity | null; container: Entity | null; npc: Entity | null };
+ private mapTiles: Phaser.GameObjects.GameObject[] = [];
+ private currentZone: MapZone | null = null;
+ private isDead: boolean = false;
+
+ constructor() {
+ super({ key: 'GameScene' });
+ }
+
+ create(): void {
+ this.cameras.main.setBackgroundColor('#1a1a2e');
+
+ this.createPlayer();
+ this.setupInput();
+ this.setupCamera();
+ this.setupEventListeners();
+
+ regenSystem.setupWithEventBus(eventBus);
+
+ scriptSystem.initialize();
+
+ inventorySystem.initializeInventory(this.playerEntity);
+ inventorySystem.addItem(this.playerEntity, 'health_potion', 3);
+ inventorySystem.addItem(this.playerEntity, 'iron_ingot', 10);
+ inventorySystem.addItem(this.playerEntity, 'leather_strips', 5);
+ inventorySystem.addItem(this.playerEntity, 'leather', 3);
+ inventorySystem.addItem(this.playerEntity, 'blue_mountain_flower', 5);
+ inventorySystem.addItem(this.playerEntity, 'wheat', 3);
+ inventorySystem.addItem(this.playerEntity, 'salt_pile', 5);
+ magicSystem.learnSpell(this.playerEntity, 'flames');
+ magicSystem.learnSpell(this.playerEntity, 'healing');
+ magicSystem.learnSpell(this.playerEntity, 'conjure_familiar');
+
+ this.loadZone('whiterun_exterior');
+ }
+
+ private createPlayer(): void {
+ const startX = 400;
+ const startY = 400;
+
+ // Player shadow
+ const shadow = this.add.ellipse(startX, startY + 16, 28, 10, 0x000000, 0.3);
+ shadow.setDepth(998);
+ this.playerShadow = shadow;
+
+ // Player body — RPG character shape
+ this.player = this.add.rectangle(startX, startY, 24, 32, 0x2288cc);
+ this.player.setStrokeStyle(2, 0x1a6699);
+ this.player.setDepth(1000);
+
+ // Selection ring
+ this.playerIndicator = this.add.circle(startX, startY, 22, 0x00ff00, 0);
+ this.playerIndicator.setStrokeStyle(2, 0x00ff66, 0.4);
+ this.playerIndicator.setDepth(999);
+
+ this.playerEntity = entityManager.createEntity('player');
+ entityManager.addComponent(this.playerEntity.id, { type: 'position', x: startX, y: startY });
+ entityManager.addComponent(this.playerEntity.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'magicka', current: 50, max: 50 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'stamina', current: 100, max: 100 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'level', level: 1, xp: 0, xpToNext: 100, perkPoints: 0 });
+ entityManager.addComponent(this.playerEntity.id, {
+ type: 'skills',
+ oneHanded: 20, twoHanded: 15, archery: 15, block: 15,
+ heavyArmor: 15, lightArmor: 15,
+ destruction: 15, conjuration: 15, illusion: 15, alteration: 15, restoration: 15, enchanting: 15,
+ sneak: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15, smithing: 15,
+ });
+ entityManager.addComponent(this.playerEntity.id, { type: 'weapon', id: 'fists', damage: 4, speed: 1.4 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'armor', rating: 0 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'blocking', isBlocking: false });
+ entityManager.addComponent(this.playerEntity.id, { type: 'movement', speed: 200 });
+ entityManager.addComponent(this.playerEntity.id, { type: 'statusEffects', active: [] });
+ entityManager.addComponent(this.playerEntity.id, { type: 'legendary', skills: {} });
+
+ this.playerEntity.sprite = this.player;
+ eventBus.emit('player:created', { entity: this.playerEntity });
+ }
+
+ private loadZone(zoneId: string): void {
+ this.clearZone();
+ const zone = mapManager.loadZone(zoneId);
+ if (!zone) return;
+
+ this.currentZone = zone;
+ this.renderZone(zone);
+ this.spawnZoneEntities(zone);
+
+ const spawnX = zone.spawnPoint.x * zone.tileSize + zone.tileSize / 2;
+ const spawnY = zone.spawnPoint.y * zone.tileSize + zone.tileSize / 2;
+ this.player.x = spawnX;
+ this.player.y = spawnY;
+ this.playerIndicator.x = spawnX;
+ this.playerIndicator.y = spawnY;
+
+ const posComponent = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position');
+ if (posComponent) { posComponent.x = spawnX; posComponent.y = spawnY; }
+
+ worldMapUI.discoverLocation(zoneId);
+ eventBus.emit('zone:entered', { zoneId, zone });
+ eventBus.emit('game:zoneChanged', { zoneId, zone });
+ }
+
+ private clearZone(): void {
+ this.mapTiles.forEach((tile) => tile.destroy());
+ this.mapTiles = [];
+ (['enemy', 'item', 'npc'] as EntityType[]).forEach((type) => {
+ entityManager.getEntitiesByType(type).forEach((e) => {
+ const sprite = e.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite) sprite.destroy();
+ entityManager.destroyEntity(e.id);
+ });
+ });
+ }
+
+ private renderZone(zone: MapZone): void {
+ const tileSize = zone.tileSize;
+ for (let y = 0; y < zone.height; y++) {
+ for (let x = 0; x < zone.width; x++) {
+ const tileId = zone.tiles[y]![x]!;
+ const tileData = mapManager.getTile(tileId);
+ if (!tileData) continue;
+ const tile = this.add.rectangle(x * tileSize + tileSize / 2, y * tileSize + tileSize / 2, tileSize, tileSize, tileData.color);
+ tile.setStrokeStyle(1, 0x222222, 0.15);
+ tile.setDepth(0);
+ this.mapTiles.push(tile);
+ }
+ }
+
+ // Grid overlay for reference
+ const totalW = zone.width * tileSize;
+ const totalH = zone.height * tileSize;
+ const gridGfx = this.add.graphics();
+ gridGfx.lineStyle(1, 0xffffff, 0.04);
+ for (let gx = 0; gx <= totalW; gx += tileSize * 4) {
+ gridGfx.lineBetween(gx, 0, gx, totalH);
+ }
+ for (let gy = 0; gy <= totalH; gy += tileSize * 4) {
+ gridGfx.lineBetween(0, gy, totalW, gy);
+ }
+ gridGfx.setDepth(1);
+ this.mapTiles.push(gridGfx);
+
+ for (const chest of zone.chests) {
+ const cx = chest.x * tileSize + tileSize / 2;
+ const cy = chest.y * tileSize + tileSize / 2;
+
+ // Chest shadow
+ const shadow = this.add.ellipse(cx, cy + 10, 20, 8, 0x000000, 0.2);
+ shadow.setDepth(49);
+ this.mapTiles.push(shadow);
+
+ const s = this.add.rectangle(cx, cy, 20, 18, 0xffaa00);
+ s.setStrokeStyle(2, 0xcc8800);
+ s.setDepth(50);
+ this.mapTiles.push(s);
+ containerSystem.createContainer(cx, cy, 'chest', chest.loot, chest.locked, chest.lockLevel);
+ }
+ }
+
+ private spawnZoneEntities(zone: MapZone): void {
+ const tileSize = zone.tileSize;
+ for (const entityData of zone.entities) {
+ if (entityData.type === 'enemy') {
+ const enemyType = typeof entityData.data.type === 'string' ? entityData.data.type : 'bandit';
+ const stats = dataRegistry.getEnemy(enemyType) || dataRegistry.getEnemy('bandit');
+ if (!stats) continue;
+ const cx = entityData.x * tileSize + tileSize / 2;
+ const cy = entityData.y * tileSize + tileSize / 2;
+
+ // Shadow
+ const shadow = this.add.ellipse(cx, cy + stats.size * 0.4, stats.size * 0.8, stats.size * 0.3, 0x000000, 0.25);
+ shadow.setDepth(99);
+ this.mapTiles.push(shadow);
+
+ const enemySprite = this.add.rectangle(cx, cy, stats.size, stats.size, stats.color);
+ enemySprite.setStrokeStyle(2, 0xaa2222);
+ enemySprite.setDepth(100);
+ const enemy = entityManager.createEntity('enemy');
+ entityManager.addComponent(enemy.id, { type: 'position', x: cx, y: cy });
+ entityManager.addComponent(enemy.id, { type: 'health', current: stats.health, max: stats.health });
+ entityManager.addComponent(enemy.id, { type: 'enemyType', name: stats.name, id: stats.id });
+ entityManager.addComponent(enemy.id, { type: 'ai', state: 'idle', detectionRange: stats.detectionRange, attackRange: stats.attackRange, attackCooldown: Math.max(250, 1000 / stats.attackSpeed), lastAttackTime: 0 });
+ entityManager.addComponent(enemy.id, { type: 'weapon', id: 'fists', damage: stats.damage, speed: 1.0 });
+ entityManager.addComponent(enemy.id, { type: 'armor', rating: stats.armor });
+ if (typeof entityData.data.script === 'string') {
+ entityManager.addComponent(enemy.id, { type: 'script', scriptId: entityData.data.script });
+ }
+ enemy.sprite = enemySprite;
+ } else if (entityData.type === 'npc') {
+ const cx = entityData.x * tileSize + tileSize / 2;
+ const cy = entityData.y * tileSize + tileSize / 2;
+
+ // Shadow
+ const shadow = this.add.ellipse(cx, cy + 14, 22, 8, 0x000000, 0.2);
+ shadow.setDepth(99);
+ this.mapTiles.push(shadow);
+
+ const npcSprite = this.add.rectangle(cx, cy, 22, 30, 0x44aaff);
+ npcSprite.setStrokeStyle(2, 0x2266cc);
+ npcSprite.setDepth(100);
+ const npc = entityManager.createEntity('npc');
+ entityManager.addComponent(npc.id, { type: 'position', x: cx, y: cy });
+ entityManager.addComponent(npc.id, { type: 'npcData', name: entityData.data.name, dialogue: entityData.data.dialogue, shop: entityData.data.shop || false, forge: entityData.data.forge || false });
+ entityManager.addComponent(npc.id, { type: 'health', current: 100, max: 100 });
+ if (typeof entityData.data.script === 'string') {
+ entityManager.addComponent(npc.id, { type: 'script', scriptId: entityData.data.script });
+ }
+ npc.sprite = npcSprite;
+ }
+ }
+ }
+
+ private setupInput(): void {
+ if (!this.input.keyboard) return;
+ this.cursors = this.input.keyboard.createCursorKeys();
+ this.wasd = {
+ W: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
+ A: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
+ S: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
+ D: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D),
+ };
+ this.interactKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E);
+ this.attackKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
+ this.powerAttackKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SHIFT);
+ this.useKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F);
+ this.inventoryKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TAB);
+ this.mapKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.M);
+ this.shoutKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q);
+ this.craftingKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.C);
+ this.transformKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.T);
+
+ // Mouse — left click attack, right click block
+ this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
+ if (this.isDead) return;
+ if (dialogueSystem.isInDialogue()) return;
+
+ if (pointer.leftButtonDown()) {
+ this.handleMouseAttack();
+ }
+ if (pointer.rightButtonDown()) {
+ combatSystem.setBlocking(this.playerEntity.id, true);
+ }
+ });
+
+ this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => {
+ if (pointer.button === 2) {
+ combatSystem.setBlocking(this.playerEntity.id, false);
+ }
+ });
+
+ // Prevent right-click context menu
+ this.input.mouse?.disableContextMenu();
+ }
+
+ private handleMouseAttack(): void {
+ // Find nearest enemy and attack it
+ const enemies = entityManager.getEntitiesByType('enemy');
+ let nearest: Entity | null = null;
+ let nearestDist = Infinity;
+
+ const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position');
+ if (!pos) return;
+
+ for (const enemy of enemies) {
+ if (corpseSystem.isCorpse(enemy)) continue;
+ const ePos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position');
+ if (!ePos) continue;
+ const dx = ePos.x - pos.x;
+ const dy = ePos.y - pos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist < nearestDist && dist < 80) {
+ nearestDist = dist;
+ nearest = enemy;
+ }
+ }
+
+ if (nearest) {
+ const isPower = this.powerAttackKey?.isDown || false;
+ const success = combatSystem.performAttack(this.playerEntity, nearest, isPower);
+ if (success) {
+ this.cameras.main.shake(40, 0.004);
+ }
+ }
+ }
+
+ private setupCamera(): void {
+ this.cameras.main.startFollow(this.player, true, 0.08, 0.08);
+ // Scale zoom for fullscreen — more zoomed out to show more of the world
+ const minDim = Math.min(window.innerWidth, window.innerHeight);
+ const zoom = Math.max(1.0, Math.min(1.8, minDim / 700));
+ this.cameras.main.setZoom(zoom);
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('entity:created', (data: { entity: Entity }) => {
+ console.log('Entity created:', data.entity.id, data.entity.type);
+ });
+ eventBus.on('entity:destroyed', (data: { entity: Entity }) => {
+ console.log('Entity destroyed:', data.entity.id);
+ });
+ eventBus.on('world:fastTravel', (data: { locationId: string }) => {
+ this.loadZone(data.locationId);
+ });
+ eventBus.on('entity:killed', (data: { entity: Entity; killer: Entity }) => {
+ if (data.killer.id === this.playerEntity.id) {
+ const enemyType = entityManager.getComponent<{ name: string }>(data.entity.id, 'enemyType');
+ if (enemyType) this.showNotification(`${enemyType.name} 被击败`);
+ }
+ if (data.entity.id === this.playerEntity.id) {
+ this.handlePlayerDeath();
+ }
+ });
+ eventBus.on('player:levelUp', (data: { entityId: string; level: number }) => {
+ if (data.entityId === this.playerEntity.id) {
+ this.showNotification(`升级! 等级 ${data.level}`);
+ this.cameras.main.flash(500, 212, 168, 67);
+ }
+ });
+ eventBus.on('combat:blockSuccess', (data: { entityId: string }) => {
+ if (data.entityId === this.playerEntity.id) this.showNotification('格挡成功!');
+ });
+ }
+
+ update(_time: number, delta: number): void {
+ if (this.isDead) return;
+
+ eventBus.emit('game:update', { delta, time: _time });
+
+ regenSystem.update(delta);
+ statusEffectSystem.update(delta);
+ corpseSystem.update(delta);
+ dayNightSystem.update(delta);
+ scriptSystem.update(delta);
+ transformationSystem.update(delta);
+ vampireSystem.update(delta);
+ this.applyDayNightTint();
+
+ // Movement — keyboard
+ movementSystem.movePlayer(this.playerEntity, {
+ up: this.cursors?.up.isDown || this.wasd?.W.isDown || false,
+ down: this.cursors?.down.isDown || this.wasd?.S.isDown || false,
+ left: this.cursors?.left.isDown || this.wasd?.A.isDown || false,
+ right: this.cursors?.right.isDown || this.wasd?.D.isDown || false,
+ }, delta);
+
+ // Sync sprite
+ const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position');
+ if (pos) {
+ this.player.x = pos.x;
+ this.player.y = pos.y;
+ this.playerIndicator.x = pos.x;
+ this.playerIndicator.y = pos.y;
+ if (this.playerShadow) {
+ this.playerShadow.x = pos.x;
+ this.playerShadow.y = pos.y + 16;
+ }
+ }
+
+ // AI
+ aiSystem.update(delta, this.playerEntity);
+
+ // Sync enemy sprites
+ entityManager.getEntitiesByType('enemy').forEach((enemy) => {
+ const ePos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position');
+ if (enemy.sprite && ePos) {
+ const sprite = enemy.sprite as unknown as Phaser.GameObjects.Rectangle;
+ sprite.x = ePos.x;
+ sprite.y = ePos.y;
+ }
+ });
+
+ this.nearbyEntities = proximitySystem.checkProximity(this.playerEntity);
+ this.updateInteractionPrompt();
+ this.handleCombat();
+ this.handleInteractions();
+ this.handleInputToggles();
+ this.handleDoorCheck();
+ this.updateEnemyHealthBars();
+ this.updatePlayerVisual();
+ }
+
+ private updatePlayerVisual(): void {
+ const health = entityManager.getComponent<{ current: number; max: number }>(this.playerEntity.id, 'health');
+ if (health && health.current <= 0) return;
+ const isBlocking = entityManager.getComponent<{ isBlocking: boolean }>(this.playerEntity.id, 'blocking')?.isBlocking;
+ if (isBlocking) {
+ this.player.setStrokeStyle(3, 0xffd700);
+ this.playerIndicator.setStrokeStyle(2, 0xffd700, 0.7);
+ } else {
+ this.player.setStrokeStyle(3, 0x00aa33);
+ this.playerIndicator.setStrokeStyle(2, 0x00ff66, 0.3);
+ }
+ }
+
+ private handlePlayerDeath(): void {
+ this.isDead = true;
+ this.player.setFillStyle(0x664444);
+ this.player.setAlpha(0.5);
+
+ const overlay = document.createElement('div');
+ overlay.id = 'death-screen';
+ overlay.style.cssText = `
+ position: fixed; inset: 0;
+ background: rgba(10,0,0,0.92);
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ z-index: 9000; font-family: Georgia, serif; color: #c42020;
+ `;
+ overlay.innerHTML = `
+ 你死了
+ 你的冒险在此终结...
+
+ `;
+ document.body.appendChild(overlay);
+ document.getElementById('respawn-btn')?.addEventListener('click', () => {
+ this.respawnPlayer();
+ overlay.remove();
+ });
+ }
+
+ private respawnPlayer(): void {
+ this.isDead = false;
+ const health = entityManager.getComponent<{ current: number; max: number }>(this.playerEntity.id, 'health');
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(this.playerEntity.id, 'magicka');
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(this.playerEntity.id, 'stamina');
+ if (health) health.current = health.max;
+ if (magicka) magicka.current = magicka.max;
+ if (stamina) stamina.current = stamina.max;
+ this.player.setFillStyle(0x2288cc);
+ this.player.setAlpha(1);
+ statusEffectSystem.clearAll(this.playerEntity.id);
+ if (this.currentZone) {
+ const sx = this.currentZone.spawnPoint.x * this.currentZone.tileSize + this.currentZone.tileSize / 2;
+ const sy = this.currentZone.spawnPoint.y * this.currentZone.tileSize + this.currentZone.tileSize / 2;
+ this.player.x = sx; this.player.y = sy;
+ this.playerIndicator.x = sx; this.playerIndicator.y = sy;
+ if (this.playerShadow) { this.playerShadow.x = sx; this.playerShadow.y = sy + 16; }
+ const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position');
+ if (pos) { pos.x = sx; pos.y = sy; }
+ }
+ this.showNotification('你已复活');
+ }
+
+ private updateInteractionPrompt(): void {
+ const prompt = document.getElementById('interact-prompt');
+ if (!prompt) return;
+ if (dialogueSystem.isInDialogue()) { prompt.style.display = 'none'; return; }
+ const text = proximitySystem.getInteractionPrompt(this.nearbyEntities);
+ if (text) { prompt.textContent = text; prompt.style.display = 'block'; }
+ else { prompt.style.display = 'none'; }
+ }
+
+ private handleCombat(): void {
+ // Space bar attack (keyboard fallback)
+ if (!this.nearbyEntities.enemy) return;
+ if (this.attackKey && Phaser.Input.Keyboard.JustDown(this.attackKey)) {
+ const isPower = this.powerAttackKey?.isDown || false;
+ const success = combatSystem.performAttack(this.playerEntity, this.nearbyEntities.enemy, isPower);
+ if (success) this.cameras.main.shake(40, 0.004);
+ }
+ }
+
+ private handleInteractions(): void {
+ if (dialogueSystem.isInDialogue()) return;
+ if (this.interactKey && Phaser.Input.Keyboard.JustDown(this.interactKey)) {
+ if (this.nearbyEntities.npc) {
+ dialogueSystem.startDialogue(this.nearbyEntities.npc, this.playerEntity);
+ } else if (this.nearbyEntities.corpse) {
+ const loot = corpseSystem.searchCorpse(this.nearbyEntities.corpse, this.playerEntity);
+ if (loot && loot.length > 0) {
+ for (const item of loot) {
+ if (item.type === 'gold') {
+ const inv = inventorySystem.getInventory(this.playerEntity);
+ if (inv) inv.gold += item.amount;
+ } else if (item.type === 'item') {
+ inventorySystem.addItem(this.playerEntity, item.id, item.quantity);
+ }
+ }
+ this.showLootNotification(loot);
+ } else {
+ this.showNotification('尸体已经被搜刮过了');
+ }
+ } else if (this.nearbyEntities.item) {
+ const groundItem = entityManager.getComponent<{ name: string; quantity: number }>(this.nearbyEntities.item.id, 'groundItem');
+ if (groundItem) {
+ groundItemSystem.pickupItem(this.playerEntity, this.nearbyEntities.item);
+ this.showNotification(`拾取了 ${groundItem.name} x${groundItem.quantity}`);
+ }
+ } else if (this.nearbyEntities.container) {
+ const info = containerSystem.getContainerInfo(this.nearbyEntities.container);
+ if (info && !info.locked && !info.isEmpty) {
+ const loot = containerSystem.lootContainer(this.nearbyEntities.container, this.playerEntity);
+ if (loot) this.showNotification(`从 ${info.name} 中获得了物品`);
+ }
+ }
+ }
+ if (this.useKey && Phaser.Input.Keyboard.JustDown(this.useKey)) {
+ if (inventorySystem.hasItem(this.playerEntity, 'health_potion')) {
+ inventorySystem.useItem(this.playerEntity, 'health_potion');
+ this.showNotification('使用了生命药水');
+ }
+ }
+ if (this.shoutKey && Phaser.Input.Keyboard.JustDown(this.shoutKey)) {
+ magicSystem.useShout(this.playerEntity, 'unrelenting_force');
+ this.cameras.main.shake(100, 0.01);
+ }
+ }
+
+ private showLootNotification(loot: unknown[]): void {
+ const text = loot.map((i) => {
+ const l = i as { type: string; amount?: number; name?: string; id?: string };
+ return l.type === 'gold' ? `${l.amount} 金币` : (l.name || l.id);
+ }).join(', ');
+ this.showNotification(`获得: ${text}`);
+ }
+
+ private showNotification(text: string): void {
+ const toast = document.createElement('div');
+ toast.className = 'oes-toast';
+ toast.textContent = text;
+ document.body.appendChild(toast);
+ setTimeout(() => toast.remove(), 2600);
+ }
+
+ private handleInputToggles(): void {
+ if (this.inventoryKey && Phaser.Input.Keyboard.JustDown(this.inventoryKey)) uiManager.toggleInventory();
+ if (this.mapKey && Phaser.Input.Keyboard.JustDown(this.mapKey)) worldMapUI.toggle();
+ if (this.craftingKey && Phaser.Input.Keyboard.JustDown(this.craftingKey)) craftingUI.toggle('smithing');
+ if (this.transformKey && Phaser.Input.Keyboard.JustDown(this.transformKey)) {
+ if (transformationSystem.isTransformed(this.playerEntity.id)) {
+ transformationSystem.revert(this.playerEntity.id);
+ } else {
+ transformationSystem.transform(this.playerEntity.id, 'werewolf');
+ }
+ }
+ }
+
+ private handleDoorCheck(): void {
+ if (!this.currentZone) return;
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position');
+ if (!playerPos) return;
+ const tileSize = this.currentZone.tileSize;
+ const door = mapManager.getDoorAt(Math.floor(playerPos.x / tileSize), Math.floor(playerPos.y / tileSize));
+ if (door) this.loadZone(door.targetZone);
+ }
+
+ private updateEnemyHealthBars(): void {
+ combatUI.clearEnemyHealthBars();
+ entityManager.getEntitiesByType('enemy').forEach((enemy) => {
+ if (corpseSystem.isCorpse(enemy)) return;
+ const health = entityManager.getComponent<{ current: number; max: number }>(enemy.id, 'health');
+ if (health && health.current < health.max) combatUI.showEnemyHealthBar(enemy, health);
+ });
+ }
+
+ private applyDayNightTint(): void {
+ const tint = dayNightSystem.getSkyTint();
+ if (tint.a > 0) {
+ this.cameras.main.setBackgroundColor(
+ Phaser.Display.Color.GetColor(
+ Math.round(tint.r * tint.a),
+ Math.round(tint.g * tint.a),
+ Math.round(tint.b * tint.a)
+ )
+ );
+ } else {
+ this.cameras.main.setBackgroundColor('#1a1a2e');
+ }
+ }
+}
diff --git a/src/systems/AISystem.ts b/src/systems/AISystem.ts
new file mode 100644
index 0000000..64c03dd
--- /dev/null
+++ b/src/systems/AISystem.ts
@@ -0,0 +1,118 @@
+import { entityManager, type Entity } from '../core/EntityManager';
+import { combatSystem } from './CombatSystem';
+import { corpseSystem } from './CorpseSystem';
+
+export type AIState = 'idle' | 'chase' | 'attack' | 'retreat' | 'patrol';
+
+export interface AIComponent {
+ type: 'ai';
+ state: AIState;
+ detectionRange: number;
+ attackRange: number;
+ attackCooldown: number;
+ lastAttackTime: number;
+ targetId?: string;
+}
+
+export class AISystem {
+ private static instance: AISystem;
+
+ static getInstance(): AISystem {
+ if (!AISystem.instance) {
+ AISystem.instance = new AISystem();
+ }
+ return AISystem.instance;
+ }
+
+ update(delta: number, playerEntity: Entity): void {
+ const enemies = entityManager.getEntitiesByType('enemy');
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(playerEntity.id, 'position');
+ if (!playerPos) return;
+
+ for (const enemy of enemies) {
+ if (corpseSystem.isCorpse(enemy)) continue;
+
+ const ai = entityManager.getComponent(enemy.id, 'ai');
+ const pos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position');
+ const health = entityManager.getComponent<{ current: number }>(enemy.id, 'health');
+
+ if (!ai || !pos || !health) continue;
+ if (health.current <= 0) continue;
+
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ this.updateAI(enemy, ai, pos, distance, delta, playerEntity);
+ }
+ }
+
+ private updateAI(
+ enemy: Entity,
+ ai: AIComponent,
+ pos: { x: number; y: number },
+ distanceToPlayer: number,
+ delta: number,
+ player: Entity
+ ): void {
+ switch (ai.state) {
+ case 'idle':
+ if (distanceToPlayer < ai.detectionRange) {
+ ai.state = 'chase';
+ }
+ break;
+
+ case 'chase':
+ if (distanceToPlayer > ai.detectionRange * 1.5) {
+ ai.state = 'idle';
+ break;
+ }
+
+ if (distanceToPlayer <= ai.attackRange) {
+ ai.state = 'attack';
+ } else {
+ const speed = 80;
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position');
+ if (playerPos) {
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist > 0) {
+ const nx = dx / dist;
+ const ny = dy / dist;
+ pos.x += nx * speed * (delta / 1000);
+ pos.y += ny * speed * (delta / 1000);
+ }
+ }
+ }
+ break;
+
+ case 'attack':
+ if (distanceToPlayer > ai.attackRange * 1.2) {
+ ai.state = 'chase';
+ break;
+ }
+
+ const now = Date.now();
+ if (now - ai.lastAttackTime > ai.attackCooldown) {
+ combatSystem.performAttack(enemy, player, false);
+ ai.lastAttackTime = now;
+ }
+ break;
+ }
+ }
+
+ setEnemyAI(entity: Entity, config: Partial): void {
+ entityManager.addComponent(entity.id, {
+ type: 'ai',
+ state: 'idle',
+ detectionRange: config.detectionRange || 150,
+ attackRange: config.attackRange || 45,
+ attackCooldown: config.attackCooldown || 1000,
+ lastAttackTime: 0,
+ ...config,
+ });
+ }
+}
+
+export const aiSystem = AISystem.getInstance();
diff --git a/src/systems/AlchemySystem.ts b/src/systems/AlchemySystem.ts
new file mode 100644
index 0000000..657f4f0
--- /dev/null
+++ b/src/systems/AlchemySystem.ts
@@ -0,0 +1,239 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface AlchemyIngredient {
+ id: string;
+ name: string;
+ weight: number;
+ value: number;
+ effects: AlchemyEffect[];
+ harvestNode?: string;
+}
+
+export interface AlchemyEffect {
+ type: 'restore_health' | 'restore_magicka' | 'restore_stamina' | 'damage_health' | 'damage_magicka' | 'damage_stamina' | 'fortify_health' | 'fortify_magicka' | 'fortify_stamina' | 'regenerate_health' | 'regenerate_magicka' | 'regenerate_stamina' | 'slow' | 'frenzy' | 'calm' | 'fear' | 'invisibility' | 'detect_life' | 'waterbreathing' | 'fire_resist' | 'frost_resist' | 'shock_resist';
+ magnitude: number;
+ duration: number;
+}
+
+export interface AlchemyRecipe {
+ id: string;
+ name: string;
+ ingredients: string[];
+ result: {
+ id: string;
+ name: string;
+ type: 'potion' | 'poison';
+ effects: AlchemyEffect[];
+ value: number;
+ };
+}
+
+export interface Potion {
+ id: string;
+ name: string;
+ type: 'potion' | 'poison';
+ effects: AlchemyEffect[];
+ weight: number;
+ value: number;
+}
+
+export class AlchemySystem {
+ private static instance: AlchemySystem;
+ private ingredients: Map = new Map();
+ private recipes: Map = new Map();
+ private discoveredEffects: Map> = new Map();
+
+ static getInstance(): AlchemySystem {
+ if (!AlchemySystem.instance) {
+ AlchemySystem.instance = new AlchemySystem();
+ }
+ return AlchemySystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.ingredients.clear();
+ this.recipes.clear();
+
+ for (const ing of dataRegistry.getAllAlchemyIngredients()) {
+ this.ingredients.set(ing.id, {
+ id: ing.id,
+ name: ing.name,
+ weight: ing.weight,
+ value: ing.value,
+ effects: ing.effects.map((e) => ({
+ type: e.type as AlchemyEffect['type'],
+ magnitude: e.magnitude,
+ duration: e.duration,
+ })),
+ harvestNode: ing.harvestNode,
+ });
+ }
+
+ for (const recipe of dataRegistry.getAllRecipes()) {
+ this.recipes.set(recipe.id, {
+ id: recipe.id,
+ name: recipe.name,
+ ingredients: recipe.ingredients,
+ result: {
+ id: recipe.result.id,
+ name: recipe.result.name,
+ type: recipe.result.type as 'potion' | 'poison',
+ effects: recipe.result.effects.map((e) => ({
+ type: e.type as AlchemyEffect['type'],
+ magnitude: e.magnitude,
+ duration: e.duration ?? 0,
+ })),
+ value: recipe.result.value,
+ },
+ });
+ }
+ }
+
+ canBrew(recipeId: string, playerEntity: Entity): boolean {
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(playerEntity.id, 'inventory');
+ if (!inventory) return false;
+
+ const requiredIngredients = new Map();
+ for (const ingId of recipe.ingredients) {
+ requiredIngredients.set(ingId, (requiredIngredients.get(ingId) || 0) + 1);
+ }
+
+ for (const [ingId, required] of requiredIngredients) {
+ const owned = inventory.items.find((i) => i.id === ingId);
+ if (!owned || owned.quantity < required) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ brew(recipeId: string, playerEntity: Entity): Potion | null {
+ if (!this.canBrew(recipeId, playerEntity)) return null;
+
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return null;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(playerEntity.id, 'inventory');
+ if (!inventory) return null;
+
+ const usedIngredients = new Set();
+ for (const ingId of recipe.ingredients) {
+ if (!usedIngredients.has(ingId)) {
+ const item = inventory.items.find((i) => i.id === ingId);
+ if (item) {
+ item.quantity -= 1;
+ if (item.quantity <= 0) {
+ const index = inventory.items.indexOf(item);
+ inventory.items.splice(index, 1);
+ }
+ }
+ usedIngredients.add(ingId);
+ }
+ }
+
+ const potion: Potion = {
+ id: recipe.result.id,
+ name: recipe.result.name,
+ type: recipe.result.type,
+ effects: [...recipe.result.effects],
+ weight: 0.5,
+ value: recipe.result.value,
+ };
+
+ eventBus.emit('alchemy:brewed', { player: playerEntity, potion, recipe });
+ return potion;
+ }
+
+ getIngredient(id: string): AlchemyIngredient | undefined {
+ return this.ingredients.get(id);
+ }
+
+ getAllIngredients(): AlchemyIngredient[] {
+ return Array.from(this.ingredients.values());
+ }
+
+ getRecipe(id: string): AlchemyRecipe | undefined {
+ return this.recipes.get(id);
+ }
+
+ getAvailableRecipes(playerEntity: Entity): AlchemyRecipe[] {
+ return Array.from(this.recipes.values()).filter((recipe) => this.canBrew(recipe.id, playerEntity));
+ }
+
+ discoverEffect(ingredientId: string, effectType: string): void {
+ if (!this.discoveredEffects.has(ingredientId)) {
+ this.discoveredEffects.set(ingredientId, new Set());
+ }
+ this.discoveredEffects.get(ingredientId)!.add(effectType);
+ }
+
+ getDiscoveredEffects(ingredientId: string): Set {
+ return this.discoveredEffects.get(ingredientId) || new Set();
+ }
+
+ applyPotionEffects(entity: Entity, potion: Potion): void {
+ for (const effect of potion.effects) {
+ switch (effect.type) {
+ case 'restore_health': {
+ const health = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'health');
+ if (health) {
+ health.current = Math.round(Math.min(health.max, health.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'restore_magicka': {
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'magicka');
+ if (magicka) {
+ magicka.current = Math.round(Math.min(magicka.max, magicka.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'restore_stamina': {
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'stamina');
+ if (stamina) {
+ stamina.current = Math.round(Math.min(stamina.max, stamina.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'damage_health': {
+ const health = entityManager.getComponent<{ current: number }>(entity.id, 'health');
+ if (health) {
+ health.current = Math.max(0, health.current - effect.magnitude);
+ }
+ break;
+ }
+ case 'fortify_health': {
+ const health = entityManager.getComponent<{ max: number }>(entity.id, 'health');
+ if (health) {
+ health.max += effect.magnitude;
+ const current = entityManager.getComponent<{ current: number }>(entity.id, 'health');
+ if (current) current.current += effect.magnitude;
+ }
+ break;
+ }
+ case 'invisibility': {
+ entityManager.addComponent(entity.id, {
+ type: 'statusEffect',
+ effect: 'invisibility',
+ duration: effect.duration,
+ startTime: Date.now(),
+ });
+ break;
+ }
+ }
+ }
+ }
+}
+
+export const alchemySystem = AlchemySystem.getInstance();
diff --git a/src/systems/CombatSystem.test.ts b/src/systems/CombatSystem.test.ts
new file mode 100644
index 0000000..8af8fc0
--- /dev/null
+++ b/src/systems/CombatSystem.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { CombatSystem } from './CombatSystem';
+import { entityManager } from '../core/EntityManager';
+
+describe('CombatSystem', () => {
+ let combatSystem: CombatSystem;
+ let mockTime = 0;
+
+ beforeEach(() => {
+ combatSystem = CombatSystem.getInstance();
+ mockTime = 0;
+ vi.spyOn(Date, 'now').mockImplementation(() => mockTime);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should perform basic attack', () => {
+ const attacker = entityManager.createEntity('player');
+ const target = entityManager.createEntity('enemy');
+
+ entityManager.addComponent(attacker.id, { type: 'position', x: 0, y: 0 });
+ entityManager.addComponent(attacker.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(attacker.id, { type: 'weapon', id: 'iron_sword', damage: 10, speed: 1.0 });
+ entityManager.addComponent(attacker.id, { type: 'skills', oneHanded: 20 });
+
+ entityManager.addComponent(target.id, { type: 'position', x: 30, y: 0 });
+ entityManager.addComponent(target.id, { type: 'health', current: 50, max: 50 });
+ entityManager.addComponent(target.id, { type: 'armor', rating: 5 });
+
+ mockTime = 1000;
+ const result = combatSystem.performAttack(attacker, target, false);
+
+ expect(result).toBe(true);
+ const health = entityManager.getComponent<{ current: number }>(target.id, 'health');
+ expect(health?.current).toBeLessThan(50);
+ });
+
+ it('should handle power attack with stamina cost', () => {
+ const attacker = entityManager.createEntity('player');
+ const target = entityManager.createEntity('enemy');
+
+ entityManager.addComponent(attacker.id, { type: 'position', x: 0, y: 0 });
+ entityManager.addComponent(attacker.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(attacker.id, { type: 'stamina', current: 100, max: 100 });
+ entityManager.addComponent(attacker.id, { type: 'weapon', id: 'iron_sword', damage: 10, speed: 1.0 });
+ entityManager.addComponent(attacker.id, { type: 'skills', oneHanded: 20 });
+
+ entityManager.addComponent(target.id, { type: 'position', x: 30, y: 0 });
+ entityManager.addComponent(target.id, { type: 'health', current: 50, max: 50 });
+ entityManager.addComponent(target.id, { type: 'armor', rating: 5 });
+
+ mockTime = 2000;
+ const result = combatSystem.performAttack(attacker, target, true);
+
+ expect(result).toBe(true);
+ const stamina = entityManager.getComponent<{ current: number }>(attacker.id, 'stamina');
+ expect(stamina?.current).toBeLessThan(100);
+ });
+
+ it('should handle target death', () => {
+ const attacker = entityManager.createEntity('player');
+ const target = entityManager.createEntity('enemy');
+
+ entityManager.addComponent(attacker.id, { type: 'position', x: 0, y: 0 });
+ entityManager.addComponent(attacker.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(attacker.id, { type: 'weapon', id: 'iron_sword', damage: 100, speed: 1.0 });
+ entityManager.addComponent(attacker.id, { type: 'skills', oneHanded: 20 });
+
+ entityManager.addComponent(target.id, { type: 'position', x: 30, y: 0 });
+ entityManager.addComponent(target.id, { type: 'health', current: 10, max: 10 });
+ entityManager.addComponent(target.id, { type: 'armor', rating: 0 });
+
+ mockTime = 3000;
+ combatSystem.performAttack(attacker, target, false);
+
+ const health = entityManager.getComponent<{ current: number }>(target.id, 'health');
+ expect(health?.current).toBe(0);
+ });
+});
diff --git a/src/systems/CombatSystem.ts b/src/systems/CombatSystem.ts
new file mode 100644
index 0000000..bf611e6
--- /dev/null
+++ b/src/systems/CombatSystem.ts
@@ -0,0 +1,289 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface AttackData {
+ attacker: Entity;
+ target: Entity;
+ damage: number;
+ type: 'melee' | 'ranged' | 'magic';
+ isPowerAttack: boolean;
+}
+
+export class CombatSystem {
+ private static instance: CombatSystem;
+ private attackCooldown: number = 500;
+ private lastAttackTime: number = 0;
+ private config = dataRegistry.getGameConfig().combat;
+
+ static getInstance(): CombatSystem {
+ if (!CombatSystem.instance) {
+ CombatSystem.instance = new CombatSystem();
+ }
+ return CombatSystem.instance;
+ }
+
+ constructor() {
+ eventBus.on('mod:dataResolved', () => {
+ this.config = dataRegistry.getGameConfig().combat;
+ });
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('combat:attack', (data: { attacker: Entity; target: Entity; isPowerAttack?: boolean }) => {
+ this.performAttack(data.attacker, data.target, data.isPowerAttack || false);
+ });
+ }
+
+ performAttack(attacker: Entity, target: Entity, isPowerAttack: boolean = false): boolean {
+ const now = Date.now();
+ if (now - this.lastAttackTime < this.attackCooldown) {
+ return false;
+ }
+
+ const attackerPos = entityManager.getComponent<{ x: number; y: number }>(attacker.id, 'position');
+ const targetPos = entityManager.getComponent<{ x: number; y: number }>(target.id, 'position');
+
+ if (!attackerPos || !targetPos) return false;
+
+ const dx = targetPos.x - attackerPos.x;
+ const dy = targetPos.y - attackerPos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ const attackRange = isPowerAttack ? this.config.attackRanges.melee : this.config.attackRanges.unarmed;
+ if (distance > attackRange) return false;
+
+ // Stamina cost
+ const staminaCost = isPowerAttack ? this.config.staminaCosts.powerAttack : this.config.staminaCosts.normal;
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(attacker.id, 'stamina');
+ if (stamina && stamina.current < staminaCost) return false;
+ if (stamina) {
+ stamina.current = Math.max(0, stamina.current - staminaCost);
+ eventBus.emit('combat:staminaUsed', { entityId: attacker.id, amount: staminaCost });
+ }
+
+ // Check if target is blocking
+ const isBlocked = this.checkBlocking(target, attacker);
+
+ const { damage, isCritical } = this.calculateDamage(attacker, target, isPowerAttack);
+
+ const finalDamage = isBlocked ? Math.round(damage * 0.2) : damage;
+
+ const targetHealth = entityManager.getComponent<{ current: number; max: number }>(target.id, 'health');
+ if (targetHealth) {
+ targetHealth.current = Math.max(0, targetHealth.current - finalDamage);
+
+ eventBus.emit('combat:beforeAttack', { attacker, target, damage: finalDamage, isPowerAttack, isBlocked, isCritical });
+
+ if (targetHealth.current <= 0) {
+ this.handleDeath(target, attacker);
+ }
+
+ eventBus.emit('combat:afterAttack', { attacker, target, damage: finalDamage, isPowerAttack, isBlocked, isCritical });
+
+ // Skill improvement
+ this.onCombatAction(attacker, target, isPowerAttack);
+
+ // Notify regen system
+ eventBus.emit('combat:staminaUsed', { entityId: attacker.id, amount: staminaCost });
+
+ this.lastAttackTime = now;
+ return true;
+ }
+
+ return false;
+ }
+
+ private checkBlocking(target: Entity, _attacker: Entity): boolean {
+ const blockState = entityManager.getComponent<{ isBlocking: boolean }>(target.id, 'blocking');
+ if (!blockState?.isBlocking) return false;
+
+ // Block success chance based on block skill and stamina
+ const skills = entityManager.getComponent<{ block?: number }>(target.id, 'skills');
+ const stamina = entityManager.getComponent<{ current: number }>(target.id, 'stamina');
+
+ const blockSkill = skills?.block || 15;
+ const blockChance = this.config.blockBaseChance + (blockSkill / 100) * this.config.blockSkillBonus;
+ const staminaBonus = stamina && stamina.current > this.config.blockStaminaThreshold ? 0.1 : this.config.blockStaminaPenalty;
+
+ const success = Math.random() < (blockChance + staminaBonus);
+
+ if (success && stamina) {
+ stamina.current = Math.max(0, stamina.current - this.config.blockStaminaCost);
+ eventBus.emit('combat:blockSuccess', { entityId: target.id });
+ }
+
+ return success;
+ }
+
+ private calculateDamage(attacker: Entity, target: Entity, isPowerAttack: boolean): { damage: number; isCritical: boolean } {
+ let baseDamage = this.config.baseDamage;
+
+ const weapon = entityManager.getComponent<{ damage: number; speed: number }>(attacker.id, 'weapon');
+ if (weapon) {
+ baseDamage = weapon.damage;
+ }
+
+ const skills = entityManager.getComponent>(attacker.id, 'skills');
+ if (skills) {
+ const skillBonus = (skills['oneHanded'] || 0) * this.config.skillBonus;
+ baseDamage += skillBonus;
+ }
+
+ const level = entityManager.getComponent<{ level: number }>(attacker.id, 'level');
+ if (level) {
+ baseDamage += level.level * this.config.skillBonus;
+ }
+
+ if (isPowerAttack) {
+ baseDamage *= this.config.powerAttackMultiplier;
+ }
+
+ const sneakSkill = skills?.['sneak'] || 15;
+ const critChance = this.config.critBaseChance + (sneakSkill - 15) * this.config.critPerSneakSkill;
+ const isCritical = Math.random() < critChance;
+ if (isCritical) {
+ baseDamage *= 1.5;
+ }
+
+ const targetArmor = entityManager.getComponent<{ rating: number }>(target.id, 'armor');
+ if (targetArmor) {
+ const armorReduction = targetArmor.rating / (targetArmor.rating + this.config.armorDivisor);
+ baseDamage *= (1 - armorReduction);
+ }
+
+ const [varMin, varMax] = this.config.damageVariance;
+ const variance = varMin + Math.random() * (varMax - varMin);
+ baseDamage *= variance;
+
+ return { damage: Math.max(1, Math.round(baseDamage)), isCritical };
+ }
+
+ private onCombatAction(attacker: Entity, target: Entity, isPowerAttack: boolean): void {
+ const skills = entityManager.getComponent>(attacker.id, 'skills');
+ if (!skills) return;
+
+ // Improve relevant combat skill
+ const weapon = entityManager.getComponent<{ damage: number }>(attacker.id, 'weapon');
+ if (weapon && weapon.damage > this.config.weaponDamageThresholds.twoHanded) {
+ // Two-handed weapons
+ if (isPowerAttack && weapon.damage > this.config.weaponDamageThresholds.powerAttack) {
+ eventBus.emit('skill:improved', { entityId: attacker.id, skill: 'twoHanded', amount: this.config.skillImprovementAmounts.attack });
+ } else {
+ eventBus.emit('skill:improved', { entityId: attacker.id, skill: 'oneHanded', amount: this.config.skillImprovementAmounts.attack });
+ }
+ } else {
+ eventBus.emit('skill:improved', { entityId: attacker.id, skill: 'oneHanded', amount: this.config.skillImprovementAmounts.attack });
+ }
+
+ // Improve armor skill when hit
+ const targetArmor = entityManager.getComponent<{ rating: number }>(target.id, 'armor');
+ if (targetArmor && targetArmor.rating > 0) {
+ eventBus.emit('skill:improved', { entityId: attacker.id, skill: 'heavyArmor', amount: this.config.skillImprovementAmounts.armor });
+ }
+ }
+
+ private handleDeath(target: Entity, killer: Entity): void {
+ eventBus.emit('entity:killed', { entity: target, killer });
+
+ const targetSprite = target.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (targetSprite) {
+ targetSprite.setFillStyle(0x444444);
+ targetSprite.setAlpha(0.7);
+ }
+
+ const targetAI = entityManager.getComponent<{ state: string }>(target.id, 'ai');
+ if (targetAI) {
+ targetAI.state = 'dead';
+ }
+
+ const position = entityManager.getComponent<{ x: number; y: number }>(target.id, 'position');
+ if (position) {
+ entityManager.addComponent(target.id, {
+ type: 'corpse',
+ state: 'fresh',
+ createdAt: Date.now(),
+ looted: false,
+ loot: this.generateLoot(target),
+ });
+ }
+
+ // Grant XP to killer
+ const enemyType = entityManager.getComponent<{ id: string }>(target.id, 'enemyType');
+ if (enemyType) {
+ const enemyData = dataRegistry.getEnemy(enemyType.id);
+ const xpReward = enemyData ? enemyData.level * 20 : 20;
+ eventBus.emit('skill:improved', { entityId: killer.id, skill: 'oneHanded', amount: xpReward * 0.01 });
+ }
+ }
+
+ private generateLoot(target: Entity): any[] {
+ const loot: any[] = [];
+ const enemyType = entityManager.getComponent<{ id: string; name: string }>(target.id, 'enemyType');
+ if (!enemyType) return loot;
+
+ const enemyData = dataRegistry.getEnemy(enemyType.id);
+ if (!enemyData?.loot) {
+ loot.push({ type: 'gold', amount: randomInt(5, 24) });
+ return loot;
+ }
+
+ if (enemyData.loot.gold) {
+ const { min, max } = enemyData.loot.gold;
+ const amount = randomInt(min, max);
+ if (amount > 0) {
+ loot.push({ type: 'gold', amount });
+ }
+ }
+
+ for (const item of enemyData.loot.items || []) {
+ if (Math.random() <= item.chance) {
+ const itemData = dataRegistry.getItem(item.id);
+ loot.push({
+ type: 'item',
+ id: item.id,
+ name: itemData?.name || item.id,
+ quantity: item.quantity || 1,
+ });
+ }
+ }
+
+ return loot;
+ }
+
+ getAttackRange(isPowerAttack: boolean = false): number {
+ return isPowerAttack ? 60 : 45;
+ }
+
+ canAttack(target: Entity, attacker: Entity, isPowerAttack: boolean = false): boolean {
+ const attackerPos = entityManager.getComponent<{ x: number; y: number }>(attacker.id, 'position');
+ const targetPos = entityManager.getComponent<{ x: number; y: number }>(target.id, 'position');
+
+ if (!attackerPos || !targetPos) return false;
+
+ const dx = targetPos.x - attackerPos.x;
+ const dy = targetPos.y - attackerPos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ return distance <= this.getAttackRange(isPowerAttack);
+ }
+
+ setBlocking(entityId: string, isBlocking: boolean): void {
+ const existing = entityManager.getComponent<{ isBlocking: boolean }>(entityId, 'blocking');
+ if (existing) {
+ existing.isBlocking = isBlocking;
+ } else {
+ entityManager.addComponent(entityId, { type: 'blocking', isBlocking });
+ }
+ }
+}
+
+function randomInt(min: number, max: number): number {
+ const low = Math.ceil(Math.min(min, max));
+ const high = Math.floor(Math.max(min, max));
+ return Math.floor(Math.random() * (high - low + 1)) + low;
+}
+
+export const combatSystem = CombatSystem.getInstance();
+(globalThis as any).__oesCombatSystem = combatSystem;
diff --git a/src/systems/ContainerSystem.ts b/src/systems/ContainerSystem.ts
new file mode 100644
index 0000000..29c49e4
--- /dev/null
+++ b/src/systems/ContainerSystem.ts
@@ -0,0 +1,175 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+
+export interface ContainerComponent {
+ type: 'container';
+ containerType: 'chest' | 'barrel' | 'urn' | 'sack' | 'display_case';
+ name: string;
+ loot: any[];
+ locked: boolean;
+ lockLevel: number;
+ isEmpty: boolean;
+ isOwned: boolean;
+}
+
+export class ContainerSystem {
+ private static instance: ContainerSystem;
+ private containers: Map = new Map();
+
+ static getInstance(): ContainerSystem {
+ if (!ContainerSystem.instance) {
+ ContainerSystem.instance = new ContainerSystem();
+ }
+ return ContainerSystem.instance;
+ }
+
+ constructor() {
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('container:open', (data: { entity: Entity; opener: Entity }) => {
+ this.openContainer(data.entity, data.opener);
+ });
+ }
+
+ createContainer(
+ x: number,
+ y: number,
+ containerType: ContainerComponent['containerType'],
+ loot: any[],
+ locked: boolean = false,
+ lockLevel: number = 0,
+ isOwned: boolean = false
+ ): Entity {
+ const containerEntity = entityManager.createEntity('item');
+ entityManager.addComponent(containerEntity.id, {
+ type: 'position',
+ x,
+ y,
+ });
+ entityManager.addComponent(containerEntity.id, {
+ type: 'container',
+ containerType,
+ name: this.getContainerName(containerType),
+ loot: [...loot],
+ locked,
+ lockLevel,
+ isEmpty: false,
+ isOwned,
+ } as ContainerComponent);
+
+ this.containers.set(containerEntity.id, containerEntity);
+ eventBus.emit('container:created', { entity: containerEntity });
+
+ return containerEntity;
+ }
+
+ private getContainerName(containerType: ContainerComponent['containerType']): string {
+ const names: Record = {
+ chest: '宝箱',
+ barrel: '木桶',
+ urn: '骨灰瓮',
+ sack: '麻袋',
+ display_case: '展示柜',
+ };
+ return names[containerType] || '容器';
+ }
+
+ openContainer(containerEntity: Entity, opener: Entity): any[] | null {
+ const container = entityManager.getComponent(containerEntity.id, 'container');
+ if (!container) return null;
+
+ if (container.locked) {
+ eventBus.emit('container:locked', { entity: containerEntity, opener, lockLevel: container.lockLevel });
+ return null;
+ }
+
+ if (container.isEmpty) {
+ eventBus.emit('container:empty', { entity: containerEntity, opener });
+ return null;
+ }
+
+ eventBus.emit('container:opened', { entity: containerEntity, opener, loot: container.loot });
+ return container.loot;
+ }
+
+ lootContainer(containerEntity: Entity, looter: Entity): boolean {
+ const container = entityManager.getComponent(containerEntity.id, 'container');
+ if (!container || container.isEmpty) return false;
+
+ for (const item of container.loot) {
+ if (item.type === 'gold') {
+ const inventory = entityManager.getComponent(looter.id, 'inventory');
+ if (inventory) {
+ inventory.gold += item.amount;
+ }
+ } else if (item.type === 'item') {
+ eventBus.emit('item:pickup', {
+ entity: looter,
+ itemId: item.id,
+ quantity: item.quantity,
+ });
+ }
+ }
+
+ container.isEmpty = true;
+ container.loot = [];
+
+ eventBus.emit('container:looted', { entity: containerEntity, looter });
+ return true;
+ }
+
+ getContainerAt(x: number, y: number): Entity | null {
+ for (const container of this.containers.values()) {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(container.id, 'position');
+ if (pos && pos.x === x && pos.y === y) {
+ return container;
+ }
+ }
+ return null;
+ }
+
+ getNearbyContainers(playerEntity: Entity, range: number = 60): Entity[] {
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(playerEntity.id, 'position');
+ if (!playerPos) return [];
+
+ const nearby: Entity[] = [];
+ this.containers.forEach((container) => {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(container.id, 'position');
+ if (!pos) return;
+
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance <= range) {
+ nearby.push(container);
+ }
+ });
+
+ return nearby;
+ }
+
+ getContainerInfo(containerEntity: Entity): { name: string; locked: boolean; isEmpty: boolean } | null {
+ const container = entityManager.getComponent(containerEntity.id, 'container');
+ if (!container) return null;
+
+ return {
+ name: container.name,
+ locked: container.locked,
+ isEmpty: container.isEmpty,
+ };
+ }
+
+ unlockContainer(containerEntity: Entity): boolean {
+ const container = entityManager.getComponent(containerEntity.id, 'container');
+ if (!container || !container.locked) return false;
+
+ container.locked = false;
+ eventBus.emit('container:unlocked', { entity: containerEntity });
+ return true;
+ }
+}
+
+export const containerSystem = ContainerSystem.getInstance();
diff --git a/src/systems/CookingSystem.ts b/src/systems/CookingSystem.ts
new file mode 100644
index 0000000..f34a2d6
--- /dev/null
+++ b/src/systems/CookingSystem.ts
@@ -0,0 +1,157 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface CookingRecipe {
+ id: string;
+ name: string;
+ ingredients: string[];
+ result: {
+ id: string;
+ name: string;
+ type: 'food' | 'drink';
+ effects: CookingEffect[];
+ weight: number;
+ value: number;
+ };
+}
+
+export interface CookingEffect {
+ type: 'restore_health' | 'restore_stamina' | 'fortify_health' | 'fortify_stamina' | 'regenerate_health' | 'regenerate_stamina' | 'restore_magicka';
+ magnitude: number;
+ duration?: number;
+}
+
+export class CookingSystem {
+ private static instance: CookingSystem;
+ private recipes: Map = new Map();
+ private cookingStations: { x: number; y: number; zone: string }[] = [];
+
+ static getInstance(): CookingSystem {
+ if (!CookingSystem.instance) {
+ CookingSystem.instance = new CookingSystem();
+ }
+ return CookingSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.recipes.clear();
+
+ const registryRecipes = dataRegistry.getAllCookingRecipes();
+ for (const data of registryRecipes) {
+ this.recipes.set(data.id, {
+ id: data.id,
+ name: data.name,
+ ingredients: [...data.ingredients],
+ result: {
+ id: data.result.id,
+ name: data.result.name,
+ type: data.result.type,
+ effects: data.result.effects.map((e) => ({
+ type: e.type as CookingEffect['type'],
+ magnitude: e.magnitude,
+ duration: e.duration,
+ })),
+ weight: data.result.weight,
+ value: data.result.value,
+ },
+ });
+ }
+
+ // Cooking stations: keep hardcoded fallback positions
+ if (this.cookingStations.length === 0) {
+ this.cookingStations = [
+ { x: 100, y: 100, zone: 'whiterun' },
+ { x: 200, y: 150, zone: 'whiterun' },
+ { x: 300, y: 200, zone: 'riverwood' },
+ ];
+ }
+ }
+
+ canCook(recipeId: string, entity: Entity): boolean {
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const requiredIngredients = new Map();
+ for (const ingId of recipe.ingredients) {
+ requiredIngredients.set(ingId, (requiredIngredients.get(ingId) || 0) + 1);
+ }
+
+ for (const [ingId, required] of requiredIngredients) {
+ const owned = inventory.items.find((i) => i.id === ingId);
+ if (!owned || owned.quantity < required) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ cook(recipeId: string, entity: Entity): boolean {
+ if (!this.canCook(recipeId, entity)) return false;
+
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const usedIngredients = new Set();
+ for (const ingId of recipe.ingredients) {
+ if (!usedIngredients.has(ingId)) {
+ const item = inventory.items.find((i) => i.id === ingId);
+ if (item) {
+ item.quantity -= 1;
+ if (item.quantity <= 0) {
+ const index = inventory.items.indexOf(item);
+ inventory.items.splice(index, 1);
+ }
+ }
+ usedIngredients.add(ingId);
+ }
+ }
+
+ eventBus.emit('item:pickup', {
+ entity,
+ itemId: recipe.result.id,
+ quantity: 1,
+ });
+
+ eventBus.emit('cooking:cooked', { entity, recipe, item: recipe.result });
+ return true;
+ }
+
+ getRecipe(id: string): CookingRecipe | undefined {
+ return this.recipes.get(id);
+ }
+
+ getAvailableRecipes(entity: Entity): CookingRecipe[] {
+ return Array.from(this.recipes.values()).filter((recipe) => this.canCook(recipe.id, entity));
+ }
+
+ getAllRecipes(): CookingRecipe[] {
+ return Array.from(this.recipes.values());
+ }
+
+ isNearCookingStation(entity: Entity, range: number = 80): boolean {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return false;
+
+ return this.cookingStations.some((station) => {
+ const dx = pos.x - station.x;
+ const dy = pos.y - station.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ return distance <= range;
+ });
+ }
+}
+
+export const cookingSystem = CookingSystem.getInstance();
diff --git a/src/systems/CorpseSystem.ts b/src/systems/CorpseSystem.ts
new file mode 100644
index 0000000..2281701
--- /dev/null
+++ b/src/systems/CorpseSystem.ts
@@ -0,0 +1,173 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+
+export type CorpseState = 'fresh' | 'looted' | 'decaying' | 'skeleton' | 'gone';
+
+export interface CorpseComponent {
+ type: 'corpse';
+ state: CorpseState;
+ createdAt: number;
+ looted: boolean;
+ loot: any[];
+ decayTime: number;
+}
+
+export class CorpseSystem {
+ private static instance: CorpseSystem;
+ private decayCheckInterval: number = 60000;
+ private lastDecayCheck: number = 0;
+ private freshDecayTime: number = 300000;
+ private lootedDecayTime: number = 120000;
+
+ static getInstance(): CorpseSystem {
+ if (!CorpseSystem.instance) {
+ CorpseSystem.instance = new CorpseSystem();
+ }
+ return CorpseSystem.instance;
+ }
+
+ constructor() {
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('entity:killed', (data: { entity: Entity; killer: Entity }) => {
+ this.createCorpse(data.entity);
+ });
+ }
+
+ createCorpse(entity: Entity): void {
+ const position = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!position) return;
+
+ const loot = this.generateLoot(entity);
+
+ const corpseComponent: CorpseComponent = {
+ type: 'corpse',
+ state: 'fresh',
+ createdAt: Date.now(),
+ looted: false,
+ loot,
+ decayTime: this.freshDecayTime,
+ };
+
+ entityManager.addComponent(entity.id, corpseComponent);
+
+ eventBus.emit('corpse:created', { entity, loot });
+ }
+
+ searchCorpse(entity: Entity, searcher: Entity): any[] | null {
+ const corpse = entityManager.getComponent(entity.id, 'corpse');
+ if (!corpse || corpse.looted) return null;
+
+ corpse.looted = true;
+ corpse.state = 'looted';
+ corpse.decayTime = this.lootedDecayTime;
+
+ const sprite = entity.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite) {
+ sprite.setFillStyle(0x333333);
+ sprite.setAlpha(0.5);
+ }
+
+ eventBus.emit('corpse:searched', { entity, searcher, loot: corpse.loot });
+
+ return corpse.loot;
+ }
+
+ private generateLoot(entity: Entity): any[] {
+ const loot: any[] = [];
+ const enemyType = entityManager.getComponent<{ name: string }>(entity.id, 'enemyType');
+
+ if (enemyType) {
+ const gold = Math.floor(Math.random() * 20) + 5;
+ loot.push({ type: 'gold', amount: gold });
+
+ if (Math.random() < 0.3) {
+ loot.push({
+ type: 'item',
+ id: 'health_potion',
+ name: '生命药水',
+ quantity: 1,
+ });
+ }
+
+ if (Math.random() < 0.1) {
+ loot.push({
+ type: 'item',
+ id: 'iron_sword',
+ name: '铁剑',
+ quantity: 1,
+ });
+ }
+ }
+
+ return loot;
+ }
+
+ update(_delta: number): void {
+ const now = Date.now();
+ if (now - this.lastDecayCheck < this.decayCheckInterval) return;
+ this.lastDecayCheck = now;
+
+ const corpses = entityManager.getEntitiesByType('enemy').filter((e) => {
+ const corpse = entityManager.getComponent(e.id, 'corpse');
+ return corpse !== undefined;
+ });
+
+ for (const corpseEntity of corpses) {
+ const corpse = entityManager.getComponent(corpseEntity.id, 'corpse');
+ if (!corpse) continue;
+
+ const elapsed = now - corpse.createdAt;
+
+ if (elapsed > corpse.decayTime) {
+ this.advanceDecayState(corpseEntity, corpse);
+ }
+ }
+ }
+
+ private advanceDecayState(entity: Entity, corpse: CorpseComponent): void {
+ switch (corpse.state) {
+ case 'fresh':
+ corpse.state = 'looted';
+ corpse.decayTime = this.lootedDecayTime;
+ corpse.createdAt = Date.now();
+ eventBus.emit('corpse:decayed', { entity, state: 'looted' });
+ break;
+ case 'looted':
+ corpse.state = 'skeleton';
+ corpse.decayTime = 60000;
+ corpse.createdAt = Date.now();
+ eventBus.emit('corpse:decayed', { entity, state: 'skeleton' });
+ const sprite1 = entity.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite1) {
+ sprite1.setFillStyle(0xccccaa);
+ sprite1.setAlpha(0.3);
+ }
+ break;
+ case 'skeleton':
+ corpse.state = 'gone';
+ eventBus.emit('corpse:decayed', { entity, state: 'gone' });
+ entityManager.destroyEntity(entity.id);
+ break;
+ }
+ }
+
+ getCorpseLoot(entity: Entity): any[] | null {
+ const corpse = entityManager.getComponent(entity.id, 'corpse');
+ if (!corpse) return null;
+ return corpse.loot;
+ }
+
+ isCorpse(entity: Entity): boolean {
+ return entityManager.getComponent(entity.id, 'corpse') !== undefined;
+ }
+
+ getCorpseState(entity: Entity): CorpseState | null {
+ const corpse = entityManager.getComponent(entity.id, 'corpse');
+ return corpse?.state || null;
+ }
+}
+
+export const corpseSystem = CorpseSystem.getInstance();
diff --git a/src/systems/DayNightSystem.ts b/src/systems/DayNightSystem.ts
new file mode 100644
index 0000000..3267a3b
--- /dev/null
+++ b/src/systems/DayNightSystem.ts
@@ -0,0 +1,141 @@
+import { eventBus } from '../core/EventBus';
+
+/**
+ * Day/Night cycle system.
+ * One game day = 20 real minutes (configurable).
+ * Outputs a normalized time value (0-1) where:
+ * 0.0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk
+ */
+export class DayNightSystem {
+ private static instance: DayNightSystem;
+ private gameTime: number = 0.35; // Start at ~morning (0.35 = ~8:24 AM)
+ private dayLengthMs: number = 20 * 60 * 1000; // 20 real minutes per game day
+ private dayCount: number = 1;
+ private isPaused: boolean = false;
+
+ static getInstance(): DayNightSystem {
+ if (!DayNightSystem.instance) {
+ DayNightSystem.instance = new DayNightSystem();
+ }
+ return DayNightSystem.instance;
+ }
+
+ update(delta: number): void {
+ if (this.isPaused) return;
+
+ this.gameTime += delta / this.dayLengthMs;
+ if (this.gameTime >= 1) {
+ this.gameTime -= 1;
+ this.dayCount++;
+ eventBus.emit('day:new', { day: this.dayCount });
+ }
+
+ // Emit time changed every ~5 seconds of real time
+ if (Math.floor((this.gameTime - delta / this.dayLengthMs) * 100) !== Math.floor(this.gameTime * 100)) {
+ eventBus.emit('day:timeChanged', {
+ time: this.gameTime,
+ hour: this.getHour(),
+ minute: this.getMinute(),
+ isNight: this.isNight(),
+ dayCount: this.dayCount,
+ });
+ }
+ }
+
+ /** Get normalized time 0-1 */
+ getTime(): number {
+ return this.gameTime;
+ }
+
+ /** Get hour 0-23 */
+ getHour(): number {
+ return Math.floor(this.gameTime * 24);
+ }
+
+ /** Get minute 0-59 */
+ getMinute(): number {
+ return Math.floor((this.gameTime * 24 * 60) % 60);
+ }
+
+ /** Is it night? (between 8 PM and 5 AM) */
+ isNight(): boolean {
+ const hour = this.getHour();
+ return hour >= 20 || hour < 5;
+ }
+
+ /** Is it dawn? (5 AM - 7 AM) */
+ isDawn(): boolean {
+ const hour = this.getHour();
+ return hour >= 5 && hour < 7;
+ }
+
+ /** Is it dusk? (6 PM - 8 PM) */
+ isDusk(): boolean {
+ const hour = this.getHour();
+ return hour >= 18 && hour < 20;
+ }
+
+ /** Get ambient light multiplier 0.2 (night) - 1.0 (day) */
+ getAmbientLight(): number {
+ const hour = this.getHour();
+ if (hour >= 7 && hour < 17) return 1.0; // Full day
+ if (hour >= 5 && hour < 7) return 0.5 + (hour - 5) * 0.25; // Dawn ramp
+ if (hour >= 17 && hour < 20) return 1.0 - (hour - 17) * 0.267; // Dusk ramp
+ return 0.2; // Night
+ }
+
+ /** Get sky tint color based on time of day */
+ getSkyTint(): { r: number; g: number; b: number; a: number } {
+ const hour = this.getHour();
+ if (hour >= 7 && hour < 17) {
+ // Day - clear
+ return { r: 0, g: 0, b: 0, a: 0 };
+ }
+ if (hour >= 5 && hour < 7) {
+ // Dawn - orange tint
+ return { r: 255, g: 140, b: 50, a: 0.15 };
+ }
+ if (hour >= 17 && hour < 20) {
+ // Dusk - purple/orange tint
+ return { r: 200, g: 80, b: 120, a: 0.12 };
+ }
+ // Night - dark blue tint
+ return { r: 10, g: 10, b: 40, a: 0.35 };
+ }
+
+ getDayCount(): number {
+ return this.dayCount;
+ }
+
+ /** Format time as "HH:MM" */
+ getTimeString(): string {
+ const h = this.getHour();
+ const m = this.getMinute();
+ return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
+ }
+
+ pause(): void { this.isPaused = true; }
+ resume(): void { this.isPaused = false; }
+
+ setTime(normalized: number): void {
+ this.gameTime = Math.max(0, Math.min(1, normalized));
+ }
+
+ /** Skip to next dawn (5 AM) */
+ skipToDawn(): void {
+ const currentHour = this.getHour();
+ let hoursToSkip: number;
+ if (currentHour >= 5) {
+ hoursToSkip = 24 - currentHour + 5;
+ } else {
+ hoursToSkip = 5 - currentHour;
+ }
+ this.gameTime += hoursToSkip / 24;
+ if (this.gameTime >= 1) {
+ this.gameTime -= 1;
+ this.dayCount++;
+ }
+ }
+}
+
+export const dayNightSystem = DayNightSystem.getInstance();
diff --git a/src/systems/DialogueSystem.ts b/src/systems/DialogueSystem.ts
new file mode 100644
index 0000000..f6b28f1
--- /dev/null
+++ b/src/systems/DialogueSystem.ts
@@ -0,0 +1,249 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface DialogueLine {
+ id: string;
+ speaker: string;
+ text: string;
+ options?: DialogueOption[];
+ conditions?: DialogueCondition[];
+ effects?: DialogueEffect[];
+}
+
+export interface DialogueOption {
+ id: string;
+ text: string;
+ nextLineId: string;
+ conditions?: DialogueCondition[];
+ effects?: DialogueEffect[];
+ skillCheck?: {
+ skill: string;
+ difficulty: number;
+ successLineId: string;
+ failLineId: string;
+ };
+}
+
+export interface DialogueCondition {
+ type: 'hasItem' | 'questActive' | 'questCompleted' | 'skillCheck' | 'gold' | 'level' | 'faction';
+ value: any;
+}
+
+export interface DialogueEffect {
+ type: 'giveItem' | 'takeItem' | 'giveGold' | 'takeGold' | 'startQuest' | 'completeQuest' | 'changeFaction' | 'learnSpell';
+ value: any;
+}
+
+export interface DialogueTree {
+ id: string;
+ npcId: string;
+ lines: Record;
+ startLineId: string;
+}
+
+export class DialogueSystem {
+ private static instance: DialogueSystem;
+ private dialogueTrees: Map = new Map();
+ private currentDialogue: DialogueTree | null = null;
+ private currentLine: DialogueLine | null = null;
+ private activeNpc: Entity | null = null;
+
+ static getInstance(): DialogueSystem {
+ if (!DialogueSystem.instance) {
+ DialogueSystem.instance = new DialogueSystem();
+ }
+ return DialogueSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.dialogueTrees.clear();
+ for (const tree of dataRegistry.getAllDialogueTrees()) {
+ this.dialogueTrees.set(tree.npcId, {
+ id: tree.id,
+ npcId: tree.npcId,
+ lines: tree.lines as Record,
+ startLineId: tree.startLineId,
+ });
+ }
+ }
+
+ startDialogue(npcEntity: Entity, playerEntity: Entity): void {
+ const npcData = entityManager.getComponent<{ name: string }>(npcEntity.id, 'npcData');
+ if (!npcData) return;
+
+ const dialogueTree = this.dialogueTrees.get(npcEntity.id);
+ if (!dialogueTree) return;
+
+ this.currentDialogue = dialogueTree;
+ this.activeNpc = npcEntity;
+
+ const startLine = dialogueTree.lines[dialogueTree.startLineId];
+ if (startLine) {
+ this.showDialogueLine(startLine, playerEntity);
+ }
+
+ eventBus.emit('dialogue:started', { npc: npcEntity, player: playerEntity });
+ }
+
+ private showDialogueLine(line: DialogueLine, playerEntity: Entity): void {
+ this.currentLine = line;
+
+ const filteredOptions = line.options?.filter((option) => {
+ if (!option.conditions) return true;
+ return this.checkConditions(option.conditions, playerEntity);
+ });
+
+ eventBus.emit('dialogue:line', {
+ speaker: line.speaker,
+ text: line.text,
+ options: filteredOptions || [],
+ npc: this.activeNpc,
+ });
+ }
+
+ selectOption(optionId: string, playerEntity: Entity): void {
+ if (!this.currentDialogue || !this.currentLine) return;
+
+ const option = this.currentLine.options?.find((o) => o.id === optionId);
+ if (!option) return;
+
+ if (option.effects) {
+ this.applyEffects(option.effects, playerEntity);
+ }
+
+ if (option.skillCheck) {
+ this.handleSkillCheck(option.skillCheck, playerEntity);
+ return;
+ }
+
+ const nextLine = this.currentDialogue.lines[option.nextLineId];
+ if (nextLine) {
+ this.showDialogueLine(nextLine, playerEntity);
+ } else {
+ this.endDialogue();
+ }
+ }
+
+ private handleSkillCheck(check: { skill: string; difficulty: number; successLineId: string; failLineId: string }, playerEntity: Entity): void {
+ const skills = entityManager.getComponent>(playerEntity.id, 'skills');
+ if (!skills) return;
+
+ const skillValue = skills[check.skill] || 0;
+ const success = skillValue >= check.difficulty;
+
+ const lineId = success ? check.successLineId : check.failLineId;
+ const nextLine = this.currentDialogue?.lines[lineId];
+ if (nextLine) {
+ this.showDialogueLine(nextLine, playerEntity);
+ }
+ }
+
+ private checkConditions(conditions: DialogueCondition[], playerEntity: Entity): boolean {
+ for (const condition of conditions) {
+ switch (condition.type) {
+ case 'hasItem': {
+ const inventory = entityManager.getComponent<{ items: any[] }>(playerEntity.id, 'inventory');
+ if (!inventory) return false;
+ const hasItem = inventory.items.some((i) => i.id === condition.value);
+ if (!hasItem) return false;
+ break;
+ }
+ case 'gold': {
+ const inventory = entityManager.getComponent<{ gold: number }>(playerEntity.id, 'inventory');
+ if (!inventory || inventory.gold < condition.value) return false;
+ break;
+ }
+ case 'level': {
+ const level = entityManager.getComponent<{ level: number }>(playerEntity.id, 'level');
+ if (!level || level.level < condition.value) return false;
+ break;
+ }
+ case 'skillCheck': {
+ const skills = entityManager.getComponent>(playerEntity.id, 'skills');
+ if (!skills) return false;
+ const skillValue = skills[condition.value.skill] || 0;
+ if (skillValue < condition.value.difficulty) return false;
+ break;
+ }
+ }
+ }
+ return true;
+ }
+
+ private applyEffects(effects: DialogueEffect[], playerEntity: Entity): void {
+ for (const effect of effects) {
+ switch (effect.type) {
+ case 'giveItem': {
+ eventBus.emit('item:pickup', {
+ entity: playerEntity,
+ itemId: effect.value.id,
+ quantity: effect.value.quantity,
+ });
+ break;
+ }
+ case 'takeItem': {
+ eventBus.emit('item:drop', {
+ entity: playerEntity,
+ itemId: effect.value.id,
+ quantity: effect.value.quantity,
+ });
+ break;
+ }
+ case 'giveGold': {
+ const inventory = entityManager.getComponent<{ gold: number }>(playerEntity.id, 'inventory');
+ if (inventory) {
+ inventory.gold += effect.value;
+ }
+ break;
+ }
+ case 'takeGold': {
+ const inventory = entityManager.getComponent<{ gold: number }>(playerEntity.id, 'inventory');
+ if (inventory && inventory.gold >= effect.value) {
+ inventory.gold -= effect.value;
+ }
+ break;
+ }
+ case 'startQuest': {
+ eventBus.emit('quest:started', { questId: effect.value, player: playerEntity });
+ break;
+ }
+ case 'completeQuest': {
+ eventBus.emit('quest:completed', { questId: effect.value, player: playerEntity });
+ break;
+ }
+ }
+ }
+ }
+
+ endDialogue(): void {
+ eventBus.emit('dialogue:ended', { npc: this.activeNpc });
+ this.currentDialogue = null;
+ this.currentLine = null;
+ this.activeNpc = null;
+ }
+
+ isInDialogue(): boolean {
+ return this.currentDialogue !== null;
+ }
+
+ getCurrentLine(): DialogueLine | null {
+ return this.currentLine;
+ }
+
+ getActiveNpc(): Entity | null {
+ return this.activeNpc;
+ }
+
+ registerDialogueTree(tree: DialogueTree): void {
+ this.dialogueTrees.set(tree.npcId, tree);
+ }
+}
+
+export const dialogueSystem = DialogueSystem.getInstance();
+(globalThis as any).__oesDialogueSystem = dialogueSystem;
diff --git a/src/systems/EnchantingSystem.ts b/src/systems/EnchantingSystem.ts
new file mode 100644
index 0000000..c95b16c
--- /dev/null
+++ b/src/systems/EnchantingSystem.ts
@@ -0,0 +1,229 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface Enchantment {
+ id: string;
+ name: string;
+ type: 'weapon' | 'armor' | 'jewelry';
+ effects: EnchantmentEffect[];
+ magnitude: number;
+ duration: number;
+}
+
+export interface EnchantmentEffect {
+ type: 'absorb_health' | 'absorb_magicka' | 'absorb_stamina' | 'damage_health' | 'damage_magicka' | 'damage_stamina' | 'fire_damage' | 'frost_damage' | 'shock_damage' | 'fortify_health' | 'fortify_magicka' | 'fortify_stamina' | 'fortify_attack' | 'fortify_armor' | 'regenerate_health' | 'regenerate_magicka' | 'regenerate_stamina' | 'slow' | 'fear' | 'silence' | 'muffle';
+ magnitude: number;
+ duration?: number;
+}
+
+export interface SoulGem {
+ id: string;
+ name: string;
+ size: 'petty' | 'lesser' | 'common' | 'greater' | 'grand' | 'black';
+ capacity: number;
+ filled: boolean;
+ soulLevel?: number;
+}
+
+export class EnchantingSystem {
+ private static instance: EnchantingSystem;
+ private enchantments: Map = new Map();
+ private soulGems: Map = new Map();
+ private knownEnchantments: Map> = new Map();
+
+ static getInstance(): EnchantingSystem {
+ if (!EnchantingSystem.instance) {
+ EnchantingSystem.instance = new EnchantingSystem();
+ }
+ return EnchantingSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.enchantments.clear();
+ this.soulGems.clear();
+
+ const enchantmentData = dataRegistry.getAllEnchantments();
+ for (const data of enchantmentData) {
+ const enchantment: Enchantment = {
+ id: data.id,
+ name: data.name,
+ type: data.type,
+ effects: data.effects.map((e) => ({
+ type: e.type as EnchantmentEffect['type'],
+ magnitude: e.magnitude,
+ duration: e.duration,
+ })),
+ magnitude: data.magnitude,
+ duration: data.duration,
+ };
+ this.enchantments.set(enchantment.id, enchantment);
+ }
+
+ const soulGemData = dataRegistry.getAllSoulGems();
+ for (const data of soulGemData) {
+ const soulGem: SoulGem = {
+ id: data.id,
+ name: data.name,
+ size: data.size,
+ capacity: data.capacity,
+ filled: data.filled,
+ soulLevel: data.soulLevel,
+ };
+ this.soulGems.set(soulGem.id, soulGem);
+ }
+ }
+
+ learnEnchantment(entity: Entity, enchantmentId: string): boolean {
+ const enchantment = this.enchantments.get(enchantmentId);
+ if (!enchantment) return false;
+
+ const npcId = 'enchanter';
+ if (!this.knownEnchantments.has(npcId)) {
+ this.knownEnchantments.set(npcId, new Set());
+ }
+
+ const known = this.knownEnchantments.get(npcId)!;
+ if (known.has(enchantmentId)) return false;
+
+ known.add(enchantmentId);
+ eventBus.emit('enchantment:learned', { entity, enchantmentId });
+ return true;
+ }
+
+ disenchant(entity: Entity, itemId: string): Enchantment | null {
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return null;
+
+ const itemIndex = inventory.items.findIndex((i) => i.id === itemId);
+ if (itemIndex === -1) return null;
+
+ const item = inventory.items[itemIndex];
+ if (!item.enchantment) return null;
+
+ const enchantment = this.enchantments.get(item.enchantment.id);
+ if (!enchantment) return null;
+
+ this.learnEnchantment(entity, item.enchantment.id);
+
+ inventory.items.splice(itemIndex, 1);
+
+ eventBus.emit('enchantment:disenchanted', { entity, itemId, enchantment });
+ return enchantment;
+ }
+
+ enchantItem(entity: Entity, itemId: string, enchantmentId: string, soulGemId: string): boolean {
+ const enchantment = this.enchantments.get(enchantmentId);
+ if (!enchantment) return false;
+
+ const soulGem = this.soulGems.get(soulGemId);
+ if (!soulGem || !soulGem.filled) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const item = inventory.items.find((i) => i.id === itemId);
+ if (!item) return false;
+
+ if (enchantment.type === 'weapon' && item.type !== 'weapon') return false;
+ if (enchantment.type === 'armor' && item.type !== 'armor') return false;
+
+ const skill = entityManager.getComponent<{ enchanting: number }>(entity.id, 'skills');
+ const enchantingLevel = skill?.enchanting || 0;
+ const magnitude = Math.round(enchantment.magnitude * (1 + enchantingLevel * 0.01));
+
+ item.enchantment = {
+ id: enchantmentId,
+ name: enchantment.name,
+ magnitude,
+ duration: enchantment.duration,
+ };
+
+ const sgIndex = inventory.items.findIndex((i) => i.id === soulGemId);
+ if (sgIndex !== -1) {
+ inventory.items.splice(sgIndex, 1);
+ }
+
+ eventBus.emit('enchantment:applied', { entity, itemId, enchantment, magnitude });
+ return true;
+ }
+
+ fillSoulGem(entity: Entity, soulGemId: string, soulLevel: number): boolean {
+ const soulGem = this.soulGems.get(soulGemId);
+ if (!soulGem || soulGem.filled) return false;
+
+ if (soulLevel > soulGem.capacity) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const sgIndex = inventory.items.findIndex((i) => i.id === soulGemId);
+ if (sgIndex === -1) return false;
+
+ soulGem.filled = true;
+ soulGem.soulLevel = soulLevel;
+
+ eventBus.emit('soulGem:filled', { entity, soulGem, soulLevel });
+ return true;
+ }
+
+ getEnchantment(id: string): Enchantment | undefined {
+ return this.enchantments.get(id);
+ }
+
+ getAllEnchantments(): Enchantment[] {
+ return Array.from(this.enchantments.values());
+ }
+
+ getEnchantmentsByType(type: Enchantment['type']): Enchantment[] {
+ return Array.from(this.enchantments.values()).filter((e) => e.type === type);
+ }
+
+ getSoulGem(id: string): SoulGem | undefined {
+ return this.soulGems.get(id);
+ }
+
+ getAllSoulGems(): SoulGem[] {
+ return Array.from(this.soulGems.values());
+ }
+
+ applyEnchantmentEffects(entity: Entity, item: any): void {
+ if (!item.enchantment) return;
+
+ const enchantment = this.enchantments.get(item.enchantment.id);
+ if (!enchantment) return;
+
+ for (const effect of enchantment.effects) {
+ switch (effect.type) {
+ case 'fortify_health': {
+ const health = entityManager.getComponent<{ max: number }>(entity.id, 'health');
+ if (health) {
+ health.max += effect.magnitude;
+ }
+ break;
+ }
+ case 'fortify_magicka': {
+ const magicka = entityManager.getComponent<{ max: number }>(entity.id, 'magicka');
+ if (magicka) {
+ magicka.max += effect.magnitude;
+ }
+ break;
+ }
+ case 'fortify_stamina': {
+ const stamina = entityManager.getComponent<{ max: number }>(entity.id, 'stamina');
+ if (stamina) {
+ stamina.max += effect.magnitude;
+ }
+ break;
+ }
+ }
+ }
+ }
+}
+
+export const enchantingSystem = EnchantingSystem.getInstance();
diff --git a/src/systems/GroundItemSystem.ts b/src/systems/GroundItemSystem.ts
new file mode 100644
index 0000000..caf5ad7
--- /dev/null
+++ b/src/systems/GroundItemSystem.ts
@@ -0,0 +1,212 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+
+export interface GroundItem {
+ type: 'groundItem';
+ itemId: string;
+ name: string;
+ quantity: number;
+ weight: number;
+ value: number;
+ spawnedAt: number;
+ canPickup: boolean;
+}
+
+export class GroundItemSystem {
+ private static instance: GroundItemSystem;
+ private pickupRange: number = 40;
+ private items: Map = new Map();
+
+ static getInstance(): GroundItemSystem {
+ if (!GroundItemSystem.instance) {
+ GroundItemSystem.instance = new GroundItemSystem();
+ }
+ return GroundItemSystem.instance;
+ }
+
+ constructor() {
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('item:dropWorld', (data: { entity: Entity; itemId: string; quantity: number }) => {
+ this.spawnGroundItem(data.entity, data.itemId, data.quantity);
+ });
+ }
+
+ spawnGroundItem(sourceEntity: Entity, itemId: string, quantity: number): Entity | null {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(sourceEntity.id, 'position');
+ if (!pos) return null;
+
+ const offsetX = (Math.random() - 0.5) * 30;
+ const offsetY = (Math.random() - 0.5) * 30;
+
+ const groundEntity = entityManager.createEntity('item');
+ entityManager.addComponent(groundEntity.id, {
+ type: 'position',
+ x: pos.x + offsetX,
+ y: pos.y + offsetY,
+ });
+ entityManager.addComponent(groundEntity.id, {
+ type: 'groundItem',
+ itemId,
+ name: this.getItemName(itemId),
+ quantity,
+ weight: this.getItemWeight(itemId),
+ value: this.getItemValue(itemId),
+ spawnedAt: Date.now(),
+ canPickup: true,
+ } as GroundItem);
+
+ const sprite = (sourceEntity as any).scene?.add?.rectangle?.(
+ pos.x + offsetX,
+ pos.y + offsetY,
+ 12,
+ 12,
+ 0xffff00
+ );
+ if (sprite) {
+ groundEntity.sprite = sprite;
+ }
+
+ this.items.set(groundEntity.id, groundEntity);
+ eventBus.emit('item:spawnedGround', { entity: groundEntity, itemId, quantity });
+
+ return groundEntity;
+ }
+
+ spawnStaticItem(x: number, y: number, itemId: string, quantity: number = 1): Entity | null {
+ const groundEntity = entityManager.createEntity('item');
+ entityManager.addComponent(groundEntity.id, {
+ type: 'position',
+ x,
+ y,
+ });
+ entityManager.addComponent(groundEntity.id, {
+ type: 'groundItem',
+ itemId,
+ name: this.getItemName(itemId),
+ quantity,
+ weight: this.getItemWeight(itemId),
+ value: this.getItemValue(itemId),
+ spawnedAt: Date.now(),
+ canPickup: true,
+ } as GroundItem);
+
+ this.items.set(groundEntity.id, groundEntity);
+ return groundEntity;
+ }
+
+ getNearbyItems(playerEntity: Entity): Entity[] {
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(playerEntity.id, 'position');
+ if (!playerPos) return [];
+
+ const nearby: Entity[] = [];
+ this.items.forEach((item) => {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(item.id, 'position');
+ if (!pos) return;
+
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance <= this.pickupRange) {
+ nearby.push(item);
+ }
+ });
+
+ return nearby;
+ }
+
+ pickupItem(playerEntity: Entity, itemEntity: Entity): boolean {
+ const groundItem = entityManager.getComponent(itemEntity.id, 'groundItem');
+ if (!groundItem || !groundItem.canPickup) return false;
+
+ eventBus.emit('item:pickup', {
+ entity: playerEntity,
+ itemId: groundItem.itemId,
+ quantity: groundItem.quantity,
+ });
+
+ const sprite = itemEntity.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite) {
+ sprite.destroy();
+ }
+
+ entityManager.destroyEntity(itemEntity.id);
+ this.items.delete(itemEntity.id);
+
+ return true;
+ }
+
+ pickupAllNearby(playerEntity: Entity): { itemId: string; quantity: number }[] {
+ const nearby = this.getNearbyItems(playerEntity);
+ const picked: { itemId: string; quantity: number }[] = [];
+
+ for (const item of nearby) {
+ const groundItem = entityManager.getComponent(item.id, 'groundItem');
+ if (groundItem && this.pickupItem(playerEntity, item)) {
+ picked.push({ itemId: groundItem.itemId, quantity: groundItem.quantity });
+ }
+ }
+
+ return picked;
+ }
+
+ private getItemName(itemId: string): string {
+ const names: Record = {
+ health_potion: '生命药水',
+ magicka_potion: '魔力药水',
+ stamina_potion: '耐力药水',
+ iron_sword: '铁剑',
+ steel_sword: '钢剑',
+ iron_dagger: '铁匕首',
+ bread: '面包',
+ gold_coin: '金币',
+ };
+ return names[itemId] || itemId;
+ }
+
+ private getItemWeight(itemId: string): number {
+ const weights: Record = {
+ health_potion: 0.5,
+ magicka_potion: 0.5,
+ stamina_potion: 0.5,
+ iron_sword: 10,
+ steel_sword: 11,
+ iron_dagger: 3,
+ bread: 0.2,
+ gold_coin: 0,
+ };
+ return weights[itemId] || 1;
+ }
+
+ private getItemValue(itemId: string): number {
+ const values: Record = {
+ health_potion: 25,
+ magicka_potion: 30,
+ stamina_potion: 20,
+ iron_sword: 50,
+ steel_sword: 120,
+ iron_dagger: 25,
+ bread: 5,
+ gold_coin: 1,
+ };
+ return values[itemId] || 10;
+ }
+
+ getAllItems(): Entity[] {
+ return Array.from(this.items.values());
+ }
+
+ removeItem(itemEntity: Entity): void {
+ const sprite = itemEntity.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite) {
+ sprite.destroy();
+ }
+ entityManager.destroyEntity(itemEntity.id);
+ this.items.delete(itemEntity.id);
+ }
+}
+
+export const groundItemSystem = GroundItemSystem.getInstance();
diff --git a/src/systems/InventorySystem.test.ts b/src/systems/InventorySystem.test.ts
new file mode 100644
index 0000000..1c1718b
--- /dev/null
+++ b/src/systems/InventorySystem.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { InventorySystem } from './InventorySystem';
+import { entityManager } from '../core/EntityManager';
+
+describe('InventorySystem', () => {
+ let inventorySystem: InventorySystem;
+
+ beforeEach(() => {
+ inventorySystem = InventorySystem.getInstance();
+ });
+
+ it('should initialize inventory for entity', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ const inventory = entityManager.getComponent<{ items: any[]; gold: number; carryWeight: number; maxCarryWeight: number }>(entity.id, 'inventory');
+ expect(inventory).toBeDefined();
+ expect(inventory?.items).toEqual([]);
+ expect(inventory?.gold).toBe(100);
+ expect(inventory?.carryWeight).toBe(0);
+ expect(inventory?.maxCarryWeight).toBe(300);
+ });
+
+ it('should add items to inventory', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ const result = inventorySystem.addItem(entity, 'health_potion', 3);
+
+ expect(result).toBe(true);
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ expect(inventory?.items).toHaveLength(1);
+ expect(inventory?.items[0]?.id).toBe('health_potion');
+ expect(inventory?.items[0]?.quantity).toBe(3);
+ });
+
+ it('should stack existing items', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ inventorySystem.addItem(entity, 'health_potion', 2);
+ inventorySystem.addItem(entity, 'health_potion', 3);
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ expect(inventory?.items).toHaveLength(1);
+ expect(inventory?.items[0]?.quantity).toBe(5);
+ });
+
+ it('should remove items from inventory', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ inventorySystem.addItem(entity, 'health_potion', 5);
+ const result = inventorySystem.removeItem(entity, 'health_potion', 2);
+
+ expect(result).toBe(true);
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ expect(inventory?.items[0]?.quantity).toBe(3);
+ });
+
+ it('should remove item completely when quantity reaches zero', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ inventorySystem.addItem(entity, 'health_potion', 2);
+ inventorySystem.removeItem(entity, 'health_potion', 2);
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ expect(inventory?.items).toHaveLength(0);
+ });
+
+ it('should check if entity has item', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ inventorySystem.addItem(entity, 'health_potion', 1);
+
+ expect(inventorySystem.hasItem(entity, 'health_potion')).toBe(true);
+ expect(inventorySystem.hasItem(entity, 'mana_potion')).toBe(false);
+ });
+
+ it('should calculate carry weight', () => {
+ const entity = entityManager.createEntity('player');
+ inventorySystem.initializeInventory(entity);
+
+ inventorySystem.addItem(entity, 'health_potion', 1);
+ inventorySystem.addItem(entity, 'iron_sword', 1);
+
+ const inventory = entityManager.getComponent<{ carryWeight: number }>(entity.id, 'inventory');
+ expect(inventory?.carryWeight).toBeGreaterThan(0);
+ });
+});
diff --git a/src/systems/InventorySystem.ts b/src/systems/InventorySystem.ts
new file mode 100644
index 0000000..3d472ab
--- /dev/null
+++ b/src/systems/InventorySystem.ts
@@ -0,0 +1,261 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry, type DataEffect, type ItemData } from '../data/DataRegistry';
+
+export interface InventoryItem {
+ id: string;
+ name: string;
+ type: string;
+ quantity: number;
+ weight: number;
+ value: number;
+ equipped?: boolean;
+ slot?: 'leftHand' | 'rightHand' | 'head' | 'chest' | 'hands' | 'feet' | 'ring' | 'necklace';
+ effects?: DataEffect[];
+ description?: string;
+}
+
+export interface Inventory {
+ items: InventoryItem[];
+ gold: number;
+ carryWeight: number;
+ maxCarryWeight: number;
+}
+
+export class InventorySystem {
+ private static instance: InventorySystem;
+
+ static getInstance(): InventorySystem {
+ if (!InventorySystem.instance) {
+ InventorySystem.instance = new InventorySystem();
+ }
+ return InventorySystem.instance;
+ }
+
+ constructor() {
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('player:created', (data: { entity: Entity }) => {
+ this.initializeInventory(data.entity);
+ });
+
+ eventBus.on('item:pickup', (data: { entity: Entity; itemId: string; quantity: number }) => {
+ this.addItem(data.entity, data.itemId, data.quantity);
+ });
+
+ eventBus.on('item:drop', (data: { entity: Entity; itemId: string; quantity: number }) => {
+ this.removeItem(data.entity, data.itemId, data.quantity);
+ });
+
+ eventBus.on('item:use', (data: { entity: Entity; itemId: string }) => {
+ this.useItem(data.entity, data.itemId);
+ });
+ }
+
+ initializeInventory(entity: Entity): void {
+ const existing = entityManager.getComponent(entity.id, 'inventory');
+ if (existing) return;
+
+ entityManager.addComponent(entity.id, {
+ type: 'inventory',
+ items: [],
+ gold: 100,
+ carryWeight: 0,
+ maxCarryWeight: 300,
+ });
+ }
+
+ addItem(entity: Entity, itemId: string, quantity: number = 1): boolean {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const existingItem = inventory.items.find((i) => i.id === itemId);
+ if (existingItem) {
+ existingItem.quantity += quantity;
+ } else {
+ const newItem = this.createInventoryItem(itemId, quantity);
+ if (newItem) {
+ inventory.items.push(newItem);
+ }
+ }
+
+ this.updateCarryWeight(entity);
+ eventBus.emit('inventory:updated', { entity });
+ return true;
+ }
+
+ removeItem(entity: Entity, itemId: string, quantity: number = 1): boolean {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const itemIndex = inventory.items.findIndex((i) => i.id === itemId);
+ if (itemIndex === -1) return false;
+
+ const item = inventory.items[itemIndex]!;
+ if (item.quantity <= quantity) {
+ inventory.items.splice(itemIndex, 1);
+ } else {
+ item.quantity -= quantity;
+ }
+
+ this.updateCarryWeight(entity);
+ eventBus.emit('inventory:updated', { entity });
+ return true;
+ }
+
+ useItem(entity: Entity, itemId: string): boolean {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const item = inventory.items.find((i) => i.id === itemId);
+ if (!item || item.quantity <= 0) return false;
+
+ if (item.type === 'consumable') {
+ this.applyConsumableEffect(entity, item);
+ item.quantity -= 1;
+
+ if (item.quantity <= 0) {
+ const index = inventory.items.indexOf(item);
+ inventory.items.splice(index, 1);
+ }
+
+ eventBus.emit('inventory:updated', { entity });
+ return true;
+ }
+
+ return false;
+ }
+
+ private applyConsumableEffect(entity: Entity, item: InventoryItem): void {
+ if (!item.effects) return;
+
+ for (const effect of item.effects) {
+ switch (effect.type) {
+ case 'restore_health': {
+ const health = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'health');
+ if (health) {
+ health.current = Math.round(Math.min(health.max, health.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'restore_magicka': {
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'magicka');
+ if (magicka) {
+ magicka.current = Math.round(Math.min(magicka.max, magicka.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'restore_stamina': {
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'stamina');
+ if (stamina) {
+ stamina.current = Math.round(Math.min(stamina.max, stamina.current + effect.magnitude));
+ }
+ break;
+ }
+ }
+ }
+
+ eventBus.emit('item:used', { entity, item });
+ }
+
+ equipItem(entity: Entity, itemId: string, slot: InventoryItem['slot']): boolean {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const item = inventory.items.find((i) => i.id === itemId);
+ if (!item) return false;
+
+ const currentEquipped = inventory.items.find((i) => i.equipped && i.slot === slot);
+ if (currentEquipped) {
+ currentEquipped.equipped = false;
+ }
+
+ item.equipped = true;
+ item.slot = slot;
+
+ if (item.type === 'weapon') {
+ entityManager.addComponent(entity.id, {
+ type: 'weapon',
+ id: item.id,
+ damage: item.effects?.find((e) => e.type === 'damage')?.magnitude || 10,
+ speed: item.effects?.find((e) => e.type === 'speed')?.magnitude || 1.0,
+ });
+ }
+
+ eventBus.emit('inventory:updated', { entity });
+ eventBus.emit('equipment:changed', { entity, item, slot });
+ return true;
+ }
+
+ unequipItem(entity: Entity, itemId: string): boolean {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const item = inventory.items.find((i) => i.id === itemId);
+ if (!item || !item.equipped) return false;
+
+ item.equipped = false;
+ const slot = item.slot;
+ item.slot = undefined;
+
+ if (item.type === 'weapon') {
+ entityManager.removeComponent(entity.id, 'weapon');
+ }
+
+ eventBus.emit('inventory:updated', { entity });
+ eventBus.emit('equipment:changed', { entity, item, slot });
+ return true;
+ }
+
+ getInventory(entity: Entity): Inventory | undefined {
+ return entityManager.getComponent(entity.id, 'inventory');
+ }
+
+ getItem(entity: Entity, itemId: string): InventoryItem | undefined {
+ const inventory = this.getInventory(entity);
+ return inventory?.items.find((i) => i.id === itemId);
+ }
+
+ hasItem(entity: Entity, itemId: string, quantity: number = 1): boolean {
+ const item = this.getItem(entity, itemId);
+ return item ? item.quantity >= quantity : false;
+ }
+
+ getItemCount(entity: Entity, itemId: string): number {
+ const item = this.getItem(entity, itemId);
+ return item?.quantity || 0;
+ }
+
+ private updateCarryWeight(entity: Entity): void {
+ const inventory = entityManager.getComponent(entity.id, 'inventory');
+ if (!inventory) return;
+
+ inventory.carryWeight = inventory.items.reduce((total, item) => {
+ return total + item.weight * item.quantity;
+ }, 0);
+ }
+
+ private createInventoryItem(itemId: string, quantity: number): InventoryItem | null {
+ const itemData = this.getItemData(itemId);
+ if (!itemData) return null;
+
+ return {
+ id: itemId,
+ name: itemData.name,
+ type: itemData.type,
+ quantity,
+ weight: itemData.weight,
+ value: itemData.value,
+ effects: itemData.effects,
+ description: itemData.description,
+ };
+ }
+
+ private getItemData(itemId: string): ItemData | null {
+ return dataRegistry.getItem(itemId) || null;
+ }
+}
+
+export const inventorySystem = InventorySystem.getInstance();
diff --git a/src/systems/LegendarySystem.test.ts b/src/systems/LegendarySystem.test.ts
new file mode 100644
index 0000000..dfe242a
--- /dev/null
+++ b/src/systems/LegendarySystem.test.ts
@@ -0,0 +1,115 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { entityManager } from '../core/EntityManager';
+import { eventBus } from '../core/EventBus';
+import { legendarySystem } from './LegendarySystem';
+
+describe('LegendarySystem', () => {
+ beforeEach(() => {
+ entityManager.clear();
+ eventBus.removeAllListeners();
+ });
+
+ function createPlayer(): string {
+ const player = entityManager.createEntity('player');
+ entityManager.addComponent(player.id, {
+ type: 'skills',
+ oneHanded: 100,
+ twoHanded: 50,
+ archery: 15,
+ block: 15,
+ heavyArmor: 15, lightArmor: 15,
+ destruction: 15, conjuration: 15, illusion: 15, alteration: 15, restoration: 15, enchanting: 15,
+ sneak: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15, smithing: 15,
+ });
+ entityManager.addComponent(player.id, { type: 'level', level: 1, xp: 0, xpToNext: 100, perkPoints: 0 });
+ entityManager.addComponent(player.id, { type: 'legendary', skills: {} });
+ return player.id;
+ }
+
+ it('legendaryizes a maxed skill', () => {
+ const playerId = createPlayer();
+ const result = legendarySystem.legendaryize(playerId, 'oneHanded');
+
+ expect(result).toBe(true);
+
+ const skills = entityManager.getComponent>(playerId, 'skills');
+ expect(skills!.oneHanded).toBe(15);
+
+ const level = entityManager.getComponent<{ perkPoints: number }>(playerId, 'level');
+ expect(level!.perkPoints).toBe(1);
+ });
+
+ it('cannot legendaryize a non-maxed skill', () => {
+ const playerId = createPlayer();
+ const result = legendarySystem.legendaryize(playerId, 'twoHanded');
+
+ expect(result).toBe(false);
+ });
+
+ it('tracks legendary count', () => {
+ const playerId = createPlayer();
+
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+ expect(legendarySystem.getLegendaryCount(playerId, 'oneHanded')).toBe(1);
+
+ const skills = entityManager.getComponent>(playerId, 'skills');
+ skills!.oneHanded = 100;
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+ expect(legendarySystem.getLegendaryCount(playerId, 'oneHanded')).toBe(2);
+ });
+
+ it('returns all legendary skills', () => {
+ const playerId = createPlayer();
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+
+ const legendary = legendarySystem.getLegendarySkills(playerId);
+ expect(legendary.oneHanded).toBe(1);
+ expect(legendary.twoHanded).toBeUndefined();
+ });
+
+ it('returns total legendary count', () => {
+ const playerId = createPlayer();
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+
+ const skills = entityManager.getComponent>(playerId, 'skills');
+ skills!.twoHanded = 100;
+ legendarySystem.legendaryize(playerId, 'twoHanded');
+
+ expect(legendarySystem.getTotalLegendaryCount(playerId)).toBe(2);
+ });
+
+ it('calculates XP bonus from legendary count', () => {
+ const playerId = createPlayer();
+
+ expect(legendarySystem.getXpBonus(playerId, 'oneHanded')).toBe(1);
+
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+ expect(legendarySystem.getXpBonus(playerId, 'oneHanded')).toBe(1.1);
+
+ const skills = entityManager.getComponent>(playerId, 'skills');
+ skills!.oneHanded = 100;
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+ expect(legendarySystem.getXpBonus(playerId, 'oneHanded')).toBe(1.2);
+ });
+
+ it('canLegendaryize checks skill level', () => {
+ const playerId = createPlayer();
+
+ expect(legendarySystem.canLegendaryize(playerId, 'oneHanded')).toBe(true);
+ expect(legendarySystem.canLegendaryize(playerId, 'twoHanded')).toBe(false);
+ });
+
+ it('emits event on legendaryize', () => {
+ const events: { skillId: string; legendaryCount: number }[] = [];
+ eventBus.on('skill:legendaryized', (data: { skillId: string; legendaryCount: number }) => {
+ events.push(data);
+ });
+
+ const playerId = createPlayer();
+ legendarySystem.legendaryize(playerId, 'oneHanded');
+
+ expect(events.length).toBe(1);
+ expect(events[0]!.skillId).toBe('oneHanded');
+ expect(events[0]!.legendaryCount).toBe(1);
+ });
+});
diff --git a/src/systems/LegendarySystem.ts b/src/systems/LegendarySystem.ts
new file mode 100644
index 0000000..2d53ffc
--- /dev/null
+++ b/src/systems/LegendarySystem.ts
@@ -0,0 +1,94 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+
+export interface LegendaryComponent {
+ type: 'legendary';
+ skills: Record;
+}
+
+export class LegendarySystem {
+ private static instance: LegendarySystem;
+
+ static getInstance(): LegendarySystem {
+ if (!LegendarySystem.instance) {
+ LegendarySystem.instance = new LegendarySystem();
+ }
+ return LegendarySystem.instance;
+ }
+
+ constructor() {
+ eventBus.on('player:created', (data: { entity: { id: string } }) => {
+ this.onPlayerCreated(data.entity.id);
+ });
+
+ eventBus.on('skill:legendaryize', (data: { entityId: string; skillId: string }) => {
+ this.legendaryize(data.entityId, data.skillId);
+ });
+ }
+
+ private onPlayerCreated(entityId: string): void {
+ if (!entityManager.hasComponent(entityId, 'legendary')) {
+ entityManager.addComponent(entityId, {
+ type: 'legendary',
+ skills: {},
+ } as LegendaryComponent);
+ }
+ }
+
+ legendaryize(entityId: string, skillId: string): boolean {
+ const skills = entityManager.getComponent>(entityId, 'skills');
+ if (!skills || (skills[skillId] ?? 0) < 100) {
+ return false;
+ }
+
+ let legendary = entityManager.getComponent(entityId, 'legendary');
+ if (!legendary) {
+ legendary = { type: 'legendary', skills: {} };
+ entityManager.addComponent(entityId, legendary);
+ }
+
+ skills[skillId] = 15;
+
+ legendary.skills[skillId] = (legendary.skills[skillId] ?? 0) + 1;
+
+ const level = entityManager.getComponent<{ perkPoints: number; level: number }>(entityId, 'level');
+ if (level) {
+ level.perkPoints += 1;
+ }
+
+ eventBus.emit('skill:legendaryized', {
+ entityId,
+ skillId,
+ legendaryCount: legendary.skills[skillId],
+ });
+
+ return true;
+ }
+
+ getLegendaryCount(entityId: string, skillId: string): number {
+ const legendary = entityManager.getComponent(entityId, 'legendary');
+ return legendary?.skills[skillId] ?? 0;
+ }
+
+ getLegendarySkills(entityId: string): Record {
+ const legendary = entityManager.getComponent(entityId, 'legendary');
+ return legendary?.skills ?? {};
+ }
+
+ getTotalLegendaryCount(entityId: string): number {
+ const skills = this.getLegendarySkills(entityId);
+ return Object.values(skills).reduce((sum, count) => sum + count, 0);
+ }
+
+ getXpBonus(entityId: string, skillId: string): number {
+ const count = this.getLegendaryCount(entityId, skillId);
+ return 1 + count * 0.1;
+ }
+
+ canLegendaryize(entityId: string, skillId: string): boolean {
+ const skills = entityManager.getComponent>(entityId, 'skills');
+ return (skills?.[skillId] ?? 0) >= 100;
+ }
+}
+
+export const legendarySystem = LegendarySystem.getInstance();
diff --git a/src/systems/LevelingSystem.ts b/src/systems/LevelingSystem.ts
new file mode 100644
index 0000000..31788ca
--- /dev/null
+++ b/src/systems/LevelingSystem.ts
@@ -0,0 +1,101 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export class LevelingSystem {
+ private static instance: LevelingSystem;
+ private config = dataRegistry.getGameConfig().leveling;
+
+ static getInstance(): LevelingSystem {
+ if (!LevelingSystem.instance) {
+ LevelingSystem.instance = new LevelingSystem();
+ }
+ return LevelingSystem.instance;
+ }
+
+ constructor() {
+ eventBus.on('mod:dataResolved', () => {
+ this.config = dataRegistry.getGameConfig().leveling;
+ });
+ this.setupListeners();
+ }
+
+ private setupListeners(): void {
+ eventBus.on('skill:improved', (data: { entityId: string; skill: string; amount: number }) => {
+ this.onSkillImproved(data.entityId, data.skill, data.amount);
+ });
+ }
+
+ private onSkillImproved(entityId: string, skill: string, amount: number): void {
+ const skills = entityManager.getComponent>(entityId, 'skills');
+ const level = entityManager.getComponent<{ level: number; perkPoints: number; xp: number }>(entityId, 'level');
+
+ if (!skills || !level) return;
+
+ const oldValue = skills[skill] || this.config.defaultSkillLevel;
+ const newValue = Math.min(this.config.maxSkillLevel, oldValue + amount);
+ skills[skill] = newValue;
+
+ const totalSkillLevels = Object.values(skills).reduce((sum, v) => sum + (v - this.config.defaultSkillLevel), 0);
+ const expectedLevel = Math.floor(totalSkillLevels / this.config.levelDivisor) + 1;
+
+ if (expectedLevel > level.level) {
+ level.level = expectedLevel;
+ level.perkPoints += 1;
+
+ const health = entityManager.getComponent<{ max: number }>(entityId, 'health');
+ const magicka = entityManager.getComponent<{ max: number }>(entityId, 'magicka');
+ const stamina = entityManager.getComponent<{ max: number }>(entityId, 'stamina');
+
+ if (health) health.max += this.config.healthPerLevel;
+ if (magicka) magicka.max += this.config.magickaPerLevel;
+ if (stamina) stamina.max += this.config.staminaPerLevel;
+
+ eventBus.emit('player:levelUp', {
+ entityId,
+ level: level.level,
+ perkPoints: level.perkPoints,
+ });
+ }
+
+ eventBus.emit('skill:updated', { entityId, skill, newValue });
+ }
+
+ makeLegendary(entityId: string, skill: string): boolean {
+ const skills = entityManager.getComponent>(entityId, 'skills');
+ const level = entityManager.getComponent<{ level: number; perkPoints: number }>(entityId, 'level');
+
+ if (!skills || !level) return false;
+ if ((skills[skill] || this.config.defaultSkillLevel) < this.config.legendaryThreshold) return false;
+
+ skills[skill] = this.config.defaultSkillLevel;
+ level.perkPoints += 1;
+
+ eventBus.emit('skill:legendary', { entityId, skill });
+ return true;
+ }
+
+ addSkillXP(entityId: string, skill: string, xp: number): void {
+ const skills = entityManager.getComponent>(entityId, 'skills');
+ if (!skills) return;
+
+ const currentLevel = skills[skill] || this.config.defaultSkillLevel;
+ if (currentLevel >= this.config.maxSkillLevel) return;
+
+ const xpNeeded = this.getXPForSkillLevel(currentLevel);
+ const level = entityManager.getComponent<{ xp: number }>(entityId, 'level');
+ if (level) {
+ level.xp += xp;
+ if (level.xp >= xpNeeded) {
+ level.xp -= xpNeeded;
+ this.onSkillImproved(entityId, skill, 1);
+ }
+ }
+ }
+
+ private getXPForSkillLevel(level: number): number {
+ return Math.floor(this.config.xpBase * Math.pow(this.config.xpScale, level - this.config.defaultSkillLevel));
+ }
+}
+
+export const levelingSystem = LevelingSystem.getInstance();
diff --git a/src/systems/MagicSystem.ts b/src/systems/MagicSystem.ts
new file mode 100644
index 0000000..19bd64e
--- /dev/null
+++ b/src/systems/MagicSystem.ts
@@ -0,0 +1,371 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export type MagicSchool = 'destruction' | 'restoration' | 'illusion' | 'conjuration' | 'alteration';
+
+export interface Spell {
+ id: string;
+ name: string;
+ school: MagicSchool;
+ type: 'self' | 'target' | 'area' | 'ranged';
+ magickaCost: number;
+ magnitude: number;
+ duration: number;
+ cooldown: number;
+ level: number;
+ description: string;
+ effects: SpellEffect[];
+}
+
+export interface SpellEffect {
+ type: 'damage' | 'heal' | 'fortify' | 'weakness' | 'fear' | 'calm' | 'frenzy' | 'invisibility' | 'conjure' | 'bound' | 'transmute' | 'slow';
+ attribute?: string;
+ magnitude: number;
+ duration?: number;
+}
+
+export interface Shout {
+ id: string;
+ name: string;
+ words: string[];
+ wordCount: number;
+ cooldown: number;
+ effects: ShoutEffect[];
+}
+
+export interface ShoutEffect {
+ type: 'damage' | 'push' | 'slow' | 'fear' | 'marked' | 'time' | 'unrelenting';
+ magnitude: number;
+ duration?: number;
+}
+
+export class MagicSystem {
+ private static instance: MagicSystem;
+ private spells: Map = new Map();
+ private shouts: Map = new Map();
+ private lastCastTime: number = 0;
+
+ static getInstance(): MagicSystem {
+ if (!MagicSystem.instance) {
+ MagicSystem.instance = new MagicSystem();
+ }
+ return MagicSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.spells.clear();
+ this.shouts.clear();
+
+ for (const spell of dataRegistry.getAllFullSpells()) {
+ this.spells.set(spell.id, {
+ id: spell.id,
+ name: spell.name,
+ school: spell.school as MagicSchool,
+ type: spell.type,
+ magickaCost: spell.magickaCost,
+ magnitude: spell.magnitude,
+ duration: spell.duration,
+ cooldown: spell.cooldown,
+ level: spell.level,
+ description: spell.description,
+ effects: spell.effects.map((e) => ({
+ type: e.type as SpellEffect['type'],
+ attribute: e.attribute,
+ magnitude: e.magnitude,
+ duration: e.duration,
+ })),
+ });
+ }
+
+ for (const shout of dataRegistry.getAllShouts()) {
+ this.shouts.set(shout.id, {
+ id: shout.id,
+ name: shout.name,
+ words: shout.words,
+ wordCount: shout.wordCount,
+ cooldown: shout.cooldown,
+ effects: shout.effects.map((e) => ({
+ type: e.type as ShoutEffect['type'],
+ magnitude: e.magnitude,
+ duration: e.duration,
+ })),
+ });
+ }
+ }
+
+ castSpell(caster: Entity, spellId: string, target?: Entity): boolean {
+ const spell = this.spells.get(spellId);
+ if (!spell) return false;
+
+ const now = Date.now();
+ if (now - this.lastCastTime < spell.cooldown) return false;
+
+ const magicka = entityManager.getComponent<{ current: number }>(caster.id, 'magicka');
+ if (!magicka || magicka.current < spell.magickaCost) return false;
+
+ magicka.current -= spell.magickaCost;
+
+ for (const effect of spell.effects) {
+ this.applySpellEffect(caster, target, spell, effect);
+ }
+
+ this.lastCastTime = now;
+ eventBus.emit('spell:cast', { caster, spell, target });
+ return true;
+ }
+
+ private applySpellEffect(caster: Entity, target: Entity | undefined, spell: Spell, effect: SpellEffect): void {
+ switch (effect.type) {
+ case 'damage': {
+ if (target) {
+ const health = entityManager.getComponent<{ current: number }>(target.id, 'health');
+ if (health) {
+ health.current = Math.max(0, health.current - effect.magnitude);
+ eventBus.emit('combat:magicDamage', { caster, target, damage: effect.magnitude, school: spell.school });
+ if (health.current <= 0) {
+ eventBus.emit('entity:killed', { entity: target, killer: caster });
+ }
+ }
+ }
+ break;
+ }
+ case 'heal': {
+ const health = entityManager.getComponent<{ current: number; max: number }>(caster.id, 'health');
+ if (health) {
+ health.current = Math.round(Math.min(health.max, health.current + effect.magnitude));
+ }
+ break;
+ }
+ case 'fortify': {
+ if (effect.attribute === 'armor') {
+ const armor = entityManager.getComponent<{ rating: number }>(caster.id, 'armor');
+ if (armor) {
+ armor.rating += effect.magnitude;
+ if (effect.duration) {
+ setTimeout(() => {
+ armor.rating -= effect.magnitude;
+ }, effect.duration);
+ }
+ }
+ }
+ break;
+ }
+ case 'fear': {
+ if (target) {
+ const ai = entityManager.getComponent<{ state: string }>(target.id, 'ai');
+ if (ai) {
+ ai.state = 'flee';
+ if (effect.duration) {
+ setTimeout(() => {
+ ai.state = 'idle';
+ }, effect.duration);
+ }
+ }
+ }
+ break;
+ }
+ case 'calm': {
+ if (target) {
+ const ai = entityManager.getComponent<{ state: string }>(target.id, 'ai');
+ if (ai) {
+ ai.state = 'idle';
+ if (effect.duration) {
+ setTimeout(() => {
+ ai.state = 'idle';
+ }, effect.duration);
+ }
+ }
+ }
+ break;
+ }
+ case 'conjure': {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(caster.id, 'position');
+ if (pos) {
+ const summoned = entityManager.createEntity('enemy');
+ entityManager.addComponent(summoned.id, {
+ type: 'position',
+ x: pos.x + 50,
+ y: pos.y,
+ });
+ entityManager.addComponent(summoned.id, {
+ type: 'health',
+ current: 50 * effect.magnitude,
+ max: 50 * effect.magnitude,
+ });
+ entityManager.addComponent(summoned.id, {
+ type: 'enemyType',
+ name: '召唤物',
+ id: 'summoned',
+ });
+ entityManager.addComponent(summoned.id, {
+ type: 'ai',
+ state: 'ally',
+ detectionRange: 300,
+ attackRange: 45,
+ attackCooldown: 1000,
+ lastAttackTime: 0,
+ });
+ entityManager.addComponent(summoned.id, {
+ type: 'weapon',
+ id: 'fists',
+ damage: 10 * effect.magnitude,
+ speed: 1.0,
+ });
+ entityManager.addComponent(summoned.id, {
+ type: 'armor',
+ rating: 5 * effect.magnitude,
+ });
+
+ if (effect.duration) {
+ setTimeout(() => {
+ const sprite = summoned.sprite as Phaser.GameObjects.Rectangle | undefined;
+ if (sprite) sprite.destroy();
+ entityManager.destroyEntity(summoned.id);
+ }, effect.duration);
+ }
+ }
+ break;
+ }
+ case 'bound': {
+ const weapon = entityManager.getComponent<{ damage: number }>(caster.id, 'weapon');
+ if (weapon) {
+ weapon.damage = effect.magnitude;
+ if (effect.duration) {
+ setTimeout(() => {
+ weapon.damage = 4;
+ }, effect.duration);
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ useShout(caster: Entity, shoutId: string): boolean {
+ const shout = this.shouts.get(shoutId);
+ if (!shout) return false;
+
+ const now = Date.now();
+ if (now - this.lastCastTime < shout.cooldown) return false;
+
+ for (const effect of shout.effects) {
+ this.applyShoutEffect(caster, shout, effect);
+ }
+
+ this.lastCastTime = now;
+ eventBus.emit('shout:used', { caster, shout });
+ return true;
+ }
+
+ private applyShoutEffect(caster: Entity, _shout: Shout, effect: ShoutEffect): void {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(caster.id, 'position');
+ if (!pos) return;
+
+ const enemies = entityManager.getEntitiesByType('enemy');
+ for (const enemy of enemies) {
+ const enemyPos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position');
+ if (!enemyPos) continue;
+
+ const dx = enemyPos.x - pos.x;
+ const dy = enemyPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance > 300) continue;
+
+ switch (effect.type) {
+ case 'push': {
+ const nx = dx / distance;
+ const ny = dy / distance;
+ enemyPos.x += nx * effect.magnitude;
+ enemyPos.y += ny * effect.magnitude;
+ break;
+ }
+ case 'damage': {
+ const health = entityManager.getComponent<{ current: number }>(enemy.id, 'health');
+ if (health) {
+ health.current = Math.max(0, health.current - effect.magnitude);
+ if (health.current <= 0) {
+ eventBus.emit('entity:killed', { entity: enemy, killer: caster });
+ }
+ }
+ break;
+ }
+ case 'slow': {
+ const ai = entityManager.getComponent<{ state: string }>(enemy.id, 'ai');
+ if (ai) {
+ const originalState = ai.state;
+ ai.state = 'slowed';
+ if (effect.duration) {
+ setTimeout(() => {
+ ai.state = originalState;
+ }, effect.duration);
+ }
+ }
+ break;
+ }
+ case 'fear': {
+ const ai = entityManager.getComponent<{ state: string }>(enemy.id, 'ai');
+ if (ai) {
+ ai.state = 'flee';
+ if (effect.duration) {
+ setTimeout(() => {
+ ai.state = 'idle';
+ }, effect.duration);
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ getSpell(spellId: string): Spell | undefined {
+ return this.spells.get(spellId);
+ }
+
+ getShout(shoutId: string): Shout | undefined {
+ return this.shouts.get(shoutId);
+ }
+
+ getSpellsBySchool(school: MagicSchool): Spell[] {
+ return Array.from(this.spells.values()).filter((s) => s.school === school);
+ }
+
+ getAllSpells(): Spell[] {
+ return Array.from(this.spells.values());
+ }
+
+ getAllShouts(): Shout[] {
+ return Array.from(this.shouts.values());
+ }
+
+ learnSpell(entity: Entity, spellId: string): boolean {
+ const knownSpells = entityManager.getComponent<{ spells: string[] }>(entity.id, 'knownSpells');
+ if (!knownSpells) {
+ entityManager.addComponent(entity.id, { type: 'knownSpells', spells: [] });
+ }
+
+ const spells = entityManager.getComponent<{ spells: string[] }>(entity.id, 'knownSpells');
+ if (spells && !spells.spells.includes(spellId)) {
+ spells.spells.push(spellId);
+ eventBus.emit('spell:learned', { entity, spellId });
+ return true;
+ }
+
+ return false;
+ }
+
+ hasSpell(entity: Entity, spellId: string): boolean {
+ const knownSpells = entityManager.getComponent<{ spells: string[] }>(entity.id, 'knownSpells');
+ return knownSpells?.spells.includes(spellId) || false;
+ }
+}
+
+export const magicSystem = MagicSystem.getInstance();
+(globalThis as any).__oesMagicSystem = magicSystem;
diff --git a/src/systems/MovementSystem.ts b/src/systems/MovementSystem.ts
new file mode 100644
index 0000000..3000c86
--- /dev/null
+++ b/src/systems/MovementSystem.ts
@@ -0,0 +1,95 @@
+import { entityManager, type Entity } from '../core/EntityManager';
+
+export interface MovementInput {
+ up: boolean;
+ down: boolean;
+ left: boolean;
+ right: boolean;
+}
+
+export class MovementSystem {
+ private static instance: MovementSystem;
+ private playerSpeed: number = 200;
+
+ static getInstance(): MovementSystem {
+ if (!MovementSystem.instance) {
+ MovementSystem.instance = new MovementSystem();
+ }
+ return MovementSystem.instance;
+ }
+
+ movePlayer(entity: Entity, input: MovementInput, delta: number): void {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return;
+
+ const movement = entityManager.getComponent<{ speed: number }>(entity.id, 'movement');
+ const speed = movement ? movement.speed : this.playerSpeed;
+
+ let vx = 0;
+ let vy = 0;
+
+ if (input.left) vx = -speed;
+ else if (input.right) vx = speed;
+
+ if (input.up) vy = -speed;
+ else if (input.down) vy = speed;
+
+ // Normalize diagonal movement
+ if (vx !== 0 && vy !== 0) {
+ vx *= 0.707;
+ vy *= 0.707;
+ }
+
+ pos.x += vx * (delta / 1000);
+ pos.y += vy * (delta / 1000);
+ }
+
+ moveEntity(entity: Entity, targetX: number, targetY: number, speed: number, delta: number): boolean {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return false;
+
+ const dx = targetX - pos.x;
+ const dy = targetY - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance < 5) return true;
+
+ const nx = dx / distance;
+ const ny = dy / distance;
+ pos.x += nx * speed * (delta / 1000);
+ pos.y += ny * speed * (delta / 1000);
+
+ return false;
+ }
+
+ faceToward(entity: Entity, targetX: number, targetY: number): void {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return;
+
+ const dx = targetX - pos.x;
+ const dy = targetY - pos.y;
+ const angle = Math.atan2(dy, dx);
+ entityManager.addComponent(entity.id, { type: 'facing', angle });
+ }
+
+ getDistance(entity1: Entity, entity2: Entity): number {
+ const pos1 = entityManager.getComponent<{ x: number; y: number }>(entity1.id, 'position');
+ const pos2 = entityManager.getComponent<{ x: number; y: number }>(entity2.id, 'position');
+ if (!pos1 || !pos2) return Infinity;
+
+ const dx = pos1.x - pos2.x;
+ const dy = pos1.y - pos2.y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ getDistanceToPoint(entity: Entity, x: number, y: number): number {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return Infinity;
+
+ const dx = pos.x - x;
+ const dy = pos.y - y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+}
+
+export const movementSystem = MovementSystem.getInstance();
diff --git a/src/systems/ProximitySystem.ts b/src/systems/ProximitySystem.ts
new file mode 100644
index 0000000..af03167
--- /dev/null
+++ b/src/systems/ProximitySystem.ts
@@ -0,0 +1,134 @@
+import { entityManager, type Entity } from '../core/EntityManager';
+import { corpseSystem } from './CorpseSystem';
+import { groundItemSystem } from './GroundItemSystem';
+import { containerSystem } from './ContainerSystem';
+
+export interface NearbyEntities {
+ enemy: Entity | null;
+ corpse: Entity | null;
+ item: Entity | null;
+ container: Entity | null;
+ npc: Entity | null;
+}
+
+export class ProximitySystem {
+ private static instance: ProximitySystem;
+ private interactionRange: number = 60;
+
+ static getInstance(): ProximitySystem {
+ if (!ProximitySystem.instance) {
+ ProximitySystem.instance = new ProximitySystem();
+ }
+ return ProximitySystem.instance;
+ }
+
+ checkProximity(playerEntity: Entity): NearbyEntities {
+ const playerPos = entityManager.getComponent<{ x: number; y: number }>(playerEntity.id, 'position');
+ if (!playerPos) {
+ return { enemy: null, corpse: null, item: null, container: null, npc: null };
+ }
+
+ const result: NearbyEntities = {
+ enemy: null,
+ corpse: null,
+ item: null,
+ container: null,
+ npc: null,
+ };
+
+ // Check enemies and corpses
+ const enemies = entityManager.getEntitiesByType('enemy');
+ for (const enemy of enemies) {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position');
+ if (!pos) continue;
+
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (corpseSystem.isCorpse(enemy)) {
+ if (distance < this.interactionRange && !result.corpse) {
+ result.corpse = enemy;
+ }
+ } else {
+ if (distance < this.interactionRange && !result.enemy) {
+ result.enemy = enemy;
+ }
+ }
+ }
+
+ // Check NPCs
+ const npcs = entityManager.getEntitiesByType('npc');
+ for (const npc of npcs) {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(npc.id, 'position');
+ if (!pos) continue;
+
+ const dx = playerPos.x - pos.x;
+ const dy = playerPos.y - pos.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance < this.interactionRange && !result.npc) {
+ result.npc = npc;
+ }
+ }
+
+ // Check ground items
+ const nearbyItems = groundItemSystem.getNearbyItems(playerEntity);
+ if (nearbyItems.length > 0) {
+ result.item = nearbyItems[0]!;
+ }
+
+ // Check containers
+ const nearbyContainers = containerSystem.getNearbyContainers(playerEntity);
+ if (nearbyContainers.length > 0) {
+ result.container = nearbyContainers[0]!;
+ }
+
+ return result;
+ }
+
+ getInteractionPrompt(nearby: NearbyEntities): string | null {
+ if (nearby.enemy) {
+ const enemyType = entityManager.getComponent<{ name: string }>(nearby.enemy.id, 'enemyType');
+ const health = entityManager.getComponent<{ current: number; max: number }>(nearby.enemy.id, 'health');
+ if (enemyType && health) {
+ return `左键攻击 ${enemyType.name} (${Math.round(health.current)}/${Math.round(health.max)})`;
+ }
+ }
+
+ if (nearby.npc) {
+ const npcData = entityManager.getComponent<{ name: string }>(nearby.npc.id, 'npcData');
+ if (npcData) {
+ return `[E] 对话 ${npcData.name}`;
+ }
+ }
+
+ if (nearby.corpse) {
+ return '[E] 搜索尸体';
+ }
+
+ if (nearby.item) {
+ const groundItem = entityManager.getComponent<{ name: string; quantity: number }>(nearby.item.id, 'groundItem');
+ if (groundItem) {
+ return `[E] 拾取 ${groundItem.name} x${groundItem.quantity}`;
+ }
+ }
+
+ if (nearby.container) {
+ const containerInfo = containerSystem.getContainerInfo(nearby.container);
+ if (containerInfo) {
+ if (containerInfo.locked) {
+ return `[E] 开锁 ${containerInfo.name}`;
+ } else if (containerInfo.isEmpty) {
+ return `${containerInfo.name} (空)`;
+ } else {
+ return `[E] 搜索 ${containerInfo.name}`;
+ }
+ }
+ }
+
+ return null;
+ }
+}
+
+export const proximitySystem = ProximitySystem.getInstance();
diff --git a/src/systems/QuestSystem.ts b/src/systems/QuestSystem.ts
new file mode 100644
index 0000000..413062d
--- /dev/null
+++ b/src/systems/QuestSystem.ts
@@ -0,0 +1,455 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry, type QuestData } from '../data/DataRegistry';
+
+export type QuestStatus = 'inactive' | 'active' | 'completed' | 'failed';
+
+export interface Quest {
+ id: string;
+ name: string;
+ description: string;
+ type: 'main' | 'side' | 'guild' | 'daedric' | 'radiant';
+ level: number;
+ objectives: QuestObjective[];
+ rewards: QuestReward;
+ prerequisites: string[];
+ status: QuestStatus;
+ currentObjective: number;
+}
+
+export interface QuestObjective {
+ id: string;
+ description: string;
+ type: 'kill' | 'collect' | 'talk' | 'explore' | 'interact' | 'choice';
+ target?: string;
+ count?: number;
+ currentCount?: number;
+ completed: boolean;
+}
+
+export interface QuestReward {
+ gold: number;
+ xp: number;
+ items: { id: string; quantity: number }[];
+ faction?: string;
+ factionRep?: number;
+}
+
+export class QuestSystem {
+ private static instance: QuestSystem;
+ private quests: Map = new Map();
+ private activeQuests: string[] = [];
+ private completedQuests: string[] = [];
+
+ static getInstance(): QuestSystem {
+ if (!QuestSystem.instance) {
+ QuestSystem.instance = new QuestSystem();
+ }
+ return QuestSystem.instance;
+ }
+
+ constructor() {
+ this.rebuildQuestCatalog();
+ this.setupEventListeners();
+ }
+
+ private rebuildQuestCatalog(): void {
+ const previous = new Map(this.quests);
+ this.quests.clear();
+
+ for (const quest of this.createBaseQuests()) {
+ this.quests.set(quest.id, this.withPreviousState(quest, previous.get(quest.id)));
+ }
+
+ for (const questData of dataRegistry.getAllQuests()) {
+ const quest = this.fromQuestData(questData);
+ this.quests.set(quest.id, this.withPreviousState(quest, previous.get(quest.id)));
+ }
+
+ this.activeQuests = this.activeQuests.filter((questId) => this.quests.get(questId)?.status === 'active');
+ this.completedQuests = this.completedQuests.filter((questId) => this.quests.get(questId)?.status === 'completed');
+ }
+
+ private createBaseQuests(): Quest[] {
+ return [
+ {
+ id: 'lost_sword',
+ name: '失落的圣剑',
+ description: '一位铁匠的祖传圣剑在附近的洞穴中丢失了',
+ type: 'side',
+ level: 5,
+ objectives: [
+ { id: 'talk_to_blacksmith', description: '与铁匠对话', type: 'talk', target: 'blacksmith_01', completed: false },
+ { id: 'find_cave', description: '找到洞穴', type: 'explore', target: 'bleakfalls_barrow', completed: false },
+ { id: 'kill_bandits', description: '消灭洞穴中的强盗', type: 'kill', target: 'bandit', count: 5, currentCount: 0, completed: false },
+ { id: 'find_sword', description: '找到失落的圣剑', type: 'collect', target: 'legendary_sword', count: 1, completed: false },
+ { id: 'return_sword', description: '将圣剑归还给铁匠', type: 'talk', target: 'blacksmith_01', completed: false },
+ ],
+ rewards: { gold: 500, xp: 200, items: [{ id: 'legendary_sword', quantity: 1 }], faction: 'companions', factionRep: 10 },
+ prerequisites: [],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ {
+ id: 'mysterious_artifact',
+ name: '神秘文物',
+ description: '一件古老的文物在废弃的神殿中被发现',
+ type: 'daedric',
+ level: 15,
+ objectives: [
+ { id: 'investigate_rumors', description: '调查传言', type: 'talk', target: 'innkeeper_01', completed: false },
+ { id: 'find_temple', description: '找到废弃神殿', type: 'explore', target: 'temple_01', completed: false },
+ { id: 'solve_puzzle', description: '解开神殿谜题', type: 'interact', target: 'puzzle_01', completed: false },
+ { id: 'defeat_guardian', description: '击败守护者', type: 'kill', target: 'temple_guardian', count: 1, completed: false },
+ { id: 'take_artifact', description: '取走文物', type: 'collect', target: 'mysterious_artifact', count: 1, completed: false },
+ { id: 'choose_fate', description: '决定文物的命运', type: 'choice', completed: false },
+ ],
+ rewards: { gold: 1000, xp: 500, items: [{ id: 'mysterious_artifact', quantity: 1 }] },
+ prerequisites: ['lost_sword'],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ {
+ id: 'companion_initiation',
+ name: '战友团入会',
+ description: '证明你的价值,加入战友团',
+ type: 'guild',
+ level: 10,
+ objectives: [
+ { id: 'find_companions', description: '找到战友团', type: 'explore', target: 'jorrvaskr', completed: false },
+ { id: 'talk_to_kodlak', description: '与科德拉克对话', type: 'talk', target: 'kodlak', completed: false },
+ { id: 'complete_trial', description: '完成试炼', type: 'kill', target: 'trial_enemy', count: 3, currentCount: 0, completed: false },
+ { id: 'report_back', description: '回报科德拉克', type: 'talk', target: 'kodlak', completed: false },
+ ],
+ rewards: { gold: 200, xp: 300, items: [], faction: 'companions', factionRep: 25 },
+ prerequisites: [],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ {
+ id: 'thieves_guild_intro',
+ name: '盗贼公会入门',
+ description: '证明你的盗贼技巧,加入盗贼公会',
+ type: 'guild',
+ level: 8,
+ objectives: [
+ { id: 'find_guild', description: '找到盗贼公会', type: 'explore', target: 'ragged_flagon', completed: false },
+ { id: 'talk_to_brynjolf', description: '与布林乔夫对话', type: 'talk', target: 'brynjolf', completed: false },
+ { id: 'steal_item', description: '偷取指定物品', type: 'collect', target: 'stolen_ring', count: 1, completed: false },
+ { id: 'deliver_item', description: '将物品交给布林乔夫', type: 'talk', target: 'brynjolf', completed: false },
+ ],
+ rewards: { gold: 150, xp: 250, items: [], faction: 'thieves_guild', factionRep: 20 },
+ prerequisites: [],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ {
+ id: 'dark_brotherhood_intro',
+ name: '黑暗兄弟会入门',
+ description: '完成一次暗杀,加入黑暗兄弟会',
+ type: 'guild',
+ level: 12,
+ objectives: [
+ { id: 'hear_rumors', description: '听到传言', type: 'talk', target: 'innkeeper_01', completed: false },
+ { id: 'find_shrine', description: '找到黑暗兄弟会圣所', type: 'explore', target: 'dark_brotherhood_shrine', completed: false },
+ { id: 'talk_to_astrid', description: '与阿斯翠对话', type: 'talk', target: 'astrid', completed: false },
+ { id: 'assassinate_target', description: '暗杀目标', type: 'kill', target: 'assassination_target', count: 1, completed: false },
+ ],
+ rewards: { gold: 300, xp: 400, items: [], faction: 'dark_brotherhood', factionRep: 30 },
+ prerequisites: [],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ {
+ id: 'college_admission',
+ name: '冬堡学院入学',
+ description: '证明你的魔法天赋,加入冬堡学院',
+ type: 'guild',
+ level: 10,
+ objectives: [
+ { id: 'find_college', description: '找到冬堡学院', type: 'explore', target: 'college_of_winterhold', completed: false },
+ { id: 'talk_to_faralda', description: '与法拉尔达对话', type: 'talk', target: 'faralda', completed: false },
+ { id: 'cast_spell', description: '展示法术', type: 'interact', target: 'spell_casting', completed: false },
+ { id: 'enter_college', description: '进入学院', type: 'explore', target: 'college_interior', completed: false },
+ ],
+ rewards: { gold: 100, xp: 350, items: [], faction: 'college', factionRep: 25 },
+ prerequisites: [],
+ status: 'inactive',
+ currentObjective: 0,
+ },
+ ];
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('mod:dataResolved', () => {
+ this.rebuildQuestCatalog();
+ });
+
+ eventBus.on('quest:started', (data: { questId: string; player: Entity }) => {
+ this.startQuest(data.questId, data.player);
+ });
+
+ eventBus.on('entity:killed', (data: { entity: Entity; killer: Entity }) => {
+ this.updateKillObjectives(data.entity);
+ });
+
+ eventBus.on('item:pickup', (data: { entity: Entity; itemId: string; quantity?: number }) => {
+ this.updateCollectObjectives(data.entity, data.itemId);
+ });
+
+ eventBus.on('dialogue:started', (data: { npc: Entity; player: Entity }) => {
+ this.updateTalkObjectives(data.npc);
+ });
+
+ eventBus.on('zone:entered', (data: { zoneId: string }) => {
+ this.updateExploreObjectives(data.zoneId);
+ });
+ }
+
+ startQuest(questId: string, player: Entity): boolean {
+ const quest = this.quests.get(questId);
+ if (!quest || quest.status !== 'inactive') return false;
+
+ for (const prereq of quest.prerequisites) {
+ if (!this.completedQuests.includes(prereq)) {
+ return false;
+ }
+ }
+
+ quest.status = 'active';
+ quest.currentObjective = 0;
+ if (!this.activeQuests.includes(questId)) {
+ this.activeQuests.push(questId);
+ }
+
+ eventBus.emit('quest:activated', { quest, player });
+ return true;
+ }
+
+ private updateKillObjectives(killedEntity: Entity): void {
+ const enemyType = entityManager.getComponent<{ id: string }>(killedEntity.id, 'enemyType');
+ if (!enemyType) return;
+
+ for (const questId of this.activeQuests) {
+ const quest = this.quests.get(questId);
+ if (!quest) continue;
+
+ for (const objective of quest.objectives) {
+ if (objective.type === 'kill' && !objective.completed && objective.target === enemyType.id) {
+ objective.currentCount = (objective.currentCount || 0) + 1;
+ if (objective.count && objective.currentCount >= objective.count) {
+ objective.completed = true;
+ this.checkQuestProgress(quest);
+ }
+ }
+ }
+ }
+ }
+
+ private updateCollectObjectives(_entity: Entity, itemId: string): void {
+ for (const questId of this.activeQuests) {
+ const quest = this.quests.get(questId);
+ if (!quest) continue;
+
+ for (const objective of quest.objectives) {
+ if (objective.type === 'collect' && !objective.completed && objective.target === itemId) {
+ objective.completed = true;
+ this.checkQuestProgress(quest);
+ }
+ }
+ }
+ }
+
+ private updateTalkObjectives(npcEntity: Entity): void {
+ const npcData = entityManager.getComponent<{ id: string }>(npcEntity.id, 'npcData');
+ if (!npcData) return;
+
+ for (const questId of this.activeQuests) {
+ const quest = this.quests.get(questId);
+ if (!quest) continue;
+
+ for (const objective of quest.objectives) {
+ if (objective.type === 'talk' && !objective.completed && objective.target === npcData.id) {
+ objective.completed = true;
+ this.checkQuestProgress(quest);
+ }
+ }
+ }
+ }
+
+ private updateExploreObjectives(zoneId: string): void {
+ for (const questId of this.activeQuests) {
+ const quest = this.quests.get(questId);
+ if (!quest) continue;
+
+ for (const objective of quest.objectives) {
+ if (objective.type === 'explore' && !objective.completed && objective.target === zoneId) {
+ objective.completed = true;
+ this.checkQuestProgress(quest);
+ }
+ }
+ }
+ }
+
+ private checkQuestProgress(quest: Quest): void {
+ const allCompleted = quest.objectives.every((o) => o.completed);
+ if (allCompleted) {
+ this.completeQuest(quest.id);
+ } else {
+ quest.currentObjective = quest.objectives.findIndex((o) => !o.completed);
+ }
+ }
+
+ completeQuest(questId: string): boolean {
+ const quest = this.quests.get(questId);
+ if (!quest || quest.status !== 'active') return false;
+
+ quest.status = 'completed';
+ this.activeQuests = this.activeQuests.filter((id) => id !== questId);
+ this.completedQuests.push(questId);
+
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (player) {
+ this.giveRewards(quest.rewards, player);
+ }
+
+ eventBus.emit('quest:completed', { quest, player });
+ return true;
+ }
+
+ private giveRewards(rewards: QuestReward, player: Entity): void {
+ const inventory = entityManager.getComponent<{ gold: number }>(player.id, 'inventory');
+ if (inventory) {
+ inventory.gold += rewards.gold;
+ }
+
+ const level = entityManager.getComponent<{ xp: number }>(player.id, 'level');
+ if (level) {
+ level.xp += rewards.xp;
+ }
+
+ for (const item of rewards.items) {
+ eventBus.emit('item:pickup', { entity: player, itemId: item.id, quantity: item.quantity });
+ }
+
+ if (rewards.faction && rewards.factionRep) {
+ eventBus.emit('faction:changeRep', { player, faction: rewards.faction, amount: rewards.factionRep });
+ }
+ }
+
+ failQuest(questId: string): boolean {
+ const quest = this.quests.get(questId);
+ if (!quest || quest.status !== 'active') return false;
+
+ quest.status = 'failed';
+ this.activeQuests = this.activeQuests.filter((id) => id !== questId);
+
+ eventBus.emit('quest:failed', { quest });
+ return true;
+ }
+
+ getQuest(questId: string): Quest | undefined {
+ return this.quests.get(questId);
+ }
+
+ getActiveQuests(): Quest[] {
+ return this.activeQuests.map((id) => this.quests.get(id)!).filter(Boolean);
+ }
+
+ getCompletedQuests(): Quest[] {
+ return this.completedQuests.map((id) => this.quests.get(id)!).filter(Boolean);
+ }
+
+ isQuestActive(questId: string): boolean {
+ return this.activeQuests.includes(questId);
+ }
+
+ isQuestCompleted(questId: string): boolean {
+ return this.completedQuests.includes(questId);
+ }
+
+ getQuestProgress(questId: string): { completed: number; total: number } | null {
+ const quest = this.quests.get(questId);
+ if (!quest) return null;
+
+ const completed = quest.objectives.filter((o) => o.completed).length;
+ return { completed, total: quest.objectives.length };
+ }
+
+ getAllQuests(): Quest[] {
+ return Array.from(this.quests.values());
+ }
+
+ resetForTests(): void {
+ this.activeQuests = [];
+ this.completedQuests = [];
+ this.rebuildQuestCatalog();
+ }
+
+ private fromQuestData(data: QuestData): Quest {
+ return {
+ id: data.id,
+ name: data.name,
+ description: data.description,
+ type: data.type,
+ level: data.level,
+ prerequisites: data.prerequisites,
+ objectives: data.objectives.map((objective) => ({
+ id: objective.id,
+ description: objective.description,
+ type: this.normalizeObjectiveType(objective.type),
+ target: objective.target,
+ count: objective.count ?? objective.quantity,
+ currentCount: 0,
+ completed: false,
+ })),
+ rewards: {
+ gold: data.rewards.gold ?? 0,
+ xp: data.rewards.xp ?? 0,
+ items: data.rewards.items ?? [],
+ faction: data.rewards.faction,
+ factionRep: data.rewards.factionRep,
+ },
+ status: 'inactive',
+ currentObjective: 0,
+ };
+ }
+
+ private withPreviousState(next: Quest, previous: Quest | undefined): Quest {
+ if (!previous) return next;
+
+ const previousObjectives = new Map(previous.objectives.map((objective) => [objective.id, objective]));
+ return {
+ ...next,
+ status: previous.status,
+ currentObjective: previous.currentObjective,
+ objectives: next.objectives.map((objective) => {
+ const previousObjective = previousObjectives.get(objective.id);
+ return previousObjective
+ ? {
+ ...objective,
+ completed: previousObjective.completed,
+ currentCount: previousObjective.currentCount,
+ }
+ : objective;
+ }),
+ };
+ }
+
+ private normalizeObjectiveType(type: string): QuestObjective['type'] {
+ if (
+ type === 'kill' ||
+ type === 'collect' ||
+ type === 'talk' ||
+ type === 'explore' ||
+ type === 'interact' ||
+ type === 'choice'
+ ) {
+ return type;
+ }
+ return 'interact';
+ }
+}
+
+export const questSystem = QuestSystem.getInstance();
+(globalThis as any).__oesQuestSystem = questSystem;
diff --git a/src/systems/RegenSystem.ts b/src/systems/RegenSystem.ts
new file mode 100644
index 0000000..2931a1d
--- /dev/null
+++ b/src/systems/RegenSystem.ts
@@ -0,0 +1,83 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+/**
+ * Regenerates HP, Magicka, and Stamina over time.
+ * Rates loaded from game config (defaults: HP 0.5/s, Magicka 3/s, Stamina 5/s).
+ * Regen pauses briefly after taking damage or using stamina.
+ */
+export class RegenSystem {
+ private static instance: RegenSystem;
+ private config = dataRegistry.getGameConfig().regen;
+
+ private lastDamageTime = 0;
+ private lastStaminaUseTime = 0;
+ private lastMagickaUseTime = 0;
+
+ static getInstance(): RegenSystem {
+ if (!RegenSystem.instance) {
+ RegenSystem.instance = new RegenSystem();
+ }
+ return RegenSystem.instance;
+ }
+
+ constructor() {
+ eventBus.on('mod:dataResolved', () => {
+ this.config = dataRegistry.getGameConfig().regen;
+ });
+ this.setupListeners();
+ }
+
+ private setupListeners(): void {
+ const eventBus = (globalThis as any).__oesEventBus;
+ if (eventBus) {
+ eventBus.on('combat:afterAttack', () => { this.lastDamageTime = Date.now(); });
+ eventBus.on('item:used', () => { this.lastStaminaUseTime = Date.now(); });
+ }
+ }
+
+ setupWithEventBus(bus: { on: (event: string, cb: (data: any) => void) => void }): void {
+ bus.on('combat:afterAttack', () => { this.lastDamageTime = Date.now(); });
+ bus.on('combat:staminaUsed', () => { this.lastStaminaUseTime = Date.now(); });
+ bus.on('magic:cast', () => { this.lastMagickaUseTime = Date.now(); });
+ }
+
+ update(delta: number): void {
+ const now = Date.now();
+ const dt = delta / 1000;
+
+ for (const entity of entityManager.getEntitiesWithComponent('health')) {
+ const id = entity.id;
+
+ if (now - this.lastDamageTime > this.config.delayMs) {
+ const health = entityManager.getComponent<{ current: number; max: number }>(id, 'health');
+ if (health && health.current < health.max) {
+ const skills = entityManager.getComponent<{ restoration?: number }>(id, 'skills');
+ const bonus = skills?.restoration ? (skills.restoration - 15) * this.config.restorationBonusPerSkill : 0;
+ health.current = Math.round(Math.min(health.max, health.current + (this.config.healthPerSecond + bonus) * dt));
+ }
+ }
+
+ if (now - this.lastMagickaUseTime > this.config.delayMs) {
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(id, 'magicka');
+ if (magicka && magicka.current < magicka.max) {
+ magicka.current = Math.round(Math.min(magicka.max, magicka.current + this.config.magickaPerSecond * dt));
+ }
+ }
+
+ if (now - this.lastStaminaUseTime > this.config.delayMs) {
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(id, 'stamina');
+ if (stamina && stamina.current < stamina.max) {
+ stamina.current = Math.round(Math.min(stamina.max, stamina.current + this.config.staminaPerSecond * dt));
+ }
+ }
+ }
+ }
+
+ notifyDamageTaken(): void { this.lastDamageTime = Date.now(); }
+ notifyStaminaUsed(): void { this.lastStaminaUseTime = Date.now(); }
+ notifyMagickaUsed(): void { this.lastMagickaUseTime = Date.now(); }
+}
+
+export const regenSystem = RegenSystem.getInstance();
diff --git a/src/systems/ScriptSystem.test.ts b/src/systems/ScriptSystem.test.ts
new file mode 100644
index 0000000..84d98e7
--- /dev/null
+++ b/src/systems/ScriptSystem.test.ts
@@ -0,0 +1,149 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { entityManager } from '../core/EntityManager';
+import { eventBus } from '../core/EventBus';
+import { modScriptEngine } from '../mods/ModScriptEngine';
+import { scriptSystem } from './ScriptSystem';
+
+describe('ScriptSystem', () => {
+ beforeEach(() => {
+ modScriptEngine.clearAll();
+ entityManager.clear();
+ eventBus.removeAllListeners();
+ scriptSystem.initialize();
+ });
+
+ function registerTestScript(scriptId: string, handlers: Record): void {
+ modScriptEngine.registerScriptDefinition(scriptId, {
+ properties: { counter: 0 },
+ handlers,
+ });
+ }
+
+ it('fires OnLoad when entity is created with script component', () => {
+ registerTestScript('test: onload_script', {
+ OnLoad: 'ctx.log("loaded")',
+ });
+
+ eventBus.emit('entity:created', {
+ entity: { id: 'test_entity' },
+ });
+
+ const instances = modScriptEngine.getInstancesForEntity('test_entity');
+ expect(instances.length).toBe(0);
+ });
+
+ it('registers script definitions and creates instances', () => {
+ registerTestScript('test:def_script', {
+ OnLoad: 'ctx.log("loaded")',
+ });
+
+ const instance = modScriptEngine.instantiateScript('test:def_script', 'entity_1');
+ expect(instance).not.toBeNull();
+ expect(instance!.scriptId).toBe('test:def_script');
+ expect(instance!.entityId).toBe('entity_1');
+ expect(instance!.properties.counter).toBe(0);
+ });
+
+ it('executes handler with script context', () => {
+ registerTestScript('test:handler_exec', {
+ OnUpdate: 'ctx.prop("counter", ctx.prop("counter") + 1)',
+ });
+
+ const instance = modScriptEngine.instantiateScript('test:handler_exec', 'entity_2');
+ expect(instance).not.toBeNull();
+
+ modScriptEngine.executeHandler('entity_2', 'test:handler_exec', 'OnUpdate', undefined, 16);
+ expect(modScriptEngine.getVariable('entity_2', 'test:handler_exec', 'counter')).toBe(1);
+
+ modScriptEngine.executeHandler('entity_2', 'test:handler_exec', 'OnUpdate', undefined, 16);
+ expect(modScriptEngine.getVariable('entity_2', 'test:handler_exec', 'counter')).toBe(2);
+ });
+
+ it('cleans up event subscriptions on removeInstance', () => {
+ registerTestScript('test:cleanup', {
+ OnLoad: 'ctx.events.on("custom:event", function() {})',
+ });
+
+ const instance = modScriptEngine.instantiateScript('test:cleanup', 'entity_3');
+ expect(instance).not.toBeNull();
+ expect(instance!.eventSubscriptions.length).toBe(0);
+
+ modScriptEngine.executeHandler('entity_3', 'test:cleanup', 'OnLoad');
+ expect(instance!.eventSubscriptions.length).toBe(1);
+
+ modScriptEngine.removeInstance('test:cleanup', 'entity_3');
+ const instances = modScriptEngine.getInstancesForEntity('entity_3');
+ expect(instances.length).toBe(0);
+ });
+
+ it('handles multiple instances for same script different entities', () => {
+ registerTestScript('test:multi', {
+ OnUpdate: 'ctx.prop("counter", ctx.prop("counter") + 1)',
+ });
+
+ modScriptEngine.instantiateScript('test:multi', 'e1');
+ modScriptEngine.instantiateScript('test:multi', 'e2');
+ modScriptEngine.instantiateScript('test:multi', 'e3');
+
+ modScriptEngine.executeHandler('e1', 'test:multi', 'OnUpdate', undefined, 16);
+ modScriptEngine.executeHandler('e2', 'test:multi', 'OnUpdate', undefined, 16);
+ modScriptEngine.executeHandler('e2', 'test:multi', 'OnUpdate', undefined, 16);
+
+ expect(modScriptEngine.getVariable('e1', 'test:multi', 'counter')).toBe(1);
+ expect(modScriptEngine.getVariable('e2', 'test:multi', 'counter')).toBe(2);
+ expect(modScriptEngine.getVariable('e3', 'test:multi', 'counter')).toBe(0);
+ });
+
+ it('emits script:error on handler execution failure', () => {
+ const errorHandler = vi.fn();
+ eventBus.on('script:error', errorHandler);
+
+ registerTestScript('test:error', {
+ OnLoad: 'throw new Error("test failure")',
+ });
+
+ modScriptEngine.instantiateScript('test:error', 'err_entity');
+ modScriptEngine.executeHandler('err_entity', 'test:error', 'OnLoad');
+
+ expect(errorHandler).toHaveBeenCalledOnce();
+ expect(errorHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ modId: 'test:error', scriptId: 'OnLoad' })
+ );
+ });
+
+ it('clearModScripts removes all instances and definitions for a mod', () => {
+ registerTestScript('mymod:script_a', {
+ OnLoad: 'ctx.log("a")',
+ });
+ registerTestScript('mymod:script_b', {
+ OnLoad: 'ctx.log("b")',
+ });
+ registerTestScript('other:script_c', {
+ OnLoad: 'ctx.log("c")',
+ });
+
+ modScriptEngine.instantiateScript('mymod:script_a', 'e1');
+ modScriptEngine.instantiateScript('mymod:script_b', 'e2');
+ modScriptEngine.instantiateScript('other:script_c', 'e3');
+
+ modScriptEngine.clearModScripts('mymod');
+
+ expect(modScriptEngine.getInstancesForEntity('e1').length).toBe(0);
+ expect(modScriptEngine.getInstancesForEntity('e2').length).toBe(0);
+ expect(modScriptEngine.getInstancesForEntity('e3').length).toBe(1);
+ });
+
+ it('removeAllForEntity cleans up all scripts on entity', () => {
+ registerTestScript('mod1:s', { OnLoad: 'ctx.log("1")' });
+ registerTestScript('mod2:s', { OnLoad: 'ctx.log("2")' });
+
+ modScriptEngine.instantiateScript('mod1:s', 'player');
+ modScriptEngine.instantiateScript('mod2:s', 'player');
+
+ expect(modScriptEngine.getInstancesForEntity('player').length).toBe(2);
+
+ modScriptEngine.removeAllForEntity('player');
+
+ expect(modScriptEngine.getInstancesForEntity('player').length).toBe(0);
+ });
+});
diff --git a/src/systems/ScriptSystem.ts b/src/systems/ScriptSystem.ts
new file mode 100644
index 0000000..d2d4716
--- /dev/null
+++ b/src/systems/ScriptSystem.ts
@@ -0,0 +1,103 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { modScriptEngine } from '../mods/ModScriptEngine';
+
+export interface ScriptComponent {
+ type: 'script';
+ scriptId: string;
+}
+
+export class ScriptSystem {
+ private static instance: ScriptSystem;
+ private initialized = false;
+
+ static getInstance(): ScriptSystem {
+ if (!ScriptSystem.instance) {
+ ScriptSystem.instance = new ScriptSystem();
+ }
+ return ScriptSystem.instance;
+ }
+
+ initialize(): void {
+ if (this.initialized) return;
+ this.initialized = true;
+
+ eventBus.on('entity:created', (data: { entity: { id: string } }) => {
+ this.onEntityCreated(data.entity.id);
+ });
+
+ eventBus.on('entity:destroyed', (data: { entity: { id: string } }) => {
+ this.onEntityDestroyed(data.entity.id);
+ });
+
+ eventBus.on('combat:afterAttack', (data: { attacker: { id: string } | null; target: { id: string } | null; damage: number; isCritical: boolean }) => {
+ if (data.target) {
+ this.fireEvent(data.target.id, 'OnHit', { attacker: data.attacker, damage: data.damage, isCritical: data.isCritical });
+ }
+ });
+
+ eventBus.on('entity:killed', (data: { entity: { id: string }; killer: { id: string } | null }) => {
+ this.fireEvent(data.entity.id, 'OnDeath', { killer: data.killer });
+ });
+
+ eventBus.on('item:used', (data: { entity: { id: string }; item: unknown }) => {
+ this.fireEvent(data.entity.id, 'OnUse', { item: data.item });
+ });
+
+ eventBus.on('equipment:changed', (data: { entity: { id: string }; item: unknown; slot: string }) => {
+ this.fireEvent(data.entity.id, 'OnEquip', { item: data.item, slot: data.slot });
+ });
+
+ eventBus.on('game:zoneChanged', (data: { zoneId: string }) => {
+ const allInstances = modScriptEngine.getAllInstances();
+ for (const instance of allInstances) {
+ this.fireEvent(instance.entityId, 'OnZoneEnter', { zoneId: data.zoneId });
+ }
+ });
+ }
+
+ update(delta: number): void {
+ const allInstances = modScriptEngine.getAllInstances();
+ for (const instance of allInstances) {
+ const entity = entityManager.getEntity(instance.entityId);
+ if (!entity || !entity.active) continue;
+ modScriptEngine.executeHandler(instance.entityId, instance.scriptId, 'OnUpdate', undefined, delta);
+ }
+ }
+
+ attachScript(entityId: string, scriptId: string): void {
+ const instance = modScriptEngine.instantiateScript(scriptId, entityId);
+ if (instance) {
+ modScriptEngine.executeHandler(entityId, scriptId, 'OnLoad');
+ }
+ }
+
+ detachScript(entityId: string, scriptId: string): void {
+ modScriptEngine.executeHandler(entityId, scriptId, 'OnUnload');
+ modScriptEngine.removeInstance(scriptId, entityId);
+ }
+
+ private onEntityCreated(entityId: string): void {
+ const scriptComp = entityManager.getComponent(entityId, 'script');
+ if (scriptComp) {
+ this.attachScript(entityId, scriptComp.scriptId);
+ }
+ }
+
+ private onEntityDestroyed(entityId: string): void {
+ const instances = modScriptEngine.getInstancesForEntity(entityId);
+ for (const instance of instances) {
+ modScriptEngine.executeHandler(entityId, instance.scriptId, 'OnUnload');
+ }
+ modScriptEngine.removeAllForEntity(entityId);
+ }
+
+ private fireEvent(entityId: string, eventName: string, eventData?: unknown): void {
+ const instances = modScriptEngine.getInstancesForEntity(entityId);
+ for (const instance of instances) {
+ modScriptEngine.executeHandler(entityId, instance.scriptId, eventName, eventData);
+ }
+ }
+}
+
+export const scriptSystem = ScriptSystem.getInstance();
diff --git a/src/systems/SmithingSystem.ts b/src/systems/SmithingSystem.ts
new file mode 100644
index 0000000..99e2e9d
--- /dev/null
+++ b/src/systems/SmithingSystem.ts
@@ -0,0 +1,200 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager, type Entity } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+
+export type MaterialTier = 'iron' | 'steel' | 'corundum' | 'orichalcum' | 'moonstone' | 'ebony' | 'daedric' | 'dragon';
+
+export interface SmithingRecipe {
+ id: string;
+ name: string;
+ type: 'weapon' | 'armor' | 'shield' | 'material';
+ tier: MaterialTier;
+ materials: { id: string; quantity: number }[];
+ result: {
+ id: string;
+ name: string;
+ type: string;
+ damage?: number;
+ armor?: number;
+ weight: number;
+ value: number;
+ };
+ skillRequired: number;
+}
+
+export interface SmithingStation {
+ type: 'forge' | 'workbench' | 'grindstone' | 'tanning_rack';
+ name: string;
+ availableRecipes: string[];
+}
+
+export class SmithingSystem {
+ private static instance: SmithingSystem;
+ private recipes: Map = new Map();
+ private stations: Map = new Map();
+ private materialTiers: Map = new Map();
+
+ static getInstance(): SmithingSystem {
+ if (!SmithingSystem.instance) {
+ SmithingSystem.instance = new SmithingSystem();
+ }
+ return SmithingSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ // Material tiers
+ this.materialTiers.clear();
+ for (const mat of dataRegistry.getAllSmithingMaterials()) {
+ this.materialTiers.set(mat.tier as MaterialTier, mat.level);
+ }
+
+ // Recipes
+ this.recipes.clear();
+ for (const data of dataRegistry.getAllSmithingRecipes()) {
+ const recipe: SmithingRecipe = {
+ id: data.id,
+ name: data.name,
+ type: data.type,
+ tier: data.tier as MaterialTier,
+ materials: data.materials.map((m) => ({ id: m.id, quantity: m.quantity })),
+ result: { ...data.result },
+ skillRequired: data.skillRequired,
+ };
+ this.recipes.set(recipe.id, recipe);
+ }
+
+ // Stations
+ this.stations.clear();
+ for (const data of dataRegistry.getAllSmithingStations()) {
+ const station: SmithingStation = {
+ type: data.type as SmithingStation['type'],
+ name: data.name,
+ availableRecipes: [...data.availableRecipes],
+ };
+ this.stations.set(station.type, station);
+ }
+ }
+
+ canForge(recipeId: string, entity: Entity): boolean {
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return false;
+
+ const skills = entityManager.getComponent<{ smithing: number }>(entity.id, 'skills');
+ if (!skills || skills.smithing < recipe.skillRequired) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ for (const material of recipe.materials) {
+ const owned = inventory.items.find((i) => i.id === material.id);
+ if (!owned || owned.quantity < material.quantity) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ forge(recipeId: string, entity: Entity): boolean {
+ if (!this.canForge(recipeId, entity)) return false;
+
+ const recipe = this.recipes.get(recipeId);
+ if (!recipe) return false;
+
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ for (const material of recipe.materials) {
+ const item = inventory.items.find((i) => i.id === material.id);
+ if (item) {
+ item.quantity -= material.quantity;
+ if (item.quantity <= 0) {
+ const index = inventory.items.indexOf(item);
+ inventory.items.splice(index, 1);
+ }
+ }
+ }
+
+ eventBus.emit('item:pickup', {
+ entity,
+ itemId: recipe.result.id,
+ quantity: 1,
+ });
+
+ const skills = entityManager.getComponent<{ smithing: number }>(entity.id, 'skills');
+ if (skills) {
+ const xp = Math.floor(recipe.result.value * 0.1);
+ eventBus.emit('skill:improved', { entityId: entity.id, skill: 'smithing', amount: xp });
+ }
+
+ eventBus.emit('smithing:crafted', { entity, recipe, item: recipe.result });
+ return true;
+ }
+
+ improveItem(entity: Entity, itemId: string, improvementMaterial: string): boolean {
+ const inventory = entityManager.getComponent<{ items: any[] }>(entity.id, 'inventory');
+ if (!inventory) return false;
+
+ const item = inventory.items.find((i) => i.id === itemId);
+ if (!item) return false;
+
+ const material = inventory.items.find((i) => i.id === improvementMaterial);
+ if (!material || material.quantity < 1) return false;
+
+ if (item.type === 'weapon') {
+ item.damage = Math.round((item.damage || 10) * 1.2);
+ } else if (item.type === 'armor') {
+ item.armor = Math.round((item.armor || 10) * 1.2);
+ }
+
+ material.quantity -= 1;
+ if (material.quantity <= 0) {
+ const index = inventory.items.indexOf(material);
+ inventory.items.splice(index, 1);
+ }
+
+ const skills = entityManager.getComponent<{ smithing: number }>(entity.id, 'skills');
+ if (skills) {
+ eventBus.emit('skill:improved', { entityId: entity.id, skill: 'smithing', amount: 5 });
+ }
+
+ eventBus.emit('smithing:improved', { entity, itemId, item });
+ return true;
+ }
+
+ getRecipe(id: string): SmithingRecipe | undefined {
+ return this.recipes.get(id);
+ }
+
+ getAvailableRecipes(entity: Entity, stationType?: string): SmithingRecipe[] {
+ let recipes = Array.from(this.recipes.values());
+
+ if (stationType) {
+ const station = this.stations.get(stationType);
+ if (station) {
+ recipes = recipes.filter((r) => station.availableRecipes.includes(r.id));
+ }
+ }
+
+ return recipes.filter((r) => this.canForge(r.id, entity));
+ }
+
+ getAllRecipes(): SmithingRecipe[] {
+ return Array.from(this.recipes.values());
+ }
+
+ getStation(type: string): SmithingStation | undefined {
+ return this.stations.get(type);
+ }
+
+ getMaterialTier(tier: MaterialTier): number {
+ return this.materialTiers.get(tier) || 0;
+ }
+}
+
+export const smithingSystem = SmithingSystem.getInstance();
diff --git a/src/systems/StatusEffectSystem.ts b/src/systems/StatusEffectSystem.ts
new file mode 100644
index 0000000..1c3b2c6
--- /dev/null
+++ b/src/systems/StatusEffectSystem.ts
@@ -0,0 +1,152 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+
+export interface StatusEffect {
+ id: string;
+ type: 'buff' | 'debuff';
+ attribute: string;
+ magnitude: number;
+ remainingMs: number;
+ totalMs: number;
+ source: string;
+}
+
+export class StatusEffectSystem {
+ private static instance: StatusEffectSystem;
+
+ static getInstance(): StatusEffectSystem {
+ if (!StatusEffectSystem.instance) {
+ StatusEffectSystem.instance = new StatusEffectSystem();
+ }
+ return StatusEffectSystem.instance;
+ }
+
+ constructor() {
+ this.setupListeners();
+ }
+
+ private setupListeners(): void {
+ eventBus.on('statusEffect:apply', (data: { entityId: string; effect: Omit; durationMs: number }) => {
+ this.applyEffect(data.entityId, { ...data.effect, remainingMs: data.durationMs, totalMs: data.durationMs });
+ });
+
+ eventBus.on('statusEffect:remove', (data: { entityId: string; effectId: string }) => {
+ this.removeEffect(data.entityId, data.effectId);
+ });
+ }
+
+ applyEffect(entityId: string, effect: StatusEffect): void {
+ const effects = entityManager.getComponent<{ active: StatusEffect[] }>(entityId, 'statusEffects');
+ if (!effects) {
+ entityManager.addComponent(entityId, { type: 'statusEffects', active: [] });
+ return this.applyEffect(entityId, effect);
+ }
+
+ // Replace existing effect of same id
+ const idx = effects.active.findIndex((e) => e.id === effect.id);
+ if (idx >= 0) {
+ effects.active[idx] = effect;
+ } else {
+ effects.active.push(effect);
+ }
+
+ this.applyStatModifier(entityId, effect, 1);
+ eventBus.emit('statusEffect:applied', { entityId, effect });
+ }
+
+ removeEffect(entityId: string, effectId: string): void {
+ const effects = entityManager.getComponent<{ active: StatusEffect[] }>(entityId, 'statusEffects');
+ if (!effects) return;
+
+ const idx = effects.active.findIndex((e) => e.id === effectId);
+ if (idx < 0) return;
+
+ const effect = effects.active[idx]!;
+ this.applyStatModifier(entityId, effect, -1);
+ effects.active.splice(idx, 1);
+
+ eventBus.emit('statusEffect:removed', { entityId, effectId });
+ }
+
+ update(delta: number): void {
+ for (const entity of entityManager.getEntitiesWithComponent('statusEffects')) {
+ const effects = entityManager.getComponent<{ active: StatusEffect[] }>(entity.id, 'statusEffects');
+ if (!effects) continue;
+
+ for (let i = effects.active.length - 1; i >= 0; i--) {
+ const effect = effects.active[i]!;
+ effect.remainingMs -= delta;
+
+ if (effect.remainingMs <= 0) {
+ this.applyStatModifier(entity.id, effect, -1);
+ effects.active.splice(i, 1);
+ eventBus.emit('statusEffect:expired', { entityId: entity.id, effect });
+ }
+ }
+ }
+ }
+
+ getEffects(entityId: string): StatusEffect[] {
+ const effects = entityManager.getComponent<{ active: StatusEffect[] }>(entityId, 'statusEffects');
+ return effects?.active || [];
+ }
+
+ hasEffect(entityId: string, effectId: string): boolean {
+ return this.getEffects(entityId).some((e) => e.id === effectId);
+ }
+
+ clearAll(entityId: string): void {
+ const effects = this.getEffects(entityId);
+ for (const effect of [...effects]) {
+ this.removeEffect(entityId, effect.id);
+ }
+ }
+
+ private applyStatModifier(entityId: string, effect: StatusEffect, sign: 1 | -1): void {
+ const delta = Math.round(effect.magnitude * sign);
+
+ switch (effect.attribute) {
+ case 'armor': {
+ const armor = entityManager.getComponent<{ rating: number }>(entityId, 'armor');
+ if (armor) armor.rating = Math.max(0, armor.rating + delta);
+ break;
+ }
+ case 'damage': {
+ const weapon = entityManager.getComponent<{ damage: number }>(entityId, 'weapon');
+ if (weapon) weapon.damage = Math.max(1, weapon.damage + delta);
+ break;
+ }
+ case 'speed': {
+ const movement = entityManager.getComponent<{ speed: number }>(entityId, 'movement');
+ if (movement) movement.speed = Math.max(50, (movement.speed || 200) + delta);
+ break;
+ }
+ case 'health_max': {
+ const health = entityManager.getComponent<{ max: number; current: number }>(entityId, 'health');
+ if (health) {
+ health.max = Math.max(1, health.max + delta);
+ health.current = Math.min(health.current, health.max);
+ }
+ break;
+ }
+ case 'magicka_max': {
+ const magicka = entityManager.getComponent<{ max: number; current: number }>(entityId, 'magicka');
+ if (magicka) {
+ magicka.max = Math.max(1, magicka.max + delta);
+ magicka.current = Math.min(magicka.current, magicka.max);
+ }
+ break;
+ }
+ case 'stamina_max': {
+ const stamina = entityManager.getComponent<{ max: number; current: number }>(entityId, 'stamina');
+ if (stamina) {
+ stamina.max = Math.max(1, stamina.max + delta);
+ stamina.current = Math.min(stamina.current, stamina.max);
+ }
+ break;
+ }
+ }
+ }
+}
+
+export const statusEffectSystem = StatusEffectSystem.getInstance();
diff --git a/src/systems/TransformationSystem.test.ts b/src/systems/TransformationSystem.test.ts
new file mode 100644
index 0000000..01207f5
--- /dev/null
+++ b/src/systems/TransformationSystem.test.ts
@@ -0,0 +1,152 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { entityManager } from '../core/EntityManager';
+import { eventBus } from '../core/EventBus';
+import { transformationSystem } from './TransformationSystem';
+
+describe('TransformationSystem', () => {
+ beforeEach(() => {
+ entityManager.clear();
+ eventBus.removeAllListeners();
+ transformationSystem.clearForTests();
+ });
+
+ function createPlayer(): string {
+ const player = entityManager.createEntity('player');
+ entityManager.addComponent(player.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(player.id, { type: 'magicka', current: 50, max: 50 });
+ entityManager.addComponent(player.id, { type: 'stamina', current: 100, max: 100 });
+ entityManager.addComponent(player.id, { type: 'weapon', id: 'fists', damage: 4, speed: 1.4 });
+ entityManager.addComponent(player.id, { type: 'armor', rating: 0 });
+ entityManager.addComponent(player.id, { type: 'movement', speed: 200 });
+ entityManager.addComponent(player.id, { type: 'statusEffects', active: [] });
+ return player.id;
+ }
+
+ it('transforms player into werewolf form', () => {
+ const playerId = createPlayer();
+ const result = transformationSystem.transform(playerId, 'werewolf');
+
+ expect(result).toBe(true);
+ expect(transformationSystem.isTransformed(playerId)).toBe(true);
+
+ const form = transformationSystem.getForm(playerId);
+ expect(form).toBeDefined();
+ expect(form!.formId).toBe('werewolf');
+ expect(form!.baseStats.healthMax).toBe(100);
+ expect(form!.baseStats.weaponDamage).toBe(4);
+ });
+
+ it('applies stat bonuses on transform', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(playerId, 'health');
+ expect(health!.max).toBe(200);
+ expect(health!.current).toBe(200);
+
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(playerId, 'stamina');
+ expect(stamina!.max).toBe(150);
+
+ const weapon = entityManager.getComponent<{ id: string; damage: number }>(playerId, 'weapon');
+ expect(weapon!.id).toBe('werewolf_claws');
+ expect(weapon!.damage).toBe(20);
+
+ const movement = entityManager.getComponent<{ speed: number }>(playerId, 'movement');
+ expect(movement!.speed).toBe(260);
+ });
+
+ it('reverts to original stats', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+ transformationSystem.revert(playerId);
+
+ expect(transformationSystem.isTransformed(playerId)).toBe(false);
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(playerId, 'health');
+ expect(health!.max).toBe(100);
+
+ const weapon = entityManager.getComponent<{ id: string; damage: number }>(playerId, 'weapon');
+ expect(weapon!.id).toBe('fists');
+ expect(weapon!.damage).toBe(4);
+
+ const movement = entityManager.getComponent<{ speed: number }>(playerId, 'movement');
+ expect(movement!.speed).toBe(200);
+ });
+
+ it('cannot transform while already transformed', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+ const result = transformationSystem.transform(playerId, 'werewolf');
+
+ expect(result).toBe(false);
+ });
+
+ it('applies cooldown after revert', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+ transformationSystem.revert(playerId);
+
+ expect(transformationSystem.getCooldown(playerId)).toBeGreaterThan(0);
+ });
+
+ it('cannot transform during cooldown', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+ transformationSystem.revert(playerId);
+
+ const result = transformationSystem.transform(playerId, 'werewolf');
+ expect(result).toBe(false);
+ });
+
+ it('reverts automatically when duration expires', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+
+ const form = transformationSystem.getForm(playerId);
+ expect(form).toBeDefined();
+
+ transformationSystem.update(form!.totalMs + 100);
+
+ expect(transformationSystem.isTransformed(playerId)).toBe(false);
+ });
+
+ it('returns remaining time', () => {
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+
+ const remaining = transformationSystem.getRemainingTime(playerId);
+ expect(remaining).toBeGreaterThan(0);
+ expect(remaining).toBeLessThanOrEqual(120000);
+ });
+
+ it('returns 0 remaining time when not transformed', () => {
+ const playerId = createPlayer();
+ expect(transformationSystem.getRemainingTime(playerId)).toBe(0);
+ });
+
+ it('getFormData returns correct data', () => {
+ const data = transformationSystem.getFormData('werewolf');
+ expect(data).toBeDefined();
+ expect(data!.name).toBe('狼人形态');
+ expect(data!.weaponDamage).toBe(20);
+ });
+
+ it('getAllForms returns all forms', () => {
+ const forms = transformationSystem.getAllForms();
+ expect(forms.length).toBe(2);
+ expect(forms.map((f) => f.id)).toContain('werewolf');
+ expect(forms.map((f) => f.id)).toContain('vampire_lord');
+ });
+
+ it('emits events on transform and revert', () => {
+ const events: string[] = [];
+ eventBus.on('form:transformed', () => events.push('transformed'));
+ eventBus.on('form:reverted', () => events.push('reverted'));
+
+ const playerId = createPlayer();
+ transformationSystem.transform(playerId, 'werewolf');
+ transformationSystem.revert(playerId);
+
+ expect(events).toEqual(['transformed', 'reverted']);
+ });
+});
diff --git a/src/systems/TransformationSystem.ts b/src/systems/TransformationSystem.ts
new file mode 100644
index 0000000..6ab1868
--- /dev/null
+++ b/src/systems/TransformationSystem.ts
@@ -0,0 +1,270 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { dataRegistry } from '../data/DataRegistry';
+import { statusEffectSystem } from './StatusEffectSystem';
+
+export interface FormComponent {
+ type: 'form';
+ formId: string;
+ baseStats: {
+ healthMax: number;
+ magickaMax: number;
+ staminaMax: number;
+ weaponDamage: number;
+ armorRating: number;
+ movementSpeed: number;
+ };
+ durationMs: number;
+ totalMs: number;
+ cooldownMs: number;
+ perks: string[];
+}
+
+export interface FormData {
+ id: string;
+ name: string;
+ healthBonus: number;
+ staminaBonus: number;
+ damageBonus: number;
+ armorBonus: number;
+ speedBonus: number;
+ durationMs: number;
+ cooldownMs: number;
+ weaponId: string;
+ weaponDamage: number;
+ weaponSpeed: number;
+ suppressMagicka: boolean;
+ effects: Array<{
+ id: string;
+ attribute: string;
+ magnitude: number;
+ durationMs: number;
+ }>;
+}
+
+export class TransformationSystem {
+ private static instance: TransformationSystem;
+ private formCooldowns: Map = new Map();
+ private formData: Map = new Map();
+
+ static getInstance(): TransformationSystem {
+ if (!TransformationSystem.instance) {
+ TransformationSystem.instance = new TransformationSystem();
+ }
+ return TransformationSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+
+ eventBus.on('form:activate', (data: { entityId: string; formId: string }) => {
+ this.transform(data.entityId, data.formId);
+ });
+
+ eventBus.on('form:deactivate', (data: { entityId: string }) => {
+ this.revert(data.entityId);
+ });
+ }
+
+ private loadFromRegistry(): void {
+ this.formData.clear();
+ for (const t of dataRegistry.getAllTransforms()) {
+ this.formData.set(t.id, { ...t });
+ }
+ }
+
+ update(delta: number): void {
+ for (const entity of entityManager.getEntitiesWithComponent('form')) {
+ const form = entityManager.getComponent(entity.id, 'form');
+ if (!form) continue;
+
+ form.durationMs -= delta;
+ if (form.durationMs <= 0) {
+ this.revert(entity.id);
+ }
+ }
+
+ for (const [entityId, cooldown] of this.formCooldowns) {
+ if (cooldown > 0) {
+ this.formCooldowns.set(entityId, cooldown - delta);
+ }
+ }
+ }
+
+ transform(entityId: string, formId: string): boolean {
+ const formData = this.formData.get(formId);
+ if (!formData) {
+ console.warn(`[Transformation] Unknown form: ${formId}`);
+ return false;
+ }
+
+ if (entityManager.getComponent(entityId, 'form')) {
+ console.warn(`[Transformation] Entity ${entityId} already transformed`);
+ return false;
+ }
+
+ const cooldown = this.formCooldowns.get(entityId) ?? 0;
+ if (cooldown > 0) {
+ console.warn(`[Transformation] Cooldown remaining: ${Math.ceil(cooldown / 1000)}s`);
+ return false;
+ }
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(entityId, 'health');
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(entityId, 'magicka');
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(entityId, 'stamina');
+ const weapon = entityManager.getComponent<{ id: string; damage: number; speed: number }>(entityId, 'weapon');
+ const armor = entityManager.getComponent<{ rating: number }>(entityId, 'armor');
+ const movement = entityManager.getComponent<{ speed: number }>(entityId, 'movement');
+
+ const baseStats = {
+ healthMax: health?.max ?? 100,
+ magickaMax: magicka?.max ?? 50,
+ staminaMax: stamina?.max ?? 100,
+ weaponDamage: weapon?.damage ?? 4,
+ armorRating: armor?.rating ?? 0,
+ movementSpeed: movement?.speed ?? 200,
+ };
+
+ const formComponent: FormComponent = {
+ type: 'form',
+ formId,
+ baseStats,
+ durationMs: formData.durationMs,
+ totalMs: formData.durationMs,
+ cooldownMs: formData.cooldownMs,
+ perks: [],
+ };
+ entityManager.addComponent(entityId, formComponent);
+
+ if (formData.suppressMagicka && magicka) {
+ magicka.current = 0;
+ }
+
+ entityManager.addComponent(entityId, {
+ type: 'weapon',
+ id: formData.weaponId,
+ damage: formData.weaponDamage,
+ speed: formData.weaponSpeed,
+ });
+
+ for (const effect of formData.effects) {
+ statusEffectSystem.applyEffect(entityId, {
+ id: effect.id,
+ type: 'buff',
+ attribute: effect.attribute,
+ magnitude: effect.magnitude,
+ remainingMs: effect.durationMs,
+ totalMs: effect.durationMs,
+ source: 'transformation',
+ });
+ }
+
+ if (armor) {
+ armor.rating += formData.armorBonus;
+ }
+
+ if (health) {
+ health.current = Math.min(health.current + formData.healthBonus, health.max);
+ }
+ if (stamina) {
+ stamina.current = Math.min(stamina.current + formData.staminaBonus, stamina.max);
+ }
+
+ const entity = entityManager.getEntity(entityId);
+ if (entity?.sprite && 'setFillStyle' in entity.sprite) {
+ (entity.sprite as Phaser.GameObjects.Rectangle).setFillStyle(formId === 'werewolf' ? 0x8B4513 : 0x4B0082);
+ if ('setSize' in entity.sprite) {
+ (entity.sprite as Phaser.GameObjects.Rectangle).setSize(28, 36);
+ }
+ }
+
+ eventBus.emit('form:transformed', { entityId, formId, formData });
+ return true;
+ }
+
+ revert(entityId: string): boolean {
+ const form = entityManager.getComponent(entityId, 'form');
+ if (!form) return false;
+
+ const formData = this.formData.get(form.formId);
+ const baseStats = form.baseStats;
+
+ const armor = entityManager.getComponent<{ rating: number }>(entityId, 'armor');
+
+ if (formData) {
+ for (const effect of formData.effects) {
+ statusEffectSystem.removeEffect(entityId, effect.id);
+ }
+ }
+
+ if (armor) {
+ armor.rating = baseStats.armorRating;
+ }
+
+ entityManager.addComponent(entityId, {
+ type: 'weapon',
+ id: 'fists',
+ damage: baseStats.weaponDamage,
+ speed: 1.4,
+ });
+
+ entityManager.removeComponent(entityId, 'form');
+
+ this.formCooldowns.set(entityId, form.cooldownMs);
+
+ const entity = entityManager.getEntity(entityId);
+ if (entity?.sprite && 'setFillStyle' in entity.sprite) {
+ (entity.sprite as Phaser.GameObjects.Rectangle).setFillStyle(0x4488ff);
+ if ('setSize' in entity.sprite) {
+ (entity.sprite as Phaser.GameObjects.Rectangle).setSize(24, 32);
+ }
+ }
+
+ eventBus.emit('form:reverted', { entityId, formId: form.formId });
+ return true;
+ }
+
+ getForm(entityId: string): FormComponent | undefined {
+ return entityManager.getComponent(entityId, 'form');
+ }
+
+ isTransformed(entityId: string): boolean {
+ return entityManager.hasComponent(entityId, 'form');
+ }
+
+ getRemainingTime(entityId: string): number {
+ const form = this.getForm(entityId);
+ return form ? form.durationMs : 0;
+ }
+
+ getCooldown(entityId: string): number {
+ return Math.max(0, this.formCooldowns.get(entityId) ?? 0);
+ }
+
+ getFormData(formId: string): FormData | undefined {
+ return this.formData.get(formId);
+ }
+
+ getAllForms(): FormData[] {
+ return Array.from(this.formData.values());
+ }
+
+ addPerk(entityId: string, perkId: string): void {
+ const form = this.getForm(entityId);
+ if (form && !form.perks.includes(perkId)) {
+ form.perks.push(perkId);
+ }
+ }
+
+ hasPerk(entityId: string, perkId: string): boolean {
+ const form = this.getForm(entityId);
+ return form ? form.perks.includes(perkId) : false;
+ }
+
+ clearForTests(): void {
+ this.formCooldowns.clear();
+ }
+}
+
+export const transformationSystem = TransformationSystem.getInstance();
diff --git a/src/systems/VampireSystem.test.ts b/src/systems/VampireSystem.test.ts
new file mode 100644
index 0000000..a05ae03
--- /dev/null
+++ b/src/systems/VampireSystem.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { entityManager } from '../core/EntityManager';
+import { eventBus } from '../core/EventBus';
+import { vampireSystem } from './VampireSystem';
+
+describe('VampireSystem', () => {
+ beforeEach(() => {
+ entityManager.clear();
+ eventBus.removeAllListeners();
+ });
+
+ function createPlayer(): string {
+ const player = entityManager.createEntity('player');
+ entityManager.addComponent(player.id, { type: 'health', current: 100, max: 100 });
+ entityManager.addComponent(player.id, { type: 'magicka', current: 50, max: 50 });
+ entityManager.addComponent(player.id, { type: 'stamina', current: 100, max: 100 });
+ entityManager.addComponent(player.id, { type: 'statusEffects', active: [] });
+ return player.id;
+ }
+
+ it('infects player with vampirism', () => {
+ const playerId = createPlayer();
+ const result = vampireSystem.infect(playerId);
+
+ expect(result).toBe(true);
+ expect(vampireSystem.isVampire(playerId)).toBe(true);
+ expect(vampireSystem.getStage(playerId)).toBe(1);
+ });
+
+ it('cannot infect already infected player', () => {
+ const playerId = createPlayer();
+ vampireSystem.infect(playerId);
+ const result = vampireSystem.infect(playerId);
+
+ expect(result).toBe(false);
+ });
+
+ it('feeding resets infection timer and heals', () => {
+ const playerId = createPlayer();
+ vampireSystem.infect(playerId);
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(playerId, 'health');
+ if (health) health.current = 50;
+
+ const result = vampireSystem.feed(playerId);
+ expect(result).toBe(true);
+
+ expect(health!.current).toBe(80);
+ });
+
+ it('cannot feed if not vampire', () => {
+ const playerId = createPlayer();
+ const result = vampireSystem.feed(playerId);
+ expect(result).toBe(false);
+ });
+
+ it('cures vampirism', () => {
+ const playerId = createPlayer();
+ vampireSystem.infect(playerId);
+ const result = vampireSystem.cure(playerId);
+
+ expect(result).toBe(true);
+ expect(vampireSystem.isVampire(playerId)).toBe(false);
+ expect(vampireSystem.getStage(playerId)).toBe(0);
+ });
+
+ it('cannot cure if not vampire', () => {
+ const playerId = createPlayer();
+ const result = vampireSystem.cure(playerId);
+ expect(result).toBe(false);
+ });
+
+ it('infection progresses over time', () => {
+ const playerId = createPlayer();
+ vampireSystem.infect(playerId);
+
+ const stageData1 = vampireSystem.getStageData(1);
+ expect(stageData1).toBeDefined();
+
+ vampireSystem.update(stageData1!.infectionThresholdMs + 1000);
+
+ expect(vampireSystem.getStage(playerId)).toBe(2);
+ });
+
+ it('returns correct stage data', () => {
+ const stage0 = vampireSystem.getStageData(0);
+ expect(stage0).toBeDefined();
+ expect(stage0!.name).toBe('未感染');
+
+ const stage4 = vampireSystem.getStageData(4);
+ expect(stage4).toBeDefined();
+ expect(stage4!.sunDamage).toBe(3);
+ });
+
+ it('emits events on infection and cure', () => {
+ const events: string[] = [];
+ eventBus.on('vampire:infected', () => events.push('infected'));
+ eventBus.on('vampire:cured', () => events.push('cured'));
+
+ const playerId = createPlayer();
+ vampireSystem.infect(playerId);
+ vampireSystem.cure(playerId);
+
+ expect(events).toEqual(['infected', 'cured']);
+ });
+});
diff --git a/src/systems/VampireSystem.ts b/src/systems/VampireSystem.ts
new file mode 100644
index 0000000..80b0ede
--- /dev/null
+++ b/src/systems/VampireSystem.ts
@@ -0,0 +1,252 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { statusEffectSystem } from './StatusEffectSystem';
+import { dayNightSystem } from './DayNightSystem';
+import { dataRegistry } from '../data/DataRegistry';
+
+export interface VampireComponent {
+ type: 'vampire';
+ stage: number;
+ infectionMs: number;
+ lastFedMs: number;
+ weaknesses: {
+ fireResist: number;
+ frostResist: number;
+ sunDamage: number;
+ };
+}
+
+interface VampireStageData {
+ stage: number;
+ name: string;
+ frostResist: number;
+ fireResist: number;
+ sunDamage: number;
+ nightBonuses: Array<{
+ id: string;
+ attribute: string;
+ magnitude: number;
+ }>;
+ infectionThresholdMs: number;
+}
+
+export class VampireSystem {
+ private static instance: VampireSystem;
+ private stageData: Map = new Map();
+
+ static getInstance(): VampireSystem {
+ if (!VampireSystem.instance) {
+ VampireSystem.instance = new VampireSystem();
+ }
+ return VampireSystem.instance;
+ }
+
+ constructor() {
+ this.loadFromRegistry();
+
+ eventBus.on('vampire:infect', (data: { entityId: string }) => {
+ this.infect(data.entityId);
+ });
+
+ eventBus.on('vampire:feed', (data: { entityId: string }) => {
+ this.feed(data.entityId);
+ });
+
+ eventBus.on('time:hourChanged', (data: { hour: number }) => {
+ this.onHourChanged(data.hour);
+ });
+
+ eventBus.on('mod:dataResolved', () => this.loadFromRegistry());
+ }
+
+ private loadFromRegistry(): void {
+ this.stageData.clear();
+ for (const stage of dataRegistry.getAllVampireStages()) {
+ this.stageData.set(stage.stage, stage as VampireStageData);
+ }
+ }
+
+ update(delta: number): void {
+ for (const entity of entityManager.getEntitiesWithComponent('vampire')) {
+ const vamp = entityManager.getComponent(entity.id, 'vampire');
+ if (!vamp || vamp.stage === 0) continue;
+
+ vamp.infectionMs += delta;
+
+ const stageData = this.stageData.get(vamp.stage);
+ if (stageData && stageData.stage < 4) {
+ const nextStage = this.stageData.get(vamp.stage + 1);
+ if (nextStage && vamp.infectionMs >= stageData.infectionThresholdMs) {
+ this.setStage(entity.id, vamp.stage + 1);
+ }
+ }
+ }
+ }
+
+ infect(entityId: string): boolean {
+ const existing = entityManager.getComponent(entityId, 'vampire');
+ if (existing && existing.stage > 0) return false;
+
+ const vampComponent: VampireComponent = {
+ type: 'vampire',
+ stage: 1,
+ infectionMs: 0,
+ lastFedMs: Date.now(),
+ weaknesses: { fireResist: 0, frostResist: 0, sunDamage: 0 },
+ };
+ entityManager.addComponent(entityId, vampComponent);
+
+ this.applyStageEffects(entityId, 1);
+ eventBus.emit('vampire:infected', { entityId, stage: 1 });
+ return true;
+ }
+
+ feed(entityId: string): boolean {
+ const vamp = entityManager.getComponent(entityId, 'vampire');
+ if (!vamp || vamp.stage === 0) return false;
+
+ vamp.lastFedMs = Date.now();
+ vamp.infectionMs = 0;
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(entityId, 'health');
+ if (health) {
+ health.current = Math.min(health.max, health.current + 30);
+ }
+
+ eventBus.emit('vampire:fed', { entityId, stage: vamp.stage });
+ return true;
+ }
+
+ cure(entityId: string): boolean {
+ const vamp = entityManager.getComponent(entityId, 'vampire');
+ if (!vamp || vamp.stage === 0) return false;
+
+ this.removeStageEffects(entityId, vamp.stage);
+ entityManager.removeComponent(entityId, 'vampire');
+
+ eventBus.emit('vampire:cured', { entityId });
+ return true;
+ }
+
+ getStage(entityId: string): number {
+ const vamp = entityManager.getComponent(entityId, 'vampire');
+ return vamp?.stage ?? 0;
+ }
+
+ getStageData(stage: number): VampireStageData | undefined {
+ return this.stageData.get(stage);
+ }
+
+ isVampire(entityId: string): boolean {
+ return this.getStage(entityId) > 0;
+ }
+
+ private setStage(entityId: string, newStage: number): void {
+ const vamp = entityManager.getComponent(entityId, 'vampire');
+ if (!vamp) return;
+
+ const oldStage = vamp.stage;
+ this.removeStageEffects(entityId, oldStage);
+
+ vamp.stage = newStage;
+ const stageData = this.stageData.get(newStage);
+ if (stageData) {
+ vamp.weaknesses = {
+ fireResist: stageData.fireResist,
+ frostResist: stageData.frostResist,
+ sunDamage: stageData.sunDamage,
+ };
+ }
+
+ this.applyStageEffects(entityId, newStage);
+ eventBus.emit('vampire:stageChanged', { entityId, oldStage, newStage });
+ }
+
+ private applyStageEffects(entityId: string, stage: number): void {
+ const stageData = this.stageData.get(stage);
+ if (!stageData) return;
+
+ if (stageData.frostResist !== 0) {
+ statusEffectSystem.applyEffect(entityId, {
+ id: `vampire_frost_resist_${stage}`,
+ type: 'buff',
+ attribute: 'armor',
+ magnitude: Math.abs(stageData.frostResist),
+ remainingMs: Infinity,
+ totalMs: Infinity,
+ source: 'vampire',
+ });
+ }
+
+ const hour = this.getCurrentHour();
+ const isNight = hour < 6 || hour >= 20;
+ if (isNight) {
+ for (const bonus of stageData.nightBonuses) {
+ statusEffectSystem.applyEffect(entityId, {
+ id: bonus.id,
+ type: 'buff',
+ attribute: bonus.attribute,
+ magnitude: bonus.magnitude,
+ remainingMs: Infinity,
+ totalMs: Infinity,
+ source: 'vampire_night',
+ });
+ }
+ }
+ }
+
+ private removeStageEffects(entityId: string, stage: number): void {
+ const stageData = this.stageData.get(stage);
+ if (!stageData) return;
+
+ statusEffectSystem.removeEffect(entityId, `vampire_frost_resist_${stage}`);
+
+ for (const bonus of stageData.nightBonuses) {
+ statusEffectSystem.removeEffect(entityId, bonus.id);
+ }
+ }
+
+ private onHourChanged(hour: number): void {
+ const isNight = hour < 6 || hour >= 20;
+ const isDay = hour >= 6 && hour < 20;
+
+ for (const entity of entityManager.getEntitiesWithComponent('vampire')) {
+ const vamp = entityManager.getComponent(entity.id, 'vampire');
+ if (!vamp || vamp.stage === 0) continue;
+
+ if (isDay && vamp.stage >= 3) {
+ const sunEffect = this.stageData.get(vamp.stage)?.sunDamage ?? 0;
+ if (sunEffect > 0) {
+ statusEffectSystem.applyEffect(entity.id, {
+ id: 'vampire_sun_damage',
+ type: 'debuff',
+ attribute: 'health_max',
+ magnitude: -Math.round(sunEffect * 10),
+ remainingMs: 60 * 60 * 1000,
+ totalMs: 60 * 60 * 1000,
+ source: 'vampire_sun',
+ });
+ }
+ }
+
+ if (isNight) {
+ this.removeStageEffects(entity.id, vamp.stage);
+ this.applyStageEffects(entity.id, vamp.stage);
+ } else {
+ for (const bonus of this.stageData.get(vamp.stage)?.nightBonuses ?? []) {
+ statusEffectSystem.removeEffect(entity.id, bonus.id);
+ }
+ }
+ }
+ }
+
+ private getCurrentHour(): number {
+ try {
+ return dayNightSystem.getHour();
+ } catch {
+ return 12;
+ }
+ }
+}
+
+export const vampireSystem = VampireSystem.getInstance();
diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts
new file mode 100644
index 0000000..c779255
--- /dev/null
+++ b/src/ui/UIManager.ts
@@ -0,0 +1,467 @@
+import { eventBus } from '../core/EventBus';
+import { entityManager } from '../core/EntityManager';
+import { inventorySystem } from '../systems/InventorySystem';
+import { T, FONT, panelStyle } from './theme';
+
+export class UIManager {
+ private static instance: UIManager;
+ private container: HTMLDivElement;
+ private hud: HTMLDivElement;
+ private healthBar: HTMLDivElement;
+ private magickaBar: HTMLDivElement;
+ private staminaBar: HTMLDivElement;
+ private interactPrompt: HTMLDivElement;
+ private goldDisplay: HTMLDivElement;
+ private weightDisplay: HTMLDivElement;
+ private inventoryPanel: HTMLDivElement;
+ private isInventoryOpen: boolean = false;
+ private levelDisplay: HTMLDivElement;
+ private zoneDisplay: HTMLDivElement;
+
+ static getInstance(): UIManager {
+ if (!UIManager.instance) {
+ UIManager.instance = new UIManager();
+ }
+ return UIManager.instance;
+ }
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'ui-container';
+ this.container.style.cssText = `
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 100;
+ `;
+ document.body.appendChild(this.container);
+
+ this.hud = this.createHUD();
+ this.container.appendChild(this.hud);
+
+ this.healthBar = this.createBar('health', 'oes-bar-fill--health', 240);
+ this.magickaBar = this.createBar('magicka', 'oes-bar-fill--magicka', 180);
+ this.staminaBar = this.createBar('stamina', 'oes-bar-fill--stamina', 180);
+ this.interactPrompt = this.createInteractPrompt();
+ this.goldDisplay = this.createGoldDisplay();
+ this.weightDisplay = this.createWeightDisplay();
+ this.inventoryPanel = this.createInventoryPanel();
+ this.levelDisplay = this.createLevelDisplay();
+ this.zoneDisplay = this.createZoneDisplay();
+ this.createCrosshair();
+
+ this.setupEventListeners();
+ this.updateBars();
+ }
+
+ private createHUD(): HTMLDivElement {
+ const hud = document.createElement('div');
+ hud.id = 'hud';
+ hud.style.cssText = `
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ `;
+ return hud;
+ }
+
+ private createBar(name: string, fillClass: string, width: number): HTMLDivElement {
+ const outer = document.createElement('div');
+ outer.className = 'oes-bar-outer';
+ outer.style.width = `${width}px`;
+
+ const fill = document.createElement('div');
+ fill.id = `${name}-bar-fill`;
+ fill.className = `oes-bar-fill ${fillClass}`;
+ fill.style.width = '100%';
+
+ const text = document.createElement('div');
+ text.id = `${name}-bar-text`;
+ text.className = 'oes-bar-text';
+ text.textContent = name.toUpperCase();
+
+ outer.appendChild(fill);
+ outer.appendChild(text);
+ this.hud.appendChild(outer);
+
+ return fill;
+ }
+
+ private createLevelDisplay(): HTMLDivElement {
+ const el = document.createElement('div');
+ el.id = 'level-display';
+ el.style.cssText = `
+ position: absolute;
+ top: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 6px 24px;
+ background: linear-gradient(180deg, #2a2420 0%, #1a1510 100%);
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ color: ${T.textGold};
+ font-size: 14px;
+ font-family: ${FONT.title};
+ letter-spacing: 1px;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.6);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+ `;
+ el.textContent = '等级 1';
+ this.container.appendChild(el);
+ return el;
+ }
+
+ private createZoneDisplay(): HTMLDivElement {
+ const el = document.createElement('div');
+ el.id = 'zone-display';
+ el.style.cssText = `
+ position: absolute;
+ top: 50px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 4px 16px;
+ background: rgba(0,0,0,0.4);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ color: ${T.textMuted};
+ font-size: 12px;
+ font-family: ${FONT.body};
+ letter-spacing: 0.5px;
+ `;
+ el.textContent = '';
+ this.container.appendChild(el);
+ return el;
+ }
+
+ private createCrosshair(): HTMLDivElement {
+ const el = document.createElement('div');
+ el.id = 'crosshair';
+ el.style.cssText = `
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 20px;
+ height: 20px;
+ pointer-events: none;
+ z-index: 150;
+ opacity: 0.4;
+ `;
+ el.innerHTML = `
+
+ `;
+ document.body.appendChild(el);
+ return el;
+ }
+
+ private createInteractPrompt(): HTMLDivElement {
+ const prompt = document.createElement('div');
+ prompt.id = 'interact-prompt';
+ prompt.className = 'oes-prompt';
+ prompt.style.display = 'none';
+ prompt.textContent = '[E] Interact';
+ this.container.appendChild(prompt);
+ return prompt;
+ }
+
+ private createGoldDisplay(): HTMLDivElement {
+ const display = document.createElement('div');
+ display.id = 'gold-display';
+ display.style.cssText = `
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ padding: 6px 14px;
+ background: linear-gradient(180deg, #2a2420 0%, #1a1510 100%);
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ color: ${T.goldAccent};
+ font-size: 13px;
+ font-weight: 600;
+ font-family: ${FONT.body};
+ text-shadow: 0 1px 2px rgba(0,0,0,0.6);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+ `;
+ display.textContent = '金币: 0';
+ this.container.appendChild(display);
+ return display;
+ }
+
+ private createWeightDisplay(): HTMLDivElement {
+ const display = document.createElement('div');
+ display.id = 'weight-display';
+ display.style.cssText = `
+ position: absolute;
+ top: 46px;
+ right: 16px;
+ padding: 6px 14px;
+ background: linear-gradient(180deg, #2a2420 0%, #1a1510 100%);
+ border: 1px solid ${T.borderIron};
+ border-radius: 3px;
+ color: ${T.textMuted};
+ font-size: 13px;
+ font-family: ${FONT.body};
+ text-shadow: 0 1px 2px rgba(0,0,0,0.6);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+ `;
+ display.textContent = '负重: 0/300';
+ this.container.appendChild(display);
+ return display;
+ }
+
+ private createInventoryPanel(): HTMLDivElement {
+ const panel = document.createElement('div');
+ panel.id = 'inventory-panel';
+ panel.style.cssText = `
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 620px;
+ max-height: 520px;
+ ${panelStyle}
+ padding: 0;
+ display: none;
+ pointer-events: auto;
+ overflow-y: auto;
+ `;
+ this.container.appendChild(panel);
+ return panel;
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('entity:killed', (data: { entity: any }) => {
+ const name = data.entity.type === 'enemy' ? '敌人' : '实体';
+ this.showNotification(`${name}被击败`);
+ });
+
+ eventBus.on('game:initialized', () => {
+ this.showNotification('Welcome to OES-WEB');
+ });
+
+ eventBus.on('inventory:updated', () => {
+ this.updateGoldDisplay();
+ this.updateWeightDisplay();
+ if (this.isInventoryOpen) {
+ this.renderInventory();
+ }
+ });
+
+ eventBus.on('player:created', () => {
+ this.updateGoldDisplay();
+ this.updateWeightDisplay();
+ this.updateLevelDisplay();
+ });
+
+ eventBus.on('player:levelUp', (_data: { entityId: string; level: number }) => {
+ this.updateLevelDisplay();
+ });
+
+ eventBus.on('game:zoneChanged', (data: { zoneId: string }) => {
+ this.updateZoneDisplay(data.zoneId);
+ });
+ }
+
+ updateLevelDisplay(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+ const level = entityManager.getComponent<{ level: number }>(player.id, 'level');
+ if (level) this.levelDisplay.textContent = `等级 ${level.level}`;
+ }
+
+ updateZoneDisplay(zoneId: string): void {
+ const zoneNames: Record = {
+ whiterun: '白漫城',
+ whiterun_exterior: '白漫城 · 外围',
+ riverwood: '溪木镇',
+ bleakfalls_barrow: '荒瀑古坟',
+ darklight_cave: '暗光洞穴',
+ ancient_ruins: '古代遗迹',
+ skyrim_overworld: '天际省 · 荒野',
+ };
+ this.zoneDisplay.textContent = zoneNames[zoneId] || zoneId;
+ }
+
+ updateBars(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+
+ const health = entityManager.getComponent<{ current: number; max: number }>(player.id, 'health');
+ const magicka = entityManager.getComponent<{ current: number; max: number }>(player.id, 'magicka');
+ const stamina = entityManager.getComponent<{ current: number; max: number }>(player.id, 'stamina');
+
+ if (health) {
+ const pct = (health.current / health.max) * 100;
+ this.healthBar.style.width = `${pct}%`;
+ const healthText = document.getElementById('health-bar-text');
+ if (healthText) healthText.textContent = `${Math.round(health.current)}/${Math.round(health.max)}`;
+ }
+
+ if (magicka) {
+ const pct = (magicka.current / magicka.max) * 100;
+ this.magickaBar.style.width = `${pct}%`;
+ const magickaText = document.getElementById('magicka-bar-text');
+ if (magickaText) magickaText.textContent = `${Math.round(magicka.current)}/${Math.round(magicka.max)}`;
+ }
+
+ if (stamina) {
+ const pct = (stamina.current / stamina.max) * 100;
+ this.staminaBar.style.width = `${pct}%`;
+ const staminaText = document.getElementById('stamina-bar-text');
+ if (staminaText) staminaText.textContent = `${Math.round(stamina.current)}/${Math.round(stamina.max)}`;
+ }
+
+ requestAnimationFrame(() => this.updateBars());
+ }
+
+ showInteractPrompt(text: string): void {
+ this.interactPrompt.textContent = text;
+ this.interactPrompt.style.display = 'block';
+ }
+
+ hideInteractPrompt(): void {
+ this.interactPrompt.style.display = 'none';
+ }
+
+ showNotification(text: string): void {
+ const toast = document.createElement('div');
+ toast.className = 'oes-toast';
+ toast.textContent = text;
+ this.container.appendChild(toast);
+ setTimeout(() => toast.remove(), 2600);
+ }
+
+ private updateGoldDisplay(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+
+ const inventory = inventorySystem.getInventory(player);
+ if (inventory) {
+ this.goldDisplay.textContent = `金币: ${inventory.gold}`;
+ }
+ }
+
+ private updateWeightDisplay(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+
+ const inventory = inventorySystem.getInventory(player);
+ if (inventory) {
+ this.weightDisplay.textContent = `负重: ${inventory.carryWeight.toFixed(1)}/${inventory.maxCarryWeight}`;
+ this.weightDisplay.style.color = inventory.carryWeight > inventory.maxCarryWeight
+ ? T.danger
+ : T.textMuted;
+ }
+ }
+
+ toggleInventory(): void {
+ this.isInventoryOpen = !this.isInventoryOpen;
+ this.inventoryPanel.style.display = this.isInventoryOpen ? 'block' : 'none';
+
+ if (this.isInventoryOpen) {
+ this.renderInventory();
+ }
+ }
+
+ private renderInventory(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+
+ const inventory = inventorySystem.getInventory(player);
+ if (!inventory) return;
+
+ this.inventoryPanel.innerHTML = `
+
+
+ 金币: ${inventory.gold}
+ 负重: ${inventory.carryWeight.toFixed(1)}/${inventory.maxCarryWeight}
+
+
+ ${inventory.items.length === 0 ? `
背包为空
` : ''}
+
+ `;
+
+ const itemsContainer = document.getElementById('inventory-items');
+ if (itemsContainer) {
+ inventory.items.forEach((item) => {
+ const itemEl = document.createElement('div');
+ itemEl.style.cssText = `
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ `;
+ itemEl.innerHTML = `
+
+
${item.name}${item.equipped ? ' [装备中]' : ''}
+
x${item.quantity} · ${item.weight} · ${item.value}金
+
+
+ ${item.type === 'consumable' ? `` : ''}
+ ${item.type === 'weapon' ? `` : ''}
+
+ `;
+
+ itemEl.addEventListener('mouseenter', () => {
+ itemEl.style.borderColor = T.borderBronze;
+ itemEl.style.background = 'rgba(255,255,255,0.05)';
+ });
+ itemEl.addEventListener('mouseleave', () => {
+ itemEl.style.borderColor = T.borderDark;
+ itemEl.style.background = 'rgba(255,255,255,0.03)';
+ });
+
+ const useBtn = itemEl.querySelector('.use-btn');
+ if (useBtn) {
+ useBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ inventorySystem.useItem(player, item.id);
+ });
+ }
+
+ const equipBtn = itemEl.querySelector('.equip-btn');
+ if (equipBtn) {
+ equipBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (item.equipped) {
+ inventorySystem.unequipItem(player, item.id);
+ } else {
+ inventorySystem.equipItem(player, item.id, 'rightHand');
+ }
+ });
+ }
+
+ itemsContainer.appendChild(itemEl);
+ });
+ }
+
+ document.getElementById('close-inventory')?.addEventListener('click', () => {
+ this.toggleInventory();
+ });
+ }
+}
+
+const uiManager = UIManager.getInstance();
+export { uiManager };
diff --git a/src/ui/components/CharacterCreationUI.ts b/src/ui/components/CharacterCreationUI.ts
new file mode 100644
index 0000000..aee5692
--- /dev/null
+++ b/src/ui/components/CharacterCreationUI.ts
@@ -0,0 +1,307 @@
+import { eventBus } from '../../core/EventBus';
+import { entityManager } from '../../core/EntityManager';
+import { dataRegistry, type RaceData } from '../../data/DataRegistry';
+import { T, FONT, panelStyle, goldTitleStyle, btnPrimaryStyle } from '../theme';
+
+export class CharacterCreationUI {
+ private container: HTMLDivElement;
+ private selectedRace: RaceData | null = null;
+ private playerName: string = '';
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'character-creation';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: radial-gradient(ellipse at center, #1a1828 0%, #0a0a10 70%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ overflow-y: auto;
+ `;
+ }
+
+ show(): void {
+ this.container.innerHTML = '';
+ this.render();
+ document.body.appendChild(this.container);
+ }
+
+ hide(): void {
+ this.container.remove();
+ }
+
+ private render(): void {
+ /* ── Title ornament ───────────────────────── */
+ const ornament = document.createElement('div');
+ ornament.style.cssText = `
+ width: 120px;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, ${T.borderGold}, transparent);
+ margin-bottom: 12px;
+ `;
+ this.container.appendChild(ornament);
+
+ const title = document.createElement('h1');
+ title.textContent = '创建角色';
+ title.style.cssText = `
+ ${goldTitleStyle}
+ font-size: 32px;
+ margin-bottom: 6px;
+ letter-spacing: 3px;
+ `;
+ this.container.appendChild(title);
+
+ const subtitle = document.createElement('div');
+ subtitle.style.cssText = `
+ color: ${T.textDim};
+ font-size: 13px;
+ margin-bottom: 28px;
+ letter-spacing: 2px;
+ font-family: ${FONT.title};
+ `;
+ subtitle.textContent = 'THE ELDER SCROLLS';
+ this.container.appendChild(subtitle);
+
+ /* ── Name input ───────────────────────────── */
+ const nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.placeholder = '输入角色名...';
+ nameInput.className = 'oes-input';
+ nameInput.style.cssText = `
+ width: 320px;
+ text-align: center;
+ margin-bottom: 28px;
+ font-size: 16px;
+ letter-spacing: 1px;
+ `;
+ nameInput.addEventListener('input', (e) => {
+ this.playerName = (e.target as HTMLInputElement).value;
+ });
+ this.container.appendChild(nameInput);
+
+ /* ── Race section title ───────────────────── */
+ const raceLabel = document.createElement('div');
+ raceLabel.style.cssText = `
+ color: ${T.textMuted};
+ font-size: 12px;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+ font-family: ${FONT.title};
+ `;
+ raceLabel.textContent = '选择种族';
+ this.container.appendChild(raceLabel);
+
+ /* ── Race grid ────────────────────────────── */
+ const raceGrid = document.createElement('div');
+ raceGrid.style.cssText = `
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 12px;
+ max-width: 800px;
+ margin-bottom: 20px;
+ `;
+
+ const races = dataRegistry.getAllRaces();
+ races.forEach((race) => {
+ raceGrid.appendChild(this.createRaceCard(race));
+ });
+ this.container.appendChild(raceGrid);
+
+ /* ── Race info panel ──────────────────────── */
+ const infoPanel = document.createElement('div');
+ infoPanel.id = 'race-info';
+ infoPanel.style.cssText = `
+ ${panelStyle}
+ max-width: 600px;
+ width: 100%;
+ min-height: 100px;
+ padding: 16px 24px;
+ margin-bottom: 24px;
+ text-align: center;
+ `;
+ infoPanel.innerHTML = `点击种族查看详情
`;
+ this.container.appendChild(infoPanel);
+
+ /* ── Divider ──────────────────────────────── */
+ const divider = document.createElement('div');
+ divider.style.cssText = `
+ width: 200px;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, ${T.borderBronze}, transparent);
+ margin-bottom: 20px;
+ `;
+ this.container.appendChild(divider);
+
+ /* ── Start button ─────────────────────────── */
+ const startButton = document.createElement('button');
+ startButton.textContent = '开始冒险';
+ startButton.style.cssText = `
+ ${btnPrimaryStyle}
+ padding: 14px 56px;
+ font-size: 17px;
+ letter-spacing: 2px;
+ font-family: ${FONT.title};
+ `;
+ startButton.addEventListener('click', () => this.startGame());
+ this.container.appendChild(startButton);
+ }
+
+ private createRaceCard(race: RaceData): HTMLDivElement {
+ const card = document.createElement('div');
+ card.style.cssText = `
+ padding: 14px 10px;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: center;
+ `;
+
+ const raceColors: Record = {
+ nord: '#6fa8dc',
+ dunmer: '#c27ba0',
+ altmer: '#ffd966',
+ argonian:'#93c47d',
+ khajiit: '#e69138',
+ breton: '#a4c2f4',
+ imperial: '#cc4125',
+ redguard:'#ea4335',
+ orc: '#6aa84f',
+ bosmer: '#76a5af',
+ };
+
+ const icon = document.createElement('div');
+ icon.style.cssText = `
+ width: 44px;
+ height: 44px;
+ background: ${raceColors[race.id] || '#666'};
+ border-radius: 50%;
+ margin: 0 auto 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ font-family: ${FONT.title};
+ color: #000;
+ font-weight: bold;
+ box-shadow: 0 0 8px ${raceColors[race.id] || '#666'}33;
+ `;
+ icon.textContent = race.name.charAt(0);
+ card.appendChild(icon);
+
+ const name = document.createElement('div');
+ name.textContent = race.name;
+ name.style.cssText = `
+ font-weight: 600;
+ font-size: 13px;
+ color: ${T.textLight};
+ letter-spacing: 0.5px;
+ `;
+ card.appendChild(name);
+
+ card.addEventListener('mouseenter', () => {
+ card.style.borderColor = T.borderGold;
+ card.style.background = 'rgba(255,255,255,0.06)';
+ });
+ card.addEventListener('mouseleave', () => {
+ if (this.selectedRace?.id !== race.id) {
+ card.style.borderColor = T.borderDark;
+ card.style.background = 'rgba(255,255,255,0.03)';
+ }
+ });
+ card.addEventListener('click', () => this.selectRace(race, card));
+
+ return card;
+ }
+
+ private selectRace(race: RaceData, card: HTMLDivElement): void {
+ this.selectedRace = race;
+
+ const allCards = this.container.querySelectorAll('[style*="border-radius: 3px"]');
+ allCards.forEach((c) => {
+ if (c !== card) {
+ (c as HTMLDivElement).style.borderColor = T.borderDark;
+ (c as HTMLDivElement).style.background = 'rgba(255,255,255,0.03)';
+ }
+ });
+
+ card.style.borderColor = T.borderGold;
+ card.style.background = 'rgba(212,168,67,0.08)';
+
+ this.updateRaceInfo(race);
+ }
+
+ private updateRaceInfo(race: RaceData): void {
+ const infoPanel = document.getElementById('race-info');
+ if (!infoPanel) return;
+
+ const bonusText = Object.entries(race.bonuses)
+ .map(([skill, value]) => `${skill} +${value}`)
+ .join(' · ');
+
+ infoPanel.innerHTML = `
+ ${race.name}
+ ${race.description}
+
+ HP ${race.baseStats.health}
+ MP ${race.baseStats.magicka}
+ SP ${race.baseStats.stamina}
+
+
+
技能加成: ${bonusText}
+ ${race.power ? `
种族能力: ${race.power.name} — ${race.power.description}
` : ''}
+ ${race.passive ? `
被动: ${race.passive.name} — ${race.passive.description}
` : ''}
+
+ `;
+ }
+
+ private startGame(): void {
+ if (!this.selectedRace) {
+ alert('请选择一个种族!');
+ return;
+ }
+ if (!this.playerName.trim()) {
+ alert('请输入角色名!');
+ return;
+ }
+
+ const player = entityManager.createEntity('player');
+ entityManager.addComponent(player.id, { type: 'race', raceId: this.selectedRace.id });
+ entityManager.addComponent(player.id, {
+ type: 'health',
+ current: this.selectedRace.baseStats.health,
+ max: this.selectedRace.baseStats.health,
+ });
+ entityManager.addComponent(player.id, {
+ type: 'magicka',
+ current: this.selectedRace.baseStats.magicka,
+ max: this.selectedRace.baseStats.magicka,
+ });
+ entityManager.addComponent(player.id, {
+ type: 'stamina',
+ current: this.selectedRace.baseStats.stamina,
+ max: this.selectedRace.baseStats.stamina,
+ });
+ entityManager.addComponent(player.id, {
+ type: 'skills',
+ oneHanded: 15, twoHanded: 15, archery: 15, block: 15, heavyArmor: 15, smithing: 15,
+ destruction: 15, conjuration: 15, illusion: 15, restoration: 15, alteration: 15, enchanting: 15,
+ sneak: 15, lightArmor: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15,
+ });
+ entityManager.addComponent(player.id, { type: 'level', level: 1, perkPoints: 0, xp: 0 });
+ entityManager.addComponent(player.id, { type: 'inventory', items: [], gold: 100, weight: 0, maxWeight: 300 });
+ entityManager.addComponent(player.id, { type: 'equipment', head: null, chest: null, hands: null, feet: null, shield: null, ring: null, necklace: null, weapon: null });
+ entityManager.addComponent(player.id, { type: 'name', value: this.playerName });
+
+ eventBus.emit('player:created', { entity: player });
+ this.hide();
+ }
+}
diff --git a/src/ui/components/CombatUI.ts b/src/ui/components/CombatUI.ts
new file mode 100644
index 0000000..40701ba
--- /dev/null
+++ b/src/ui/components/CombatUI.ts
@@ -0,0 +1,172 @@
+import { eventBus } from '../../core/EventBus';
+import { entityManager } from '../../core/EntityManager';
+import { T, FONT } from '../theme';
+
+export class CombatUI {
+ private static instance: CombatUI;
+ private container: HTMLDivElement;
+
+ static getInstance(): CombatUI {
+ if (!CombatUI.instance) {
+ CombatUI.instance = new CombatUI();
+ }
+ return CombatUI.instance;
+ }
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'combat-ui';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 150;
+ `;
+ document.body.appendChild(this.container);
+
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('combat:afterAttack', (data: { attacker: any; target: any; damage: number }) => {
+ this.showDamageNumber(data.target, data.damage);
+ });
+
+ eventBus.on('item:used', (data: { entity: any; item: any }) => {
+ this.showHealNumber(data.entity, data.item.effects?.magnitude || 0);
+ });
+ }
+
+ private showDamageNumber(entity: any, damage: number): void {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return;
+
+ const gameCanvas = document.querySelector('canvas');
+ if (!gameCanvas) return;
+
+ const rect = gameCanvas.getBoundingClientRect();
+ const camera = (entity.scene as any)?.cameras?.main;
+ if (!camera) return;
+
+ const screenX = (pos.x - camera.scrollX) * camera.zoom + rect.left;
+ const screenY = (pos.y - camera.scrollY) * camera.zoom + rect.top;
+
+ const el = document.createElement('div');
+ el.style.cssText = `
+ position: fixed;
+ left: ${screenX}px;
+ top: ${screenY - 20}px;
+ color: ${T.danger};
+ font-size: 16px;
+ font-weight: 700;
+ font-family: ${FONT.title};
+ text-shadow: 0 1px 4px rgba(0,0,0,0.8);
+ pointer-events: none;
+ z-index: 200;
+ animation: oesDamageFloat 0.9s ease-out forwards;
+ `;
+ el.textContent = `-${damage}`;
+
+ this.ensureKeyframes();
+ this.container.appendChild(el);
+ setTimeout(() => el.remove(), 950);
+ }
+
+ private showHealNumber(entity: any, amount: number): void {
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return;
+
+ const gameCanvas = document.querySelector('canvas');
+ if (!gameCanvas) return;
+
+ const rect = gameCanvas.getBoundingClientRect();
+ const camera = (entity.scene as any)?.cameras?.main;
+ if (!camera) return;
+
+ const screenX = (pos.x - camera.scrollX) * camera.zoom + rect.left;
+ const screenY = (pos.y - camera.scrollY) * camera.zoom + rect.top;
+
+ const el = document.createElement('div');
+ el.style.cssText = `
+ position: fixed;
+ left: ${screenX}px;
+ top: ${screenY - 20}px;
+ color: ${T.staminaGreen};
+ font-size: 16px;
+ font-weight: 700;
+ font-family: ${FONT.title};
+ text-shadow: 0 1px 4px rgba(0,0,0,0.8);
+ pointer-events: none;
+ z-index: 200;
+ animation: oesDamageFloat 0.9s ease-out forwards;
+ `;
+ el.textContent = `+${amount}`;
+
+ this.ensureKeyframes();
+ this.container.appendChild(el);
+ setTimeout(() => el.remove(), 950);
+ }
+
+ private keyframesInjected = false;
+ private ensureKeyframes(): void {
+ if (this.keyframesInjected) return;
+ this.keyframesInjected = true;
+ const style = document.createElement('style');
+ style.textContent = `
+ @keyframes oesDamageFloat {
+ 0% { opacity: 1; transform: translateY(0) scale(1); }
+ 30% { opacity: 1; transform: translateY(-12px) scale(1.1); }
+ 100% { opacity: 0; transform: translateY(-36px) scale(0.8); }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ showEnemyHealthBar(entity: any, health: { current: number; max: number }): void {
+ const existing = document.getElementById(`enemy-health-${entity.id}`);
+ if (existing) existing.remove();
+
+ const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position');
+ if (!pos) return;
+
+ const gameCanvas = document.querySelector('canvas');
+ if (!gameCanvas) return;
+
+ const rect = gameCanvas.getBoundingClientRect();
+ const camera = (entity.scene as any)?.cameras?.main;
+ if (!camera) return;
+
+ const screenX = (pos.x - camera.scrollX) * camera.zoom + rect.left;
+ const screenY = (pos.y - camera.scrollY) * camera.zoom + rect.top - 28;
+
+ const bar = document.createElement('div');
+ bar.id = `enemy-health-${entity.id}`;
+ bar.style.cssText = `
+ position: fixed;
+ left: ${screenX - 24}px;
+ top: ${screenY}px;
+ width: 48px;
+ height: 5px;
+ background: rgba(0,0,0,0.7);
+ border: 1px solid ${T.borderIron};
+ border-radius: 2px;
+ `;
+
+ const fill = document.createElement('div');
+ fill.style.cssText = `
+ width: ${(health.current / health.max) * 100}%;
+ height: 100%;
+ background: linear-gradient(180deg, ${T.healthRed} 0%, ${T.healthRedDark} 100%);
+ border-radius: 1px;
+ `;
+
+ bar.appendChild(fill);
+ this.container.appendChild(bar);
+ }
+
+ clearEnemyHealthBars(): void {
+ this.container.querySelectorAll('[id^="enemy-health-"]').forEach((bar) => bar.remove());
+ }
+}
+
+export const combatUI = CombatUI.getInstance();
diff --git a/src/ui/components/CraftingUI.ts b/src/ui/components/CraftingUI.ts
new file mode 100644
index 0000000..7b991bb
--- /dev/null
+++ b/src/ui/components/CraftingUI.ts
@@ -0,0 +1,364 @@
+import { eventBus } from '../../core/EventBus';
+import { entityManager } from '../../core/EntityManager';
+import { alchemySystem, type AlchemyRecipe } from '../../systems/AlchemySystem';
+import { enchantingSystem, type Enchantment } from '../../systems/EnchantingSystem';
+import { smithingSystem, type SmithingRecipe } from '../../systems/SmithingSystem';
+import { T, FONT, goldTitleStyle } from '../theme';
+
+export type CraftingTab = 'alchemy' | 'enchanting' | 'smithing';
+
+export class CraftingUI {
+ private static instance: CraftingUI;
+ private container: HTMLDivElement;
+ private isOpen: boolean = false;
+ private currentTab: CraftingTab = 'alchemy';
+ private selectedRecipe: any = null;
+
+ static getInstance(): CraftingUI {
+ if (!CraftingUI.instance) {
+ CraftingUI.instance = new CraftingUI();
+ }
+ return CraftingUI.instance;
+ }
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'crafting-ui';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: ${T.bgOverlay};
+ display: none;
+ z-index: 500;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ `;
+ }
+
+ show(tab: CraftingTab = 'alchemy'): void {
+ this.isOpen = true;
+ this.currentTab = tab;
+ this.container.style.display = 'block';
+ this.render();
+ document.body.appendChild(this.container);
+ }
+
+ hide(): void {
+ this.isOpen = false;
+ this.container.style.display = 'none';
+ }
+
+ toggle(tab?: CraftingTab): void {
+ if (this.isOpen) {
+ this.hide();
+ } else {
+ this.show(tab);
+ }
+ }
+
+ private render(): void {
+ const tabs: { id: CraftingTab; label: string; color: string }[] = [
+ { id: 'alchemy', label: '炼金', color: T.alchemy },
+ { id: 'enchanting', label: '附魔', color: T.enchanting },
+ { id: 'smithing', label: '锻造', color: T.smithing },
+ ];
+
+ this.container.innerHTML = `
+
+
+
+
+
+
+ ${tabs.map((t) => `
+
+ `).join('')}
+
+
+
+
+
+ `;
+
+ this.renderRecipeList();
+
+ document.getElementById('close-crafting')?.addEventListener('click', () => this.hide());
+
+ document.querySelectorAll('.crafting-tab').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ this.currentTab = (btn as HTMLElement).getAttribute('data-tab') as CraftingTab;
+ this.selectedRecipe = null;
+ this.render();
+ });
+ });
+ }
+
+ private renderRecipeList(): void {
+ const list = document.getElementById('recipe-list');
+ if (!list) return;
+
+ const playerEntity = entityManager.getEntitiesByType('player')[0];
+ if (!playerEntity) return;
+
+ list.innerHTML = '';
+
+ const tabColors: Record = {
+ alchemy: T.alchemy,
+ enchanting: T.enchanting,
+ smithing: T.smithing,
+ };
+ const accentColor = tabColors[this.currentTab];
+
+ switch (this.currentTab) {
+ case 'alchemy': {
+ const recipes = alchemySystem.getAvailableRecipes(playerEntity);
+ if (recipes.length === 0) {
+ list.innerHTML = `没有可用的配方
`;
+ return;
+ }
+ for (const recipe of recipes) {
+ const item = document.createElement('div');
+ item.style.cssText = `
+ padding: 8px 10px;
+ background: rgba(255,255,255,0.02);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+ `;
+ item.innerHTML = `
+ ${recipe.name}
+ ${recipe.ingredients.join(' + ')}
+ `;
+ item.addEventListener('mouseenter', () => { item.style.borderColor = accentColor; });
+ item.addEventListener('mouseleave', () => { item.style.borderColor = T.borderDark; });
+ item.addEventListener('click', () => {
+ this.selectedRecipe = { type: 'alchemy', data: recipe };
+ this.renderRecipeDetails();
+ });
+ list.appendChild(item);
+ }
+ break;
+ }
+ case 'enchanting': {
+ const enchantments = enchantingSystem.getAllEnchantments();
+ if (enchantments.length === 0) {
+ list.innerHTML = `没有可用的附魔
`;
+ return;
+ }
+ for (const enc of enchantments) {
+ const item = document.createElement('div');
+ item.style.cssText = `
+ padding: 8px 10px;
+ background: rgba(255,255,255,0.02);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+ `;
+ item.innerHTML = `
+ ${enc.name}
+ 类型: ${enc.type} | 强度: ${enc.magnitude}
+ `;
+ item.addEventListener('mouseenter', () => { item.style.borderColor = accentColor; });
+ item.addEventListener('mouseleave', () => { item.style.borderColor = T.borderDark; });
+ item.addEventListener('click', () => {
+ this.selectedRecipe = { type: 'enchanting', data: enc };
+ this.renderRecipeDetails();
+ });
+ list.appendChild(item);
+ }
+ break;
+ }
+ case 'smithing': {
+ const recipes = smithingSystem.getAvailableRecipes(playerEntity);
+ if (recipes.length === 0) {
+ list.innerHTML = `没有可用的配方 (需要材料和技能)
`;
+ return;
+ }
+ for (const recipe of recipes) {
+ const item = document.createElement('div');
+ item.style.cssText = `
+ padding: 8px 10px;
+ background: rgba(255,255,255,0.02);
+ border: 1px solid ${T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+ `;
+ item.innerHTML = `
+ ${recipe.name}
+ ${recipe.materials.map((m) => `${m.id} x${m.quantity}`).join(', ')}
+ `;
+ item.addEventListener('mouseenter', () => { item.style.borderColor = accentColor; });
+ item.addEventListener('mouseleave', () => { item.style.borderColor = T.borderDark; });
+ item.addEventListener('click', () => {
+ this.selectedRecipe = { type: 'smithing', data: recipe };
+ this.renderRecipeDetails();
+ });
+ list.appendChild(item);
+ }
+ break;
+ }
+ }
+ }
+
+ private renderRecipeDetails(): void {
+ const details = document.getElementById('recipe-details');
+ if (!details || !this.selectedRecipe) return;
+
+ const playerEntity = entityManager.getEntitiesByType('player')[0];
+ if (!playerEntity) return;
+
+ const tabColors: Record = {
+ alchemy: T.alchemy,
+ enchanting: T.enchanting,
+ smithing: T.smithing,
+ };
+ const accentColor = tabColors[this.selectedRecipe.type as CraftingTab];
+
+ switch (this.selectedRecipe.type) {
+ case 'alchemy': {
+ const recipe = this.selectedRecipe.data as AlchemyRecipe;
+ details.innerHTML = `
+
+
${recipe.name}
+
类型: ${recipe.result.type === 'potion' ? '药水' : '毒药'}
+
+
+
效果
+ ${recipe.result.effects.map((e) => `
+
+ ${e.type}
+ ${e.magnitude}
+
+ `).join('')}
+
+ 价值: ${recipe.result.value} 金币
+
+ `;
+
+ document.getElementById('craft-btn')?.addEventListener('click', () => {
+ const potion = alchemySystem.brew(recipe.id, playerEntity);
+ if (potion) {
+ eventBus.emit('item:pickup', { entity: playerEntity, itemId: potion.id, quantity: 1 });
+ this.showNotification(`制作了 ${potion.name}`);
+ this.renderRecipeList();
+ }
+ });
+ break;
+ }
+ case 'enchanting': {
+ const enc = this.selectedRecipe.data as Enchantment;
+ details.innerHTML = `
+
+
${enc.name}
+
类型: ${enc.type}
+
+
+
效果
+ ${enc.effects.map((e) => `
+
+ ${e.type}
+ ${e.magnitude}${e.duration ? ` (${e.duration / 1000}秒)` : ''}
+
+ `).join('')}
+
+ 需要: 灵魂石 + 装备
+ `;
+ break;
+ }
+ case 'smithing': {
+ const recipe = this.selectedRecipe.data as SmithingRecipe;
+ const skills = entityManager.getComponent<{ smithing: number }>(playerEntity.id, 'skills');
+ const smithingLevel = skills?.smithing || 0;
+ const canCraft = smithingSystem.canForge(recipe.id, playerEntity);
+
+ details.innerHTML = `
+
+
${recipe.name}
+
类型: ${recipe.type} | 材质: ${recipe.tier}
+
+
+ 需要技能:
+ ${recipe.skillRequired}
+ (你有: ${smithingLevel})
+
+
+
材料
+ ${recipe.materials.map((m) => {
+ const inventory = entityManager.getComponent<{ items: any[] }>(playerEntity.id, 'inventory');
+ const owned = inventory?.items.find((i) => i.id === m.id);
+ const count = owned?.quantity || 0;
+ const enough = count >= m.quantity;
+ return `
+
+ ${m.id} x${m.quantity}
+ 你有: ${count} ${enough ? '✓' : '✗'}
+
+ `;
+ }).join('')}
+
+
+
属性
+ ${recipe.result.damage ? `
伤害: ${recipe.result.damage}
` : ''}
+ ${recipe.result.armor ? `
护甲: ${recipe.result.armor}
` : ''}
+
重量: ${recipe.result.weight} | 价值: ${recipe.result.value}
+
+
+ `;
+
+ if (canCraft) {
+ document.getElementById('forge-btn')?.addEventListener('click', () => {
+ if (smithingSystem.forge(recipe.id, playerEntity)) {
+ this.showNotification(`锻造了 ${recipe.name}`);
+ this.renderRecipeList();
+ this.renderRecipeDetails();
+ }
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ private showNotification(text: string): void {
+ const toast = document.createElement('div');
+ toast.className = 'oes-toast';
+ toast.textContent = text;
+ document.body.appendChild(toast);
+ setTimeout(() => toast.remove(), 2600);
+ }
+
+ isCraftingOpen(): boolean {
+ return this.isOpen;
+ }
+}
+
+export const craftingUI = CraftingUI.getInstance();
diff --git a/src/ui/components/DialogueUI.ts b/src/ui/components/DialogueUI.ts
new file mode 100644
index 0000000..fc402b4
--- /dev/null
+++ b/src/ui/components/DialogueUI.ts
@@ -0,0 +1,178 @@
+import { eventBus } from '../../core/EventBus';
+import { dialogueSystem } from '../../systems/DialogueSystem';
+import { entityManager } from '../../core/EntityManager';
+import { T, FONT, goldTitleStyle } from '../theme';
+
+export class DialogueUI {
+ private static instance: DialogueUI;
+ private container: HTMLDivElement;
+ private isOpen: boolean = false;
+
+ static getInstance(): DialogueUI {
+ if (!DialogueUI.instance) {
+ DialogueUI.instance = new DialogueUI();
+ }
+ return DialogueUI.instance;
+ }
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'dialogue-ui';
+ this.container.style.cssText = `
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ background: linear-gradient(180deg, rgba(10,10,15,0.0) 0%, rgba(10,10,15,0.97) 30px);
+ display: none;
+ z-index: 400;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ padding: 40px 0 24px;
+ box-sizing: border-box;
+ `;
+ this.setupEventListeners();
+ }
+
+ private setupEventListeners(): void {
+ eventBus.on('dialogue:line', (data: { speaker: string; text: string; options: any[]; npc: any }) => {
+ this.showDialogue(data.speaker, data.text, data.options, data.npc);
+ });
+
+ eventBus.on('dialogue:ended', () => {
+ this.hide();
+ });
+ }
+
+ private showDialogue(speaker: string, text: string, options: any[], _npc: any): void {
+ this.isOpen = true;
+ this.container.style.display = 'block';
+
+ const playerEntity = entityManager.getEntitiesByType('player')[0];
+ const playerLevel = entityManager.getComponent<{ level: number }>(playerEntity?.id || '', 'level');
+
+ this.container.innerHTML = `
+
+
+
+
+
${speaker}
+
+
+
+
+
+
+ ${text}
+
+
+
+
+ ${options.map((opt, i) => {
+ let disabled = false;
+ let tooltip = '';
+
+ if (opt.conditions) {
+ for (const cond of opt.conditions) {
+ if (cond.type === 'level' && playerLevel && playerLevel.level < cond.value) {
+ disabled = true;
+ tooltip = `需要等级 ${cond.value}`;
+ }
+ if (cond.type === 'gold') {
+ const inventory = entityManager.getComponent<{ gold: number }>(playerEntity?.id || '', 'inventory');
+ if (!inventory || inventory.gold < cond.value) {
+ disabled = true;
+ tooltip = `需要 ${cond.value} 金币`;
+ }
+ }
+ }
+ }
+
+ if (opt.skillCheck) {
+ const skills = entityManager.getComponent
>(playerEntity?.id || '', 'skills');
+ const skillValue = skills?.[opt.skillCheck.skill] || 0;
+ tooltip = `${opt.skillCheck.skill} 检定 (需要 ${opt.skillCheck.difficulty}, 你有 ${skillValue})`;
+ }
+
+ return `
+
+ `;
+ }).join('')}
+
+
+ `;
+
+ document.getElementById('end-dialogue')?.addEventListener('click', () => {
+ dialogueSystem.endDialogue();
+ });
+
+ document.querySelectorAll('.dialogue-option').forEach((btn) => {
+ btn.addEventListener('mouseenter', () => {
+ if (!(btn as HTMLButtonElement).disabled) {
+ (btn as HTMLDivElement).style.borderColor = T.borderGold;
+ }
+ });
+ btn.addEventListener('mouseleave', () => {
+ if (!(btn as HTMLButtonElement).disabled) {
+ (btn as HTMLDivElement).style.borderColor = T.borderIron;
+ }
+ });
+ btn.addEventListener('click', () => {
+ const optionId = btn.getAttribute('data-option-id');
+ if (optionId && playerEntity) {
+ dialogueSystem.selectOption(optionId, playerEntity);
+ }
+ });
+ });
+
+ document.addEventListener('keydown', this.handleEscapeKey);
+ }
+
+ private handleEscapeKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape' && this.isOpen) {
+ dialogueSystem.endDialogue();
+ }
+ };
+
+ hide(): void {
+ this.isOpen = false;
+ this.container.style.display = 'none';
+ document.removeEventListener('keydown', this.handleEscapeKey);
+ }
+
+ isDialogueOpen(): boolean {
+ return this.isOpen;
+ }
+}
+
+export const dialogueUI = DialogueUI.getInstance();
diff --git a/src/ui/components/ModManagerUI.ts b/src/ui/components/ModManagerUI.ts
new file mode 100644
index 0000000..11fa4da
--- /dev/null
+++ b/src/ui/components/ModManagerUI.ts
@@ -0,0 +1,248 @@
+import { modManager } from '../../mods/ModManager';
+import { modLoader, type LoadedMod } from '../../mods/ModLoader';
+import { T, FONT, panelStyle } from '../theme';
+
+export class ModManagerUI {
+ private container: HTMLDivElement;
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'mod-manager';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: ${T.bgOverlay};
+ display: none;
+ z-index: 600;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ padding: 30px;
+ box-sizing: border-box;
+ overflow-y: auto;
+ `;
+ }
+
+ show(): void {
+ this.container.style.display = 'block';
+ this.render();
+ document.body.appendChild(this.container);
+ }
+
+ hide(): void {
+ this.container.style.display = 'none';
+ }
+
+ private render(): void {
+ this.container.innerHTML = '';
+
+ /* ── Header ───────────────────────────────── */
+ const header = document.createElement('div');
+ header.className = 'oes-panel-header';
+ header.innerHTML = `
+ Mod 管理器
+
+
+
+
+ `;
+ this.container.appendChild(header);
+
+ /* ── Validation errors (from last load) ───── */
+ const issues = modLoader.getLastIssues();
+ if (issues.length > 0) {
+ const errorAlert = document.createElement('div');
+ errorAlert.style.cssText = `
+ padding: 14px 16px;
+ background: rgba(200,48,48,0.12);
+ border: 1px solid #8a3030;
+ border-left: 3px solid ${T.danger};
+ border-radius: 3px;
+ margin-bottom: 16px;
+ `;
+ errorAlert.innerHTML = `
+ 校验错误
+
+ ${issues.map((issue) => `
+ -
+ [${issue.severity}]
+ ${issue.code}: ${issue.message}
+
+ `).join('')}
+
+ `;
+ this.container.appendChild(errorAlert);
+ }
+
+ /* ── Conflict detection ───────────────────── */
+ const conflicts = modManager.getConflicts();
+ if (conflicts.length > 0) {
+ const conflictAlert = document.createElement('div');
+ conflictAlert.style.cssText = `
+ padding: 14px 16px;
+ background: rgba(200,160,48,0.1);
+ border: 1px solid #8a7030;
+ border-left: 3px solid ${T.textGold};
+ border-radius: 3px;
+ margin-bottom: 16px;
+ `;
+ conflictAlert.innerHTML = `
+ 冲突检测
+
+ ${conflicts.map((c) => `
+ - ${c}
+ `).join('')}
+
+ `;
+ this.container.appendChild(conflictAlert);
+ }
+
+ /* ── Mod list ─────────────────────────────── */
+ const modList = document.createElement('div');
+ modList.id = 'mod-list';
+ modList.style.cssText = `
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: calc(100vh - 220px);
+ overflow-y: auto;
+ `;
+
+ const mods = modManager.getModList();
+
+ if (mods.length === 0) {
+ modList.innerHTML = `
+
+
没有已安装的 Mod
+
点击"导入 Mod"按钮添加 .json 格式的 Mod 文件
+
+ `;
+ } else {
+ mods.forEach((mod) => {
+ modList.appendChild(this.createModCard(mod));
+ });
+ }
+
+ this.container.appendChild(modList);
+
+ document.getElementById('close-mod-manager')?.addEventListener('click', () => this.hide());
+ document.getElementById('import-mod-btn')?.addEventListener('click', () => this.importMod());
+ }
+
+ private createModCard(mod: LoadedMod): HTMLDivElement {
+ const card = document.createElement('div');
+ card.style.cssText = `
+ ${panelStyle}
+ padding: 16px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ opacity: ${mod.enabled ? 1 : 0.55};
+ `;
+
+ /* ── Left: info ───────────────────────────── */
+ const info = document.createElement('div');
+ info.style.cssText = `flex: 1; min-width: 0;`;
+
+ const statusColor = mod.enabled ? T.success : T.textDim;
+ const statusDot = mod.enabled ? '●' : '○';
+
+ info.innerHTML = `
+
+ ${statusDot}
+ ${mod.manifest.name}
+ v${mod.manifest.version}
+ by ${mod.manifest.author}
+
+ ${mod.manifest.description}
+
+ 优先级: ${mod.manifest.priority} · ID: ${mod.manifest.id}
+ ${mod.manifest.dependencies.length > 0 ? ` · 依赖: ${mod.manifest.dependencies.join(', ')}` : ''}
+
+ ${mod.warnings.length > 0 ? `
+
+ ${mod.warnings.map((w) => `
⚠ ${w.message}
`).join('')}
+
+ ` : ''}
+ `;
+ card.appendChild(info);
+
+ /* ── Right: actions ───────────────────────── */
+ const actions = document.createElement('div');
+ actions.style.cssText = `display: flex; gap: 6px; margin-left: 16px; flex-shrink: 0;`;
+
+ const toggleBtn = document.createElement('button');
+ toggleBtn.textContent = mod.enabled ? '禁用' : '启用';
+ toggleBtn.className = `oes-btn ${mod.enabled ? 'oes-btn--danger' : 'oes-btn--success'}`;
+ toggleBtn.style.cssText = 'padding: 6px 14px; font-size: 12px;';
+ toggleBtn.addEventListener('click', () => {
+ modManager.toggleMod(mod.manifest.id);
+ this.render();
+ });
+ actions.appendChild(toggleBtn);
+
+ const exportBtn = document.createElement('button');
+ exportBtn.textContent = '导出';
+ exportBtn.className = 'oes-btn';
+ exportBtn.style.cssText = 'padding: 6px 14px; font-size: 12px;';
+ exportBtn.addEventListener('click', () => this.exportMod(mod.manifest.id));
+ actions.appendChild(exportBtn);
+
+ const uninstallBtn = document.createElement('button');
+ uninstallBtn.textContent = '卸载';
+ uninstallBtn.className = 'oes-btn oes-btn--danger';
+ uninstallBtn.style.cssText = 'padding: 6px 14px; font-size: 12px; opacity: 0.7;';
+ uninstallBtn.addEventListener('click', () => {
+ if (confirm(`确定要卸载 ${mod.manifest.name} 吗?`)) {
+ modManager.uninstallMod(mod.manifest.id);
+ this.render();
+ }
+ });
+ actions.appendChild(uninstallBtn);
+
+ card.appendChild(actions);
+ return card;
+ }
+
+ private async importMod(): Promise {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file) {
+ const success = await modManager.importMod(file);
+ if (success) {
+ this.showNotification('Mod 导入成功');
+ } else {
+ this.showNotification('Mod 导入失败,请检查文件格式');
+ }
+ this.render();
+ }
+ });
+ input.click();
+ }
+
+ private async exportMod(modId: string): Promise {
+ await modLoader.getMod(modId);
+ const modJson = modManager.exportMod(modId);
+ if (!modJson) return;
+
+ const blob = new Blob([modJson], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${modId}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ private showNotification(text: string): void {
+ const toast = document.createElement('div');
+ toast.className = 'oes-toast';
+ toast.textContent = text;
+ document.body.appendChild(toast);
+ setTimeout(() => toast.remove(), 2600);
+ }
+}
+
+export const modManagerUI = new ModManagerUI();
diff --git a/src/ui/components/SkillTreeUI.ts b/src/ui/components/SkillTreeUI.ts
new file mode 100644
index 0000000..85c9712
--- /dev/null
+++ b/src/ui/components/SkillTreeUI.ts
@@ -0,0 +1,230 @@
+import { eventBus } from '../../core/EventBus';
+import { entityManager } from '../../core/EntityManager';
+import { dataRegistry, type PerkData } from '../../data/DataRegistry';
+import { T, FONT, goldTitleStyle } from '../theme';
+
+export class SkillTreeUI {
+ private container: HTMLDivElement;
+ private currentSkill: string | null = null;
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'skill-tree';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: ${T.bgOverlay};
+ display: none;
+ z-index: 500;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ `;
+ }
+
+ show(): void {
+ this.container.style.display = 'flex';
+ this.render();
+ document.body.appendChild(this.container);
+ }
+
+ hide(): void {
+ this.container.style.display = 'none';
+ }
+
+ private render(): void {
+ this.container.innerHTML = '';
+
+ /* ── Header ───────────────────────────────── */
+ const header = document.createElement('div');
+ header.style.cssText = `
+ padding: 16px 24px;
+ text-align: center;
+ border-bottom: 1px solid ${T.borderBronze};
+ background: linear-gradient(180deg, rgba(26,24,36,0.8) 0%, transparent 100%);
+ `;
+ header.innerHTML = `
+ 技能树
+
+ `;
+ this.container.appendChild(header);
+
+ /* ── Content ──────────────────────────────── */
+ const content = document.createElement('div');
+ content.style.cssText = `display: flex; height: calc(100% - 70px);`;
+
+ /* Sidebar */
+ const sidebar = document.createElement('div');
+ sidebar.style.cssText = `
+ width: 180px;
+ padding: 16px 12px;
+ border-right: 1px solid ${T.borderDark};
+ overflow-y: auto;
+ background: rgba(0,0,0,0.2);
+ `;
+
+ const categories = [
+ { name: '战斗', skills: ['oneHanded', 'twoHanded', 'archery', 'block', 'heavyArmor', 'smithing'] },
+ { name: '魔法', skills: ['destruction', 'conjuration', 'illusion', 'restoration', 'alteration', 'enchanting'] },
+ { name: '潜行', skills: ['sneak', 'lightArmor', 'lockpicking', 'pickpocket', 'speech', 'alchemy'] },
+ ];
+
+ const skillNames: Record = {
+ oneHanded: '单手武器', twoHanded: '双手武器', archery: '弓箭',
+ block: '格挡', heavyArmor: '重甲', smithing: '锻造',
+ destruction: '毁灭', conjuration: '召唤', illusion: '幻术',
+ restoration: '恢复', alteration: '变化', enchanting: '附魔',
+ sneak: '潜行', lightArmor: '轻甲', lockpicking: '开锁',
+ pickpocket: '扒窃', speech: '口才', alchemy: '炼金',
+ };
+
+ categories.forEach((cat) => {
+ const catTitle = document.createElement('div');
+ catTitle.textContent = cat.name;
+ catTitle.style.cssText = `
+ color: ${T.textGold};
+ font-weight: 600;
+ margin: 14px 0 8px;
+ font-size: 12px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ font-family: ${FONT.title};
+ `;
+ sidebar.appendChild(catTitle);
+
+ cat.skills.forEach((skillId) => {
+ const skillBtn = document.createElement('button');
+ skillBtn.textContent = skillNames[skillId] || skillId;
+ skillBtn.style.cssText = `
+ display: block;
+ width: 100%;
+ padding: 7px 10px;
+ margin: 2px 0;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ color: ${T.textMuted};
+ cursor: pointer;
+ text-align: left;
+ font-size: 13px;
+ font-family: ${FONT.body};
+ transition: all 0.15s;
+ `;
+ skillBtn.addEventListener('mouseenter', () => {
+ skillBtn.style.background = 'rgba(255,255,255,0.04)';
+ skillBtn.style.color = T.textLight;
+ skillBtn.style.borderColor = T.borderDark;
+ });
+ skillBtn.addEventListener('mouseleave', () => {
+ skillBtn.style.background = 'transparent';
+ skillBtn.style.color = T.textMuted;
+ skillBtn.style.borderColor = 'transparent';
+ });
+ skillBtn.addEventListener('click', () => this.selectSkill(skillId));
+ sidebar.appendChild(skillBtn);
+ });
+ });
+ content.appendChild(sidebar);
+
+ /* Tree panel */
+ const treePanel = document.createElement('div');
+ treePanel.id = 'tree-panel';
+ treePanel.style.cssText = `
+ flex: 1;
+ padding: 30px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ `;
+ treePanel.innerHTML = `选择一个技能查看天赋树
`;
+ content.appendChild(treePanel);
+
+ this.container.appendChild(content);
+ this.updatePerkPoints();
+ }
+
+ private selectSkill(skillId: string): void {
+ this.currentSkill = skillId;
+ const treePanel = document.getElementById('tree-panel');
+ if (!treePanel) return;
+
+ const treeData = dataRegistry.getPerkTree(skillId);
+ if (!treeData) {
+ treePanel.innerHTML = `该技能天赋树尚未实现
`;
+ return;
+ }
+
+ treePanel.innerHTML = `
+ ${treeData.name}
+
+ `;
+
+ const perkGrid = document.getElementById('perk-grid');
+ if (!perkGrid) return;
+
+ treeData.perks.forEach((perk) => {
+ perkGrid.appendChild(this.createPerkNode(perk));
+ });
+ }
+
+ private createPerkNode(perk: PerkData): HTMLDivElement {
+ const node = document.createElement('div');
+ node.style.cssText = `
+ padding: 14px 24px;
+ background: rgba(255,255,255,0.04);
+ border: 1px solid ${T.borderIron};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s;
+ min-width: 220px;
+ text-align: center;
+ `;
+
+ node.innerHTML = `
+ ${perk.name}
+ ${perk.description}
+ 等级: ${perk.rank}/${perk.maxRank}
+ `;
+
+ node.addEventListener('mouseenter', () => {
+ node.style.borderColor = T.borderGold;
+ node.style.background = 'rgba(212,168,67,0.06)';
+ });
+ node.addEventListener('mouseleave', () => {
+ node.style.borderColor = T.borderIron;
+ node.style.background = 'rgba(255,255,255,0.04)';
+ });
+ node.addEventListener('click', () => this.unlockPerk(perk));
+
+ return node;
+ }
+
+ private unlockPerk(perk: PerkData): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ if (!player) return;
+
+ const level = entityManager.getComponent<{ level: number; perkPoints: number }>(player.id, 'level');
+ if (!level || level.perkPoints <= 0) {
+ alert('没有可用的天赋点!');
+ return;
+ }
+
+ level.perkPoints -= 1;
+ eventBus.emit('perk:unlocked', { entityId: player.id, perkId: perk.id });
+ this.updatePerkPoints();
+
+ if (this.currentSkill) {
+ this.selectSkill(this.currentSkill);
+ }
+ }
+
+ private updatePerkPoints(): void {
+ const player = entityManager.getEntitiesByType('player')[0];
+ const pointsEl = document.getElementById('perk-points');
+ if (!player || !pointsEl) return;
+
+ const level = entityManager.getComponent<{ perkPoints: number }>(player.id, 'level');
+ pointsEl.textContent = `天赋点: ${level?.perkPoints || 0}`;
+ }
+}
+
+export const skillTreeUI = new SkillTreeUI();
diff --git a/src/ui/components/WorldMapUI.ts b/src/ui/components/WorldMapUI.ts
new file mode 100644
index 0000000..dee044d
--- /dev/null
+++ b/src/ui/components/WorldMapUI.ts
@@ -0,0 +1,313 @@
+import { eventBus } from '../../core/EventBus';
+import { T, FONT, goldTitleStyle, panelStyle } from '../theme';
+
+export interface MapLocation {
+ id: string;
+ name: string;
+ description: string;
+ x: number;
+ y: number;
+ discovered: boolean;
+ type: 'city' | 'town' | 'village' | 'dungeon' | 'camp' | 'landmark';
+}
+
+export class WorldMapUI {
+ private static instance: WorldMapUI;
+ private container: HTMLDivElement;
+ private isOpen: boolean = false;
+ private locations: MapLocation[] = [];
+ private selectedLocation: MapLocation | null = null;
+
+ static getInstance(): WorldMapUI {
+ if (!WorldMapUI.instance) {
+ WorldMapUI.instance = new WorldMapUI();
+ }
+ return WorldMapUI.instance;
+ }
+
+ constructor() {
+ this.container = document.createElement('div');
+ this.container.id = 'world-map';
+ this.container.style.cssText = `
+ position: fixed;
+ inset: 0;
+ background: ${T.bgOverlay};
+ display: none;
+ z-index: 500;
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ `;
+ this.initializeLocations();
+ }
+
+ private initializeLocations(): void {
+ this.locations = [
+ { id: 'whiterun', name: '雪漫城', description: '天际省的首府', x: 400, y: 300, discovered: true, type: 'city' },
+ { id: 'whiterun_exterior', name: '雪漫城外', description: '雪漫城周围的平原', x: 400, y: 350, discovered: true, type: 'landmark' },
+ { id: 'bleakfalls_barrow', name: '荒瀑古坟', description: '古老的诺德遗迹', x: 500, y: 250, discovered: false, type: 'dungeon' },
+ { id: 'riverwood', name: '河木镇', description: '宁静的河边小镇', x: 300, y: 400, discovered: false, type: 'town' },
+ { id: 'windhelm', name: '风盔城', description: '古老的诺德城市', x: 600, y: 200, discovered: false, type: 'city' },
+ { id: 'solitude', name: '独孤城', description: '帝国在天际的首都', x: 200, y: 150, discovered: false, type: 'city' },
+ { id: 'riften', name: '裂谷城', description: '盗贼公会的据点', x: 550, y: 450, discovered: false, type: 'city' },
+ { id: 'markarth', name: '马卡斯城', description: '古老的矮人城市', x: 150, y: 350, discovered: false, type: 'city' },
+ { id: 'dark_brotherhood', name: '黑暗兄弟会', description: '暗杀组织的藏身处', x: 350, y: 200, discovered: false, type: 'dungeon' },
+ { id: 'college_of_winterhold', name: '冬堡学院', description: '魔法学院', x: 550, y: 100, discovered: false, type: 'landmark' },
+ { id: 'thieves_guild', name: '盗贼公会', description: '盗贼的地下总部', x: 560, y: 440, discovered: false, type: 'dungeon' },
+ ];
+ }
+
+ show(): void {
+ this.isOpen = true;
+ this.container.style.display = 'block';
+ this.render();
+ document.body.appendChild(this.container);
+ }
+
+ hide(): void {
+ this.isOpen = false;
+ this.container.style.display = 'none';
+ }
+
+ toggle(): void {
+ if (this.isOpen) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
+ discoverLocation(locationId: string): void {
+ const location = this.locations.find((l) => l.id === locationId);
+ if (location) {
+ location.discovered = true;
+ }
+ }
+
+ private render(): void {
+ this.container.innerHTML = `
+
+ `;
+
+ this.renderMap();
+ this.renderLocationList();
+
+ document.getElementById('close-map')?.addEventListener('click', () => this.hide());
+ }
+
+ private renderMap(): void {
+ const canvas = document.getElementById('map-canvas') as HTMLCanvasElement;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ /* Background */
+ const grad = ctx.createRadialGradient(350, 250, 50, 350, 250, 400);
+ grad.addColorStop(0, '#1a1828');
+ grad.addColorStop(1, '#0e0c16');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ /* Grid */
+ ctx.strokeStyle = 'rgba(74,106,30,0.08)';
+ ctx.lineWidth = 1;
+ for (let x = 0; x < canvas.width; x += 50) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, canvas.height);
+ ctx.stroke();
+ }
+ for (let y = 0; y < canvas.height; y += 50) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(canvas.width, y);
+ ctx.stroke();
+ }
+
+ /* Roads */
+ const roads = [
+ { from: 'whiterun', to: 'whiterun_exterior' },
+ { from: 'whiterun_exterior', to: 'bleakfalls_barrow' },
+ { from: 'whiterun', to: 'riverwood' },
+ { from: 'whiterun', to: 'windhelm' },
+ { from: 'whiterun', to: 'solitude' },
+ { from: 'whiterun', to: 'riften' },
+ { from: 'whiterun', to: 'markarth' },
+ ];
+
+ ctx.strokeStyle = 'rgba(138,128,112,0.3)';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([6, 4]);
+ for (const road of roads) {
+ const from = this.locations.find((l) => l.id === road.from);
+ const to = this.locations.find((l) => l.id === road.to);
+ if (from && to && from.discovered && to.discovered) {
+ ctx.beginPath();
+ ctx.moveTo(from.x, from.y);
+ ctx.lineTo(to.x, to.y);
+ ctx.stroke();
+ }
+ }
+ ctx.setLineDash([]);
+
+ /* Locations */
+ for (const location of this.locations) {
+ if (!location.discovered) continue;
+
+ const color = this.getLocationColor(location.type);
+ const isSelected = this.selectedLocation?.id === location.id;
+
+ /* Glow for selected */
+ if (isSelected) {
+ ctx.fillStyle = color + '30';
+ ctx.beginPath();
+ ctx.arc(location.x, location.y, 16, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ /* Dot */
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(location.x, location.y, isSelected ? 7 : 5, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#f0e8d8';
+ ctx.lineWidth = isSelected ? 2 : 1;
+ ctx.stroke();
+
+ /* Label */
+ ctx.fillStyle = isSelected ? '#f0e8d8' : '#a09888';
+ ctx.font = `${isSelected ? 'bold ' : ''}11px "${FONT.body}"`;
+ ctx.textAlign = 'center';
+ ctx.fillText(location.name, location.x, location.y + 18);
+ }
+
+ /* Click handler */
+ canvas.onclick = (e) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ for (const location of this.locations) {
+ if (!location.discovered) continue;
+ const dx = x - location.x;
+ const dy = y - location.y;
+ if (Math.sqrt(dx * dx + dy * dy) <= 15) {
+ this.selectedLocation = location;
+ this.renderMap();
+ this.renderLocationDetails();
+ break;
+ }
+ }
+ };
+ }
+
+ private getLocationColor(type: MapLocation['type']): string {
+ const colors: Record = {
+ city: T.textGold,
+ town: T.enchanting,
+ village: T.success,
+ dungeon: T.danger,
+ camp: T.smithing,
+ landmark: '#8060c0',
+ };
+ return colors[type] || '#ffffff';
+ }
+
+ private renderLocationList(): void {
+ const list = document.getElementById('location-list');
+ if (!list) return;
+
+ list.innerHTML = '';
+
+ const discovered = this.locations.filter((l) => l.discovered);
+ for (const location of discovered) {
+ const item = document.createElement('div');
+ const isSelected = this.selectedLocation?.id === location.id;
+ item.style.cssText = `
+ padding: 8px 10px;
+ background: ${isSelected ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.02)'};
+ border: 1px solid ${isSelected ? T.borderBronze : T.borderDark};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: all 0.15s;
+ `;
+ item.innerHTML = `
+ ${location.name}
+ ${location.description}
+ `;
+ item.addEventListener('mouseenter', () => {
+ if (!isSelected) item.style.borderColor = T.borderBronze;
+ });
+ item.addEventListener('mouseleave', () => {
+ if (!isSelected) item.style.borderColor = T.borderDark;
+ });
+ item.addEventListener('click', () => {
+ this.selectedLocation = location;
+ this.renderLocationList();
+ this.renderLocationDetails();
+ this.renderMap();
+ });
+ list.appendChild(item);
+ }
+ }
+
+ private renderLocationDetails(): void {
+ const details = document.getElementById('location-details');
+ if (!details || !this.selectedLocation) {
+ if (details) details.innerHTML = `点击地图或列表选择地点
`;
+ return;
+ }
+
+ const loc = this.selectedLocation;
+ details.innerHTML = `
+
+
${loc.name}
+
${loc.description}
+
类型: ${loc.type}
+
+
+ `;
+
+ document.getElementById('fast-travel-btn')?.addEventListener('click', () => {
+ this.fastTravel(loc.id);
+ });
+ }
+
+ private fastTravel(locationId: string): void {
+ eventBus.emit('world:fastTravel', { locationId });
+ this.hide();
+ }
+
+ getDiscoveredLocations(): MapLocation[] {
+ return this.locations.filter((l) => l.discovered);
+ }
+
+ isLocationDiscovered(locationId: string): boolean {
+ const location = this.locations.find((l) => l.id === locationId);
+ return location?.discovered || false;
+ }
+}
+
+export const worldMapUI = WorldMapUI.getInstance();
diff --git a/src/ui/theme.ts b/src/ui/theme.ts
new file mode 100644
index 0000000..e9e91ec
--- /dev/null
+++ b/src/ui/theme.ts
@@ -0,0 +1,295 @@
+/**
+ * Elder Scrolls UI Theme — 深色石纹 + 羊皮纸 + 金属边框
+ * 全局样式常量和 CSS 注入,所有 UI 组件共用。
+ */
+
+/* ── 色彩系统 ──────────────────────────────────── */
+export const T = {
+ /* 背景层级 */
+ bgVoid: '#0a0a0f',
+ bgStone: '#1a1a24',
+ bgStoneLight: '#22222e',
+ bgPanel: '#1e1c2a',
+ bgParchment: '#2a2420',
+ bgParchLight: '#352e28',
+ bgOverlay: 'rgba(5,5,10,0.92)',
+
+ /* 金属边框 */
+ borderBronze: '#7a6530',
+ borderGold: '#b8960c',
+ borderCopper: '#8a5a2a',
+ borderIron: '#4a4a55',
+ borderDark: '#2a2a33',
+
+ /* 文字 */
+ textGold: '#d4a843',
+ textGoldBright:'#f0c850',
+ textLight: '#d8d0c0',
+ textMuted: '#8a8070',
+ textDim: '#5a5550',
+ textWhite: '#f0e8d8',
+
+ /* 功能色 */
+ healthRed: '#c42020',
+ healthRedDark: '#8a1515',
+ magickaBlue: '#2060c4',
+ magickaBlueDk: '#153a80',
+ staminaGreen: '#20a040',
+ staminaGreenDk:'#156a2a',
+ goldAccent: '#d4a843',
+
+ /* 学派 / 类型 */
+ alchemy: '#4a9a4a',
+ enchanting: '#5080c8',
+ smithing: '#c87030',
+ magic: '#7050c0',
+ danger: '#c83030',
+ success: '#40a060',
+} as const;
+
+/* ── 字体 ──────────────────────────────────────── */
+export const FONT = {
+ body: '"Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif',
+ title: '"Georgia", "Times New Roman", serif',
+} as const;
+
+/* ── 通用样式片段 ───────────────────────────────── */
+
+export const panelStyle = `
+ background: ${T.bgPanel};
+ border: 2px solid ${T.borderBronze};
+ border-radius: 4px;
+ box-shadow: 0 0 12px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.3);
+`;
+
+export const panelStyleLight = `
+ background: ${T.bgStoneLight};
+ border: 1px solid ${T.borderIron};
+ border-radius: 3px;
+`;
+
+export const goldTitleStyle = `
+ color: ${T.textGold};
+ font-family: ${FONT.title};
+ font-size: 20px;
+ letter-spacing: 1px;
+ text-shadow: 0 0 6px rgba(212,168,67,0.3);
+ margin: 0;
+`;
+
+export const btnStyle = `
+ padding: 8px 18px;
+ font-family: ${FONT.body};
+ font-size: 13px;
+ color: ${T.textLight};
+ background: linear-gradient(180deg, #3a3540 0%, #2a2530 100%);
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
+`;
+
+export const btnPrimaryStyle = `
+ ${btnStyle}
+ background: linear-gradient(180deg, #5a4a20 0%, #3a3010 100%);
+ border-color: ${T.borderGold};
+ color: ${T.textGoldBright};
+`;
+
+export const btnDangerStyle = `
+ ${btnStyle}
+ background: linear-gradient(180deg, #5a2020 0%, #3a1515 100%);
+ border-color: #8a3030;
+ color: #e0a0a0;
+`;
+
+export const btnSuccessStyle = `
+ ${btnStyle}
+ background: linear-gradient(180deg, #1a4a2a 0%, #103020 100%);
+ border-color: #407050;
+ color: #a0d0a0;
+`;
+
+export const scrollbarStyle = `
+ ::-webkit-scrollbar { width: 8px; }
+ ::-webkit-scrollbar-track { background: ${T.bgStone}; }
+ ::-webkit-scrollbar-thumb { background: ${T.borderBronze}; border-radius: 4px; }
+ ::-webkit-scrollbar-thumb:hover { background: ${T.borderGold}; }
+`;
+
+/* ── 注入全局 CSS ──────────────────────────────── */
+
+let injected = false;
+
+export function injectGlobalTheme(): void {
+ if (injected) return;
+ injected = true;
+
+ const style = document.createElement('style');
+ style.id = 'oes-global-theme';
+ style.textContent = `
+ /* Reset */
+ *, *::before, *::after { box-sizing: border-box; }
+
+ body {
+ margin: 0;
+ padding: 0;
+ background: ${T.bgVoid};
+ font-family: ${FONT.body};
+ color: ${T.textLight};
+ -webkit-font-smoothing: antialiased;
+ }
+
+ /* Global scrollbar */
+ ${scrollbarStyle}
+
+ /* ── HUD bars ───────────────────────────────── */
+ .oes-bar-outer {
+ position: relative;
+ height: 22px;
+ background: rgba(0,0,0,0.75);
+ border: 1px solid ${T.borderIron};
+ border-radius: 2px;
+ overflow: hidden;
+ box-shadow: inset 0 1px 4px rgba(0,0,0,0.6), 0 1px 4px rgba(0,0,0,0.3);
+ }
+ .oes-bar-fill {
+ height: 100%;
+ transition: width 0.2s ease;
+ }
+ .oes-bar-fill--health { background: linear-gradient(180deg, ${T.healthRed} 0%, ${T.healthRedDark} 100%); }
+ .oes-bar-fill--magicka { background: linear-gradient(180deg, ${T.magickaBlue} 0%, ${T.magickaBlueDk} 100%); }
+ .oes-bar-fill--stamina { background: linear-gradient(180deg, ${T.staminaGreen} 0%, ${T.staminaGreenDk} 100%); }
+ .oes-bar-text {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${T.textWhite};
+ font-size: 11px;
+ font-weight: 600;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ pointer-events: none;
+ }
+
+ /* ── Panel ornaments ────────────────────────── */
+ .oes-panel {
+ ${panelStyle}
+ padding: 20px;
+ }
+ .oes-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 12px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid ${T.borderBronze};
+ }
+ .oes-panel-title {
+ ${goldTitleStyle}
+ }
+
+ /* ── Buttons ────────────────────────────────── */
+ .oes-btn { ${btnStyle} }
+ .oes-btn:hover { border-color: ${T.borderGold}; background: linear-gradient(180deg, #4a4550 0%, #3a3540 100%); }
+ .oes-btn:active { transform: translateY(1px); }
+ .oes-btn--primary { ${btnPrimaryStyle} }
+ .oes-btn--primary:hover { border-color: ${T.textGoldBright}; }
+ .oes-btn--danger { ${btnDangerStyle} }
+ .oes-btn--danger:hover { border-color: #c84040; }
+ .oes-btn--success { ${btnSuccessStyle} }
+ .oes-btn--success:hover { border-color: #60a070; }
+
+ /* ── Notification toast ─────────────────────── */
+ .oes-toast {
+ position: fixed;
+ top: 60px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 10px 28px;
+ background: linear-gradient(180deg, #2a2420 0%, #1a1510 100%);
+ border: 1px solid ${T.borderGold};
+ border-radius: 3px;
+ color: ${T.textGoldBright};
+ font-size: 15px;
+ font-family: ${FONT.title};
+ letter-spacing: 0.5px;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.6);
+ z-index: 9999;
+ pointer-events: none;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5), 0 0 15px rgba(212,168,67,0.15);
+ animation: oesToast 2.5s ease forwards;
+ }
+ @keyframes oesToast {
+ 0% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
+ 12% { opacity: 1; transform: translateX(-50%) translateY(0); }
+ 75% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+
+ /* ── Interact prompt ────────────────────────── */
+ .oes-prompt {
+ position: fixed;
+ bottom: 110px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 22px;
+ background: linear-gradient(180deg, #2a2420 0%, #1a1510 100%);
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ color: ${T.textGold};
+ font-size: 14px;
+ font-family: ${FONT.body};
+ text-shadow: 0 1px 3px rgba(0,0,0,0.6);
+ pointer-events: none;
+ z-index: 200;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.4);
+ letter-spacing: 0.3px;
+ }
+
+ /* ── Input fields ───────────────────────────── */
+ .oes-input {
+ padding: 10px 16px;
+ font-size: 16px;
+ font-family: ${FONT.body};
+ color: ${T.textWhite};
+ background: rgba(0,0,0,0.4);
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ outline: none;
+ transition: border-color 0.2s;
+ }
+ .oes-input:focus {
+ border-color: ${T.borderGold};
+ box-shadow: 0 0 8px rgba(212,168,67,0.2);
+ }
+ .oes-input::placeholder {
+ color: ${T.textDim};
+ }
+
+ /* ── Divider ────────────────────────────────── */
+ .oes-divider {
+ border: none;
+ border-top: 1px solid ${T.borderBronze};
+ margin: 12px 0;
+ opacity: 0.6;
+ }
+
+ /* ── Tooltip / hover card ───────────────────── */
+ .oes-tooltip {
+ padding: 10px 14px;
+ background: ${T.bgStone};
+ border: 1px solid ${T.borderBronze};
+ border-radius: 3px;
+ color: ${T.textLight};
+ font-size: 13px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
+ pointer-events: none;
+ z-index: 1000;
+ max-width: 280px;
+ }
+ `;
+ document.head.appendChild(style);
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c20051d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "es2023",
+ "module": "esnext",
+ "lib": ["ES2023", "DOM"],
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Strict Mode */
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..b31b820
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ },
+ },
+});