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 EgameOES-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 EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-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 EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods b/public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-WEBpublicdatamods new file mode 100644 index 0000000..d5fde4f --- /dev/null +++ b/public/data/mods && cp EgameOES-WEBsrcdatamodsexample-quest-mod.json EgameOES-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'], + }, + }, +});