From 86ceec98b3e6fc96f5b9f48c3720abd760d8bfe0 Mon Sep 17 00:00:00 2001 From: Evilom <33251918+Evilom@users.noreply.github.com> Date: Wed, 13 May 2026 21:17:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:mod=E7=AE=A1=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- public/data/mods/example-armor-mod.json | 54 ++ public/data/mods/example-spell-mod.json | 50 +- src/core/EntityManager.ts | 17 +- src/data/DataRegistry.ts | 26 + src/data/dialogue/trees.json | 38 + src/data/enemies/enemies.json | 36 +- src/data/quests/quests.json | 16 + src/data/skills/perks.json | 90 +- src/main.ts | 59 +- src/scenes/GameScene.ts | 1107 +++++++++++++++++++++-- src/systems/CombatSystem.ts | 51 +- src/systems/DialogueSystem.ts | 5 +- src/systems/InventorySystem.ts | 30 +- src/systems/MovementSystem.ts | 16 +- src/systems/PerkSystem.ts | 91 ++ src/systems/ProximitySystem.ts | 12 +- src/systems/QuestSystem.ts | 42 +- src/ui/UIManager.ts | 416 ++++++--- src/ui/components/CharacterInfoUI.ts | 168 ++++ src/ui/components/CombatUI.ts | 75 +- src/ui/components/CompassHUD.ts | 266 ++++-- src/ui/components/CraftingUI.ts | 3 + src/ui/components/DialogueUI.ts | 8 + src/ui/components/FavoritesUI.ts | 257 ++++++ src/ui/components/LevelUpUI.ts | 138 +++ src/ui/components/LockpickingUI.ts | 249 +++++ src/ui/components/LootUI.ts | 192 ++++ src/ui/components/MagicUI.ts | 3 + src/ui/components/MinimapHUD.ts | 192 ++++ src/ui/components/ModManagerUI.ts | 7 + src/ui/components/PauseMenuUI.ts | 144 +++ src/ui/components/QuestJournalUI.ts | 3 + src/ui/components/QuestTrackerHUD.ts | 139 +++ src/ui/components/QuickbarHUD.ts | 180 ++++ src/ui/components/RadialMenuUI.ts | 49 + src/ui/components/SettingsUI.ts | 290 ++++++ src/ui/components/ShopUI.ts | 251 +++++ src/ui/components/SkillTreeUI.ts | 29 +- src/ui/components/TitleMenuUI.ts | 128 +++ src/ui/components/WorldMapUI.ts | 3 + 41 files changed, 4533 insertions(+), 400 deletions(-) create mode 100644 public/data/mods/example-armor-mod.json create mode 100644 src/systems/PerkSystem.ts create mode 100644 src/ui/components/CharacterInfoUI.ts create mode 100644 src/ui/components/FavoritesUI.ts create mode 100644 src/ui/components/LevelUpUI.ts create mode 100644 src/ui/components/LockpickingUI.ts create mode 100644 src/ui/components/LootUI.ts create mode 100644 src/ui/components/MinimapHUD.ts create mode 100644 src/ui/components/PauseMenuUI.ts create mode 100644 src/ui/components/QuestTrackerHUD.ts create mode 100644 src/ui/components/QuickbarHUD.ts create mode 100644 src/ui/components/SettingsUI.ts create mode 100644 src/ui/components/ShopUI.ts create mode 100644 src/ui/components/TitleMenuUI.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cac7ccd..3e11695 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(npx tsc *)", - "Bash(npx vitest *)" + "Bash(npx vitest *)", + "WebSearch" ] } } diff --git a/public/data/mods/example-armor-mod.json b/public/data/mods/example-armor-mod.json new file mode 100644 index 0000000..444821a --- /dev/null +++ b/public/data/mods/example-armor-mod.json @@ -0,0 +1,54 @@ +{ + "manifest": { + "id": "example-armor", + "name": "示例护甲包", + "version": "1.0.0", + "author": "OES-WEB", + "description": "添加 3 套新护甲到游戏中,用于测试 Mod 系统", + "priority": 150, + "dependencies": [] + }, + "data": { + "armor": { + "flame_plate": { + "id": "flame_plate", + "name": "烈焰板甲", + "type": "armor", + "subtype": "heavy", + "material": "daedric", + "tier": 5, + "armor": 40, + "weight": 35, + "value": 800, + "keywords": ["heavy", "fire", "daedric"], + "description": "由魔族锻造的烈焰板甲,散发着灼热的气息" + }, + "frost_cloak": { + "id": "frost_cloak", + "name": "冰霜斗篷", + "type": "armor", + "subtype": "light", + "material": "moonstone", + "tier": 3, + "armor": 20, + "weight": 5, + "value": 400, + "keywords": ["light", "frost", "cloak"], + "description": "散发寒气的精致斗篷" + }, + "shadow_mask": { + "id": "shadow_mask", + "name": "暗影面具", + "type": "armor", + "subtype": "head", + "material": "ebony", + "tier": 4, + "armor": 15, + "weight": 2, + "value": 600, + "keywords": ["light", "head", "shadow"], + "description": "增加潜行能力的神秘面具" + } + } + } +} diff --git a/public/data/mods/example-spell-mod.json b/public/data/mods/example-spell-mod.json index 3e5481d..5d326a4 100644 --- a/public/data/mods/example-spell-mod.json +++ b/public/data/mods/example-spell-mod.json @@ -4,13 +4,51 @@ "name": "自定义法术包", "version": "1.0.0", "author": "OES-WEB", - "description": "添加自定义法术,展示脚本化法术系统", + "description": "添加 3 个自定义法术到游戏中,用于测试 Mod 系统", "priority": 300, - "dependencies": [], - "scripts": ["spells.js"] + "dependencies": [] }, - "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');" + "data": { + "spells": { + "chain_lightning": { + "id": "chain_lightning", + "name": "连锁闪电", + "school": "destruction", + "type": "target", + "magickaCost": 35, + "magnitude": 20, + "duration": 0, + "cooldown": 1200, + "level": 5, + "description": "发射闪电,造成电击伤害", + "effects": [{ "type": "damage", "magnitude": 20 }] + }, + "blood_heal": { + "id": "blood_heal", + "name": "血疗术", + "school": "restoration", + "type": "self", + "magickaCost": 25, + "magnitude": 40, + "duration": 0, + "cooldown": 2000, + "level": 3, + "description": "消耗少量生命值来治疗自身", + "effects": [{ "type": "heal", "magnitude": 40 }] + }, + "shadow_cloak": { + "id": "shadow_cloak", + "name": "暗影斗篷", + "school": "illusion", + "type": "self", + "magickaCost": 30, + "magnitude": 0, + "duration": 30, + "cooldown": 10000, + "level": 4, + "description": "隐入暗影,提高潜行能力", + "effects": [{ "type": "sneak_bonus", "magnitude": 30, "duration": 30000 }] + } + } } } diff --git a/src/core/EntityManager.ts b/src/core/EntityManager.ts index b79dadf..3e318be 100644 --- a/src/core/EntityManager.ts +++ b/src/core/EntityManager.ts @@ -18,6 +18,7 @@ export interface Entity { export class EntityManager { private static instance: EntityManager; private entities: Map = new Map(); + private typeIndex: Map> = new Map(); private nextId: number = 0; static getInstance(): EntityManager { @@ -36,6 +37,9 @@ export class EntityManager { active: true, }; this.entities.set(id, entity); + let idx = this.typeIndex.get(type); + if (!idx) { idx = new Set(); this.typeIndex.set(type, idx); } + idx.add(id); eventBus.emit('entity:created', { entity }); return entity; } @@ -44,6 +48,7 @@ export class EntityManager { const entity = this.entities.get(id); if (entity) { entity.active = false; + this.typeIndex.get(entity.type)?.delete(id); this.entities.delete(id); eventBus.emit('entity:destroyed', { entity }); } @@ -54,9 +59,14 @@ export class EntityManager { } getEntitiesByType(type: EntityType): Entity[] { - return Array.from(this.entities.values()).filter( - (e) => e.type === type && e.active - ); + const ids = this.typeIndex.get(type); + if (!ids || ids.size === 0) return []; + const result: Entity[] = []; + for (const id of ids) { + const e = this.entities.get(id); + if (e?.active) result.push(e); + } + return result; } getAllEntities(): Entity[] { @@ -95,6 +105,7 @@ export class EntityManager { clear(): void { this.entities.clear(); + this.typeIndex.clear(); this.nextId = 0; } } diff --git a/src/data/DataRegistry.ts b/src/data/DataRegistry.ts index 3cbc4ed..b2941bc 100644 --- a/src/data/DataRegistry.ts +++ b/src/data/DataRegistry.ts @@ -46,6 +46,11 @@ export interface ItemData { effects: DataEffect[]; enchantment?: unknown; keywords?: string[]; + // Visual representation (for 2D top-down rendering) + twoHanded?: boolean; + visualColor?: number; + weaponLength?: number; + weaponWidth?: number; } export interface EnemyLootTable { @@ -82,6 +87,13 @@ export interface EnemyData { loot?: EnemyLootTable; abilities?: EnemyAbility[]; aiBehavior?: 'melee' | 'ranged' | 'caster' | 'mixed'; + // Attack animation config (moddable) + attackAnimation?: { + type?: 'lunge' | 'swing' | 'thrust' | 'bite'; + reach?: number; + duration?: number; + arc?: number; + }; } export interface SpellData { @@ -147,6 +159,13 @@ export interface PerkData { requiresPerkPoints?: number; skill: string; skillLevel: number; + effects?: PerkEffect[]; +} + +export interface PerkEffect { + type: string; + value: number; + school?: string; } export interface PerkTreeData { @@ -1291,6 +1310,13 @@ function normalizePerkTree(id: string, value: UnknownRecord): PerkTreeData | nul requiresPerkPoints: readOptionalNumber(p, 'requiresPerkPoints'), skill: readString(p, 'skill', id), skillLevel: readNumber(p, 'skillLevel', 0), + effects: Array.isArray(p.effects) + ? p.effects.filter(isRecord).map((e) => ({ + type: readString(e, 'type', ''), + value: readNumber(e, 'value', 0), + school: readOptionalString(e, 'school'), + })).filter((e) => e.type) + : undefined, })).filter((p) => p.id) : []; diff --git a/src/data/dialogue/trees.json b/src/data/dialogue/trees.json index 25f7614..370124e 100644 --- a/src/data/dialogue/trees.json +++ b/src/data/dialogue/trees.json @@ -191,6 +191,44 @@ } }, "startLineId": "start" + }, + "riverwood_elder": { + "id": "riverwood_elder", + "npcId": "riverwood_elder", + "lines": { + "start": { + "id": "start", + "speaker": "镇长", + "text": "欢迎来到溪木镇。这里虽然小,但很安宁。", + "options": [ + { "id": "about_town", "text": "关于这个镇子", "nextLineId": "about_town" }, + { "id": "about_danger", "text": "附近有什么危险吗?", "nextLineId": "danger" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_town": { + "id": "about_town", + "speaker": "镇长", + "text": "溪木镇靠河而建,居民多是伐木工和猎人。我们有个铁匠和商人,你需要什么可以去找他们。", + "options": [ + { "id": "back", "text": "明白了", "nextLineId": "start" } + ] + }, + "danger": { + "id": "danger", + "speaker": "镇长", + "text": "北边的荒瀑古坟最近有些不安宁,听说有尸鬼出没。南边的洞穴里也有蜘蛛。出门要小心。", + "options": [ + { "id": "back", "text": "谢谢提醒", "nextLineId": "start" } + ] + }, + "end": { + "id": "end", + "speaker": "镇长", + "text": "保重,旅人。" + } + }, + "startLineId": "start" } } } diff --git a/src/data/enemies/enemies.json b/src/data/enemies/enemies.json index 42aeb2f..5558fff 100644 --- a/src/data/enemies/enemies.json +++ b/src/data/enemies/enemies.json @@ -20,7 +20,8 @@ ] }, "color": "#cc3333", - "size": 24 + "size": 24, + "attackAnimation": { "type": "lunge", "reach": 20, "duration": 150 } }, "wolf": { "id": "wolf", @@ -50,7 +51,8 @@ ] }, "color": "#888888", - "size": 20 + "size": 20, + "attackAnimation": { "type": "bite", "reach": 18, "duration": 120 } }, "skeleton": { "id": "skeleton", @@ -72,7 +74,8 @@ ] }, "color": "#ccccaa", - "size": 24 + "size": 24, + "attackAnimation": { "type": "swing", "reach": 22, "duration": 180 } }, "draugr": { "id": "draugr", @@ -112,7 +115,8 @@ ] }, "color": "#556644", - "size": 26 + "size": 26, + "attackAnimation": { "type": "swing", "reach": 24, "duration": 180 } }, "draugr_wight": { "id": "draugr_wight", @@ -151,7 +155,8 @@ ] }, "color": "#445533", - "size": 28 + "size": 28, + "attackAnimation": { "type": "swing", "reach": 26, "duration": 200 } }, "bear": { "id": "bear", @@ -184,7 +189,8 @@ ] }, "color": "#8B4513", - "size": 32 + "size": 32, + "attackAnimation": { "type": "bite", "reach": 28, "duration": 200 } }, "cave_bear": { "id": "cave_bear", @@ -225,7 +231,8 @@ ] }, "color": "#6B3410", - "size": 34 + "size": 34, + "attackAnimation": { "type": "bite", "reach": 30, "duration": 220 } }, "spider": { "id": "spider", @@ -259,7 +266,8 @@ ] }, "color": "#440044", - "size": 22 + "size": 22, + "attackAnimation": { "type": "bite", "reach": 20, "duration": 140 } }, "frostbite_spider": { "id": "frostbite_spider", @@ -301,7 +309,8 @@ ] }, "color": "#2244aa", - "size": 26 + "size": 26, + "attackAnimation": { "type": "bite", "reach": 22, "duration": 150 } }, "bandit_outlaw": { "id": "bandit_outlaw", @@ -332,7 +341,8 @@ ] }, "color": "#aa2222", - "size": 26 + "size": 26, + "attackAnimation": { "type": "lunge", "reach": 22, "duration": 160 } }, "bandit_thug": { "id": "bandit_thug", @@ -365,7 +375,8 @@ ] }, "color": "#992222", - "size": 28 + "size": 28, + "attackAnimation": { "type": "swing", "reach": 26, "duration": 200 } }, "necromancer": { "id": "necromancer", @@ -411,7 +422,8 @@ ] }, "color": "#6622aa", - "size": 24 + "size": 24, + "attackAnimation": { "type": "thrust", "reach": 20, "duration": 180 } } } } diff --git a/src/data/quests/quests.json b/src/data/quests/quests.json index 678c3e9..a1352c3 100644 --- a/src/data/quests/quests.json +++ b/src/data/quests/quests.json @@ -271,6 +271,22 @@ "xp": 400, "items": [{ "id": "steel_sword", "quantity": 1 }] } + }, + "lost_sword": { + "id": "lost_sword", + "name": "失落的圣剑", + "type": "side", + "level": 5, + "description": "铁匠哈蒙的祖传圣剑被强盗抢走了,藏在附近的洞穴中。", + "objectives": [ + { "id": "find_sword", "description": "在洞穴中找到圣剑", "type": "collect", "target": "iron_sword", "quantity": 1, "completed": false }, + { "id": "return_sword", "description": "将圣剑归还给铁匠", "type": "talk", "target": "blacksmith_01", "completed": false } + ], + "rewards": { + "gold": 200, + "xp": 150, + "items": [{ "id": "steel_sword", "quantity": 1 }] + } } } } diff --git a/src/data/skills/perks.json b/src/data/skills/perks.json index 26f0116..f544d81 100644 --- a/src/data/skills/perks.json +++ b/src/data/skills/perks.json @@ -3,41 +3,41 @@ "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 } + { "id": "oneHanded1", "name": "单手武器训练 I", "description": "单手武器伤害+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "meleeDamageBonus", "value": 0.20 }] }, + { "id": "oneHanded2", "name": "战斗愤怒", "description": "双持攻击速度+25%", "requires": ["oneHanded1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "attackSpeedBonus", "value": 0.25 }] }, + { "id": "oneHanded3", "name": "虎之撕裂", "description": "单手武器流血伤害", "requires": ["oneHanded1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "bleedDamage", "value": 5 }] }, + { "id": "oneHanded4", "name": "猛击", "description": "强力攻击伤害+50%", "requires": ["oneHanded2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "powerAttackDamageBonus", "value": 0.50 }] }, + { "id": "oneHanded5", "name": "致命一击", "description": "暴击伤害+50%", "requires": ["oneHanded3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "critDamageBonus", "value": 0.50 }] } ] }, "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 } + { "id": "destruction1", "name": "毁灭训练 I", "description": "毁灭法术伤害+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "spellDamageBonus", "value": 0.20, "school": "destruction" }] }, + { "id": "destration2", "name": "冲击波", "description": "双持施法击退敌人", "requires": ["destruction1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "knockbackOnDualCast", "value": 1 }] }, + { "id": "destruction3", "name": "火焰大师", "description": "火焰伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "spellDamageBonus", "value": 0.50, "school": "fire" }] }, + { "id": "destruction4", "name": "冰霜大师", "description": "冰霜伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "spellDamageBonus", "value": 0.50, "school": "frost" }] }, + { "id": "destruction5", "name": "闪电大师", "description": "闪电伤害+50%", "requires": ["destruction1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "spellDamageBonus", "value": 0.50, "school": "shock" }] } ] }, "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 } + { "id": "sneak1", "name": "潜行训练 I", "description": "被发现几率-20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "stealthBonus", "value": 0.20 }] }, + { "id": "sneak2", "name": "静步", "description": "移动噪音-50%", "requires": ["sneak1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "noiseMultiplier", "value": -0.50 }] }, + { "id": "sneak3", "name": "轻脚", "description": "不触发压力板", "requires": ["sneak1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "trapImmunity", "value": 1 }] }, + { "id": "sneak4", "name": "暗影战士", "description": "蹲下短暂隐身", "requires": ["sneak2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "crouchInvisibility", "value": 1 }] }, + { "id": "sneak5", "name": "刺客之刃", "description": "匕首潜行攻击x15", "requires": ["sneak3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "sneakAttackMultiplier", "value": 15 }] } ] }, "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 } + { "id": "restoration1", "name": "恢复训练 I", "description": "恢复法术效果+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "spellDamageBonus", "value": 0.20, "school": "restoration" }] }, + { "id": "restoration2", "name": "快速施法", "description": "恢复法术施法速度+25%", "requires": ["restoration1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "castSpeedBonus", "value": 0.25, "school": "restoration" }] }, + { "id": "restoration3", "name": "反击", "description": "格挡时恢复魔力", "requires": ["restoration1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "blockMagickaRestore", "value": 5 }] }, + { "id": "restoration4", "name": "亡灵杀手", "description": "对亡灵伤害+50%", "requires": ["restoration2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "damageBonusVsUndead", "value": 0.50 }] }, + { "id": "restoration5", "name": "符文大师", "description": "符文持续时间+50%", "requires": ["restoration3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "runeDurationBonus", "value": 0.50 }] } ] }, "smithing": { @@ -55,51 +55,51 @@ "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 } + { "id": "twoHanded1", "name": "双手武器训练 I", "description": "双手武器伤害+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "meleeDamageBonus", "value": 0.20 }] }, + { "id": "twoHanded2", "name": "横扫千军", "description": "双手武器攻击范围+25%", "requires": ["twoHanded1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "attackRangeBonus", "value": 0.25 }] }, + { "id": "twoHanded3", "name": "破甲重击", "description": "无视30%护甲", "requires": ["twoHanded1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "armorPenetration", "value": 0.30 }] }, + { "id": "twoHanded4", "name": "震地猛击", "description": "强力攻击击倒敌人", "requires": ["twoHanded2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "powerAttackKnockdown", "value": 1 }] }, + { "id": "twoHanded5", "name": "处决者", "description": "低生命敌人即死", "requires": ["twoHanded3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "executeLowHp", "value": 0.20 }] } ] }, "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 } + { "id": "archery1", "name": "弓术训练 I", "description": "弓箭伤害+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "rangedDamageBonus", "value": 0.20 }] }, + { "id": "archery2", "name": "精准射击", "description": "弓箭暴击率+15%", "requires": ["archery1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "critChanceBonus", "value": 0.15 }] }, + { "id": "archery3", "name": "快速拉弦", "description": "射速+25%", "requires": ["archery1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "attackSpeedBonus", "value": 0.25 }] }, + { "id": "archery4", "name": "鹰眼", "description": "瞄准时减速时间延长", "requires": ["archery2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "zoomDurationBonus", "value": 0.50 }] }, + { "id": "archery5", "name": "致命射击", "description": "远程暴击伤害x3", "requires": ["archery3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "critDamageBonus", "value": 2.00 }] } ] }, "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 } + { "id": "block1", "name": "格挡训练 I", "description": "格挡减伤+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "blockDamageReduction", "value": 0.20 }] }, + { "id": "block2", "name": "盾牌猛击", "description": "盾牌攻击击退敌人", "requires": ["block1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "shieldBashKnockback", "value": 1 }] }, + { "id": "block3", "name": "不动如山", "description": "格挡不消耗耐力", "requires": ["block1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "blockNoStaminaCost", "value": 1 }] }, + { "id": "block4", "name": "反射投射", "description": "格挡反弹弓箭", "requires": ["block2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "reflectProjectile", "value": 1 }] }, + { "id": "block5", "name": "完美格挡", "description": "精确时机格挡免伤", "requires": ["block3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "perfectBlockFullReduction", "value": 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 } + { "id": "heavyArmor1", "name": "重甲训练 I", "description": "重甲防御+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "armorRatingBonus", "value": 0.20 }] }, + { "id": "heavyArmor2", "name": "钢铁之肤", "description": "重甲不减速", "requires": ["heavyArmor1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "armorSpeedPenaltyRemoval", "value": 1 }] }, + { "id": "heavyArmor3", "name": "坚韧", "description": "重甲损坏率-50%", "requires": ["heavyArmor1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "armorConditionBonus", "value": 0.50 }] }, + { "id": "heavyArmor4", "name": "铁壁", "description": "受到攻击时反弹伤害", "requires": ["heavyArmor2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "damageReflect", "value": 0.10 }] }, + { "id": "heavyArmor5", "name": "不屈", "description": "濒死时短暂无敌", "requires": ["heavyArmor3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "deathSaveInvulnerability", "value": 3 }] } ] }, "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 } + { "id": "lightArmor1", "name": "轻甲训练 I", "description": "轻甲防御+20%", "requires": [], "rank": 1, "maxRank": 5, "effects": [{ "type": "armorRatingBonus", "value": 0.20 }] }, + { "id": "lightArmor2", "name": "灵活身法", "description": "轻甲增加移动速度", "requires": ["lightArmor1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "speedMultiplier", "value": 0.10 }] }, + { "id": "lightArmor3", "name": "闪避", "description": "10%几率完全闪避攻击", "requires": ["lightArmor1"], "rank": 1, "maxRank": 1, "effects": [{ "type": "dodgeChance", "value": 0.10 }] }, + { "id": "lightArmor4", "name": "疾风步", "description": "被攻击后短暂加速", "requires": ["lightArmor2"], "rank": 1, "maxRank": 1, "effects": [{ "type": "hitSpeedBoost", "value": 0.30 }] }, + { "id": "lightArmor5", "name": "影舞者", "description": "闪避后下一次攻击暴击", "requires": ["lightArmor3"], "rank": 1, "maxRank": 1, "effects": [{ "type": "dodgeNextAttackCrit", "value": 1 }] } ] }, "conjuration": { diff --git a/src/main.ts b/src/main.ts index bf956ee..01cc644 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,15 +4,54 @@ import { injectGlobalTheme } from './ui/theme'; import './ui/UIManager'; import { modManager } from './mods/ModManager'; import { dataRegistry } from './data/DataRegistry'; +import { titleMenuUI } from './ui/components/TitleMenuUI'; +import { eventBus } from './core/EventBus'; injectGlobalTheme(); +let gameStarted = false; + async function initGame(): Promise { modManager.loadConfig(); await loadExampleMods(); await dataRegistry.loadAll(); + // Show title menu instead of starting game directly + titleMenuUI.show(); + + // Listen for title menu actions + eventBus.on('game:newGame', () => { + startGame(); + }); + + eventBus.on('game:continue', () => { + startGame(); + }); + + eventBus.on('game:loadMenu', () => { + // For now, just start game - full load menu would show save list + startGame(); + }); + + // Handle resize (debounced) + let resizeTimer: ReturnType; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const game = gameManager.getGame(); + if (game) { + game.scale.resize(window.innerWidth, window.innerHeight); + } + }, 100); + }); + +} + +function startGame(): void { + if (gameStarted) return; + gameStarted = true; + gameManager.init( { width: window.innerWidth, @@ -22,15 +61,10 @@ async function initGame(): Promise { [GameScene] ); - // Handle resize - window.addEventListener('resize', () => { - const game = gameManager.getGame(); - if (game) { - game.scale.resize(window.innerWidth, window.innerHeight); - } + // Allow returning to title menu + eventBus.on('game:returnToMenu', () => { + gameStarted = false; }); - - console.log('OES-WEB initialized'); } async function loadExampleMods(): Promise { @@ -38,20 +72,21 @@ async function loadExampleMods(): Promise { '/data/mods/example-weapons-mod.json', '/data/mods/example-quest-mod.json', '/data/mods/base-scripts-mod.json', + '/data/mods/example-spell-mod.json', + '/data/mods/example-armor-mod.json', ]; - for (const url of mods) { + await Promise.all(mods.map(async (url) => { 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`); + // Mod not found, skip } - } + })); } initGame(); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 7fe9f63..4802754 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -31,11 +31,45 @@ import { radialMenuUI } from '../ui/components/RadialMenuUI'; import { compassHUD } from '../ui/components/CompassHUD'; import { T } from '../ui/theme'; import { skillTreeUI } from '../ui/components/SkillTreeUI'; +import { saveManager } from '../save/SaveManager'; +import { pauseMenuUI } from '../ui/components/PauseMenuUI'; +import { quickbarHUD } from '../ui/components/QuickbarHUD'; +import { shopUI } from '../ui/components/ShopUI'; +import { lootUI } from '../ui/components/LootUI'; +import { levelUpUI } from '../ui/components/LevelUpUI'; +import { questTrackerHUD } from '../ui/components/QuestTrackerHUD'; +import { characterInfoUI } from '../ui/components/CharacterInfoUI'; +import { settingsUI } from '../ui/components/SettingsUI'; +import { favoritesUI } from '../ui/components/FavoritesUI'; +import { lockpickingUI } from '../ui/components/LockpickingUI'; +import { dialogueUI } from '../ui/components/DialogueUI'; +import { modManagerUI } from '../ui/components/ModManagerUI'; +import { minimapHUD } from '../ui/components/MinimapHUD'; +import { perkSystem } from '../systems/PerkSystem'; +import { titleMenuUI } from '../ui/components/TitleMenuUI'; + +// Shared material color maps — used by updatePlayerVisual and showPlayerHitFlash +const MATERIAL_COLORS: Record = { + iron: 0x8a7d6b, steel: 0x9a9a9a, leather: 0x8b6c42, + corundum: 0x7a8a9a, orichalcum: 0x8a6a3a, moonstone: 0xc8c8a0, + elven: 0xc8b84a, orcish: 0x5a7a3a, ebony: 0x2a1a2a, + daedric: 0x8a1a1a, dragon: 0x3a5a6a, wood: 0x8b6c42, +}; +const ARMOR_STROKE: Record = { + iron: 0x6b6050, steel: 0x7a7a7a, leather: 0x6b4c22, + corundum: 0x5a6a7a, orichalcum: 0x6a4a1a, moonstone: 0xa8a880, + elven: 0xa89830, orcish: 0x3a5a1a, ebony: 0x1a0a1a, + daedric: 0x4a0a0a, dragon: 0x2a4a5a, +}; export class GameScene extends Phaser.Scene { private player!: Phaser.GameObjects.Rectangle; private playerIndicator!: Phaser.GameObjects.Arc; private playerShadow!: Phaser.GameObjects.Ellipse; + private leftHand!: Phaser.GameObjects.Arc; + private rightHand!: Phaser.GameObjects.Arc; + private leftWeapon!: Phaser.GameObjects.Rectangle; + private rightWeapon!: Phaser.GameObjects.Rectangle; 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; @@ -47,17 +81,36 @@ export class GameScene extends Phaser.Scene { private mapKey!: Phaser.Input.Keyboard.Key; private shoutKey!: Phaser.Input.Keyboard.Key; private craftingKey!: Phaser.Input.Keyboard.Key; + private characterInfoKey!: Phaser.Input.Keyboard.Key; + private favoritesKey!: Phaser.Input.Keyboard.Key; private transformKey!: Phaser.Input.Keyboard.Key; private journalKey!: Phaser.Input.Keyboard.Key; private magicKey!: Phaser.Input.Keyboard.Key; + private escapeKey!: Phaser.Input.Keyboard.Key; + private quickSaveKey!: Phaser.Input.Keyboard.Key; + private quickLoadKey!: Phaser.Input.Keyboard.Key; + private drawSheathKey!: Phaser.Input.Keyboard.Key; + private quickbarKeys!: 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; private isUIOpen: boolean = false; + private lastBarsUpdate: number = 0; + private lastEnemyBarsUpdate: number = 0; + private combatFeedbackContainer: HTMLDivElement; + + // Facing & attack animation + private facingAngle: number = 0; + private leftSwingTween: Phaser.Tweens.Tween | Phaser.Tweens.TweenChain | null = null; + private rightSwingTween: Phaser.Tweens.Tween | Phaser.Tweens.TweenChain | null = null; + private weaponDrawn: boolean = true; constructor() { super({ key: 'GameScene' }); + this.combatFeedbackContainer = document.createElement('div'); + this.combatFeedbackContainer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:160;'; + document.body.appendChild(this.combatFeedbackContainer); } create(): void { @@ -74,6 +127,9 @@ export class GameScene extends Phaser.Scene { void statusEffectHUD; void timeDisplayHUD; compassHUD.start(); + quickbarHUD.start(); + questTrackerHUD.start(); + minimapHUD.start(); radialMenuUI.registerItems([ { id: 'skills', label: '技能', key: '↑', color: T.textGold, action: () => skillTreeUI.toggle() }, @@ -85,6 +141,7 @@ export class GameScene extends Phaser.Scene { scriptSystem.initialize(); inventorySystem.initializeInventory(this.playerEntity); + perkSystem.initialize(this.playerEntity); inventorySystem.addItem(this.playerEntity, 'health_potion', 3); inventorySystem.addItem(this.playerEntity, 'iron_ingot', 10); inventorySystem.addItem(this.playerEntity, 'leather_strips', 5); @@ -97,6 +154,9 @@ export class GameScene extends Phaser.Scene { magicSystem.learnSpell(this.playerEntity, 'conjure_familiar'); this.loadZone('whiterun_exterior'); + + // Auto-start first main quest + eventBus.emit('quest:started', { questId: 'main_01_unbound', player: this.playerEntity }); } private createPlayer(): void { @@ -118,6 +178,22 @@ export class GameScene extends Phaser.Scene { this.playerIndicator.setStrokeStyle(2, 0x00ff66, 0.4); this.playerIndicator.setDepth(999); + // Left hand indicator (fist by default) + this.leftHand = this.add.circle(startX - 16, startY, 5, 0xddb888); + this.leftHand.setDepth(1002); + + // Right hand indicator (fist by default) + this.rightHand = this.add.circle(startX + 16, startY, 5, 0xddb888); + this.rightHand.setDepth(1002); + + // Weapon visuals — small rectangles extending from hands + this.leftWeapon = this.add.rectangle(startX - 16, startY, 3, 0, 0xaaaaaa); + this.leftWeapon.setDepth(1001); + this.leftWeapon.setVisible(false); + this.rightWeapon = this.add.rectangle(startX + 16, startY, 3, 0, 0xaaaaaa); + this.rightWeapon.setDepth(1001); + this.rightWeapon.setVisible(false); + 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 }); @@ -142,7 +218,10 @@ export class GameScene extends Phaser.Scene { eventBus.emit('player:created', { entity: this.playerEntity }); } - private loadZone(zoneId: string): void { + private loadZone(zoneId: string, savedPosition?: { x: number; y: number }): void { + // Zone transition fade + this.cameras.main.fadeOut(250, 0, 0, 0); + this.clearZone(); const zone = mapManager.loadZone(zoneId); if (!zone) return; @@ -151,19 +230,32 @@ export class GameScene extends Phaser.Scene { 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 px = savedPosition?.x ?? (zone.spawnPoint.x * zone.tileSize + zone.tileSize / 2); + const py = savedPosition?.y ?? (zone.spawnPoint.y * zone.tileSize + zone.tileSize / 2); + this.player.x = px; + this.player.y = py; + this.playerIndicator.x = px; + this.playerIndicator.y = py; const posComponent = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); - if (posComponent) { posComponent.x = spawnX; posComponent.y = spawnY; } + if (posComponent) { posComponent.x = px; posComponent.y = py; } worldMapUI.discoverLocation(zoneId); eventBus.emit('zone:entered', { zoneId, zone }); eventBus.emit('game:zoneChanged', { zoneId, zone }); + + const zoneNames: Record = { + whiterun: '白漫城', + whiterun_exterior: '白漫城 · 外围', + riverwood: '溪木镇', + bleakfalls_barrow: '荒瀑古坟', + darklight_cave: '暗光洞穴', + ancient_ruins: '古代遗迹', + skyrim_overworld: '天际省 · 荒野', + }; + compassHUD.showZoneName(zoneNames[zoneId] || zoneId); + + this.cameras.main.fadeIn(300, 0, 0, 0); } private clearZone(): void { @@ -236,7 +328,6 @@ export class GameScene extends Phaser.Scene { // 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); @@ -284,6 +375,7 @@ export class GameScene extends Phaser.Scene { if (typeof entityData.data.script === 'string') { entityManager.addComponent(enemy.id, { type: 'script', scriptId: entityData.data.script }); } + entityManager.addComponent(enemy.id, { type: 'shadow', sprite: shadow }); enemy.sprite = enemySprite; } else if (entityData.type === 'npc') { const cx = entityData.x * tileSize + tileSize / 2; @@ -299,7 +391,7 @@ export class GameScene extends Phaser.Scene { 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: 'npcData', entityId: entityData.id, 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 }); @@ -326,9 +418,25 @@ export class GameScene extends Phaser.Scene { 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.characterInfoKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.I); + this.favoritesKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Z); this.transformKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.T); this.journalKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J); this.magicKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.X); + this.escapeKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC); + this.quickSaveKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F5); + this.quickLoadKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F9); + this.drawSheathKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.R); + this.quickbarKeys = [ + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ONE), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TWO), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.THREE), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FOUR), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.FIVE), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SIX), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SEVEN), + this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.EIGHT), + ]; // Mouse — left click attack, right click block this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { @@ -339,13 +447,36 @@ export class GameScene extends Phaser.Scene { this.handleMouseAttack(); } if (pointer.rightButtonDown()) { - combatSystem.setBlocking(this.playerEntity.id, true); + // Skyrim-style right-click: check left hand equipment + const inv = inventorySystem.getInventory(this.playerEntity); + const leftItem = inv?.items.find((i) => i.equipped && i.slot === 'leftHand'); + const equippedSpells = entityManager.getComponent<{ left?: string; right?: string }>(this.playerEntity.id, 'equippedSpells'); + const hasShield = leftItem && (leftItem.type === 'armor' || leftItem.id.includes('shield')); + const hasLeftSpell = equippedSpells?.left; + + if (hasShield) { + // Shield equipped → block + combatSystem.setBlocking(this.playerEntity.id, true); + this.playBlockPose(); + } else if (hasLeftSpell) { + // Spell equipped → cast left-hand spell + this.castEquippedSpell('left'); + } else { + // Weapon or empty → left-hand attack + this.handleDualWieldAttack(); + } } }); this.input.on('pointerup', (pointer: Phaser.Input.Pointer) => { if (pointer.button === 2) { - combatSystem.setBlocking(this.playerEntity.id, false); + // Only release blocking if we were blocking (shield equipped) + const inv = inventorySystem.getInventory(this.playerEntity); + const leftItem = inv?.items.find((i) => i.equipped && i.slot === 'leftHand'); + const hasShield = leftItem && (leftItem.type === 'armor' || leftItem.id.includes('shield')); + if (hasShield) { + combatSystem.setBlocking(this.playerEntity.id, false); + } } }); @@ -354,14 +485,16 @@ export class GameScene extends Phaser.Scene { } private handleMouseAttack(): void { - // Find nearest enemy and attack it + const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); + if (!pos) return; + + const isPower = this.powerAttackKey?.isDown || false; + + // Find nearest enemy 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'); @@ -375,30 +508,104 @@ export class GameScene extends Phaser.Scene { } } + // Always play right-hand attack animation (even empty swing) + this.playAttackSwing('right', isPower); + 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); + this.showHitFlash(nearest); } } } + private handleDualWieldAttack(): void { + const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); + if (!pos) return; + + const isPower = this.powerAttackKey?.isDown || false; + + // Find nearest enemy + const enemies = entityManager.getEntitiesByType('enemy'); + let nearest: Entity | null = null; + let nearestDist = Infinity; + + 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; + } + } + + // Always play left-hand attack animation (even empty swing) + this.playAttackSwing('left', isPower); + + if (nearest) { + const success = combatSystem.performAttack(this.playerEntity, nearest, isPower); + if (success) { + this.cameras.main.shake(40, 0.004); + this.showHitFlash(nearest); + } + } + } + + private castEquippedSpell(hand: 'left' | 'right'): void { + const equippedSpells = entityManager.getComponent<{ left?: string; right?: string }>(this.playerEntity.id, 'equippedSpells'); + const spellId = hand === 'left' ? equippedSpells?.left : equippedSpells?.right; + if (!spellId) return; + + // Find nearest enemy for target (if applicable) + const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); + if (!pos) return; + + const enemies = entityManager.getEntitiesByType('enemy'); + let nearest: Entity | null = null; + let nearestDist = Infinity; + + 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 < 100) { + nearestDist = dist; + nearest = enemy; + } + } + + // Play hand animation + this.playAttackSwing(hand, false); + + // Cast spell + if (nearest) { + magicSystem.castSpell(this.playerEntity, spellId, nearest); + } else { + // Empty cast (no target) + magicSystem.castSpell(this.playerEntity, spellId); + } + } + 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)); + const baseZoom = Math.max(1.0, Math.min(1.8, minDim / 700)); + const settingsZoom = settingsUI.getSettings().cameraZoom; + const zoom = Math.max(0.5, Math.min(2.5, baseZoom * (settingsZoom / 80))); 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('entity:created', () => {}); + eventBus.on('entity:destroyed', () => {}); eventBus.on('world:fastTravel', (data: { locationId: string }) => { this.loadZone(data.locationId); }); @@ -413,19 +620,54 @@ export class GameScene extends Phaser.Scene { }); eventBus.on('player:levelUp', (data: { entityId: string; level: number }) => { if (data.entityId === this.playerEntity.id) { - this.showNotification(`升级! 等级 ${data.level}`); + levelUpUI.show(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('格挡成功!'); + if (data.entityId === this.playerEntity.id) { + this.showCombatText(this.playerEntity, '格挡!', '#ffd700', 14); + } + }); + eventBus.on('combat:afterAttack', (data: { attacker: any; target: any; damage: number; isCritical?: boolean }) => { + if (data.attacker.id === this.playerEntity.id && data.isCritical) { + this.showCombatText(data.target, `暴击! -${data.damage}`, '#ff6600', 18); + } + // Enemy attack animation + if (data.target.id === this.playerEntity.id) { + this.playEnemyAttackAnimation(data.attacker); + this.showPlayerHitFlash(); + } + }); + eventBus.on('ui:openCharacterInfo', () => { + characterInfoUI.show(); + }); + eventBus.on('ui:openSettings', () => { + settingsUI.show(); + }); + eventBus.on('ui:notification', (data: { text: string }) => { + this.showNotification(data.text); + }); + eventBus.on('game:returnToMenu', () => { + // Restart the scene to return to title + this.scene.restart(); + titleMenuUI.show(); + }); + eventBus.on('container:unlocked', (data: { entity: Entity }) => { + this.showNotification('锁已打开!'); + // Auto-loot the container after unlocking + const info = containerSystem.getContainerInfo(data.entity); + if (info && !info.isEmpty) { + containerSystem.lootContainer(data.entity, this.playerEntity); + this.showNotification(`从 ${info.name} 中获得了物品`); + } }); } update(_time: number, delta: number): void { if (this.isDead) return; - this.isUIOpen = radialMenuUI.getIsOpen() || uiManager.getIsInventoryOpen() || worldMapUI.getIsOpen() || craftingUI.getIsOpen() || questJournalUI.getIsOpen() || magicUI.getIsOpen() || skillTreeUI.getIsOpen(); + this.isUIOpen = radialMenuUI.getIsOpen() || uiManager.getIsInventoryOpen() || worldMapUI.getIsOpen() || craftingUI.getIsOpen() || questJournalUI.getIsOpen() || magicUI.getIsOpen() || skillTreeUI.getIsOpen() || pauseMenuUI.getIsOpen() || shopUI.getIsOpen() || lootUI.getIsOpen() || levelUpUI.getIsOpen() || characterInfoUI.getIsOpen() || settingsUI.getIsOpen() || favoritesUI.getIsOpen() || lockpickingUI.getIsOpen() || dialogueUI.getIsOpen() || modManagerUI.getIsOpen(); eventBus.emit('game:update', { delta, time: _time }); @@ -459,42 +701,279 @@ export class GameScene extends Phaser.Scene { this.playerShadow.x = pos.x; this.playerShadow.y = pos.y + 16; } + + // Update facing angle toward mouse cursor + const pointer = this.input.activePointer; + const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y); + this.facingAngle = Math.atan2(worldPoint.y - pos.y, worldPoint.x - pos.x); + entityManager.addComponent(this.playerEntity.id, { type: 'facing', angle: this.facingAngle }); + + // Update crosshair to follow mouse cursor on screen + uiManager.updateCrosshair(pointer.x, pointer.y); } // AI aiSystem.update(delta, this.playerEntity); - // Sync enemy sprites + // Sync enemy sprites and shadows 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; + // Update shadow position + const shadowComp = entityManager.getComponent<{ sprite: Phaser.GameObjects.Ellipse }>(enemy.id, 'shadow'); + if (shadowComp?.sprite) { + shadowComp.sprite.x = ePos.x; + shadowComp.sprite.y = ePos.y + (sprite.height || 24) * 0.4; + } } }); this.nearbyEntities = proximitySystem.checkProximity(this.playerEntity); + + // Throttled HUD bar update (every 100ms instead of every frame) + if (_time - this.lastBarsUpdate > 100) { + this.lastBarsUpdate = _time; + uiManager.updateBars(); + } + this.updateInteractionPrompt(); this.handleCombat(); this.handleInteractions(); this.handleInputToggles(); this.handleDoorCheck(); - this.updateEnemyHealthBars(); + // Throttled enemy health bar update (every 200ms) + if (_time - this.lastEnemyBarsUpdate > 200) { + this.lastEnemyBarsUpdate = _time; + 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 px = this.player.x; + const py = this.player.y; + + // Armor color from equipped chest piece — use dataRegistry for material + const inventory = inventorySystem.getInventory(this.playerEntity); + const chestItem = inventory?.items.find((i) => i.equipped && i.slot === 'chest'); + if (chestItem) { + const itemData = dataRegistry.getItem(chestItem.id); + const mat = itemData?.material || chestItem.id.split('_')[0] || ''; + const fill = MATERIAL_COLORS[mat]; + const stroke = ARMOR_STROKE[mat]; + if (fill) { + this.player.setFillStyle(fill); + this.player.setStrokeStyle(2, stroke || 0x555555); + } + } else { + this.player.setFillStyle(0x2288cc); + this.player.setStrokeStyle(2, 0x1a6699); + } + + // Blocking override 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); } + + // Hand indicators — orbit player based on facing angle + const HAND_RADIUS = 14; + const FIST_COLOR = 0xddb888; + const SPELL_COLORS: Record = { + fire: 0xe04020, frost: 0x60c0e0, shock: 0xe0e040, + heal: 0x40e060, conjuration: 0x8040c0, default: 0x6080e0, + }; + + const angle = this.facingAngle; + const equippedSpells = entityManager.getComponent<{ left?: string; right?: string }>(this.playerEntity.id, 'equippedSpells'); + const weapon = entityManager.getComponent<{ id: string; damage: number }>(this.playerEntity.id, 'weapon'); + + // Look up full weapon data for type/material + const weaponData = weapon ? dataRegistry.getItem(weapon.id) : null; + const isTwoHanded = weaponData?.type?.startsWith('two_handed') ?? false; + const isDagger = weaponData?.type === 'dagger'; + const weaponMat = weaponData?.material || ''; + + // Left hand — offset to the LEFT of facing direction (angle - π/2) + const leftAngle = angle - Math.PI / 2; + const lx = px + Math.cos(leftAngle) * HAND_RADIUS; + const ly = py + Math.sin(leftAngle) * HAND_RADIUS; + // Skip position update if attack animation is playing + const leftAnimating = this.leftSwingTween && (this.leftSwingTween.isPlaying?.() ?? false); + if (!leftAnimating) { + this.leftHand.setPosition(lx, ly); + } + this.leftHand.setRadius(5); + + const leftSpellId = equippedSpells?.left; + if (leftSpellId) { + const key = Object.keys(SPELL_COLORS).find((k) => k !== 'default' && leftSpellId.includes(k)) || 'default'; + this.leftHand.setFillStyle(SPELL_COLORS[key]); + this.leftWeapon.setVisible(false); + } else if (isTwoHanded && weapon && weapon.id !== 'fists' && this.weaponDrawn) { + // Two-handed: show weapon on both hands + this.leftHand.setFillStyle(FIST_COLOR); + this.leftWeapon.setVisible(true); + const wLen = 24; + this.leftWeapon.setPosition( + lx + Math.cos(angle) * wLen * 0.5, + ly + Math.sin(angle) * wLen * 0.5, + ); + this.leftWeapon.setSize(4, wLen); + this.leftWeapon.setRotation(angle + Math.PI / 2); + this.leftWeapon.setFillStyle(MATERIAL_COLORS[weaponMat] || 0xaaaaaa); + } else { + this.leftHand.setFillStyle(FIST_COLOR); + this.leftWeapon.setVisible(false); + } + + // Right hand — offset to the RIGHT of facing direction (angle + π/2) + const rightAngle = angle + Math.PI / 2; + const rx = px + Math.cos(rightAngle) * HAND_RADIUS; + const ry = py + Math.sin(rightAngle) * HAND_RADIUS; + // Skip position update if attack animation is playing + const rightAnimating = this.rightSwingTween && (this.rightSwingTween.isPlaying?.() ?? false); + if (!rightAnimating) { + this.rightHand.setPosition(rx, ry); + } + this.rightHand.setRadius(5); + + const rightSpellId = equippedSpells?.right; + if (rightSpellId) { + const key = Object.keys(SPELL_COLORS).find((k) => k !== 'default' && rightSpellId.includes(k)) || 'default'; + this.rightHand.setFillStyle(SPELL_COLORS[key]); + this.rightWeapon.setVisible(false); + } else if (weapon && weapon.id !== 'fists' && this.weaponDrawn) { + this.rightHand.setFillStyle(FIST_COLOR); + // Weapon dimensions from data or defaults + const wLen = weaponData?.weaponLength ?? (isTwoHanded ? 24 : isDagger ? 12 : 18); + const wWid = weaponData?.weaponWidth ?? (isTwoHanded ? 4 : isDagger ? 2 : 3); + this.rightWeapon.setVisible(true); + this.rightWeapon.setPosition( + rx + Math.cos(angle) * wLen * 0.5, + ry + Math.sin(angle) * wLen * 0.5, + ); + this.rightWeapon.setSize(wWid, wLen); + this.rightWeapon.setRotation(angle + Math.PI / 2); + this.rightWeapon.setFillStyle(MATERIAL_COLORS[weaponMat] || 0xaaaaaa); + } else { + this.rightHand.setFillStyle(FIST_COLOR); + this.rightWeapon.setVisible(false); + } + } + + private playAttackSwing(hand: 'left' | 'right', isPower: boolean): void { + const arc = hand === 'left' ? this.leftHand : this.rightHand; + const weaponRect = hand === 'left' ? this.leftWeapon : this.rightWeapon; + const existing = hand === 'left' ? this.leftSwingTween : this.rightSwingTween; + if (existing?.isPlaying()) existing.stop(); + + const angle = this.facingAngle; + const SWING_REACH = isPower ? 30 : 22; + const DURATION = isPower ? 250 : 150; + const SWING_ARC = Math.PI * 0.6; // 108 degree swing arc + const isFist = !weaponRect.visible; + + // Store rest position + const restX = arc.x; + const restY = arc.y; + const weaponRestX = weaponRect.x; + const weaponRestY = weaponRect.y; + const restRadius = arc.radius; + + // Swing: arc from -SWING_ARC/2 to +SWING_ARC/2 relative to facing direction + const midAngle = angle; + const endAngle = angle + SWING_ARC / 2; + + // Phase 1: swing forward (start → mid) + const midX = this.player.x + Math.cos(midAngle) * SWING_REACH; + const midY = this.player.y + Math.sin(midAngle) * SWING_REACH; + + // Phase 2: continue swing (mid → end) + const endX = this.player.x + Math.cos(endAngle) * SWING_REACH; + const endY = this.player.y + Math.sin(endAngle) * SWING_REACH; + + const FIST_PULSE_RADIUS = 8; + + const tween = this.tweens.chain({ + targets: arc, + tweens: [ + { + x: midX, y: midY, + ...(isFist ? { radius: FIST_PULSE_RADIUS } : {}), + duration: DURATION * 0.4, + ease: 'Quad.easeIn', + onUpdate: () => { + if (weaponRect.visible) { + const wLen = parseInt(String(weaponRect.height)) || 18; + weaponRect.setPosition( + arc.x + Math.cos(midAngle) * wLen * 0.5, + arc.y + Math.sin(midAngle) * wLen * 0.5, + ); + weaponRect.setRotation(midAngle + Math.PI / 2); + } + }, + }, + { + x: endX, y: endY, + duration: DURATION * 0.3, + ease: 'Quad.easeOut', + onUpdate: () => { + if (weaponRect.visible) { + const wLen = parseInt(String(weaponRect.height)) || 18; + weaponRect.setPosition( + arc.x + Math.cos(endAngle) * wLen * 0.5, + arc.y + Math.sin(endAngle) * wLen * 0.5, + ); + weaponRect.setRotation(endAngle + Math.PI / 2); + } + }, + }, + { + x: restX, y: restY, + ...(isFist ? { radius: restRadius } : {}), + duration: DURATION * 0.3, + ease: 'Back.easeOut', + onComplete: () => { + if (weaponRect.visible) { + weaponRect.x = weaponRestX; + weaponRect.y = weaponRestY; + weaponRect.setRotation(angle + Math.PI / 2); + } + }, + }, + ], + }); + + if (hand === 'left') this.leftSwingTween = tween; + else this.rightSwingTween = tween; + } + + private playBlockPose(): void { + // Move left hand forward to block position + const angle = this.facingAngle; + const BLOCK_REACH = 18; + const targetX = this.player.x + Math.cos(angle) * BLOCK_REACH; + const targetY = this.player.y + Math.sin(angle) * BLOCK_REACH; + + if (this.leftSwingTween?.isPlaying()) this.leftSwingTween.stop(); + + this.leftSwingTween = this.tweens.add({ + targets: this.leftHand, + x: targetX, + y: targetY, + duration: 100, + ease: 'Back.easeOut', + }); } private handlePlayerDeath(): void { @@ -552,22 +1031,34 @@ export class GameScene extends Phaser.Scene { } private updateInteractionPrompt(): void { - const prompt = document.getElementById('interact-prompt'); - if (!prompt) return; - if (dialogueSystem.isInDialogue()) { prompt.style.display = 'none'; return; } + if (dialogueSystem.isInDialogue()) { + uiManager.hideInteractPrompt(); + return; + } const text = proximitySystem.getInteractionPrompt(this.nearbyEntities); - if (text) { prompt.textContent = text; prompt.style.display = 'block'; } - else { prompt.style.display = 'none'; } + if (text) { + uiManager.showInteractPrompt(text); + } else { + uiManager.hideInteractPrompt(); + } } private handleCombat(): void { if (this.isUIOpen) return; // 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); + + // Always play right-hand attack animation (even empty swing) + this.playAttackSwing('right', isPower); + + if (this.nearbyEntities.enemy) { + const success = combatSystem.performAttack(this.playerEntity, this.nearbyEntities.enemy, isPower); + if (success) { + this.cameras.main.shake(40, 0.004); + this.showHitFlash(this.nearbyEntities.enemy); + } + } } } @@ -575,19 +1066,30 @@ export class GameScene extends Phaser.Scene { if (dialogueSystem.isInDialogue()) return; if (this.interactKey && Phaser.Input.Keyboard.JustDown(this.interactKey)) { if (this.nearbyEntities.npc) { - dialogueSystem.startDialogue(this.nearbyEntities.npc, this.playerEntity); + const npcData = entityManager.getComponent<{ name: string; shop?: boolean; forge?: boolean }>(this.nearbyEntities.npc.id, 'npcData'); + if (npcData?.shop) { + const shopItems = this.getShopItemsForNPC(this.nearbyEntities.npc); + shopUI.show(`${npcData.name} 的商店`, shopItems); + } else if (npcData?.forge) { + craftingUI.toggle('smithing'); + } else { + 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); + // Read loot before searching (searchCorpse marks as looted) + const corpseComp = entityManager.getComponent<{ looted: boolean; loot: { id: string; name: string; type: string; quantity: number; value: number; weight: number }[] }>(this.nearbyEntities.corpse.id, 'corpse'); + if (corpseComp && !corpseComp.looted && corpseComp.loot.length > 0) { + const lootEntries = corpseComp.loot.map(i => ({ + id: i.id || 'gold', + name: i.name || '金币', + type: i.type || 'misc', + quantity: i.quantity || 1, + value: i.value || 0, + weight: i.weight || 0, + })); + lootUI.show('搜索尸体', lootEntries, this.nearbyEntities.corpse); + // Mark as looted after showing UI + corpseSystem.searchCorpse(this.nearbyEntities.corpse, this.playerEntity); } else { this.showNotification('尸体已经被搜刮过了'); } @@ -599,9 +1101,29 @@ export class GameScene extends Phaser.Scene { } } 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 (info) { + if (info.locked) { + // Trigger lockpicking mini-game + const lockComp = entityManager.getComponent<{ lockLevel: number }>(this.nearbyEntities.container.id, 'container'); + lockpickingUI.show(this.nearbyEntities.container, lockComp?.lockLevel || 1); + } else if (!info.isEmpty) { + // Get container loot items for display + const containerComp = entityManager.getComponent<{ loot: { id: string; name: string; type: string; quantity: number; value: number; weight: number }[] }>(this.nearbyEntities.container.id, 'container'); + if (containerComp && containerComp.loot.length > 0) { + const lootEntries = containerComp.loot.map(i => ({ + id: i.id || 'gold', + name: i.name || '金币', + type: i.type || 'misc', + quantity: i.quantity || 1, + value: i.value || 0, + weight: i.weight || 0, + })); + lootUI.show(info.name, lootEntries, this.nearbyEntities.container); + } else { + containerSystem.lootContainer(this.nearbyEntities.container, this.playerEntity); + this.showNotification(`从 ${info.name} 中获得了物品`); + } + } } } } @@ -617,33 +1139,102 @@ export class GameScene extends Phaser.Scene { } } - 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 getShopItemsForNPC(_npc: Entity): { id: string; name: string; type: string; value: number }[] { + // Generate shop inventory from data registry - mix of categories + const items: { id: string; name: string; type: string; value: number }[] = []; + const allItems = dataRegistry.getAllItems(); + + // Prioritize consumables and materials for general shops + const consumables = allItems.filter(i => i.type === 'consumable').slice(0, 4); + const materials = allItems.filter(i => i.type === 'material').slice(0, 4); + const weapons = allItems.filter(i => i.type === 'weapon').slice(0, 3); + const armor = allItems.filter(i => i.type === 'armor').slice(0, 3); + + const shopItems = [...consumables, ...materials, ...weapons, ...armor]; + for (const item of shopItems) { + items.push({ id: item.id, name: item.name, type: item.type, value: item.value }); + } + + // Fallback: basic items if registry empty + if (items.length === 0) { + items.push( + { id: 'health_potion', name: '生命药水', type: 'consumable', value: 25 }, + { id: 'iron_ingot', name: '铁锭', type: 'material', value: 10 }, + { id: 'leather', name: '皮革', type: 'material', value: 15 }, + { id: 'iron_sword', name: '铁剑', type: 'weapon', value: 60 }, + { id: 'iron_armor', name: '铁甲', type: 'armor', value: 100 }, + ); + } + return items; } 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); + uiManager.showNotification(text); } private handleInputToggles(): void { - if (this.isUIOpen) { - if (this.inventoryKey && Phaser.Input.Keyboard.JustDown(this.inventoryKey)) { + // Tab — radial menu toggle (always active, even when other UIs are open) + if (this.inventoryKey && Phaser.Input.Keyboard.JustDown(this.inventoryKey)) { + if (radialMenuUI.getIsOpen()) { radialMenuUI.hide(); + } else if (skillTreeUI.getIsOpen()) { + skillTreeUI.toggle(); + } else if (magicUI.getIsOpen()) { + magicUI.toggle(); + } else if (uiManager.getIsInventoryOpen()) { uiManager.toggleInventory(); + } else if (worldMapUI.getIsOpen()) { + worldMapUI.toggle(); + } else { + radialMenuUI.show(); } return; } - if (this.inventoryKey && Phaser.Input.Keyboard.JustDown(this.inventoryKey)) radialMenuUI.toggle(); + + // Escape — close topmost menu, or open pause menu + if (this.escapeKey && Phaser.Input.Keyboard.JustDown(this.escapeKey)) { + if (this.isUIOpen) { + this.closeTopmostMenu(); + return; + } + pauseMenuUI.toggle(); + return; + } + + // F5 quicksave + if (this.quickSaveKey && Phaser.Input.Keyboard.JustDown(this.quickSaveKey)) { + this.performQuickSave(); + return; + } + + // F9 quickload + if (this.quickLoadKey && Phaser.Input.Keyboard.JustDown(this.quickLoadKey)) { + this.performQuickLoad(); + return; + } + + // R key — draw/sheath weapon + if (!this.isUIOpen && this.drawSheathKey && Phaser.Input.Keyboard.JustDown(this.drawSheathKey)) { + this.weaponDrawn = !this.weaponDrawn; + } + + // Number keys 1-8 for quickbar + if (!this.isUIOpen) { + for (let i = 0; i < this.quickbarKeys.length; i++) { + const key = this.quickbarKeys[i]; + if (key && Phaser.Input.Keyboard.JustDown(key)) { + this.useQuickbarSlot(i); + return; + } + } + } + + if (this.isUIOpen) return; + 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.characterInfoKey && Phaser.Input.Keyboard.JustDown(this.characterInfoKey)) characterInfoUI.toggle(); + if (this.favoritesKey && Phaser.Input.Keyboard.JustDown(this.favoritesKey)) favoritesUI.toggle(); if (this.journalKey && Phaser.Input.Keyboard.JustDown(this.journalKey)) questJournalUI.toggle(); if (this.magicKey && Phaser.Input.Keyboard.JustDown(this.magicKey)) magicUI.toggle(); if (this.transformKey && Phaser.Input.Keyboard.JustDown(this.transformKey)) { @@ -655,6 +1246,187 @@ export class GameScene extends Phaser.Scene { } } + private closeTopmostMenu(): void { + if (pauseMenuUI.getIsOpen()) { pauseMenuUI.hide(); return; } + if (modManagerUI.getIsOpen()) { modManagerUI.hide(); return; } + if (settingsUI.getIsOpen()) { settingsUI.hide(); return; } + if (skillTreeUI.getIsOpen()) { skillTreeUI.toggle(); return; } + if (magicUI.getIsOpen()) { magicUI.toggle(); return; } + if (worldMapUI.getIsOpen()) { worldMapUI.toggle(); return; } + if (craftingUI.getIsOpen()) { craftingUI.toggle('smithing'); return; } + if (questJournalUI.getIsOpen()) { questJournalUI.toggle(); return; } + if (lockpickingUI.getIsOpen()) { lockpickingUI.hide(); return; } + if (favoritesUI.getIsOpen()) { favoritesUI.hide(); return; } + if (characterInfoUI.getIsOpen()) { characterInfoUI.hide(); return; } + if (shopUI.getIsOpen()) { shopUI.hide(); return; } + if (lootUI.getIsOpen()) { lootUI.hide(); return; } + if (dialogueUI.getIsOpen()) { dialogueUI.hide(); return; } + if (uiManager.getIsInventoryOpen()) { uiManager.toggleInventory(); return; } + if (radialMenuUI.getIsOpen()) { radialMenuUI.hide(); return; } + } + + private useQuickbarSlot(slot: number): void { + const player = this.playerEntity; + const inventory = inventorySystem.getInventory(player); + if (!inventory) return; + + // Build same list as quickbar: weapon + consumables + materials + const quickItems: { id: string; name: string; type: string }[] = []; + const weapon = entityManager.getComponent<{ id: string }>(player.id, 'weapon'); + if (weapon && weapon.id !== 'fists') { + quickItems.push({ id: weapon.id, name: weapon.id, type: 'weapon' }); + } + const consumables = inventory.items.filter(i => i.type === 'consumable'); + for (const c of consumables) { + quickItems.push({ id: c.id, name: c.name, type: c.type }); + } + if (quickItems.length < 8) { + const materials = inventory.items.filter(i => i.type === 'material').slice(0, 8 - quickItems.length); + for (const m of materials) { + quickItems.push({ id: m.id, name: m.name, type: m.type }); + } + } + + const item = quickItems[slot]; + if (!item) return; + + switch (item.type) { + case 'weapon': + inventorySystem.equipItem(player, item.id, 'rightHand'); + this.showNotification(`装备了 ${item.name}`); + break; + case 'consumable': + inventorySystem.useItem(player, item.id); + this.showNotification(`使用了 ${item.name}`); + break; + default: + this.showNotification(`${item.name}`); + break; + } + } + + private async performQuickSave(): Promise { + try { + const player = this.playerEntity; + 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'); + const level = entityManager.getComponent<{ level: number; xp: number; xpToNext: number; perkPoints: number }>(player.id, 'level'); + const skills = entityManager.getComponent>(player.id, 'skills'); + const pos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); + const inventory = inventorySystem.getInventory(player); + + await saveManager.saveGame('快速存档', { + playTime: 0, + character: { + entityId: player.id, + name: '玩家', + level: level?.level || 1, + xp: level?.xp || 0, + xpToNext: level?.xpToNext || 100, + perkPoints: level?.perkPoints || 0, + health: health ? { current: health.current, max: health.max } : undefined, + magicka: magicka ? { current: magicka.current, max: magicka.max } : undefined, + stamina: stamina ? { current: stamina.current, max: stamina.max } : undefined, + position: pos ? { x: pos.x, y: pos.y } : undefined, + skills: skills ? { ...skills } : undefined, + }, + inventory: inventory ? { + items: inventory.items.map(i => ({ ...i })), + gold: inventory.gold, + carryWeight: inventory.carryWeight, + maxCarryWeight: inventory.maxCarryWeight, + } : undefined, + worldState: { currentZone: this.currentZone?.id || 'whiterun_exterior' }, + quests: {}, + factions: {}, + npcs: {}, + mapStates: {}, + }); + this.showNotification('游戏已快速保存'); + } catch (e) { + this.showNotification('保存失败!'); + console.error('Quick save failed:', e); + } + } + + private async performQuickLoad(): Promise { + try { + const saves = await saveManager.getSaveList(); + if (saves.length === 0) { + this.showNotification('没有找到存档'); + return; + } + const latest = saves.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); + const saveData = await saveManager.loadGame(latest.id!); + if (!saveData) { + this.showNotification('读取存档失败'); + return; + } + + const player = this.playerEntity; + const savedPos = saveData.character?.position; + + // Restore zone first (with saved position to avoid spawn point override) + if (saveData.worldState?.currentZone) { + this.loadZone(saveData.worldState.currentZone, savedPos); + } + + // Restore character state + if (saveData.character) { + const c = saveData.character; + if (c.health) { + const hp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'health'); + if (hp) { hp.current = c.health.current; hp.max = c.health.max; } + } + if (c.magicka) { + const mp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'magicka'); + if (mp) { mp.current = c.magicka.current; mp.max = c.magicka.max; } + } + if (c.stamina) { + const sp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'stamina'); + if (sp) { sp.current = c.stamina.current; sp.max = c.stamina.max; } + } + if (c.level) { + const lv = entityManager.getComponent<{ level: number; xp: number; xpToNext: number; perkPoints: number }>(player.id, 'level'); + if (lv) { + lv.level = c.level; + if (c.xp !== undefined) lv.xp = c.xp; + if (c.xpToNext !== undefined) lv.xpToNext = c.xpToNext; + if (c.perkPoints !== undefined) lv.perkPoints = c.perkPoints; + } + } + if (c.skills) { + const skills = entityManager.getComponent>(player.id, 'skills'); + if (skills) { + for (const key of Object.keys(c.skills)) { + if (key !== 'type') skills[key] = c.skills[key]; + } + } + } + } + + // Restore inventory + if (saveData.inventory) { + const inv = inventorySystem.getInventory(player); + if (inv) { + inv.items = saveData.inventory.items || []; + inv.gold = saveData.inventory.gold ?? 100; + inv.carryWeight = saveData.inventory.carryWeight ?? 0; + inv.maxCarryWeight = saveData.inventory.maxCarryWeight ?? 300; + } + } + + this.isDead = false; + this.player.setFillStyle(0x2288cc); + this.player.setAlpha(1); + this.showNotification('游戏已快速读取'); + } catch (e) { + this.showNotification('读取失败!'); + console.error('Quick load failed:', e); + } + } + private handleDoorCheck(): void { if (!this.currentZone) return; const playerPos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); @@ -665,12 +1437,24 @@ export class GameScene extends Phaser.Scene { } private updateEnemyHealthBars(): void { - combatUI.clearEnemyHealthBars(); + if (!settingsUI.getSettings().showEnemyHealthBars) { + combatUI.clearEnemyHealthBars(); + return; + } + // Track which enemies are visible + const visibleEnemies = new Set(); + 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); + if (health && health.current < health.max) { + combatUI.showEnemyHealthBar(enemy, health); + visibleEnemies.add(enemy.id); + } }); + + // Hide bars for enemies no longer visible + combatUI.hideInactiveHealthBars(visibleEnemies); } private applyDayNightTint(): void { @@ -687,4 +1471,187 @@ export class GameScene extends Phaser.Scene { this.cameras.main.setBackgroundColor('#1a1a2e'); } } + + private showHitFlash(entity: Entity): void { + const sprite = entity.sprite as Phaser.GameObjects.Rectangle | undefined; + if (!sprite) return; + + // Flash white briefly + sprite.setFillStyle(0xffffff); + this.time.delayedCall(80, () => { + const enemyType = entityManager.getComponent<{ name: string; id: string }>(entity.id, 'enemyType'); + if (enemyType) { + const stats = dataRegistry.getEnemy(enemyType.id); + if (stats) sprite.setFillStyle(stats.color); + } + }); + + // Show damage number via CombatUI event + const health = entityManager.getComponent<{ current: number; max: number }>(entity.id, 'health'); + if (health && health.current <= 0) { + this.showCombatText(entity, '击杀!', '#ff4444', 20); + } + } + + private playEnemyAttackAnimation(enemy: Entity): void { + const sprite = enemy.sprite as Phaser.GameObjects.Rectangle | undefined; + if (!sprite) return; + + const enemyType = entityManager.getComponent<{ id: string }>(enemy.id, 'enemyType'); + if (!enemyType) return; + + const stats = dataRegistry.getEnemy(enemyType.id); + if (!stats) return; + + const anim = stats.attackAnimation; + const reach = anim?.reach ?? 18; + const duration = anim?.duration ?? 150; + const type = anim?.type ?? 'lunge'; + + const pos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position'); + const playerPos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); + if (!pos || !playerPos) return; + + // Calculate direction toward player + const dx = playerPos.x - pos.x; + const dy = playerPos.y - pos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) return; + const nx = dx / dist; + const ny = dy / dist; + + const restX = sprite.x; + const restY = sprite.y; + + if (type === 'swing') { + // Swing: scale pulse + slight rotation + const origScaleX = sprite.scaleX; + const origScaleY = sprite.scaleY; + this.tweens.chain({ + targets: sprite, + tweens: [ + { + scaleX: origScaleX * 1.3, + scaleY: origScaleY * 0.8, + duration: duration * 0.4, + ease: 'Quad.easeIn', + }, + { + scaleX: origScaleX, + scaleY: origScaleY, + duration: duration * 0.6, + ease: 'Back.easeOut', + }, + ], + }); + } else if (type === 'bite') { + // Bite: quick lunge forward + snap back + this.tweens.chain({ + targets: sprite, + tweens: [ + { + x: restX + nx * reach, + y: restY + ny * reach, + duration: duration * 0.3, + ease: 'Quad.easeIn', + }, + { + x: restX, + y: restY, + duration: duration * 0.7, + ease: 'Back.easeOut', + }, + ], + }); + } else if (type === 'thrust') { + // Thrust: slow push forward, fast return + this.tweens.chain({ + targets: sprite, + tweens: [ + { + x: restX + nx * reach, + y: restY + ny * reach, + duration: duration * 0.6, + ease: 'Sine.easeIn', + }, + { + x: restX, + y: restY, + duration: duration * 0.4, + ease: 'Quad.easeOut', + }, + ], + }); + } else { + // Default lunge: fast forward, slow return + this.tweens.chain({ + targets: sprite, + tweens: [ + { + x: restX + nx * reach, + y: restY + ny * reach, + duration: duration * 0.4, + ease: 'Quad.easeIn', + }, + { + x: restX, + y: restY, + duration: duration * 0.6, + ease: 'Back.easeOut', + }, + ], + }); + } + } + + private showPlayerHitFlash(): void { + this.player.setFillStyle(0xff4444); + this.cameras.main.shake(60, 0.006); + this.time.delayedCall(120, () => { + // Restore armor color + const inventory = inventorySystem.getInventory(this.playerEntity); + const chestItem = inventory?.items.find((i) => i.equipped && i.slot === 'chest'); + if (chestItem) { + const itemData = dataRegistry.getItem(chestItem.id); + const mat = itemData?.material || chestItem.id.split('_')[0] || ''; + this.player.setFillStyle(MATERIAL_COLORS[mat] || 0x2288cc); + } else { + this.player.setFillStyle(0x2288cc); + } + }); + } + + showCombatText(entity: Entity, text: string, color: string, size: number = 16): void { + if (!settingsUI.getSettings().showDamageNumbers) return; + 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 = this.cameras.main; + + 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: ${color}; + font-size: ${size}px; + font-weight: 700; + font-family: Georgia, serif; + text-shadow: 0 1px 4px rgba(0,0,0,0.8), 0 0 8px ${color}44; + pointer-events: none; + z-index: 200; + animation: oesDamageFloat 1.0s ease-out forwards; + `; + el.textContent = text; + + this.combatFeedbackContainer.appendChild(el); + setTimeout(() => el.remove(), 1050); + } } diff --git a/src/systems/CombatSystem.ts b/src/systems/CombatSystem.ts index c02de60..d39f047 100644 --- a/src/systems/CombatSystem.ts +++ b/src/systems/CombatSystem.ts @@ -1,6 +1,7 @@ import { eventBus } from '../core/EventBus'; import { entityManager, type Entity } from '../core/EntityManager'; import { dataRegistry } from '../data/DataRegistry'; +import { perkSystem } from './PerkSystem'; export interface AttackData { attacker: Entity; @@ -137,30 +138,74 @@ export class CombatSystem { baseDamage += level.level * this.config.skillBonus; } + // Perk: melee damage bonus + const meleeBonus = perkSystem.getEffectValue(attacker, 'meleeDamageBonus'); + if (meleeBonus > 0) baseDamage *= (1 + meleeBonus); + + // Perk: ranged damage bonus + const rangedBonus = perkSystem.getEffectValue(attacker, 'rangedDamageBonus'); + if (rangedBonus > 0) baseDamage *= (1 + rangedBonus); + if (isPowerAttack) { baseDamage *= this.config.powerAttackMultiplier; + const powerBonus = perkSystem.getEffectValue(attacker, 'powerAttackDamageBonus'); + if (powerBonus > 0) baseDamage *= (1 + powerBonus); } + // Perk: crit chance and damage const sneakSkill = skills?.['sneak'] || 15; - const critChance = this.config.critBaseChance + (sneakSkill - 15) * this.config.critPerSneakSkill; + const critChanceBonus = perkSystem.getEffectValue(attacker, 'critChanceBonus'); + const critChance = this.config.critBaseChance + (sneakSkill - 15) * this.config.critPerSneakSkill + critChanceBonus; const isCritical = Math.random() < critChance; if (isCritical) { - baseDamage *= 1.5; + const critDmgBonus = perkSystem.getEffectValue(attacker, 'critDamageBonus'); + baseDamage *= (1.5 + critDmgBonus); } + // Perk: armor penetration + const armorPen = perkSystem.getEffectValue(attacker, 'armorPenetration'); + const targetArmor = entityManager.getComponent<{ rating: number }>(target.id, 'armor'); if (targetArmor) { - const armorReduction = targetArmor.rating / (targetArmor.rating + this.config.armorDivisor); + const effectiveRating = targetArmor.rating * (1 - armorPen); + const armorReduction = effectiveRating / (effectiveRating + this.config.armorDivisor); baseDamage *= (1 - armorReduction); } + // Perk: sneak attack multiplier + const isSneaking = entityManager.getComponent<{ isSneaking: boolean }>(attacker.id, 'stealth')?.isSneaking; + if (isSneaking) { + const sneakMult = perkSystem.getEffectValue(attacker, 'sneakAttackMultiplier'); + if (sneakMult > 0) baseDamage *= (1 + sneakMult); + } + const [varMin, varMax] = this.config.damageVariance; const variance = varMin + Math.random() * (varMax - varMin); baseDamage *= variance; + // Difficulty multiplier + const diffMult = this.getDifficultyMultiplier(); + if (attacker.type === 'player') { + baseDamage *= diffMult; + } else { + baseDamage /= diffMult; + } + return { damage: Math.max(1, Math.round(baseDamage)), isCritical }; } + private getDifficultyMultiplier(): number { + try { + const saved = localStorage.getItem('oes-settings'); + if (saved) { + const settings = JSON.parse(saved); + const map: Record = { easy: 0.7, normal: 1.0, hard: 1.3, veryhard: 1.6 }; + return map[settings.difficulty] || 1.0; + } + } catch { /* ignore */ } + return 1.0; + } + private onCombatAction(attacker: Entity, target: Entity, isPowerAttack: boolean): void { const skills = entityManager.getComponent>(attacker.id, 'skills'); if (!skills) return; diff --git a/src/systems/DialogueSystem.ts b/src/systems/DialogueSystem.ts index f6b28f1..38fa728 100644 --- a/src/systems/DialogueSystem.ts +++ b/src/systems/DialogueSystem.ts @@ -74,10 +74,11 @@ export class DialogueSystem { } startDialogue(npcEntity: Entity, playerEntity: Entity): void { - const npcData = entityManager.getComponent<{ name: string }>(npcEntity.id, 'npcData'); + const npcData = entityManager.getComponent<{ name: string; entityId?: string }>(npcEntity.id, 'npcData'); if (!npcData) return; - const dialogueTree = this.dialogueTrees.get(npcEntity.id); + const lookupId = npcData.entityId || npcEntity.id; + const dialogueTree = this.dialogueTrees.get(lookupId); if (!dialogueTree) return; this.currentDialogue = dialogueTree; diff --git a/src/systems/InventorySystem.ts b/src/systems/InventorySystem.ts index 3d472ab..7558128 100644 --- a/src/systems/InventorySystem.ts +++ b/src/systems/InventorySystem.ts @@ -176,14 +176,19 @@ export class InventorySystem { item.slot = slot; if (item.type === 'weapon') { + const itemData = dataRegistry.getItem(itemId); 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, + damage: itemData?.damage || item.effects?.find((e) => e.type === 'damage')?.magnitude || 10, + speed: itemData?.speed || item.effects?.find((e) => e.type === 'speed')?.magnitude || 1.0, }); } + if (item.type === 'armor') { + this.recalculateArmor(entity); + } + eventBus.emit('inventory:updated', { entity }); eventBus.emit('equipment:changed', { entity, item, slot }); return true; @@ -204,11 +209,32 @@ export class InventorySystem { entityManager.removeComponent(entity.id, 'weapon'); } + if (item.type === 'armor') { + this.recalculateArmor(entity); + } + eventBus.emit('inventory:updated', { entity }); eventBus.emit('equipment:changed', { entity, item, slot }); return true; } + recalculateArmor(entity: Entity): void { + const inventory = entityManager.getComponent(entity.id, 'inventory'); + if (!inventory) return; + + let totalArmor = 0; + for (const item of inventory.items) { + if (item.equipped && item.type === 'armor') { + const armorData = dataRegistry.getArmor(item.id); + if (armorData) { + totalArmor += armorData.armor; + } + } + } + + entityManager.addComponent(entity.id, { type: 'armor', rating: totalArmor }); + } + getInventory(entity: Entity): Inventory | undefined { return entityManager.getComponent(entity.id, 'inventory'); } diff --git a/src/systems/MovementSystem.ts b/src/systems/MovementSystem.ts index 3000c86..6fc6b26 100644 --- a/src/systems/MovementSystem.ts +++ b/src/systems/MovementSystem.ts @@ -1,4 +1,6 @@ import { entityManager, type Entity } from '../core/EntityManager'; +import { perkSystem } from './PerkSystem'; +import { inventorySystem } from './InventorySystem'; export interface MovementInput { up: boolean; @@ -23,7 +25,19 @@ export class MovementSystem { if (!pos) return; const movement = entityManager.getComponent<{ speed: number }>(entity.id, 'movement'); - const speed = movement ? movement.speed : this.playerSpeed; + let speed = movement ? movement.speed : this.playerSpeed; + + // Perk: speed multiplier + const speedBonus = perkSystem.getEffectValue(entity, 'speedMultiplier'); + if (speedBonus > 0) speed *= (1 + speedBonus); + + // Carry weight penalty + const inventory = inventorySystem.getInventory(entity); + if (inventory && inventory.carryWeight > inventory.maxCarryWeight) { + const overRatio = (inventory.carryWeight - inventory.maxCarryWeight) / inventory.maxCarryWeight; + const penalty = Math.max(0.3, 1 - overRatio); + speed *= penalty; + } let vx = 0; let vy = 0; diff --git a/src/systems/PerkSystem.ts b/src/systems/PerkSystem.ts new file mode 100644 index 0000000..9f0ffff --- /dev/null +++ b/src/systems/PerkSystem.ts @@ -0,0 +1,91 @@ +import { eventBus } from '../core/EventBus'; +import { entityManager, type Entity } from '../core/EntityManager'; +import { dataRegistry, type PerkEffect } from '../data/DataRegistry'; + +export interface PerksComponent { + type: 'perks'; + unlocked: string[]; + rankedPerks: Record; +} + +class PerkSystem { + private static instance: PerkSystem; + + static getInstance(): PerkSystem { + if (!PerkSystem.instance) PerkSystem.instance = new PerkSystem(); + return PerkSystem.instance; + } + + constructor() { + eventBus.on('perk:unlocked', (data: { entityId: string; perkId: string }) => { + const entity = entityManager.getEntity(data.entityId); + if (entity) this.onPerkUnlocked(entity, data.perkId); + }); + } + + initialize(entity: Entity): void { + entityManager.addComponent(entity.id, { + type: 'perks', + unlocked: [], + rankedPerks: {}, + }); + } + + private onPerkUnlocked(entity: Entity, perkId: string): void { + const perks = entityManager.getComponent(entity.id, 'perks'); + if (!perks) return; + + const allPerks = dataRegistry.getAllPerkTrees().flatMap((t) => t.perks); + const perk = allPerks.find((p) => p.id === perkId); + if (!perk) return; + + if (!perks.unlocked.includes(perkId)) { + perks.unlocked.push(perkId); + } + perks.rankedPerks[perkId] = (perks.rankedPerks[perkId] || 0) + 1; + } + + hasPerk(entity: Entity, perkId: string): boolean { + const perks = entityManager.getComponent(entity.id, 'perks'); + return perks?.unlocked.includes(perkId) ?? false; + } + + getPerkRank(entity: Entity, perkId: string): number { + const perks = entityManager.getComponent(entity.id, 'perks'); + return perks?.rankedPerks[perkId] ?? 0; + } + + getActiveEffects(entity: Entity): PerkEffect[] { + const perks = entityManager.getComponent(entity.id, 'perks'); + if (!perks) return []; + + const allPerks = dataRegistry.getAllPerkTrees().flatMap((t) => t.perks); + const effects: PerkEffect[] = []; + + for (const perkId of perks.unlocked) { + const perk = allPerks.find((p) => p.id === perkId); + if (!perk?.effects) continue; + const rank = perks.rankedPerks[perkId] || 1; + for (const effect of perk.effects) { + effects.push({ ...effect, value: effect.value * rank }); + } + } + return effects; + } + + getEffectValue(entity: Entity, effectType: string, school?: string): number { + const effects = this.getActiveEffects(entity); + let total = 0; + for (const e of effects) { + if (e.type === effectType) { + if (school === undefined || e.school === school) { + total += e.value; + } + } + } + return total; + } +} + +export const perkSystem = PerkSystem.getInstance(); +(globalThis as any).__oesPerkSystem = perkSystem; diff --git a/src/systems/ProximitySystem.ts b/src/systems/ProximitySystem.ts index af03167..40a33e9 100644 --- a/src/systems/ProximitySystem.ts +++ b/src/systems/ProximitySystem.ts @@ -92,14 +92,14 @@ export class ProximitySystem { 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)})`; + 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}`; + return `[E] 对话 ${npcData.name}`; } } @@ -110,7 +110,7 @@ export class ProximitySystem { if (nearby.item) { const groundItem = entityManager.getComponent<{ name: string; quantity: number }>(nearby.item.id, 'groundItem'); if (groundItem) { - return `[E] 拾取 ${groundItem.name} x${groundItem.quantity}`; + return `[E] 拾取 ${groundItem.name} x${groundItem.quantity}`; } } @@ -118,11 +118,11 @@ export class ProximitySystem { const containerInfo = containerSystem.getContainerInfo(nearby.container); if (containerInfo) { if (containerInfo.locked) { - return `[E] 开锁 ${containerInfo.name}`; + return `[E] 开锁 ${containerInfo.name}`; } else if (containerInfo.isEmpty) { - return `${containerInfo.name} (空)`; + return `${containerInfo.name} (空)`; } else { - return `[E] 搜索 ${containerInfo.name}`; + return `[E] 搜索 ${containerInfo.name}`; } } } diff --git a/src/systems/QuestSystem.ts b/src/systems/QuestSystem.ts index 413062d..ddf29b1 100644 --- a/src/systems/QuestSystem.ts +++ b/src/systems/QuestSystem.ts @@ -20,7 +20,7 @@ export interface Quest { export interface QuestObjective { id: string; description: string; - type: 'kill' | 'collect' | 'talk' | 'explore' | 'interact' | 'choice'; + type: 'kill' | 'collect' | 'talk' | 'explore' | 'reach' | 'interact' | 'steal' | 'choice'; target?: string; count?: number; currentCount?: number; @@ -204,6 +204,14 @@ export class QuestSystem { eventBus.on('zone:entered', (data: { zoneId: string }) => { this.updateExploreObjectives(data.zoneId); }); + + eventBus.on('quest:interact', (data: { targetId: string }) => { + this.updateInteractObjectives(data.targetId); + }); + + eventBus.on('quest:steal', (data: { itemId: string }) => { + this.updateStealObjectives(data.itemId); + }); } startQuest(questId: string, player: Entity): boolean { @@ -283,7 +291,35 @@ export class QuestSystem { if (!quest) continue; for (const objective of quest.objectives) { - if (objective.type === 'explore' && !objective.completed && objective.target === zoneId) { + if ((objective.type === 'explore' || objective.type === 'reach') && !objective.completed && objective.target === zoneId) { + objective.completed = true; + this.checkQuestProgress(quest); + } + } + } + } + + private updateInteractObjectives(targetId: 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 === 'interact' && !objective.completed && objective.target === targetId) { + objective.completed = true; + this.checkQuestProgress(quest); + } + } + } + } + + private updateStealObjectives(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 === 'steal' && !objective.completed && objective.target === itemId) { objective.completed = true; this.checkQuestProgress(quest); } @@ -442,7 +478,9 @@ export class QuestSystem { type === 'collect' || type === 'talk' || type === 'explore' || + type === 'reach' || type === 'interact' || + type === 'steal' || type === 'choice' ) { return type; diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts index 33cc5fa..b3bc02d 100644 --- a/src/ui/UIManager.ts +++ b/src/ui/UIManager.ts @@ -1,7 +1,9 @@ import { eventBus } from '../core/EventBus'; import { entityManager } from '../core/EntityManager'; import { inventorySystem } from '../systems/InventorySystem'; -import { T, FONT, panelStyle } from './theme'; +import { T, FONT } from './theme'; + +type InventoryTab = 'all' | 'weapon' | 'armor' | 'consumable' | 'material'; export class UIManager { private static instance: UIManager; @@ -13,6 +15,8 @@ export class UIManager { private inventoryPanel: HTMLDivElement; private isInventoryOpen: boolean = false; private zoneDisplay: HTMLDivElement; + private currentInventoryTab: InventoryTab = 'all'; + private crosshairEl: HTMLDivElement | null = null; static getInstance(): UIManager { if (!UIManager.instance) { @@ -102,28 +106,37 @@ export class UIManager { el.id = 'crosshair'; el.style.cssText = ` position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + top: 0; + left: 0; width: 20px; height: 20px; pointer-events: none; z-index: 150; - opacity: 0.4; + opacity: 0.6; + transform: translate(-50%, -50%); `; el.innerHTML = ` - - - - - + + + + + + `; document.body.appendChild(el); + this.crosshairEl = el; return el; } + updateCrosshair(x: number, y: number): void { + if (this.crosshairEl) { + this.crosshairEl.style.left = `${x}px`; + this.crosshairEl.style.top = `${y}px`; + } + } + private createInteractPrompt(): HTMLDivElement { const prompt = document.createElement('div'); prompt.id = 'interact-prompt'; @@ -132,19 +145,19 @@ export class UIManager { bottom: 120px; left: 50%; transform: translateX(-50%); - padding: 8px 22px; - background: linear-gradient(180deg, #2a2420, #1a1510); + padding: 8px 20px; + background: linear-gradient(180deg, rgba(30,26,20,0.92), rgba(18,14,10,0.92)); border: 1px solid ${T.borderBronze}; border-radius: 3px; - color: ${T.textGold}; font-size: 14px; font-family: ${FONT.body}; letter-spacing: 0.3px; pointer-events: none; z-index: 200; display: none; + white-space: nowrap; `; - prompt.textContent = 'E 交互'; + prompt.textContent = ''; this.container.appendChild(prompt); return prompt; } @@ -153,22 +166,253 @@ export class UIManager { const panel = document.createElement('div'); panel.id = 'inventory-panel'; panel.style.cssText = ` - position: absolute; + position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 620px; + width: 420px; max-height: 520px; - ${panelStyle} - padding: 0; + background: linear-gradient(180deg, rgba(22,18,14,0.96), rgba(14,10,8,0.96)); + border: 1px solid ${T.borderDark}; + border-radius: 4px; + z-index: 300; display: none; pointer-events: auto; - overflow-y: auto; + overflow: hidden; + font-family: ${FONT.body}; `; this.container.appendChild(panel); return panel; } + showInteractPrompt(text: string): void { + this.interactPrompt.innerHTML = ''; + const match = text.match(/^\[([^\]]+)\]\s*(.+)$/); + if (match) { + const keyBox = document.createElement('span'); + keyBox.style.cssText = ` + display: inline-block; + padding: 2px 7px; + margin-right: 8px; + background: rgba(60,50,30,0.6); + border: 1px solid ${T.borderBronze}; + border-radius: 2px; + color: ${T.textGoldBright}; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.5px; + vertical-align: middle; + `; + keyBox.textContent = match[1] || ''; + + const label = document.createElement('span'); + label.style.cssText = `color: ${T.textGold}; vertical-align: middle;`; + label.textContent = match[2] || ''; + + this.interactPrompt.appendChild(keyBox); + this.interactPrompt.appendChild(label); + } else { + this.interactPrompt.style.color = T.textGold; + this.interactPrompt.textContent = text; + } + this.interactPrompt.style.display = 'block'; + } + + hideInteractPrompt(): void { + this.interactPrompt.style.display = 'none'; + } + + private notificationQueue: HTMLDivElement[] = []; + + showNotification(text: string): void { + const toast = document.createElement('div'); + toast.className = 'oes-toast'; + toast.textContent = text; + + const offset = this.notificationQueue.length * 40; + toast.style.top = `${60 + offset}px`; + + this.notificationQueue.push(toast); + this.container.appendChild(toast); + + setTimeout(() => { + toast.remove(); + this.notificationQueue = this.notificationQueue.filter(t => t !== toast); + this.notificationQueue.forEach((t, i) => { + t.style.top = `${60 + i * 40}px`; + }); + }, 2600); + } + + toggleInventory(): void { + this.isInventoryOpen = !this.isInventoryOpen; + this.inventoryPanel.style.display = this.isInventoryOpen ? 'block' : 'none'; + if (this.isInventoryOpen) { + this.currentInventoryTab = 'all'; + this.renderInventory(); + } + } + + closeAllMenus(): void { + if (this.isInventoryOpen) this.toggleInventory(); + } + + getIsInventoryOpen(): boolean { + return this.isInventoryOpen; + } + + private renderInventory(): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + const inventory = inventorySystem.getInventory(player); + if (!inventory) return; + + const level = entityManager.getComponent<{ level: number }>(player.id, 'level'); + const levelText = level ? `等级 ${level.level}` : ''; + + const tabs: { id: InventoryTab; label: string }[] = [ + { id: 'all', label: '全部' }, + { id: 'weapon', label: '武器' }, + { id: 'armor', label: '护甲' }, + { id: 'consumable', label: '消耗品' }, + { id: 'material', label: '材料' }, + ]; + + const filteredItems = this.currentInventoryTab === 'all' + ? inventory.items + : inventory.items.filter(i => i.type === this.currentInventoryTab); + + const overweight = inventory.carryWeight > inventory.maxCarryWeight; + + this.inventoryPanel.innerHTML = ` +
+
+

物品

+ ${levelText ? `${levelText}` : ''} +
+ +
+
+ ${tabs.map(t => ` + + `).join('')} +
+
+ 物品 ${filteredItems.length} + + ${inventory.gold} 金币 + | + 负重 ${inventory.carryWeight.toFixed(1)}/${inventory.maxCarryWeight} + +
+
+ ${filteredItems.length === 0 ? `
空空如也
` : ''} +
+ `; + + const tabButtons = this.inventoryPanel.querySelectorAll('.inv-tab'); + tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + this.currentInventoryTab = (btn as HTMLElement).dataset.tab as InventoryTab; + this.renderInventory(); + }); + btn.addEventListener('mouseenter', () => { + if ((btn as HTMLElement).dataset.tab !== this.currentInventoryTab) { + (btn as HTMLElement).style.color = T.textLight; + } + }); + btn.addEventListener('mouseleave', () => { + if ((btn as HTMLElement).dataset.tab !== this.currentInventoryTab) { + (btn as HTMLElement).style.color = T.textMuted; + } + }); + }); + + const itemsContainer = document.getElementById('inventory-items'); + if (itemsContainer) { + filteredItems.forEach((item) => { + const itemEl = document.createElement('div'); + const typeColors: Record = { + weapon: '#e06040', + armor: '#4080c0', + consumable: '#40a060', + material: '#a08040', + }; + const typeColor = typeColors[item.type] || T.textDim; + itemEl.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px 10px; + background: rgba(255,255,255,0.02); + border: 1px solid ${T.borderDark}; + border-radius: 3px; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; + `; + itemEl.innerHTML = ` +
+ +
+
+ ${item.name}${item.equipped ? ` [已装备]` : ''} +
+
+ ${item.quantity > 1 ? `x${item.quantity} ` : ''}${item.weight} · ${item.value}金 +
+
+
+
+ ${item.type === 'consumable' ? `` : ''} + ${item.type === 'weapon' || item.type === 'armor' ? `` : ''} +
+ `; + + itemEl.addEventListener('mouseenter', () => { + itemEl.style.borderColor = T.borderBronze; + itemEl.style.background = 'rgba(255,255,255,0.04)'; + }); + itemEl.addEventListener('mouseleave', () => { + itemEl.style.borderColor = T.borderDark; + itemEl.style.background = 'rgba(255,255,255,0.02)'; + }); + + 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(); + }); + } + private setupEventListeners(): void { eventBus.on('entity:killed', (data: { entity: any }) => { const name = data.entity.type === 'enemy' ? '敌人' : '实体'; @@ -203,6 +447,10 @@ export class UIManager { this.zoneDisplay.textContent = zoneNames[zoneId] || zoneId; } + private healthTextEl: HTMLElement | null = null; + private magickaTextEl: HTMLElement | null = null; + private staminaTextEl: HTMLElement | null = null; + updateBars(): void { const player = entityManager.getEntitiesByType('player')[0]; if (!player) return; @@ -211,143 +459,27 @@ export class UIManager { const magicka = entityManager.getComponent<{ current: number; max: number }>(player.id, 'magicka'); const stamina = entityManager.getComponent<{ current: number; max: number }>(player.id, 'stamina'); + if (!this.healthTextEl) this.healthTextEl = document.getElementById('health-bar-text'); + if (!this.magickaTextEl) this.magickaTextEl = document.getElementById('magicka-bar-text'); + if (!this.staminaTextEl) this.staminaTextEl = document.getElementById('stamina-bar-text'); + 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 (this.healthTextEl) this.healthTextEl.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 (this.magickaTextEl) this.magickaTextEl.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)}`; + if (this.staminaTextEl) this.staminaTextEl.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); - } - - toggleInventory(): void { - this.isInventoryOpen = !this.isInventoryOpen; - this.inventoryPanel.style.display = this.isInventoryOpen ? 'block' : 'none'; - - if (this.isInventoryOpen) { - this.renderInventory(); - } - } - - getIsInventoryOpen(): boolean { - return this.isInventoryOpen; - } - - 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(); - }); } } diff --git a/src/ui/components/CharacterInfoUI.ts b/src/ui/components/CharacterInfoUI.ts new file mode 100644 index 0000000..46ab30e --- /dev/null +++ b/src/ui/components/CharacterInfoUI.ts @@ -0,0 +1,168 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager } from '../../core/EntityManager'; +import { inventorySystem } from '../../systems/InventorySystem'; + +export class CharacterInfoUI { + private static instance: CharacterInfoUI; + private container: HTMLDivElement; + private isOpen = false; + + static getInstance(): CharacterInfoUI { + if (!CharacterInfoUI.instance) { + CharacterInfoUI.instance = new CharacterInfoUI(); + } + return CharacterInfoUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'character-info-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 400; + background: rgba(5,5,10,0.85); + font-family: ${FONT.body}; + `; + } + + toggle(): void { + if (this.isOpen) this.hide(); + else this.show(); + } + + show(): void { + if (this.isOpen) return; + this.isOpen = true; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'characterInfo' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'characterInfo' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + + 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'); + const level = entityManager.getComponent<{ level: number; xp: number; xpToNext: number; perkPoints: number }>(player.id, 'level'); + const skills = entityManager.getComponent>(player.id, 'skills'); + const weapon = entityManager.getComponent<{ id: string; damage: number }>(player.id, 'weapon'); + const armor = entityManager.getComponent<{ rating: number }>(player.id, 'armor'); + const inv = inventorySystem.getInventory(player); + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 580px; max-height: 520px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; border-radius: 4px; + display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 0 40px rgba(0,0,0,0.5); + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid ${T.borderDark}; + `; + const title = document.createElement('div'); + title.style.cssText = `font-size: 16px; color: ${T.textGold}; font-family: ${FONT.title}; letter-spacing: 1px;`; + title.textContent = '角色信息'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn'; + closeBtn.style.cssText = `padding: 4px 12px; font-size: 12px;`; + closeBtn.textContent = 'ESC'; + closeBtn.addEventListener('click', () => this.hide()); + + header.appendChild(title); + header.appendChild(closeBtn); + panel.appendChild(header); + + // Content + const content = document.createElement('div'); + content.style.cssText = `padding: 16px 20px; overflow-y: auto; flex: 1; display: flex; gap: 20px;`; + + // Left: Stats + const left = document.createElement('div'); + left.style.cssText = `flex: 1;`; + + const addStat = (label: string, value: string, color: string) => { + const row = document.createElement('div'); + row.style.cssText = `display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid ${T.borderDark};`; + row.innerHTML = `${label}${value}`; + left.appendChild(row); + }; + + addStat('等级', `${level?.level || 1}`, T.textGold); + addStat('经验值', `${level?.xp || 0} / ${level?.xpToNext || 100}`, T.textMuted); + addStat('天赋点', `${level?.perkPoints || 0}`, T.textGoldBright); + addStat('生命', `${Math.round(health?.current || 0)} / ${health?.max || 100}`, T.healthRed); + addStat('魔法', `${Math.round(magicka?.current || 0)} / ${magicka?.max || 50}`, T.magickaBlue); + addStat('耐力', `${Math.round(stamina?.current || 0)} / ${stamina?.max || 100}`, T.staminaGreen); + addStat('武器伤害', `${weapon?.damage || 0}`, T.danger); + addStat('护甲值', `${armor?.rating || 0}`, T.textLight); + addStat('金币', `${inv?.gold || 0}`, T.goldAccent); + addStat('负重', `${inv ? inv.carryWeight.toFixed(1) : '0'} / ${inv?.maxCarryWeight || 300}`, inv && inv.carryWeight > (inv.maxCarryWeight || 300) ? T.danger : T.textMuted); + + // Right: Skills + const right = document.createElement('div'); + right.style.cssText = `flex: 1;`; + + const skillTitle = document.createElement('div'); + skillTitle.style.cssText = `font-size: 13px; color: ${T.textGold}; margin-bottom: 8px; font-family: ${FONT.title};`; + skillTitle.textContent = '技能'; + right.appendChild(skillTitle); + + const skillNames: Record = { + oneHanded: '单手', twoHanded: '双手', archery: '弓术', block: '格挡', + heavyArmor: '重甲', lightArmor: '轻甲', + destruction: '毁灭', conjuration: '召唤', illusion: '幻术', alteration: '变化', restoration: '恢复', enchanting: '附魔', + sneak: '潜行', lockpicking: '开锁', pickpocket: '扒窃', speech: '口才', alchemy: '炼金', smithing: '锻造', + }; + + if (skills) { + for (const [key, name] of Object.entries(skillNames)) { + const val = (skills as Record)[key] || 0; + const row = document.createElement('div'); + row.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 3px 0;`; + row.innerHTML = ` + ${name} +
+
+
+
+ ${val} +
+ `; + right.appendChild(row); + } + } + + content.appendChild(left); + content.appendChild(right); + panel.appendChild(content); + this.container.appendChild(panel); + } +} + +export const characterInfoUI = CharacterInfoUI.getInstance(); diff --git a/src/ui/components/CombatUI.ts b/src/ui/components/CombatUI.ts index 40701ba..a42e660 100644 --- a/src/ui/components/CombatUI.ts +++ b/src/ui/components/CombatUI.ts @@ -1,6 +1,7 @@ import { eventBus } from '../../core/EventBus'; import { entityManager } from '../../core/EntityManager'; import { T, FONT } from '../theme'; +import { settingsUI } from './SettingsUI'; export class CombatUI { private static instance: CombatUI; @@ -38,6 +39,7 @@ export class CombatUI { } private showDamageNumber(entity: any, damage: number): void { + if (!settingsUI.getSettings().showDamageNumbers) return; const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position'); if (!pos) return; @@ -73,6 +75,7 @@ export class CombatUI { } private showHealNumber(entity: any, amount: number): void { + if (!settingsUI.getSettings().showDamageNumbers) return; const pos = entityManager.getComponent<{ x: number; y: number }>(entity.id, 'position'); if (!pos) return; @@ -123,9 +126,6 @@ export class CombatUI { } 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; @@ -139,33 +139,58 @@ export class CombatUI { 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; - `; + let bar = document.getElementById(`enemy-health-${entity.id}`); + let fill = bar?.querySelector('div'); - 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; - `; + if (bar && fill) { + // Update existing bar position and fill + bar.style.left = `${screenX - 24}px`; + bar.style.top = `${screenY}px`; + (fill as HTMLElement).style.width = `${(health.current / health.max) * 100}%`; + } else { + // Create new bar + 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; + `; - bar.appendChild(fill); - this.container.appendChild(bar); + 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()); + // Just hide bars, don't remove from DOM + this.container.querySelectorAll('[id^="enemy-health-"]').forEach((bar) => { + (bar as HTMLElement).style.display = 'none'; + }); + } + + hideInactiveHealthBars(activeIds: Set): void { + this.container.querySelectorAll('[id^="enemy-health-"]').forEach((bar) => { + const id = bar.id.replace('enemy-health-', ''); + if (!activeIds.has(id)) { + (bar as HTMLElement).style.display = 'none'; + } else { + (bar as HTMLElement).style.display = ''; + } + }); } } diff --git a/src/ui/components/CompassHUD.ts b/src/ui/components/CompassHUD.ts index cd7748a..29ff0d3 100644 --- a/src/ui/components/CompassHUD.ts +++ b/src/ui/components/CompassHUD.ts @@ -1,5 +1,6 @@ import { T, FONT } from '../theme'; import { entityManager } from '../../core/EntityManager'; +import { mapManager } from '../../maps/MapManager'; interface CompassMarker { id: string; @@ -12,6 +13,10 @@ export class CompassHUD { private static instance: CompassHUD; private container: HTMLDivElement; private markerContainer: HTMLDivElement; + private markerPool: HTMLDivElement[] = []; + private levelBadge: HTMLDivElement; + private directionLabel: HTMLDivElement; + private zoneLabel: HTMLDivElement; private updateTimer: ReturnType | null = null; static getInstance(): CompassHUD { @@ -29,14 +34,20 @@ export class CompassHUD { top: 8px; left: 50%; transform: translateX(-50%); - width: 400px; - height: 28px; - background: rgba(10,10,15,0.6); + width: 440px; + height: 32px; + pointer-events: none; + z-index: 150; + `; + + const bar = document.createElement('div'); + bar.style.cssText = ` + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(15,15,20,0.75), rgba(10,10,15,0.55)); border: 1px solid ${T.borderDark}; border-radius: 3px; overflow: hidden; - pointer-events: none; - z-index: 150; `; const centerLine = document.createElement('div'); @@ -45,19 +56,83 @@ export class CompassHUD { top: 0; left: 50%; transform: translateX(-50%); - width: 2px; + width: 1px; height: 100%; background: ${T.borderBronze}; - opacity: 0.6; + opacity: 0.5; `; - this.container.appendChild(centerLine); + bar.appendChild(centerLine); + + const tickN = document.createElement('div'); + tickN.style.cssText = ` + position: absolute; top: 0; left: 50%; transform: translateX(-50%); + font-size: 9px; color: ${T.textMuted}; font-family: ${FONT.body}; + padding: 1px 3px; letter-spacing: 1px; + `; + tickN.textContent = 'N'; + bar.appendChild(tickN); this.markerContainer = document.createElement('div'); - this.markerContainer.style.cssText = ` + this.markerContainer.style.cssText = `position: absolute; inset: 0;`; + bar.appendChild(this.markerContainer); + + this.levelBadge = document.createElement('div'); + this.levelBadge.style.cssText = ` position: absolute; - inset: 0; + top: 50%; + left: -52px; + transform: translateY(-50%); + padding: 2px 8px; + background: linear-gradient(180deg, #2a2420, #1a1510); + border: 1px solid ${T.borderBronze}; + border-radius: 3px; + color: ${T.textGold}; + font-size: 11px; + font-family: ${FONT.title}; + white-space: nowrap; `; - this.container.appendChild(this.markerContainer); + this.levelBadge.textContent = 'Lv.1'; + + this.directionLabel = document.createElement('div'); + this.directionLabel.style.cssText = ` + position: absolute; + top: 50%; + right: -70px; + transform: translateY(-50%); + padding: 2px 8px; + background: rgba(10,10,15,0.6); + border: 1px solid ${T.borderDark}; + border-radius: 3px; + color: ${T.textMuted}; + font-size: 11px; + font-family: ${FONT.body}; + white-space: nowrap; + `; + this.directionLabel.textContent = 'N 0°'; + + this.zoneLabel = document.createElement('div'); + this.zoneLabel.style.cssText = ` + position: absolute; + top: 40px; + left: 50%; + transform: translateX(-50%); + padding: 3px 14px; + background: rgba(0,0,0,0.45); + border: 1px solid ${T.borderDark}; + border-radius: 3px; + color: ${T.textMuted}; + font-size: 12px; + font-family: ${FONT.body}; + letter-spacing: 0.5px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.4s; + `; + + this.container.appendChild(bar); + this.container.appendChild(this.levelBadge); + this.container.appendChild(this.directionLabel); + this.container.appendChild(this.zoneLabel); this.startUpdate(); } @@ -76,51 +151,70 @@ export class CompassHUD { } } + showZoneName(name: string): void { + this.zoneLabel.textContent = name; + this.zoneLabel.style.opacity = '1'; + setTimeout(() => { this.zoneLabel.style.opacity = '0'; }, 3000); + } + updateMarkers(markers: CompassMarker[]): void { - this.markerContainer.innerHTML = ''; - const width = 400; + const width = 440; const centerX = width / 2; - for (const marker of markers) { - const offset = Math.max(-180, Math.min(180, marker.angle)); - const x = centerX + (offset / 180) * (width / 2); - + // Reuse or create marker elements from pool + while (this.markerPool.length < markers.length) { const el = document.createElement('div'); el.style.cssText = ` position: absolute; top: 50%; - left: ${x}px; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; pointer-events: none; `; - const dot = document.createElement('div'); - const color = this.getMarkerColor(marker.type); - dot.style.cssText = ` - width: 6px; - height: 6px; - background: ${color}; - border-radius: 50%; - box-shadow: 0 0 4px ${color}; - `; - const label = document.createElement('div'); label.style.cssText = ` font-size: 9px; - color: ${T.textMuted}; + color: ${T.textLight}; font-family: ${FONT.body}; white-space: nowrap; - margin-top: 1px; + margin-top: 2px; + text-shadow: 0 1px 2px rgba(0,0,0,0.8); `; - label.textContent = marker.label; - el.appendChild(dot); el.appendChild(label); + this.markerPool.push(el); this.markerContainer.appendChild(el); } + + // Hide unused markers and update visible ones + for (let i = 0; i < this.markerPool.length; i++) { + const el = this.markerPool[i]!; + if (i >= markers.length) { + el.style.display = 'none'; + continue; + } + el.style.display = ''; + + const marker = markers[i]!; + const offset = Math.max(-180, Math.min(180, marker.angle)); + const x = centerX + (offset / 180) * (width / 2); + el.style.left = `${x}px`; + + const color = this.getMarkerColor(marker.type); + const shape = marker.type === 'door' ? '6px 6px 0 0' : '50%'; + const dot = el.children[0] as HTMLElement; + dot.style.cssText = ` + width: 7px; + height: 7px; + background: ${color}; + border-radius: ${shape}; + box-shadow: 0 0 5px ${color}; + `; + (el.children[1] as HTMLElement).textContent = marker.label; + } } private getMarkerColor(type: string): string { @@ -134,48 +228,94 @@ export class CompassHUD { private startUpdate(): void { if (this.updateTimer) return; - this.updateTimer = setInterval(() => { - this.autoUpdate(); - }, 500); + this.updateTimer = setInterval(() => this.autoUpdate(), 250); } private autoUpdate(): void { const player = entityManager.getEntitiesByType('player')[0]; if (!player) return; - const playerPos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); - if (!playerPos) return; + const playerPos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); + if (!playerPos) return; - const markers: CompassMarker[] = []; - const enemies = entityManager.getEntitiesByType('enemy'); - for (const enemy of enemies) { - const pos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position'); - const health = entityManager.getComponent<{ current: number }>(enemy.id, 'health'); - if (!pos || !health || health.current <= 0) continue; - const dx = pos.x - playerPos.x; - const dy = pos.y - playerPos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 300) continue; - const angle = Math.atan2(dx, -dy) * (180 / Math.PI); - const name = entityManager.getComponent<{ name: string }>(enemy.id, 'enemyType'); - markers.push({ id: enemy.id, label: name?.name || '敌', type: 'enemy', angle }); + const level = entityManager.getComponent<{ level: number }>(player.id, 'level'); + if (level) this.levelBadge.textContent = `Lv.${level.level}`; + + const angle = Math.atan2( + Math.sin((player as any).rotation || 0), + -Math.cos((player as any).rotation || 0) + ) * (180 / Math.PI); + const normalizedAngle = ((angle % 360) + 360) % 360; + const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + const dirIdx = Math.round(normalizedAngle / 45) % 8; + this.directionLabel.textContent = `${dirs[dirIdx]} ${Math.round(normalizedAngle)}°`; + + const markers: CompassMarker[] = []; + const enemies = entityManager.getEntitiesByType('enemy'); + for (const enemy of enemies) { + const pos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position'); + const health = entityManager.getComponent<{ current: number }>(enemy.id, 'health'); + if (!pos || !health || health.current <= 0) continue; + const dx = pos.x - playerPos.x; + const dy = pos.y - playerPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 400) continue; + const a = Math.atan2(dx, -dy) * (180 / Math.PI); + const name = entityManager.getComponent<{ name: string }>(enemy.id, 'enemyType'); + markers.push({ id: enemy.id, label: name?.name || '敌', type: 'enemy', angle: a }); + } + + 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 = pos.x - playerPos.x; + const dy = pos.y - playerPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 400) continue; + const a = Math.atan2(dx, -dy) * (180 / Math.PI); + const name = entityManager.getComponent<{ name: string }>(npc.id, 'npcData'); + markers.push({ id: npc.id, label: name?.name || 'NPC', type: 'npc', angle: a }); + } + + // Door markers from current zone + try { + const currentZone = mapManager.getCurrentZone?.(); + if (currentZone?.doors) { + const tileSize = currentZone.tileSize || 32; + for (const door of currentZone.doors) { + const doorX = door.x * tileSize + tileSize / 2; + const doorY = door.y * tileSize + tileSize / 2; + const dx = doorX - playerPos.x; + const dy = doorY - playerPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 500) continue; + const a = Math.atan2(dx, -dy) * (180 / Math.PI); + markers.push({ id: `door_${door.x}_${door.y}`, label: door.targetZone || '出口', type: 'door', angle: a }); + } } + } catch { /* ignore if mapManager not ready */ } - 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 = pos.x - playerPos.x; - const dy = pos.y - playerPos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 300) continue; - const angle = Math.atan2(dx, -dy) * (180 / Math.PI); - const name = entityManager.getComponent<{ name: string }>(npc.id, 'npcData'); - markers.push({ id: npc.id, label: name?.name || 'NPC', type: 'npc', angle }); + // Container/chest markers from current zone + try { + const currentZone2 = mapManager.getCurrentZone?.(); + if (currentZone2?.chests) { + const tileSize2 = currentZone2.tileSize || 32; + for (const chest of currentZone2.chests) { + const cx = chest.x * tileSize2 + tileSize2 / 2; + const cy = chest.y * tileSize2 + tileSize2 / 2; + const dx = cx - playerPos.x; + const dy = cy - playerPos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 300) continue; + const a = Math.atan2(dx, -dy) * (180 / Math.PI); + markers.push({ id: `chest_${chest.x}_${chest.y}`, label: '宝箱', type: 'poi', angle: a }); + } } + } catch { /* ignore */ } - markers.sort((a, b) => a.angle - b.angle); - this.updateMarkers(markers); + markers.sort((a, b) => a.angle - b.angle); + this.updateMarkers(markers); } } diff --git a/src/ui/components/CraftingUI.ts b/src/ui/components/CraftingUI.ts index 038ede6..c43a9ce 100644 --- a/src/ui/components/CraftingUI.ts +++ b/src/ui/components/CraftingUI.ts @@ -37,6 +37,7 @@ export class CraftingUI { } show(tab: CraftingTab = 'alchemy'): void { + if (this.isOpen) return; this.isOpen = true; this.currentTab = tab; this.container.style.display = 'block'; @@ -45,8 +46,10 @@ export class CraftingUI { } hide(): void { + if (!this.isOpen) return; this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); } toggle(tab?: CraftingTab): void { diff --git a/src/ui/components/DialogueUI.ts b/src/ui/components/DialogueUI.ts index fc402b4..da9980e 100644 --- a/src/ui/components/DialogueUI.ts +++ b/src/ui/components/DialogueUI.ts @@ -45,8 +45,10 @@ export class DialogueUI { } private showDialogue(speaker: string, text: string, options: any[], _npc: any): void { + if (this.isOpen) this.container.remove(); this.isOpen = true; this.container.style.display = 'block'; + document.body.appendChild(this.container); const playerEntity = entityManager.getEntitiesByType('player')[0]; const playerLevel = entityManager.getComponent<{ level: number }>(playerEntity?.id || '', 'level'); @@ -165,11 +167,17 @@ export class DialogueUI { }; hide(): void { + if (!this.isOpen) return; this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); document.removeEventListener('keydown', this.handleEscapeKey); } + getIsOpen(): boolean { + return this.isOpen; + } + isDialogueOpen(): boolean { return this.isOpen; } diff --git a/src/ui/components/FavoritesUI.ts b/src/ui/components/FavoritesUI.ts new file mode 100644 index 0000000..7b1403a --- /dev/null +++ b/src/ui/components/FavoritesUI.ts @@ -0,0 +1,257 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager, type Entity } from '../../core/EntityManager'; +import { inventorySystem } from '../../systems/InventorySystem'; +import { magicSystem } from '../../systems/MagicSystem'; + +interface FavoriteEntry { + id: string; + name: string; + type: 'weapon' | 'spell' | 'consumable' | 'armor'; + slot: number; +} + +export class FavoritesUI { + private static instance: FavoritesUI; + private container: HTMLDivElement; + private isOpen = false; + private favorites: FavoriteEntry[] = []; + + static getInstance(): FavoritesUI { + if (!FavoritesUI.instance) { + FavoritesUI.instance = new FavoritesUI(); + } + return FavoritesUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'favorites-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 450; + background: rgba(5,5,10,0.75); + font-family: ${FONT.body}; + `; + this.loadFavorites(); + } + + toggle(): void { + if (this.isOpen) this.hide(); + else this.show(); + } + + show(): void { + if (this.isOpen) return; + this.isOpen = true; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'favorites' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'favorites' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + getFavorites(): ReadonlyArray { + return this.favorites; + } + + addToFavorites(id: string, name: string, type: FavoriteEntry['type']): void { + if (this.favorites.length >= 8) return; + if (this.favorites.some(f => f.id === id)) return; + this.favorites.push({ id, name, type, slot: this.favorites.length + 1 }); + this.saveFavorites(); + eventBus.emit('ui:notification', { text: `已添加到收藏: ${name}` }); + } + + removeFromFavorites(id: string): void { + this.favorites = this.favorites.filter(f => f.id !== id); + this.favorites.forEach((f, i) => { f.slot = i + 1; }); + this.saveFavorites(); + } + + useFavorite(slot: number): void { + const fav = this.favorites.find(f => f.slot === slot); + if (!fav) return; + + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + switch (fav.type) { + case 'weapon': + case 'armor': + inventorySystem.equipItem(player, fav.id, 'rightHand'); + break; + case 'spell': { + // Find nearest enemy to cast spell on + const enemies = entityManager.getEntitiesByType('enemy'); + let target: Entity | null = null; + let minDist = Infinity; + const pos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); + if (pos) { + for (const e of enemies) { + const ep = entityManager.getComponent<{ x: number; y: number }>(e.id, 'position'); + if (!ep) continue; + const d = Math.hypot(ep.x - pos.x, ep.y - pos.y); + if (d < minDist && d < 200) { minDist = d; target = e; } + } + } + magicSystem.castSpell(player, fav.id, target || undefined); + break; + } + case 'consumable': + inventorySystem.useItem(player, fav.id); + break; + } + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 360px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; border-radius: 4px; + box-shadow: 0 0 40px rgba(0,0,0,0.5); + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid ${T.borderDark}; + `; + const title = document.createElement('div'); + title.style.cssText = `font-size: 15px; color: ${T.textGold}; font-family: ${FONT.title}; letter-spacing: 1px;`; + title.textContent = '收藏'; + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn'; + closeBtn.style.cssText = `padding: 3px 10px; font-size: 11px;`; + closeBtn.textContent = 'ESC'; + closeBtn.addEventListener('click', () => this.hide()); + header.appendChild(title); + header.appendChild(closeBtn); + panel.appendChild(header); + + // Quick use hint + const hint = document.createElement('div'); + hint.style.cssText = `padding: 8px 16px; font-size: 11px; color: ${T.textDim}; border-bottom: 1px solid ${T.borderDark};`; + hint.textContent = '按 1-8 快速使用收藏物品 | 点击添加/移除'; + panel.appendChild(hint); + + // Add from inventory/spells + const addSection = document.createElement('div'); + addSection.style.cssText = `padding: 8px 16px; border-bottom: 1px solid ${T.borderDark};`; + + const addTitle = document.createElement('div'); + addTitle.style.cssText = `font-size: 11px; color: ${T.textMuted}; margin-bottom: 6px;`; + addTitle.textContent = '点击添加到收藏:'; + addSection.appendChild(addTitle); + + const player = entityManager.getEntitiesByType('player')[0]; + if (player) { + const inv = inventorySystem.getInventory(player); + if (inv) { + const equipable = inv.items.filter(i => i.type === 'weapon' || i.type === 'armor' || i.type === 'consumable'); + for (const item of equipable.slice(0, 6)) { + const isFav = this.favorites.some(f => f.id === item.id); + const btn = document.createElement('button'); + btn.className = 'oes-btn'; + btn.style.cssText = ` + padding: 4px 10px; margin: 2px; font-size: 11px; + ${isFav ? `border-color: ${T.success}; color: ${T.success};` : ''} + `; + btn.textContent = `${isFav ? '- ' : '+ '}${item.name}`; + btn.addEventListener('click', () => { + if (isFav) { + this.removeFromFavorites(item.id); + } else { + this.addToFavorites(item.id, item.name, item.type as FavoriteEntry['type']); + } + this.render(); + }); + addSection.appendChild(btn); + } + } + } + panel.appendChild(addSection); + + // Current favorites list + const list = document.createElement('div'); + list.style.cssText = `padding: 8px 16px; max-height: 240px; overflow-y: auto;`; + + if (this.favorites.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `text-align: center; color: ${T.textDim}; padding: 20px 0; font-size: 12px;`; + empty.textContent = '没有收藏项目'; + list.appendChild(empty); + } else { + for (const fav of this.favorites) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; justify-content: space-between; align-items: center; + padding: 6px 10px; margin-bottom: 2px; + border: 1px solid ${T.borderDark}; border-radius: 3px; + cursor: pointer; transition: border-color 0.12s; + `; + + const typeColors: Record = { + weapon: '#e06040', armor: '#4080c0', spell: '#7050c0', consumable: '#40a060', + }; + + row.innerHTML = ` +
+ [${fav.slot}] + + ${fav.name} +
+ + `; + + row.addEventListener('mouseenter', () => { row.style.borderColor = T.borderBronze; }); + row.addEventListener('mouseleave', () => { row.style.borderColor = T.borderDark; }); + row.addEventListener('click', () => this.useFavorite(fav.slot)); + + const removeBtn = row.querySelector('.fav-remove'); + removeBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeFromFavorites(fav.id); + this.render(); + }); + + list.appendChild(row); + } + } + + panel.appendChild(list); + this.container.appendChild(panel); + } + + private saveFavorites(): void { + try { + localStorage.setItem('oes-favorites', JSON.stringify(this.favorites)); + } catch { /* ignore */ } + } + + private loadFavorites(): void { + try { + const saved = localStorage.getItem('oes-favorites'); + if (saved) this.favorites = JSON.parse(saved); + } catch { /* ignore */ } + } +} + +export const favoritesUI = FavoritesUI.getInstance(); diff --git a/src/ui/components/LevelUpUI.ts b/src/ui/components/LevelUpUI.ts new file mode 100644 index 0000000..57cc3ac --- /dev/null +++ b/src/ui/components/LevelUpUI.ts @@ -0,0 +1,138 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager } from '../../core/EntityManager'; + +export class LevelUpUI { + private static instance: LevelUpUI; + private container: HTMLDivElement; + private isOpen = false; + private newLevel = 1; + + static getInstance(): LevelUpUI { + if (!LevelUpUI.instance) { + LevelUpUI.instance = new LevelUpUI(); + } + return LevelUpUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'levelup-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 500; + background: rgba(5,5,10,0.9); + font-family: ${FONT.body}; + `; + } + + show(level: number): void { + if (this.isOpen) return; + this.isOpen = true; + this.newLevel = level; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'levelup' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'levelup' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + this.container.style.flexDirection = 'column'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 480px; + background: linear-gradient(180deg, rgba(30,25,18,0.97), rgba(18,14,10,0.97)); + border: 1px solid ${T.borderGold}; border-radius: 4px; + padding: 36px 32px; text-align: center; + box-shadow: 0 0 60px rgba(212,168,67,0.15), 0 0 120px rgba(0,0,0,0.4); + `; + + // Title + const glow = document.createElement('div'); + glow.style.cssText = ` + font-size: 14px; color: ${T.textGoldBright}; letter-spacing: 4px; + text-transform: uppercase; margin-bottom: 8px; + text-shadow: 0 0 20px rgba(240,200,80,0.5); + `; + glow.textContent = '等级提升'; + + const levelNum = document.createElement('div'); + levelNum.style.cssText = ` + font-family: ${FONT.title}; font-size: 64px; color: ${T.textGoldBright}; + text-shadow: 0 0 30px rgba(212,168,67,0.6); + margin: 8px 0 20px; + `; + levelNum.textContent = `${this.newLevel}`; + + const divider = document.createElement('div'); + divider.style.cssText = `border-top: 1px solid ${T.borderBronze}; margin: 0 40px 20px; opacity: 0.5;`; + + // Info + const info = document.createElement('div'); + info.style.cssText = `color: ${T.textMuted}; font-size: 13px; margin-bottom: 6px;`; + info.textContent = `你获得了 1 点天赋点`; + + const hpInfo = document.createElement('div'); + hpInfo.style.cssText = `color: ${T.healthRed}; font-size: 12px; margin-bottom: 4px;`; + hpInfo.textContent = '生命值 +10'; + + const mpInfo = document.createElement('div'); + mpInfo.style.cssText = `color: ${T.magickaBlue}; font-size: 12px; margin-bottom: 4px;`; + mpInfo.textContent = '魔法值 +5'; + + const spInfo = document.createElement('div'); + spInfo.style.cssText = `color: ${T.staminaGreen}; font-size: 12px; margin-bottom: 20px;`; + spInfo.textContent = '耐力值 +5'; + + // Apply bonuses + const player = entityManager.getEntitiesByType('player')[0]; + if (player) { + const hp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'health'); + const mp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'magicka'); + const sp = entityManager.getComponent<{ current: number; max: number }>(player.id, 'stamina'); + const lv = entityManager.getComponent<{ perkPoints: number }>(player.id, 'level'); + if (hp) hp.max += 10; + if (mp) mp.max += 5; + if (sp) sp.max += 5; + if (lv) lv.perkPoints += 1; + } + + // Close button + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn oes-btn--primary'; + closeBtn.style.cssText = ` + padding: 12px 40px; font-size: 15px; letter-spacing: 2px; + `; + closeBtn.textContent = '继续冒险'; + closeBtn.addEventListener('click', () => this.hide()); + + panel.appendChild(glow); + panel.appendChild(levelNum); + panel.appendChild(divider); + panel.appendChild(info); + panel.appendChild(hpInfo); + panel.appendChild(mpInfo); + panel.appendChild(spInfo); + panel.appendChild(closeBtn); + + this.container.appendChild(panel); + } +} + +export const levelUpUI = LevelUpUI.getInstance(); diff --git a/src/ui/components/LockpickingUI.ts b/src/ui/components/LockpickingUI.ts new file mode 100644 index 0000000..a09280b --- /dev/null +++ b/src/ui/components/LockpickingUI.ts @@ -0,0 +1,249 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager, type Entity } from '../../core/EntityManager'; + +export class LockpickingUI { + private static instance: LockpickingUI; + private container: HTMLDivElement; + private isOpen = false; + private lockLevel = 0; + private targetEntity: Entity | null = null; + private lockpickX = 50; + private sweetSpotX = 50; + private attempts = 0; + private maxAttempts = 5; + private lockpickBroken = false; + + static getInstance(): LockpickingUI { + if (!LockpickingUI.instance) { + LockpickingUI.instance = new LockpickingUI(); + } + return LockpickingUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'lockpicking-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 500; + background: rgba(5,5,10,0.92); + font-family: ${FONT.body}; + `; + } + + show(entity: Entity, lockLevel: number): void { + if (this.isOpen) return; + this.isOpen = true; + this.targetEntity = entity; + this.lockLevel = lockLevel; + this.attempts = 0; + this.maxAttempts = Math.max(3, 6 - lockLevel); + this.lockpickX = 50; + this.sweetSpotX = 20 + Math.random() * 60; + this.lockpickBroken = false; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'lockpicking' }); + this.startPickMovement(); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + document.removeEventListener('mousemove', this.onMouseMove); + document.removeEventListener('click', this.onClick); + eventBus.emit('ui:menuClosed', { menu: 'lockpicking' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private onMouseMove = (e: MouseEvent): void => { + if (!this.isOpen || this.lockpickBroken) return; + const rect = this.container.querySelector('.lock-circle') as HTMLDivElement; + if (!rect) return; + const r = rect.getBoundingClientRect(); + this.lockpickX = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)); + this.updatePickPosition(); + }; + + private onClick = (): void => { + if (!this.isOpen || this.lockpickBroken) return; + this.attemptPick(); + }; + + private startPickMovement(): void { + document.addEventListener('mousemove', this.onMouseMove); + document.addEventListener('click', this.onClick); + } + + private updatePickPosition(): void { + const pick = this.container.querySelector('.lockpick-indicator') as HTMLDivElement; + if (pick) { + pick.style.left = `${this.lockpickX}%`; + } + } + + private attemptPick(): void { + const diff = Math.abs(this.lockpickX - this.sweetSpotX); + const success = diff < (15 - this.lockLevel * 2); + + if (success) { + this.showResult(true); + } else { + this.attempts++; + if (this.attempts >= this.maxAttempts) { + this.lockpickBroken = true; + this.showResult(false); + } else { + // Show shake feedback + const circle = this.container.querySelector('.lock-circle') as HTMLDivElement; + if (circle) { + circle.style.animation = 'none'; + circle.offsetHeight; // reflow + circle.style.animation = 'oesLockShake 0.3s ease'; + } + // Move sweet spot + this.sweetSpotX = 20 + Math.random() * 60; + this.updateSweetSpot(); + } + } + } + + private updateSweetSpot(): void { + const spot = this.container.querySelector('.sweet-spot') as HTMLDivElement; + if (spot) { + spot.style.left = `${this.sweetSpotX}%`; + } + } + + private showResult(success: boolean): void { + const resultEl = document.createElement('div'); + resultEl.style.cssText = ` + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + font-size: 20px; font-family: ${FONT.title}; letter-spacing: 2px; + color: ${success ? T.success : T.danger}; + text-shadow: 0 0 20px ${success ? T.success : T.danger}66; + z-index: 10; + `; + resultEl.textContent = success ? '开锁成功!' : '锁针断裂!'; + this.container.appendChild(resultEl); + + setTimeout(() => { + if (success) { + // Unlock the container + if (this.targetEntity) { + const container = entityManager.getComponent<{ locked: boolean }>(this.targetEntity.id, 'container'); + if (container) container.locked = false; + eventBus.emit('container:unlocked', { entity: this.targetEntity }); + } + } + this.hide(); + }, 1200); + } + + private render(): void { + this.container.innerHTML = ''; + + // Inject keyframes + if (!document.getElementById('lockpick-keyframes')) { + const style = document.createElement('style'); + style.id = 'lockpick-keyframes'; + style.textContent = ` + @keyframes oesLockShake { + 0%, 100% { transform: translate(-50%, -50%) rotate(0); } + 25% { transform: translate(-50%, -50%) rotate(-5deg); } + 75% { transform: translate(-50%, -50%) rotate(5deg); } + } + `; + document.head.appendChild(style); + } + + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + this.container.style.flexDirection = 'column'; + + // Title + const title = document.createElement('div'); + title.style.cssText = ` + font-family: ${FONT.title}; font-size: 18px; color: ${T.textGold}; + letter-spacing: 2px; margin-bottom: 20px; + `; + title.textContent = `开锁 (等级 ${this.lockLevel})`; + this.container.appendChild(title); + + // Lock circle + const circle = document.createElement('div'); + circle.className = 'lock-circle'; + circle.style.cssText = ` + width: 200px; height: 200px; border-radius: 50%; + border: 3px solid ${T.borderBronze}; + background: radial-gradient(circle, rgba(20,18,14,0.9), rgba(10,8,6,0.95)); + position: relative; cursor: pointer; + box-shadow: 0 0 30px rgba(0,0,0,0.5), inset 0 0 20px rgba(0,0,0,0.3); + `; + + // Sweet spot indicator + const sweetSpot = document.createElement('div'); + sweetSpot.className = 'sweet-spot'; + sweetSpot.style.cssText = ` + position: absolute; top: 0; left: ${this.sweetSpotX}%; + width: 20px; height: 8px; margin-left: -10px; + background: ${T.success}; border-radius: 0 0 4px 4px; + opacity: 0.6; + box-shadow: 0 0 8px ${T.success}44; + `; + circle.appendChild(sweetSpot); + + // Lockpick indicator + const pick = document.createElement('div'); + pick.className = 'lockpick-indicator'; + pick.style.cssText = ` + position: absolute; top: 50%; left: ${this.lockpickX}%; + width: 4px; height: 60px; margin-left: -2px; margin-top: -30px; + background: linear-gradient(180deg, ${T.textLight}, ${T.borderBronze}); + border-radius: 2px; + transform-origin: bottom center; + box-shadow: 0 0 6px rgba(255,255,255,0.2); + `; + circle.appendChild(pick); + + // Center dot + const center = document.createElement('div'); + center.style.cssText = ` + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + width: 8px; height: 8px; border-radius: 50%; + background: ${T.borderBronze}; + `; + circle.appendChild(center); + + this.container.appendChild(circle); + + // Attempts display + const attemptsEl = document.createElement('div'); + attemptsEl.style.cssText = ` + margin-top: 16px; font-size: 13px; color: ${T.textMuted}; + `; + attemptsEl.textContent = `剩余尝试: ${this.maxAttempts - this.attempts}`; + this.container.appendChild(attemptsEl); + + // Instructions + const hint = document.createElement('div'); + hint.style.cssText = `margin-top: 8px; font-size: 11px; color: ${T.textDim};`; + hint.textContent = '移动鼠标对准绿色区域,点击尝试开锁'; + this.container.appendChild(hint); + + // ESC hint + const esc = document.createElement('div'); + esc.style.cssText = `margin-top: 12px; font-size: 11px; color: ${T.textDim};`; + esc.textContent = '按 ESC 放弃'; + this.container.appendChild(esc); + } +} + +export const lockpickingUI = LockpickingUI.getInstance(); diff --git a/src/ui/components/LootUI.ts b/src/ui/components/LootUI.ts new file mode 100644 index 0000000..0f7215b --- /dev/null +++ b/src/ui/components/LootUI.ts @@ -0,0 +1,192 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager, type Entity } from '../../core/EntityManager'; +import { inventorySystem } from '../../systems/InventorySystem'; + +interface LootEntry { + id: string; + name: string; + type: string; + quantity: number; + value: number; + weight: number; +} + +export class LootUI { + private static instance: LootUI; + private container: HTMLDivElement; + private isOpen = false; + private lootItems: LootEntry[] = []; + private containerName = ''; + + static getInstance(): LootUI { + if (!LootUI.instance) { + LootUI.instance = new LootUI(); + } + return LootUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'loot-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 400; + background: rgba(5,5,10,0.82); + font-family: ${FONT.body}; + `; + } + + show(name: string, items: LootEntry[], _entity: Entity | null = null): void { + if (this.isOpen) return; + this.isOpen = true; + this.containerName = name; + this.lootItems = items; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'loot' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'loot' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 420px; max-height: 480px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; border-radius: 4px; + display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 0 40px rgba(0,0,0,0.5); + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid ${T.borderDark}; + `; + const title = document.createElement('div'); + title.style.cssText = `font-size: 15px; color: ${T.textGold}; font-family: ${FONT.title}; letter-spacing: 1px;`; + title.textContent = this.containerName; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn'; + closeBtn.style.cssText = `padding: 4px 12px; font-size: 12px;`; + closeBtn.textContent = 'ESC'; + closeBtn.addEventListener('click', () => this.hide()); + + header.appendChild(title); + header.appendChild(closeBtn); + panel.appendChild(header); + + // Items + const list = document.createElement('div'); + list.style.cssText = `padding: 8px 16px; max-height: 360px; overflow-y: auto; flex: 1;`; + + if (this.lootItems.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `text-align: center; color: ${T.textDim}; padding: 40px 0;`; + empty.textContent = '空空如也'; + list.appendChild(empty); + } else { + // Take All button + const takeAll = document.createElement('button'); + takeAll.className = 'oes-btn oes-btn--success'; + takeAll.style.cssText = `width: 100%; padding: 8px; margin-bottom: 8px; font-size: 13px;`; + takeAll.textContent = '全部拿走'; + takeAll.addEventListener('click', () => this.takeAll()); + list.appendChild(takeAll); + + for (const item of this.lootItems) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; justify-content: space-between; align-items: center; + padding: 8px 10px; margin-bottom: 2px; + border: 1px solid ${T.borderDark}; border-radius: 3px; + cursor: pointer; transition: border-color 0.12s, background 0.12s; + `; + + const typeColors: Record = { + weapon: '#e06040', armor: '#4080c0', consumable: '#40a060', material: '#a08040', misc: '#888', + }; + const color = typeColors[item.type] || T.textDim; + + row.innerHTML = ` +
+ +
+
${item.name}
+
${item.weight} · ${item.value}金${item.quantity > 1 ? ` · x${item.quantity}` : ''}
+
+
+ + `; + + row.addEventListener('mouseenter', () => { + row.style.borderColor = T.borderBronze; + row.style.background = 'rgba(255,255,255,0.03)'; + }); + row.addEventListener('mouseleave', () => { + row.style.borderColor = T.borderDark; + row.style.background = 'transparent'; + }); + + const takeBtn = row.querySelector('.loot-take'); + takeBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + this.takeItem(item); + }); + + list.appendChild(row); + } + } + + panel.appendChild(list); + this.container.appendChild(panel); + } + + private takeItem(item: LootEntry): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + inventorySystem.addItem(player, item.id, item.quantity); + this.lootItems = this.lootItems.filter(i => i.id !== item.id); + eventBus.emit('inventory:updated'); + eventBus.emit('ui:notification', { text: `获得了 ${item.name} x${item.quantity}` }); + + if (this.lootItems.length === 0) { + this.hide(); + } else { + this.render(); + } + } + + private takeAll(): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + for (const item of this.lootItems) { + inventorySystem.addItem(player, item.id, item.quantity); + } + eventBus.emit('inventory:updated'); + eventBus.emit('ui:notification', { text: `获得了所有物品` }); + this.hide(); + } +} + +export const lootUI = LootUI.getInstance(); diff --git a/src/ui/components/MagicUI.ts b/src/ui/components/MagicUI.ts index 7e8c219..f78fed4 100644 --- a/src/ui/components/MagicUI.ts +++ b/src/ui/components/MagicUI.ts @@ -29,6 +29,7 @@ export class MagicUI { } show(): void { + if (this.isOpen) return; this.isOpen = true; this.container.style.display = 'block'; this.render(); @@ -36,8 +37,10 @@ export class MagicUI { } hide(): void { + if (!this.isOpen) return; this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); } toggle(): void { diff --git a/src/ui/components/MinimapHUD.ts b/src/ui/components/MinimapHUD.ts new file mode 100644 index 0000000..5eee535 --- /dev/null +++ b/src/ui/components/MinimapHUD.ts @@ -0,0 +1,192 @@ +import { T } from '../theme'; +import { entityManager } from '../../core/EntityManager'; +import { mapManager } from '../../maps/MapManager'; +import { corpseSystem } from '../../systems/CorpseSystem'; + +export class MinimapHUD { + private static instance: MinimapHUD; + private container: HTMLDivElement; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private size = 140; + private updateTimer: ReturnType | null = null; + private colorCache = new Map(); + + static getInstance(): MinimapHUD { + if (!MinimapHUD.instance) { + MinimapHUD.instance = new MinimapHUD(); + } + return MinimapHUD.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'minimap-hud'; + this.container.style.cssText = ` + position: fixed; + bottom: 80px; + left: 16px; + width: ${this.size}px; + height: ${this.size}px; + pointer-events: none; + z-index: 110; + `; + + // Outer frame + const frame = document.createElement('div'); + frame.style.cssText = ` + width: 100%; height: 100%; + border: 2px solid ${T.borderBronze}; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 0 12px rgba(0,0,0,0.6), inset 0 0 8px rgba(0,0,0,0.3); + background: rgba(10,10,15,0.7); + `; + + this.canvas = document.createElement('canvas'); + this.canvas.width = this.size; + this.canvas.height = this.size; + this.canvas.style.cssText = `width: 100%; height: 100%;`; + this.ctx = this.canvas.getContext('2d')!; + + frame.appendChild(this.canvas); + this.container.appendChild(frame); + this.startUpdate(); + } + + start(): void { + if (!this.container.parentElement) { + document.body.appendChild(this.container); + } + this.startUpdate(); + } + + stop(): void { + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = null; + } + } + + private startUpdate(): void { + if (this.updateTimer) return; + this.updateTimer = setInterval(() => this.render(), 500); + } + + private render(): void { + const ctx = this.ctx; + const w = this.size; + const h = this.size; + + ctx.clearRect(0, 0, w, h); + + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + const playerPos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); + if (!playerPos) return; + + const zone = mapManager.getCurrentZone(); + if (!zone) return; + + const tileSize = zone.tileSize; + const scale = 0.3; + const centerX = w / 2; + const centerY = h / 2; + + // Draw map tiles + const startX = Math.floor((playerPos.x / tileSize) - (centerX / (tileSize * scale))); + const endX = Math.ceil((playerPos.x / tileSize) + (centerX / (tileSize * scale))); + const startY = Math.floor((playerPos.y / tileSize) - (centerY / (tileSize * scale))); + const endY = Math.ceil((playerPos.y / tileSize) + (centerY / (tileSize * scale))); + + for (let y = startY; y <= endY; y++) { + for (let x = startX; x <= endX; x++) { + if (y < 0 || y >= zone.height || x < 0 || x >= zone.width) continue; + const tileId = zone.tiles[y]?.[x]; + if (tileId === undefined) continue; + const tileData = mapManager.getTile(tileId); + if (!tileData) continue; + + const screenX = centerX + (x * tileSize - playerPos.x) * scale; + const screenY = centerY + (y * tileSize - playerPos.y) * scale; + const size = tileSize * scale; + + let hex = this.colorCache.get(tileData.color); + if (!hex) { + hex = `#${tileData.color.toString(16).padStart(6, '0')}`; + this.colorCache.set(tileData.color, hex); + } + ctx.fillStyle = hex; + ctx.fillRect(screenX, screenY, size + 1, size + 1); + } + } + + // Draw doors + if (zone.doors) { + for (const door of zone.doors) { + const dx = door.x * tileSize + tileSize / 2; + const dy = door.y * tileSize + tileSize / 2; + const screenX = centerX + (dx - playerPos.x) * scale; + const screenY = centerY + (dy - playerPos.y) * scale; + if (screenX < 0 || screenX > w || screenY < 0 || screenY > h) continue; + + ctx.fillStyle = T.goldAccent; + ctx.beginPath(); + ctx.moveTo(screenX, screenY - 4); + ctx.lineTo(screenX + 3, screenY + 2); + ctx.lineTo(screenX - 3, screenY + 2); + ctx.closePath(); + ctx.fill(); + } + } + + // Draw enemies + const enemies = entityManager.getEntitiesByType('enemy'); + for (const enemy of enemies) { + if (corpseSystem.isCorpse(enemy)) continue; + const ePos = entityManager.getComponent<{ x: number; y: number }>(enemy.id, 'position'); + const hp = entityManager.getComponent<{ current: number }>(enemy.id, 'health'); + if (!ePos || !hp || hp.current <= 0) continue; + + const screenX = centerX + (ePos.x - playerPos.x) * scale; + const screenY = centerY + (ePos.y - playerPos.y) * scale; + if (screenX < -5 || screenX > w + 5 || screenY < -5 || screenY > h + 5) continue; + + ctx.fillStyle = T.danger; + ctx.beginPath(); + ctx.arc(screenX, screenY, 3, 0, Math.PI * 2); + ctx.fill(); + } + + // Draw NPCs + const npcs = entityManager.getEntitiesByType('npc'); + for (const npc of npcs) { + const nPos = entityManager.getComponent<{ x: number; y: number }>(npc.id, 'position'); + if (!nPos) continue; + + const screenX = centerX + (nPos.x - playerPos.x) * scale; + const screenY = centerY + (nPos.y - playerPos.y) * scale; + if (screenX < -5 || screenX > w + 5 || screenY < -5 || screenY > h + 5) continue; + + ctx.fillStyle = '#4488cc'; + ctx.fillRect(screenX - 2, screenY - 2, 4, 4); + } + + // Draw player (center) + ctx.fillStyle = '#00ff66'; + ctx.beginPath(); + ctx.arc(centerX, centerY, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#00aa33'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Border overlay + ctx.strokeStyle = T.borderBronze; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + } +} + +export const minimapHUD = MinimapHUD.getInstance(); diff --git a/src/ui/components/ModManagerUI.ts b/src/ui/components/ModManagerUI.ts index 11fa4da..a6432a3 100644 --- a/src/ui/components/ModManagerUI.ts +++ b/src/ui/components/ModManagerUI.ts @@ -4,6 +4,7 @@ import { T, FONT, panelStyle } from '../theme'; export class ModManagerUI { private container: HTMLDivElement; + private isOpen = false; constructor() { this.container = document.createElement('div'); @@ -23,15 +24,21 @@ export class ModManagerUI { } 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'; } + getIsOpen(): boolean { + return this.isOpen; + } + private render(): void { this.container.innerHTML = ''; diff --git a/src/ui/components/PauseMenuUI.ts b/src/ui/components/PauseMenuUI.ts new file mode 100644 index 0000000..e7240c9 --- /dev/null +++ b/src/ui/components/PauseMenuUI.ts @@ -0,0 +1,144 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { modManagerUI } from './ModManagerUI'; + +export class PauseMenuUI { + private static instance: PauseMenuUI; + private container: HTMLDivElement; + private isOpen = false; + + static getInstance(): PauseMenuUI { + if (!PauseMenuUI.instance) { + PauseMenuUI.instance = new PauseMenuUI(); + } + return PauseMenuUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'pause-menu'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 600; + background: rgba(5,5,10,0.88); + font-family: ${FONT.body}; + `; + } + + toggle(): void { + if (this.isOpen) this.hide(); + else this.show(); + } + + show(): void { + if (this.isOpen) return; + this.isOpen = true; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'pause' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'pause' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + this.container.style.flexDirection = 'column'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 320px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; + border-radius: 4px; + padding: 32px 24px; + text-align: center; + box-shadow: 0 0 40px rgba(0,0,0,0.6), 0 0 80px rgba(0,0,0,0.3); + `; + + const title = document.createElement('div'); + title.style.cssText = ` + font-family: ${FONT.title}; + font-size: 24px; + color: ${T.textGold}; + letter-spacing: 3px; + margin-bottom: 8px; + text-shadow: 0 0 12px rgba(212,168,67,0.3); + `; + title.textContent = '天际省'; + + const subtitle = document.createElement('div'); + subtitle.style.cssText = ` + font-size: 11px; + color: ${T.textDim}; + letter-spacing: 2px; + margin-bottom: 28px; + text-transform: uppercase; + `; + subtitle.textContent = 'OES-WEB'; + + const divider = document.createElement('div'); + divider.style.cssText = `border-top: 1px solid ${T.borderBronze}; margin-bottom: 20px; opacity: 0.5;`; + + panel.appendChild(title); + panel.appendChild(subtitle); + panel.appendChild(divider); + + const buttons: { label: string; action: () => void }[] = [ + { label: '返回游戏', action: () => this.hide() }, + { label: '角色信息', action: () => { this.hide(); eventBus.emit('ui:openCharacterInfo'); } }, + { label: '设置', action: () => { this.hide(); eventBus.emit('ui:openSettings'); } }, + { label: 'Mod 管理器', action: () => { this.hide(); modManagerUI.show(); } }, + { label: '退出到主菜单', action: () => { this.hide(); eventBus.emit('game:returnToMenu'); } }, + ]; + + for (const btn of buttons) { + const el = document.createElement('button'); + el.className = 'oes-btn'; + el.style.cssText = ` + display: block; width: 100%; + padding: 12px 0; margin-bottom: 8px; + font-family: ${FONT.body}; font-size: 14px; + color: ${T.textGold}; background: rgba(40,35,30,0.6); + border: 1px solid ${T.borderDark}; border-radius: 3px; + cursor: pointer; letter-spacing: 1px; + transition: border-color 0.15s, background 0.15s; + `; + el.textContent = btn.label; + el.addEventListener('mouseenter', () => { + el.style.borderColor = T.borderBronze; + el.style.background = 'rgba(60,50,30,0.5)'; + }); + el.addEventListener('mouseleave', () => { + el.style.borderColor = T.borderDark; + el.style.background = 'rgba(40,35,30,0.6)'; + }); + el.addEventListener('click', btn.action); + panel.appendChild(el); + } + + const escHint = document.createElement('div'); + escHint.style.cssText = ` + margin-top: 16px; font-size: 11px; color: ${T.textDim}; + letter-spacing: 0.5px; + `; + escHint.textContent = '按 ESC 返回游戏'; + panel.appendChild(escHint); + + this.container.appendChild(panel); + } +} + +export const pauseMenuUI = PauseMenuUI.getInstance(); diff --git a/src/ui/components/QuestJournalUI.ts b/src/ui/components/QuestJournalUI.ts index d2862ae..0f2ee96 100644 --- a/src/ui/components/QuestJournalUI.ts +++ b/src/ui/components/QuestJournalUI.ts @@ -31,6 +31,7 @@ export class QuestJournalUI { } show(): void { + if (this.isOpen) return; this.isOpen = true; this.container.style.display = 'block'; this.render(); @@ -38,8 +39,10 @@ export class QuestJournalUI { } hide(): void { + if (!this.isOpen) return; this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); } toggle(): void { diff --git a/src/ui/components/QuestTrackerHUD.ts b/src/ui/components/QuestTrackerHUD.ts new file mode 100644 index 0000000..877316e --- /dev/null +++ b/src/ui/components/QuestTrackerHUD.ts @@ -0,0 +1,139 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; + +interface TrackedQuest { + id: string; + name: string; + objectives: { text: string; current: number; target: number }[]; +} + +export class QuestTrackerHUD { + private static instance: QuestTrackerHUD; + private container: HTMLDivElement; + private updateTimer: ReturnType | null = null; + + static getInstance(): QuestTrackerHUD { + if (!QuestTrackerHUD.instance) { + QuestTrackerHUD.instance = new QuestTrackerHUD(); + } + return QuestTrackerHUD.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'quest-tracker-hud'; + this.container.style.cssText = ` + position: fixed; + top: 50px; + right: 16px; + width: 240px; + pointer-events: none; + z-index: 110; + font-family: ${FONT.body}; + `; + + this.startUpdate(); + this.setupEvents(); + } + + start(): void { + if (!this.container.parentElement) { + document.body.appendChild(this.container); + } + this.startUpdate(); + } + + stop(): void { + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = null; + } + } + + private setupEvents(): void { + eventBus.on('quest:activated', () => this.render()); + eventBus.on('quest:completed', () => this.render()); + eventBus.on('quest:failed', () => this.render()); + eventBus.on('quest:objectiveUpdated', () => this.render()); + } + + private startUpdate(): void { + if (this.updateTimer) return; + this.updateTimer = setInterval(() => this.render(), 1000); + } + + private render(): void { + this.container.innerHTML = ''; + + // Find active tracked quest from questSystem + const activeQuest = this.getActiveQuest(); + if (!activeQuest) return; + + const card = document.createElement('div'); + card.style.cssText = ` + background: rgba(10,10,15,0.6); + border: 1px solid ${T.borderDark}; + border-radius: 3px; + padding: 8px 12px; + margin-bottom: 4px; + `; + + const title = document.createElement('div'); + title.style.cssText = ` + font-size: 12px; color: ${T.textGold}; font-family: ${FONT.title}; + letter-spacing: 0.5px; margin-bottom: 4px; + `; + title.textContent = activeQuest.name; + card.appendChild(title); + + for (const obj of activeQuest.objectives) { + const objEl = document.createElement('div'); + objEl.style.cssText = ` + font-size: 11px; color: ${T.textMuted}; padding: 1px 0; + display: flex; align-items: center; gap: 6px; + `; + + const dot = document.createElement('span'); + dot.style.cssText = ` + display: inline-block; width: 5px; height: 5px; border-radius: 50%; + background: ${obj.current >= obj.target ? T.success : T.textDim}; + `; + + const text = document.createElement('span'); + const done = obj.current >= obj.target; + text.style.cssText = done ? `color: ${T.textDim}; text-decoration: line-through;` : ''; + text.textContent = obj.target > 1 ? `${obj.text} (${obj.current}/${obj.target})` : obj.text; + + objEl.appendChild(dot); + objEl.appendChild(text); + card.appendChild(objEl); + } + + this.container.appendChild(card); + } + + private getActiveQuest(): TrackedQuest | null { + // Try to get from quest system via eventBus or entityManager + // Simplified: return the first active quest if available + try { + // Access quest system through globalThis (circular dep pattern) + const qs = (globalThis as Record)['__oesQuestSystem'] as { + getActiveQuests?: () => { id: string; name: string; objectives: { description: string; current: number; target: number }[] }[]; + } | undefined; + if (qs?.getActiveQuests) { + const quests = qs.getActiveQuests(); + if (quests.length > 0) { + const q = quests[0]!; + return { + id: q.id, + name: q.name, + objectives: q.objectives.map(o => ({ text: o.description, current: o.current, target: o.target })), + }; + } + } + } catch { /* ignore */ } + return null; + } +} + +export const questTrackerHUD = QuestTrackerHUD.getInstance(); diff --git a/src/ui/components/QuickbarHUD.ts b/src/ui/components/QuickbarHUD.ts new file mode 100644 index 0000000..924ebfd --- /dev/null +++ b/src/ui/components/QuickbarHUD.ts @@ -0,0 +1,180 @@ +import { T, FONT } from '../theme'; +import { entityManager } from '../../core/EntityManager'; +import { inventorySystem } from '../../systems/InventorySystem'; + +export class QuickbarHUD { + private static instance: QuickbarHUD; + private container: HTMLDivElement; + private slots: HTMLDivElement[] = []; + private updateTimer: ReturnType | null = null; + + static getInstance(): QuickbarHUD { + if (!QuickbarHUD.instance) { + QuickbarHUD.instance = new QuickbarHUD(); + } + return QuickbarHUD.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'quickbar-hud'; + this.container.style.cssText = ` + position: fixed; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 3px; + z-index: 120; + pointer-events: none; + `; + + for (let i = 0; i < 8; i++) { + const slot = document.createElement('div'); + slot.style.cssText = ` + width: 44px; + height: 44px; + background: rgba(10,10,15,0.7); + border: 1px solid ${T.borderIron}; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + `; + + const keyLabel = document.createElement('div'); + keyLabel.style.cssText = ` + position: absolute; + top: 1px; + left: 3px; + font-size: 8px; + color: ${T.textDim}; + font-family: ${FONT.body}; + `; + keyLabel.textContent = `${i + 1}`; + + const icon = document.createElement('div'); + icon.className = 'qb-icon'; + icon.style.cssText = ` + font-size: 18px; + color: ${T.textMuted}; + line-height: 1; + `; + + slot.appendChild(keyLabel); + slot.appendChild(icon); + this.slots.push(slot); + this.container.appendChild(slot); + } + + this.startUpdate(); + } + + start(): void { + if (!this.container.parentElement) { + document.body.appendChild(this.container); + } + this.startUpdate(); + } + + stop(): void { + if (this.updateTimer) { + clearInterval(this.updateTimer); + this.updateTimer = null; + } + } + + private startUpdate(): void { + if (this.updateTimer) return; + this.updateTimer = setInterval(() => this.render(), 300); + } + + private render(): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + + const inventory = inventorySystem.getInventory(player); + if (!inventory) return; + + // Build quickbar items: equipped weapons + spells + consumables + const quickItems: { id: string; name: string; type: string; quantity: number }[] = []; + + // Add equipped weapon + const weapon = entityManager.getComponent<{ id: string; damage: number }>(player.id, 'weapon'); + if (weapon && weapon.id !== 'fists') { + quickItems.push({ id: weapon.id, name: weapon.id, type: 'weapon', quantity: 1 }); + } + + // Add consumables + const consumables = inventory.items.filter(i => i.type === 'consumable'); + for (const c of consumables) { + quickItems.push({ id: c.id, name: c.name, type: c.type, quantity: c.quantity }); + } + + // Add some materials if slots available + if (quickItems.length < 8) { + const materials = inventory.items.filter(i => i.type === 'material').slice(0, 8 - quickItems.length); + for (const m of materials) { + quickItems.push({ id: m.id, name: m.name, type: m.type, quantity: m.quantity }); + } + } + + for (let i = 0; i < 8; i++) { + const slot = this.slots[i]; + if (!slot) continue; + const icon = slot.querySelector('.qb-icon') as HTMLDivElement | null; + + if (i < quickItems.length) { + const item = quickItems[i]!; + if (icon) { + icon.textContent = this.getItemIcon(item.id, item.type); + const typeColors: Record = { + weapon: '#e06040', consumable: '#40a060', material: '#a08040', armor: '#4080c0', + }; + icon.style.color = typeColors[item.type] || T.textGold; + } + slot.style.borderColor = T.borderBronze; + slot.style.background = 'rgba(30,25,18,0.75)'; + + // Quantity badge + let qty = slot.querySelector('.qb-qty') as HTMLDivElement | null; + if (!qty) { + qty = document.createElement('div'); + qty.className = 'qb-qty'; + qty.style.cssText = ` + position: absolute; bottom: 1px; right: 3px; + font-size: 9px; color: ${T.textMuted}; font-family: ${FONT.body}; + `; + slot.appendChild(qty); + } + qty.textContent = item.quantity > 1 ? `${item.quantity}` : ''; + } else { + if (icon) { + icon.textContent = ''; + icon.style.color = T.textDim; + } + slot.style.borderColor = T.borderIron; + slot.style.background = 'rgba(10,10,15,0.7)'; + const qty = slot.querySelector('.qb-qty'); + if (qty) qty.textContent = ''; + } + } + } + + private getItemIcon(id: string, type?: string): string { + if (type === 'weapon') return '⚔'; + if (type === 'armor') return '🛡'; + if (id.includes('potion') || id.includes('heal')) return '♥'; + if (id.includes('poison')) return '☠'; + if (id.includes('scroll')) return '✉'; + if (id.includes('food') || id.includes('bread')) return '◆'; + if (id.includes('ingot') || id.includes('ore')) return '◆'; + if (id.includes('leather')) return '◇'; + if (id.includes('flower') || id.includes('herb')) return '✿'; + return '●'; + } +} + +export const quickbarHUD = QuickbarHUD.getInstance(); diff --git a/src/ui/components/RadialMenuUI.ts b/src/ui/components/RadialMenuUI.ts index 750f9ae..c0dec78 100644 --- a/src/ui/components/RadialMenuUI.ts +++ b/src/ui/components/RadialMenuUI.ts @@ -14,6 +14,9 @@ export class RadialMenuUI { private container: HTMLDivElement; private isOpen = false; private items: RadialMenuItem[] = []; + private selectedIndex = -1; + private keyHandler: ((e: KeyboardEvent) => void) | null = null; + private positionTransforms: string[] = []; static getInstance(): RadialMenuUI { if (!RadialMenuUI.instance) { @@ -41,9 +44,12 @@ export class RadialMenuUI { show(): void { if (this.isOpen) return; this.isOpen = true; + this.selectedIndex = -1; this.render(); this.container.style.display = 'block'; document.body.appendChild(this.container); + this.keyHandler = (e: KeyboardEvent) => this.handleKeyDown(e); + document.addEventListener('keydown', this.keyHandler); eventBus.emit('ui:menuOpened', { menu: 'radial' }); } @@ -52,6 +58,10 @@ export class RadialMenuUI { this.isOpen = false; this.container.style.display = 'none'; this.container.remove(); + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler); + this.keyHandler = null; + } eventBus.emit('ui:menuClosed', { menu: 'radial' }); } @@ -116,11 +126,14 @@ export class RadialMenuUI { { top: '100%', left: '50%', transform: 'translate(-50%, -100%)' }, ]; + this.positionTransforms = positions.map(p => p?.transform || ''); + this.items.forEach((item, i) => { const pos = positions[i]; if (!pos) return; const btn = document.createElement('div'); + btn.className = 'radial-btn'; btn.style.cssText = ` position: absolute; top: ${pos.top}; @@ -190,6 +203,42 @@ export class RadialMenuUI { }); } + private handleKeyDown(e: KeyboardEvent): void { + const arrowMap: Record = { + ArrowUp: 0, ArrowLeft: 1, ArrowRight: 2, ArrowDown: 3, + }; + + if (e.key in arrowMap) { + e.preventDefault(); + this.selectedIndex = arrowMap[e.key]!; + this.updateSelection(); + } else if (e.key === 'Enter' && this.selectedIndex >= 0) { + e.preventDefault(); + const item = this.items[this.selectedIndex]; + if (item) { + this.hide(); + item.action(); + } + } + } + + private updateSelection(): void { + const buttons = this.container.querySelectorAll('.radial-btn'); + buttons.forEach((btn, i) => { + const el = btn as HTMLElement; + const transform = this.positionTransforms[i] || ''; + if (i === this.selectedIndex) { + el.style.borderColor = this.items[i]?.color || T.borderBronze; + el.style.background = 'rgba(30,28,42,0.95)'; + el.style.transform = `${transform} scale(1.08)`; + } else { + el.style.borderColor = T.borderDark; + el.style.background = 'rgba(20,18,30,0.85)'; + el.style.transform = transform; + } + }); + } + private getItemIcon(id: string): string { switch (id) { case 'skills': return '✦'; diff --git a/src/ui/components/SettingsUI.ts b/src/ui/components/SettingsUI.ts new file mode 100644 index 0000000..b699e99 --- /dev/null +++ b/src/ui/components/SettingsUI.ts @@ -0,0 +1,290 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; + +interface GameSettings { + masterVolume: number; + sfxVolume: number; + musicVolume: number; + difficulty: 'easy' | 'normal' | 'hard' | 'veryhard'; + showDamageNumbers: boolean; + showEnemyHealthBars: boolean; + cameraZoom: number; +} + +const defaultSettings: GameSettings = { + masterVolume: 80, + sfxVolume: 70, + musicVolume: 60, + difficulty: 'normal', + showDamageNumbers: true, + showEnemyHealthBars: true, + cameraZoom: 1.2, +}; + +export class SettingsUI { + private static instance: SettingsUI; + private container: HTMLDivElement; + private isOpen = false; + private settings: GameSettings = { ...defaultSettings }; + + static getInstance(): SettingsUI { + if (!SettingsUI.instance) { + SettingsUI.instance = new SettingsUI(); + } + return SettingsUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'settings-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 550; + background: rgba(5,5,10,0.9); + font-family: ${FONT.body}; + `; + this.loadSettings(); + } + + toggle(): void { + if (this.isOpen) this.hide(); + else this.show(); + } + + show(): void { + if (this.isOpen) return; + this.isOpen = true; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'settings' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + this.saveSettings(); + eventBus.emit('ui:menuClosed', { menu: 'settings' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + getSettings(): Readonly { + return this.settings; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 420px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; border-radius: 4px; + box-shadow: 0 0 40px rgba(0,0,0,0.5); + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid ${T.borderDark}; + `; + const title = document.createElement('div'); + title.style.cssText = `font-size: 16px; color: ${T.textGold}; font-family: ${FONT.title}; letter-spacing: 1px;`; + title.textContent = '设置'; + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn'; + closeBtn.style.cssText = `padding: 4px 12px; font-size: 12px;`; + closeBtn.textContent = 'ESC'; + closeBtn.addEventListener('click', () => this.hide()); + header.appendChild(title); + header.appendChild(closeBtn); + panel.appendChild(header); + + // Content + const content = document.createElement('div'); + content.style.cssText = `padding: 16px 20px; max-height: 400px; overflow-y: auto;`; + + // Audio section + this.addSection(content, '音频'); + this.addSlider(content, '主音量', this.settings.masterVolume, (v) => { this.settings.masterVolume = v; }); + this.addSlider(content, '音效', this.settings.sfxVolume, (v) => { this.settings.sfxVolume = v; }); + this.addSlider(content, '音乐', this.settings.musicVolume, (v) => { this.settings.musicVolume = v; }); + + // Gameplay section + this.addSection(content, '游戏'); + this.addSelect(content, '难度', this.settings.difficulty, [ + { value: 'easy', label: '简单' }, + { value: 'normal', label: '普通' }, + { value: 'hard', label: '困难' }, + { value: 'veryhard', label: '传奇' }, + ], (v) => { this.settings.difficulty = v as GameSettings['difficulty']; }); + + this.addToggle(content, '伤害数字', this.settings.showDamageNumbers, (v) => { this.settings.showDamageNumbers = v; }); + this.addToggle(content, '敌人血条', this.settings.showEnemyHealthBars, (v) => { this.settings.showEnemyHealthBars = v; }); + + // Controls section + this.addSection(content, '按键'); + const keys = [ + ['W/A/S/D', '移动'], + ['鼠标左键', '攻击'], + ['鼠标右键', '格挡'], + ['Shift', '强力攻击'], + ['E', '互动'], + ['F', '使用药水'], + ['Tab', '径向菜单'], + ['I', '角色信息'], + ['M', '世界地图'], + ['C', '锻造'], + ['J', '任务日志'], + ['X', '魔法'], + ['Q', '龙吼'], + ['T', '变形'], + ['1-8', '快捷栏'], + ['F5', '快速保存'], + ['F9', '快速读取'], + ['ESC', '暂停/关闭菜单'], + ]; + for (const [key, action] of keys) { + const row = document.createElement('div'); + row.style.cssText = `display: flex; justify-content: space-between; padding: 4px 0;`; + row.innerHTML = ` + ${action} + ${key} + `; + content.appendChild(row); + } + + // Reset button + const resetBtn = document.createElement('button'); + resetBtn.className = 'oes-btn oes-btn--danger'; + resetBtn.style.cssText = `width: 100%; margin-top: 16px; padding: 10px; font-size: 13px;`; + resetBtn.textContent = '恢复默认设置'; + resetBtn.addEventListener('click', () => { + this.settings = { ...defaultSettings }; + this.render(); + }); + content.appendChild(resetBtn); + + panel.appendChild(content); + this.container.appendChild(panel); + } + + private addSection(parent: HTMLDivElement, title: string): void { + const el = document.createElement('div'); + el.style.cssText = ` + font-size: 13px; color: ${T.textGold}; font-family: ${FONT.title}; + margin: 16px 0 8px; padding-bottom: 4px; + border-bottom: 1px solid ${T.borderDark}; letter-spacing: 0.5px; + `; + el.textContent = title; + parent.appendChild(el); + } + + private addSlider(parent: HTMLDivElement, label: string, value: number, onChange: (v: number) => void): void { + const row = document.createElement('div'); + row.style.cssText = `margin: 8px 0;`; + row.innerHTML = ` +
+ ${label} + ${value}% +
+ + `; + const slider = row.querySelector('input') as HTMLInputElement; + const valDisplay = row.querySelector('.slider-val') as HTMLSpanElement; + slider.addEventListener('input', () => { + const v = parseInt(slider.value); + valDisplay.textContent = `${v}%`; + onChange(v); + }); + parent.appendChild(row); + } + + private addSelect(parent: HTMLDivElement, label: string, value: string, options: { value: string; label: string }[], onChange: (v: string) => void): void { + const row = document.createElement('div'); + row.style.cssText = `margin: 8px 0; display: flex; justify-content: space-between; align-items: center;`; + const lbl = document.createElement('span'); + lbl.style.cssText = `color: ${T.textMuted}; font-size: 12px;`; + lbl.textContent = label; + + const select = document.createElement('select'); + select.style.cssText = ` + padding: 4px 8px; background: ${T.bgStone}; color: ${T.textLight}; + border: 1px solid ${T.borderIron}; border-radius: 3px; font-size: 12px; + font-family: ${FONT.body}; cursor: pointer; + `; + for (const opt of options) { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + o.selected = opt.value === value; + select.appendChild(o); + } + select.addEventListener('change', () => onChange(select.value)); + + row.appendChild(lbl); + row.appendChild(select); + parent.appendChild(row); + } + + private addToggle(parent: HTMLDivElement, label: string, value: boolean, onChange: (v: boolean) => void): void { + const row = document.createElement('div'); + row.style.cssText = `margin: 8px 0; display: flex; justify-content: space-between; align-items: center;`; + const lbl = document.createElement('span'); + lbl.style.cssText = `color: ${T.textMuted}; font-size: 12px;`; + lbl.textContent = label; + + const toggle = document.createElement('div'); + toggle.style.cssText = ` + width: 36px; height: 20px; border-radius: 10px; cursor: pointer; + background: ${value ? T.success : T.borderDark}; border: 1px solid ${T.borderIron}; + transition: background 0.2s; position: relative; + `; + const knob = document.createElement('div'); + knob.style.cssText = ` + width: 16px; height: 16px; border-radius: 50%; background: ${T.textLight}; + position: absolute; top: 1px; ${value ? 'right: 1px;' : 'left: 1px;'} + transition: left 0.2s, right 0.2s; + `; + toggle.appendChild(knob); + toggle.addEventListener('click', () => { + value = !value; + toggle.style.background = value ? T.success : T.borderDark; + knob.style.left = value ? 'auto' : '1px'; + knob.style.right = value ? '1px' : 'auto'; + onChange(value); + }); + + row.appendChild(lbl); + row.appendChild(toggle); + parent.appendChild(row); + } + + private saveSettings(): void { + try { + localStorage.setItem('oes-settings', JSON.stringify(this.settings)); + } catch { /* ignore */ } + } + + private loadSettings(): void { + try { + const saved = localStorage.getItem('oes-settings'); + if (saved) { + this.settings = { ...defaultSettings, ...JSON.parse(saved) }; + } + } catch { /* ignore */ } + } +} + +export const settingsUI = SettingsUI.getInstance(); diff --git a/src/ui/components/ShopUI.ts b/src/ui/components/ShopUI.ts new file mode 100644 index 0000000..5fc7bd3 --- /dev/null +++ b/src/ui/components/ShopUI.ts @@ -0,0 +1,251 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; +import { entityManager } from '../../core/EntityManager'; +import { inventorySystem } from '../../systems/InventorySystem'; + +interface ShopItem { + id: string; + name: string; + type: string; + value: number; + quantity?: number; +} + +export class ShopUI { + private static instance: ShopUI; + private container: HTMLDivElement; + private isOpen = false; + private shopName = ''; + private shopItems: ShopItem[] = []; + private isSelling = false; + + static getInstance(): ShopUI { + if (!ShopUI.instance) { + ShopUI.instance = new ShopUI(); + } + return ShopUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'shop-ui'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 400; + background: rgba(5,5,10,0.85); + font-family: ${FONT.body}; + `; + } + + toggle(): void { + if (this.isOpen) this.hide(); + else this.show(this.shopName || '商店', this.shopItems || []); + } + + show(name: string, items: ShopItem[]): void { + if (this.isOpen) return; + this.isOpen = true; + this.shopName = name; + this.shopItems = items; + this.isSelling = false; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'shop' }); + } + + hide(): void { + if (!this.isOpen) return; + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'shop' }); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + + const panel = document.createElement('div'); + panel.style.cssText = ` + width: 640px; + max-height: 520px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; + border-radius: 4px; + display: flex; flex-direction: column; + overflow: hidden; + box-shadow: 0 0 40px rgba(0,0,0,0.5); + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid ${T.borderDark}; + `; + const title = document.createElement('div'); + title.style.cssText = `font-size: 16px; color: ${T.textGold}; font-family: ${FONT.title}; letter-spacing: 1px;`; + title.textContent = this.shopName; + + const gold = document.createElement('div'); + gold.style.cssText = `font-size: 13px; color: ${T.goldAccent};`; + const player = entityManager.getEntitiesByType('player')[0]; + const inv = player ? inventorySystem.getInventory(player) : null; + gold.textContent = `金币: ${inv?.gold || 0}`; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'oes-btn'; + closeBtn.style.cssText = `padding: 4px 12px; font-size: 12px;`; + closeBtn.textContent = 'ESC 关闭'; + closeBtn.addEventListener('click', () => this.hide()); + + header.appendChild(title); + header.appendChild(gold); + header.appendChild(closeBtn); + panel.appendChild(header); + + // Buy/Sell tabs + const tabs = document.createElement('div'); + tabs.style.cssText = `display: flex; border-bottom: 1px solid ${T.borderDark};`; + + const buyTab = document.createElement('button'); + buyTab.style.cssText = this.tabStyle(!this.isSelling); + buyTab.textContent = '购买'; + buyTab.addEventListener('click', () => { this.isSelling = false; this.render(); }); + + const sellTab = document.createElement('button'); + sellTab.style.cssText = this.tabStyle(this.isSelling); + sellTab.textContent = '出售'; + sellTab.addEventListener('click', () => { this.isSelling = true; this.render(); }); + + tabs.appendChild(buyTab); + tabs.appendChild(sellTab); + panel.appendChild(tabs); + + // Items list + const list = document.createElement('div'); + list.style.cssText = `padding: 8px 16px; max-height: 360px; overflow-y: auto; flex: 1;`; + + const items = this.isSelling ? this.getPlayerSellableItems() : this.shopItems; + + if (items.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `text-align: center; color: ${T.textDim}; padding: 40px 0;`; + empty.textContent = this.isSelling ? '没有可出售的物品' : '商店空空如也'; + list.appendChild(empty); + } else { + for (const item of items) { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; justify-content: space-between; align-items: center; + padding: 8px 10px; margin-bottom: 2px; + border: 1px solid ${T.borderDark}; border-radius: 3px; + cursor: pointer; transition: border-color 0.12s, background 0.12s; + `; + row.innerHTML = ` +
+ ${item.name} + ${item.quantity && item.quantity > 1 ? ` x${item.quantity}` : ''} +
+
+ ${item.value} 金 + +
+ `; + + row.addEventListener('mouseenter', () => { + row.style.borderColor = T.borderBronze; + row.style.background = 'rgba(255,255,255,0.03)'; + }); + row.addEventListener('mouseleave', () => { + row.style.borderColor = T.borderDark; + row.style.background = 'transparent'; + }); + + const actionBtn = row.querySelector('.shop-action'); + actionBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.isSelling) { + this.sellItem(item); + } else { + this.buyItem(item); + } + }); + + list.appendChild(row); + } + } + + panel.appendChild(list); + this.container.appendChild(panel); + } + + private tabStyle(active: boolean): string { + return ` + flex: 1; padding: 10px 0; background: transparent; border: none; + border-bottom: 2px solid ${active ? T.borderBronze : 'transparent'}; + color: ${active ? T.textGold : T.textMuted}; + font-size: 13px; font-family: ${FONT.body}; cursor: pointer; + transition: color 0.15s; + `; + } + + private buyItem(item: ShopItem): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + const inv = inventorySystem.getInventory(player); + if (!inv) return; + + if (inv.gold < item.value) { + eventBus.emit('ui:notification', { text: '金币不足!' }); + return; + } + + inv.gold -= item.value; + inventorySystem.addItem(player, item.id, 1); + eventBus.emit('ui:notification', { text: `购买了 ${item.name}` }); + eventBus.emit('inventory:updated'); + this.render(); + } + + private sellItem(item: ShopItem): void { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return; + const inv = inventorySystem.getInventory(player); + if (!inv) return; + + const sellPrice = Math.floor(item.value * 0.5); + inv.gold += sellPrice; + inventorySystem.removeItem(player, item.id, 1); + eventBus.emit('ui:notification', { text: `出售了 ${item.name} (+${sellPrice} 金)` }); + eventBus.emit('inventory:updated'); + this.render(); + } + + private getPlayerSellableItems(): ShopItem[] { + const player = entityManager.getEntitiesByType('player')[0]; + if (!player) return []; + const inv = inventorySystem.getInventory(player); + if (!inv) return []; + + return inv.items + .filter(i => i.type !== 'quest') + .map(i => ({ + id: i.id, + name: i.name, + type: i.type, + value: i.value, + quantity: i.quantity, + })); + } +} + +export const shopUI = ShopUI.getInstance(); diff --git a/src/ui/components/SkillTreeUI.ts b/src/ui/components/SkillTreeUI.ts index 75e57e0..2861101 100644 --- a/src/ui/components/SkillTreeUI.ts +++ b/src/ui/components/SkillTreeUI.ts @@ -4,9 +4,16 @@ import { dataRegistry, type PerkData } from '../../data/DataRegistry'; import { T, FONT, goldTitleStyle } from '../theme'; export class SkillTreeUI { + private static instance: SkillTreeUI; private container: HTMLDivElement; + private isOpen: boolean = false; private currentSkill: string | null = null; + static getInstance(): SkillTreeUI { + if (!SkillTreeUI.instance) SkillTreeUI.instance = new SkillTreeUI(); + return SkillTreeUI.instance; + } + constructor() { this.container = document.createElement('div'); this.container.id = 'skill-tree'; @@ -22,21 +29,26 @@ export class SkillTreeUI { } show(): void { + if (this.isOpen) return; + this.isOpen = true; this.container.style.display = 'flex'; this.render(); document.body.appendChild(this.container); } hide(): void { + if (!this.isOpen) return; + this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); } toggle(): void { - if (this.container.style.display === 'flex') this.hide(); else this.show(); + if (this.isOpen) this.hide(); else this.show(); } getIsOpen(): boolean { - return this.container.style.display === 'flex'; + return this.isOpen; } private render(): void { @@ -51,8 +63,13 @@ export class SkillTreeUI { background: linear-gradient(180deg, rgba(26,24,36,0.8) 0%, transparent 100%); `; header.innerHTML = ` -

技能树

-

+
+
+

技能树

+

+
+ +
`; this.container.appendChild(header); @@ -148,6 +165,8 @@ export class SkillTreeUI { this.container.appendChild(content); this.updatePerkPoints(); + + document.getElementById('close-skill-tree')?.addEventListener('click', () => this.hide()); } private selectSkill(skillId: string): void { @@ -235,4 +254,4 @@ export class SkillTreeUI { } } -export const skillTreeUI = new SkillTreeUI(); +export const skillTreeUI = SkillTreeUI.getInstance(); diff --git a/src/ui/components/TitleMenuUI.ts b/src/ui/components/TitleMenuUI.ts new file mode 100644 index 0000000..703f024 --- /dev/null +++ b/src/ui/components/TitleMenuUI.ts @@ -0,0 +1,128 @@ +import { T, FONT } from '../theme'; +import { eventBus } from '../../core/EventBus'; + +export class TitleMenuUI { + private static instance: TitleMenuUI; + private container: HTMLDivElement; + private isOpen = false; + + static getInstance(): TitleMenuUI { + if (!TitleMenuUI.instance) { + TitleMenuUI.instance = new TitleMenuUI(); + } + return TitleMenuUI.instance; + } + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'title-menu'; + this.container.style.cssText = ` + position: fixed; inset: 0; + display: none; z-index: 8000; + background: radial-gradient(ellipse at center, #1a1520 0%, #0a0a0f 100%); + font-family: ${FONT.body}; + `; + } + + show(): void { + this.isOpen = true; + this.render(); + this.container.style.display = 'flex'; + document.body.appendChild(this.container); + } + + hide(): void { + this.isOpen = false; + this.container.style.display = 'none'; + this.container.remove(); + } + + getIsOpen(): boolean { + return this.isOpen; + } + + private render(): void { + this.container.innerHTML = ''; + this.container.style.alignItems = 'center'; + this.container.style.justifyContent = 'center'; + this.container.style.flexDirection = 'column'; + + // Title + const titleWrap = document.createElement('div'); + titleWrap.style.cssText = `text-align: center; margin-bottom: 60px;`; + + const subtitle = document.createElement('div'); + subtitle.style.cssText = ` + font-size: 12px; color: ${T.textDim}; letter-spacing: 6px; + text-transform: uppercase; margin-bottom: 12px; + `; + subtitle.textContent = 'The Elder Scrolls'; + + const mainTitle = document.createElement('div'); + mainTitle.style.cssText = ` + font-family: ${FONT.title}; font-size: 52px; color: ${T.textGoldBright}; + letter-spacing: 8px; text-shadow: 0 0 40px rgba(212,168,67,0.3), 0 2px 8px rgba(0,0,0,0.6); + `; + mainTitle.textContent = '天际省'; + + const webTitle = document.createElement('div'); + webTitle.style.cssText = ` + font-size: 14px; color: ${T.textMuted}; letter-spacing: 4px; + margin-top: 8px; + `; + webTitle.textContent = 'OES-WEB'; + + titleWrap.appendChild(subtitle); + titleWrap.appendChild(mainTitle); + titleWrap.appendChild(webTitle); + this.container.appendChild(titleWrap); + + // Menu buttons + const btnWrap = document.createElement('div'); + btnWrap.style.cssText = `display: flex; flex-direction: column; gap: 8px; min-width: 240px;`; + + const buttons: { label: string; action: () => void }[] = [ + { label: '新游戏', action: () => { this.hide(); eventBus.emit('game:newGame'); } }, + { label: '继续游戏', action: () => { this.hide(); eventBus.emit('game:continue'); } }, + { label: '加载存档', action: () => { this.hide(); eventBus.emit('game:loadMenu'); } }, + ]; + + for (const btn of buttons) { + const el = document.createElement('button'); + el.style.cssText = ` + padding: 14px 24px; + background: transparent; + border: 1px solid ${T.borderDark}; border-radius: 3px; + color: ${T.textGold}; font-size: 16px; font-family: ${FONT.title}; + letter-spacing: 3px; cursor: pointer; + transition: border-color 0.2s, background 0.2s, color 0.2s; + `; + el.textContent = btn.label; + el.addEventListener('mouseenter', () => { + el.style.borderColor = T.borderBronze; + el.style.background = 'rgba(60,50,30,0.2)'; + el.style.color = T.textGoldBright; + }); + el.addEventListener('mouseleave', () => { + el.style.borderColor = T.borderDark; + el.style.background = 'transparent'; + el.style.color = T.textGold; + }); + el.addEventListener('click', btn.action); + btnWrap.appendChild(el); + } + + this.container.appendChild(btnWrap); + + // Footer + const footer = document.createElement('div'); + footer.style.cssText = ` + position: absolute; bottom: 20px; + font-size: 11px; color: ${T.textDim}; letter-spacing: 1px; + `; + footer.textContent = 'OES-WEB v0.1 — Built with AI'; + this.container.appendChild(footer); + } +} + +export const titleMenuUI = TitleMenuUI.getInstance(); diff --git a/src/ui/components/WorldMapUI.ts b/src/ui/components/WorldMapUI.ts index fd43854..3f3fd7e 100644 --- a/src/ui/components/WorldMapUI.ts +++ b/src/ui/components/WorldMapUI.ts @@ -57,6 +57,7 @@ export class WorldMapUI { } show(): void { + if (this.isOpen) return; this.isOpen = true; this.container.style.display = 'block'; this.render(); @@ -64,8 +65,10 @@ export class WorldMapUI { } hide(): void { + if (!this.isOpen) return; this.isOpen = false; this.container.style.display = 'none'; + this.container.remove(); } toggle(): void {