diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3e11695..8e564ff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,10 @@ "allow": [ "Bash(npx tsc *)", "Bash(npx vitest *)", - "WebSearch" + "WebSearch", + "Bash(netstat -ano)", + "Bash(findstr :5173)", + "Bash(findstr :517)" ] } } diff --git a/package-lock.json b/package-lock.json index 5f64668..6d1b6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "phaser": "^4.1.0" }, "devDependencies": { + "@types/howler": "^2.2.12", "@types/lodash-es": "^4.17.12", "typescript": "~6.0.2", "vite": "^8.0.10", @@ -371,6 +372,12 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, + "node_modules/@types/howler": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", + "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", + "dev": true + }, "node_modules/@types/lodash": { "version": "4.17.24", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", @@ -1462,6 +1469,12 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, + "@types/howler": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", + "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", + "dev": true + }, "@types/lodash": { "version": "4.17.24", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", diff --git a/package.json b/package.json index cb7cf58..704b2c7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/howler": "^2.2.12", "@types/lodash-es": "^4.17.12", "typescript": "~6.0.2", "vite": "^8.0.10", diff --git a/src/data/DataRegistry.ts b/src/data/DataRegistry.ts index b2941bc..0940ed2 100644 --- a/src/data/DataRegistry.ts +++ b/src/data/DataRegistry.ts @@ -136,6 +136,7 @@ export interface RaceData { id: string; name: string; description: string; + color: string; bonuses: Record; baseStats: { health: number; magicka: number; stamina: number }; power: { id: string; name: string; description: string; cooldown: number }; @@ -358,6 +359,64 @@ export interface GameConfigData { staminaPerSecond: number; restorationBonusPerSkill: number; }; + player: { + baseHealth: number; + baseMagicka: number; + baseStamina: number; + baseGold: number; + maxCarryWeight: number; + fistDamage: number; + fistSpeed: number; + movementSpeed: number; + startingSkills: Record; + startingItems: { id: string; quantity: number }[]; + startingSpells: string[]; + startingZone: string; + startingQuest: string; + }; + ai: { + chaseSpeed: number; + retreatSpeed: number; + patrolSpeed: number; + }; + ui: { + itemTypeColors: Record; + questTypeLabels: Record; + questTypeColors: Record; + questStatusLabels: Record; + questStatusColors: Record; + magicSchoolLabels: Record; + magicSchoolColors: Record; + locationTypeColors: Record; + materialColors: Record; + worldMap: { + parchmentBase: string; + parchmentLight: string; + fogAlpha: number; + roadColor: string; + gridOpacity: number; + playerMarkerColor: string; + }; + magicUI: { + schoolSymbols: Record; + showOnlyKnownSpells: boolean; + }; + skillTree: { + starSize: number; + starGlowRadius: number; + lineWidth: number; + constellationLineColor: string; + unlockedColor: string; + lockedColor: string; + backgroundColor: string; + starFieldDensity: number; + }; + inventory: { + showWeight: boolean; + showValue: boolean; + sortBy: string; + }; + }; } type UnknownRecord = Record; @@ -756,10 +815,104 @@ export class DataRegistry { staminaPerSecond: readNumber(record.regen, 'staminaPerSecond', 5), restorationBonusPerSkill: readNumber(record.regen, 'restorationBonusPerSkill', 0.02), }, + player: this.normalizePlayerConfig(isRecord(record.player) ? record.player : {}), + ai: this.normalizeAIConfig(isRecord(record.ai) ? record.ai : {}), + ui: this.normalizeUIConfig(isRecord(record.ui) ? record.ui : {}), }; } } + private normalizePlayerConfig(r: UnknownRecord): GameConfigData['player'] { + const skills = isRecord(r.startingSkills) ? r.startingSkills : {}; + const items = Array.isArray(r.startingItems) ? r.startingItems : []; + const spells = Array.isArray(r.startingSpells) ? r.startingSpells : []; + return { + baseHealth: readNumber(r, 'baseHealth', 100), + baseMagicka: readNumber(r, 'baseMagicka', 50), + baseStamina: readNumber(r, 'baseStamina', 100), + baseGold: readNumber(r, 'baseGold', 100), + maxCarryWeight: readNumber(r, 'maxCarryWeight', 300), + fistDamage: readNumber(r, 'fistDamage', 4), + fistSpeed: readNumber(r, 'fistSpeed', 1.4), + movementSpeed: readNumber(r, 'movementSpeed', 200), + startingSkills: Object.fromEntries( + Object.entries(skills).map(([k, v]) => [k, Number(v) || 15]) + ), + startingItems: items.map((item: unknown) => { + const o = isRecord(item) ? item : {}; + return { id: readString(o, 'id', ''), quantity: readNumber(o, 'quantity', 1) }; + }).filter((item: { id: string }) => item.id), + startingSpells: spells.filter((s: unknown) => typeof s === 'string') as string[], + startingZone: readString(r, 'startingZone', 'whiterun_exterior'), + startingQuest: readString(r, 'startingQuest', 'main_01_unbound'), + }; + } + + private normalizeAIConfig(r: UnknownRecord): GameConfigData['ai'] { + return { + chaseSpeed: readNumber(r, 'chaseSpeed', 80), + retreatSpeed: readNumber(r, 'retreatSpeed', 100), + patrolSpeed: readNumber(r, 'patrolSpeed', 30), + }; + } + + private normalizeUIConfig(r: UnknownRecord): GameConfigData['ui'] { + const wm = isRecord(r.worldMap) ? r.worldMap : {}; + const mu = isRecord(r.magicUI) ? r.magicUI : {}; + const st = isRecord(r.skillTree) ? r.skillTree : {}; + const inv = isRecord(r.inventory) ? r.inventory : {}; + + return { + itemTypeColors: this.toStringMap(isRecord(r.itemTypeColors) ? r.itemTypeColors : {}), + questTypeLabels: this.toStringMap(isRecord(r.questTypeLabels) ? r.questTypeLabels : {}), + questTypeColors: this.toStringMap(isRecord(r.questTypeColors) ? r.questTypeColors : {}), + questStatusLabels: this.toStringMap(isRecord(r.questStatusLabels) ? r.questStatusLabels : {}), + questStatusColors: this.toStringMap(isRecord(r.questStatusColors) ? r.questStatusColors : {}), + magicSchoolLabels: this.toStringMap(isRecord(r.magicSchoolLabels) ? r.magicSchoolLabels : {}), + magicSchoolColors: this.toStringMap(isRecord(r.magicSchoolColors) ? r.magicSchoolColors : {}), + locationTypeColors: this.toStringMap(isRecord(r.locationTypeColors) ? r.locationTypeColors : {}), + materialColors: Object.fromEntries( + Object.entries(isRecord(r.materialColors) ? r.materialColors : {}).map(([k, v]) => { + const m = isRecord(v) ? v : {}; + return [k, { fill: readString(m, 'fill', '#888888'), stroke: readString(m, 'stroke', '#666666') }]; + }) + ), + worldMap: { + parchmentBase: readString(wm, 'parchmentBase', '#2a2420'), + parchmentLight: readString(wm, 'parchmentLight', '#352e28'), + fogAlpha: readNumber(wm, 'fogAlpha', 0.85), + roadColor: readString(wm, 'roadColor', '#8a8070'), + gridOpacity: readNumber(wm, 'gridOpacity', 0.08), + playerMarkerColor: readString(wm, 'playerMarkerColor', '#d4a843'), + }, + magicUI: { + schoolSymbols: this.toStringMap(isRecord(mu.schoolSymbols) ? mu.schoolSymbols : {}), + showOnlyKnownSpells: readString(mu, 'showOnlyKnownSpells', 'true') === 'true', + }, + skillTree: { + starSize: readNumber(st, 'starSize', 8), + starGlowRadius: readNumber(st, 'starGlowRadius', 16), + lineWidth: readNumber(st, 'lineWidth', 1.5), + constellationLineColor: readString(st, 'constellationLineColor', 'rgba(212,168,67,0.4)'), + unlockedColor: readString(st, 'unlockedColor', '#d4a843'), + lockedColor: readString(st, 'lockedColor', '#4a4a55'), + backgroundColor: readString(st, 'backgroundColor', '#0a0a14'), + starFieldDensity: readNumber(st, 'starFieldDensity', 120), + }, + inventory: { + showWeight: readString(inv, 'showWeight', 'true') === 'true', + showValue: readString(inv, 'showValue', 'true') === 'true', + sortBy: readString(inv, 'sortBy', 'name'), + }, + }; + } + + private toStringMap(record: UnknownRecord): Record { + return Object.fromEntries( + Object.entries(record).map(([k, v]) => [k, String(v)]) + ); + } + getItem(id: string): ItemData | undefined { return this.items.get(id); } @@ -985,6 +1138,37 @@ export class DataRegistry { delayMs: 3000, healthPerSecond: 0.5, magickaPerSecond: 3, staminaPerSecond: 5, restorationBonusPerSkill: 0.02, }, + player: { + baseHealth: 100, baseMagicka: 50, baseStamina: 100, baseGold: 100, + maxCarryWeight: 300, fistDamage: 4, fistSpeed: 1.4, movementSpeed: 200, + startingSkills: { oneHanded: 20, twoHanded: 15, archery: 15, block: 15, heavyArmor: 15, lightArmor: 15, smithing: 15, destruction: 15, conjuration: 15, illusion: 15, alteration: 15, restoration: 15, enchanting: 15, sneak: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15 }, + startingItems: [{ id: 'health_potion', quantity: 3 }], + startingSpells: ['flames', 'healing'], + startingZone: 'whiterun_exterior', startingQuest: 'main_01_unbound', + }, + ai: { chaseSpeed: 80, retreatSpeed: 100, patrolSpeed: 30 }, + ui: { + itemTypeColors: { weapon: '#e06040', armor: '#4080c0', consumable: '#40a060', material: '#a08040', spell: '#7050c0', misc: '#888888' }, + questTypeLabels: { main: '主线', side: '支线', guild: '公会', daedric: '魔神', radiant: '辐射' }, + questTypeColors: { main: '#e06040', side: '#40a060', guild: '#4080c0', daedric: '#7050c0', radiant: '#a08040' }, + questStatusLabels: { active: '进行中', completed: '已完成', failed: '已失败' }, + questStatusColors: { active: '#ffd700', completed: '#40a060', failed: '#e06040' }, + magicSchoolLabels: { destruction: '毁灭', restoration: '恢复', conjuration: '召唤', illusion: '幻术', alteration: '变化' }, + magicSchoolColors: { destruction: '#e06040', restoration: '#40a060', conjuration: '#7050c0', illusion: '#c0a040', alteration: '#4080c0' }, + locationTypeColors: { city: '#ffd700', town: '#40a060', village: '#8bc34a', dungeon: '#e06040', camp: '#ff9800', landmark: '#4080c0' }, + materialColors: { iron: { fill: '#8c7e6a', stroke: '#6b5f4f' }, steel: { fill: '#b0b0b0', stroke: '#888888' }, leather: { fill: '#8b6914', stroke: '#6b4f10' }, corundum: { fill: '#cd7f32', stroke: '#a06020' }, orichalcum: { fill: '#4a7a4a', stroke: '#3a5a3a' }, moonstone: { fill: '#7ab8a0', stroke: '#5a9880' }, elven: { fill: '#c8b050', stroke: '#a89030' }, orcish: { fill: '#5a7a3a', stroke: '#4a5a2a' }, ebony: { fill: '#2a2a2a', stroke: '#1a1a1a' }, daedric: { fill: '#8b0000', stroke: '#5a0000' }, dragon: { fill: '#4a6a8a', stroke: '#3a5a7a' }, wood: { fill: '#a0703c', stroke: '#80502c' } }, + worldMap: { + parchmentBase: '#2a2420', parchmentLight: '#352e28', fogAlpha: 0.85, + roadColor: '#8a8070', gridOpacity: 0.08, playerMarkerColor: '#d4a843', + }, + magicUI: { schoolSymbols: { destruction: '✦', restoration: '✚', illusion: '◉', conjuration: '☠', alteration: '◈' }, showOnlyKnownSpells: true }, + skillTree: { + starSize: 8, starGlowRadius: 16, lineWidth: 1.5, + constellationLineColor: 'rgba(212,168,67,0.4)', unlockedColor: '#d4a843', + lockedColor: '#4a4a55', backgroundColor: '#0a0a14', starFieldDensity: 120, + }, + inventory: { showWeight: true, showValue: true, sortBy: 'name' }, + }, }; } @@ -1264,6 +1448,7 @@ function normalizeRace(value: UnknownRecord): RaceData | null { id, name: readString(value, 'name', id), description: readString(value, 'description', ''), + color: readString(value, 'color', '#888888'), bonuses, baseStats: { health: readNumber(baseStats, 'health', 100), diff --git a/src/data/dialogue/trees.json b/src/data/dialogue/trees.json index 370124e..e3f463d 100644 --- a/src/data/dialogue/trees.json +++ b/src/data/dialogue/trees.json @@ -229,6 +229,534 @@ } }, "startLineId": "start" + }, + "whiterun_jarls": { + "id": "whiterun_jarls", + "npcId": "whiterun_jarls", + "lines": { + "start": { + "id": "start", + "speaker": "雪漫领主", + "text": "我是雪漫城的领主。天际省正面临前所未有的威胁——龙的回归。你愿意为天际而战吗?", + "options": [ + { "id": "about_dragons", "text": "关于龙的事", "nextLineId": "about_dragons" }, + { "id": "quest_main_03", "text": "有什么我能帮忙的吗?", "nextLineId": "quest_main_03", "conditions": [{ "type": "questCompleted", "value": "main_02_bleakfalls" }] }, + { "id": "quest_main_04", "text": "关于龙墓的传说", "nextLineId": "quest_main_04", "conditions": [{ "type": "questCompleted", "value": "main_03_dragonsreach" }] }, + { "id": "about_city", "text": "关于雪漫城", "nextLineId": "about_city" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_dragons": { + "id": "about_dragons", + "speaker": "雪漫领主", + "text": "古老的龙正在苏醒。它们曾被龙裔封印在龙墓之中,但现在封印正在瓦解。我们需要勇士来阻止这场灾难。", + "options": [ + { "id": "back", "text": "我明白了", "nextLineId": "start" } + ] + }, + "quest_main_03": { + "id": "quest_main_03", + "speaker": "雪漫领主", + "text": "你来得正好!有报告说一条龙出现在天际省的荒野。我们需要你去消灭它。这是危险的任务,但只有你能做到。", + "options": [ + { "id": "accept", "text": "我接受这个任务", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "main_03_dragonsreach" }] }, + { "id": "decline", "text": "我需要准备一下", "nextLineId": "quest_declined" } + ] + }, + "quest_main_04": { + "id": "quest_main_04", + "speaker": "雪漫领主", + "text": "古老的预言说,当龙血后裔觉醒时,远古巨龙将从沉睡中醒来。你体内的龙血……你就是那个预言中的人。去龙墓,终结这一切。", + "options": [ + { "id": "accept", "text": "我去龙墓", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "main_04_dragoncall" }] }, + { "id": "decline", "text": "我还没准备好", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "雪漫领主", + "text": "愿艾拉的庇佑与你同在。去吧,为天际省而战!", + "options": [ + { "id": "bye", "text": "我不会让你失望的", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "雪漫领主", + "text": "好吧。但时间不等人,龙不会等待我们准备好。", + "options": [ + { "id": "bye", "text": "我会尽快回来", "nextLineId": "end" } + ] + }, + "about_city": { + "id": "about_city", + "speaker": "雪漫领主", + "text": "雪漫城是天际省的心脏。我们的守卫忠诚勇敢,铁匠技术精湛,百姓勤劳朴实。这里是你的家。", + "options": [ + { "id": "back", "text": "谢谢领主", "nextLineId": "start" } + ] + }, + "end": { + "id": "end", + "speaker": "雪漫领主", + "text": "愿天际省的星辰照亮你的道路。" + } + }, + "startLineId": "start" + }, + "greymane_leader": { + "id": "greymane_leader", + "npcId": "greymane_leader", + "lines": { + "start": { + "id": "start", + "speaker": "灰鬃族长", + "text": "灰鬃家族是雪漫城最古老的家族之一。我们的荣耀可以追溯到第一纪元。有什么事?", + "options": [ + { "id": "quest", "text": "关于灰鬃家族与风暴斗篷的冲突", "nextLineId": "quest", "conditions": [{ "type": "questActive", "value": "companion_02_greymane" }] }, + { "id": "about_family", "text": "关于灰鬃家族", "nextLineId": "about_family" }, + { "id": "about_conflict", "text": "关于家族冲突", "nextLineId": "about_conflict" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_family": { + "id": "about_family", + "speaker": "灰鬃族长", + "text": "灰鬃家族世代效忠雪漫城。我们以战士的传统闻名,家族中的每个人都精通武艺。", + "options": [ + { "id": "back", "text": "了不起", "nextLineId": "start" } + ] + }, + "about_conflict": { + "id": "about_conflict", + "speaker": "灰鬃族长", + "text": "风暴斗篷的叛乱让天际省四分五裂。我们灰鬃家族支持帝国,但有些人认为我们应该加入风暴斗篷。内部的分歧正在撕裂我们的家族。", + "options": [ + { "id": "back", "text": "我理解了", "nextLineId": "start" } + ] + }, + "quest": { + "id": "quest", + "speaker": "灰鬃族长", + "text": "你来了。家族内部的冲突越来越严重。一些年轻成员想要加入风暴斗篷,但老一辈坚持效忠帝国。我需要你帮我调查冲突的根源,找到解决办法。", + "options": [ + { "id": "investigate", "text": "我来帮你调查", "nextLineId": "quest_investigate" }, + { "id": "back", "text": "我需要更多信息", "nextLineId": "start" } + ] + }, + "quest_investigate": { + "id": "quest_investigate", + "speaker": "灰鬃族长", + "text": "去城里问问人们,看看能不能找到冲突的根源。也许有人知道内情。", + "options": [ + { "id": "bye", "text": "我会查明真相的", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "灰鬃族长", + "text": "灰鬃家族的命运就靠你了。" + } + }, + "startLineId": "start" + }, + "companion_leader": { + "id": "companion_leader", + "npcId": "companion_leader", + "lines": { + "start": { + "id": "start", + "speaker": "战士公会首领", + "text": "欢迎来到战士公会。我们是天际省最强大的战士组织。想加入我们吗?", + "options": [ + { "id": "quest", "text": "有什么任务可以让我证明自己?", "nextLineId": "quest", "conditions": [{ "type": "questCompleted", "value": "companion_01_proving" }, { "type": "not", "conditions": [{ "type": "questCompleted", "value": "companion_02_greymane" }] }] }, + { "id": "quest_proving", "text": "我想加入战士公会", "nextLineId": "quest_proving", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "companion_01_proving" }] }] }, + { "id": "about_guild", "text": "关于战士公会", "nextLineId": "about_guild" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_guild": { + "id": "about_guild", + "speaker": "战士公会首领", + "text": "战士公会由天际省最勇猛的战士组成。我们保护平民,打击强盗,维护正义。每一位成员都经过严格的考验。", + "options": [ + { "id": "back", "text": "了不起", "nextLineId": "start" } + ] + }, + "quest_proving": { + "id": "quest_proving", + "speaker": "战士公会首领", + "text": "想加入?先证明你的实力。暗光洞穴里有蜘蛛出没,去消灭它们,带回胜利的消息。这就是你的考验。", + "options": [ + { "id": "accept", "text": "我接受考验", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "companion_01_proving" }] }, + { "id": "decline", "text": "我还没准备好", "nextLineId": "quest_declined" } + ] + }, + "quest": { + "id": "quest", + "speaker": "战士公会首领", + "text": "你通过了考验,表现不错。现在有一个更重要的任务。灰鬃家族的内斗正在威胁雪漫城的稳定。去帮他们解决冲突。", + "options": [ + { "id": "accept", "text": "我来处理", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "companion_02_greymane" }] }, + { "id": "decline", "text": "我现在有别的事", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "战士公会首领", + "text": "好。去找灰鬃族长,他会告诉你详情。愿你的剑锋利如昔。", + "options": [ + { "id": "bye", "text": "我这就去", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "战士公会首领", + "text": "好吧。但别让太久,时间不等人。", + "options": [ + { "id": "bye", "text": "我会尽快", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "战士公会首领", + "text": "为了战士公会!" + } + }, + "startLineId": "start" + }, + "riverwood_alchemist": { + "id": "riverwood_alchemist", + "npcId": "riverwood_alchemist", + "lines": { + "start": { + "id": "start", + "speaker": "炼金术士", + "text": "你好啊,旅人。我是溪木镇的炼金术士。需要药水吗?", + "options": [ + { "id": "shop", "text": "看看药水", "nextLineId": "shop" }, + { "id": "quest", "text": "你需要帮忙吗?", "nextLineId": "quest", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "misc_herb_gathering" }] }] }, + { "id": "about_alchemy", "text": "关于炼金术", "nextLineId": "about_alchemy" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "shop": { + "id": "shop", + "speaker": "炼金术士", + "text": "我有各种药水。生命药水 25 金币,魔力药水 30 金币。", + "options": [ + { "id": "buy_health", "text": "买生命药水 (25金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 25 }, { "type": "giveItem", "value": { "id": "health_potion", "quantity": 1 } }] }, + { "id": "buy_magicka", "text": "买魔力药水 (30金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 30 }, { "type": "giveItem", "value": { "id": "magicka_potion", "quantity": 1 } }] }, + { "id": "back", "text": "返回", "nextLineId": "start" } + ] + }, + "bought": { + "id": "bought", + "speaker": "炼金术士", + "text": "好选择!还需要什么?", + "options": [ + { "id": "back", "text": "看看其他东西", "nextLineId": "shop" }, + { "id": "bye", "text": "谢谢,再见", "nextLineId": "end" } + ] + }, + "quest": { + "id": "quest", + "speaker": "炼金术士", + "text": "你来得正好!我需要蓝色山花来配药,但最近蜘蛛太多,我不敢出去采。你能帮我收集 5 朵蓝色山花吗?", + "options": [ + { "id": "accept", "text": "我去帮你找", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "misc_herb_gathering" }] }, + { "id": "decline", "text": "我现在没空", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "炼金术士", + "text": "太好了!蓝色山花通常长在溪边或山坡上。找到后带回来给我。", + "options": [ + { "id": "bye", "text": "我这就去", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "炼金术士", + "text": "好吧。如果你改变主意了,随时来找我。", + "options": [ + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_alchemy": { + "id": "about_alchemy", + "speaker": "炼金术士", + "text": "炼金术是将植物和矿物的特性混合,创造出药水和毒药的艺术。每种材料都有独特的效果,组合起来更是千变万化。", + "options": [ + { "id": "back", "text": "有趣的学问", "nextLineId": "start" } + ] + }, + "end": { + "id": "end", + "speaker": "炼金术士", + "text": "保重!" + } + }, + "startLineId": "start" + }, + "thief_leader": { + "id": "thief_leader", + "npcId": "thief_leader", + "lines": { + "start": { + "id": "start", + "speaker": "布林乔夫", + "text": "嘘……低调点。你是新来的?想加入盗贼公会?", + "options": [ + { "id": "quest", "text": "有什么任务可以证明自己?", "nextLineId": "quest", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "thief_01_pickpocket" }] }] }, + { "id": "about_guild", "text": "关于盗贼公会", "nextLineId": "about_guild" }, + { "id": "about_riften", "text": "关于裂谷城", "nextLineId": "about_riften" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_guild": { + "id": "about_guild", + "speaker": "布林乔夫", + "text": "盗贼公会不是普通的犯罪团伙。我们有自己的规矩:不偷穷人的东西,不杀人,讲信用。在这里,信誉比金币更重要。", + "options": [ + { "id": "back", "text": "有意思", "nextLineId": "start" } + ] + }, + "about_riften": { + "id": "about_riften", + "speaker": "布林乔夫", + "text": "裂谷城表面光鲜,暗地里却是天际省最大的黑市。鱼市下面就是我们的地盘。领主?他只关心自己的利益。", + "options": [ + { "id": "back", "text": "明白了", "nextLineId": "start" } + ] + }, + "quest": { + "id": "quest", + "speaker": "布林乔夫", + "text": "想入会?先证明你的胆量。去溪木镇,从商人那里偷一封信。别被发现。这就是你的考验。", + "options": [ + { "id": "accept", "text": "我来试试", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "thief_01_pickpocket" }] }, + { "id": "decline", "text": "我再想想", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "布林乔夫", + "text": "记住:快、准、无声。被守卫抓住可不关我的事。完成后把信带回来。", + "options": [ + { "id": "bye", "text": "放心吧", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "布林乔夫", + "text": "好吧。但别把今天看到的说出去。", + "options": [ + { "id": "bye", "text": "我的嘴和手一样紧", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "布林乔夫", + "text": "低调行事。" + } + }, + "startLineId": "start" + }, + "archmage": { + "id": "archmage", + "npcId": "archmage", + "lines": { + "start": { + "id": "start", + "speaker": "大法师", + "text": "欢迎来到冬堡学院。我是这里的院长。魔法的奥秘无穷无尽,你准备好探索了吗?", + "options": [ + { "id": "quest", "text": "有什么魔法试炼吗?", "nextLineId": "quest", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "guild_mage_trial" }] }] }, + { "id": "about_college", "text": "关于冬堡学院", "nextLineId": "about_college" }, + { "id": "about_magic", "text": "关于魔法学派", "nextLineId": "about_magic" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_college": { + "id": "about_college", + "speaker": "大法师", + "text": "冬堡学院是天际省最高的魔法学府。这里有毁灭、恢复、召唤、变化、幻术五大学派的导师。无论你擅长什么,都能在这里找到方向。", + "options": [ + { "id": "back", "text": "了不起的地方", "nextLineId": "start" } + ] + }, + "about_magic": { + "id": "about_magic", + "speaker": "大法师", + "text": "毁灭魔法擅长伤害,恢复魔法用于治疗,召唤魔法可以创造仆从,变化魔法增强防护,幻术魔法操控心智。每个学派都有独特的魅力。", + "options": [ + { "id": "back", "text": "我受教了", "nextLineId": "start" } + ] + }, + "quest": { + "id": "quest", + "speaker": "大法师", + "text": "你想证明自己的魔法实力?古代遗迹中有一本珍贵的魔法书,但那里盘踞着强大的不死生物。去取回那本书,这就是你的试炼。", + "options": [ + { "id": "accept", "text": "我接受试炼", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "guild_mage_trial" }] }, + { "id": "decline", "text": "我需要准备", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "大法师", + "text": "愿魔法与你同在。小心那些尸鬼,它们虽是不死之身,但魔法可以终结它们。", + "options": [ + { "id": "bye", "text": "我这就去", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "大法师", + "text": "不急。知识的积累需要时间。准备好了再来找我。", + "options": [ + { "id": "bye", "text": "谢谢院长", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "大法师", + "text": "愿奥瑞的智慧指引你。" + } + }, + "startLineId": "start" + }, + "mad_mage": { + "id": "mad_mage", + "npcId": "mad_mage", + "lines": { + "start": { + "id": "start", + "speaker": "疯狂法师", + "text": "你……你能看到它吗?黑色的星辰……它在呼唤我……不,它在呼唤所有人……", + "options": [ + { "id": "about_star", "text": "关于黑色星辰", "nextLineId": "about_star" }, + { "id": "quest", "text": "我能帮你什么吗?", "nextLineId": "quest", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "daedric_02_star" }] }] }, + { "id": "about_mage", "text": "你是谁?", "nextLineId": "about_mage" }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "about_star": { + "id": "about_star", + "speaker": "疯狂法师", + "text": "黑色星辰……它是魔神莫拉的造物。一颗被污染的灵魂石,能捕获任何灵魂,甚至不朽者的灵魂。我曾试图净化它,但它的力量太强大了……", + "options": [ + { "id": "back", "text": "你看起来不太好", "nextLineId": "start" } + ] + }, + "about_mage": { + "id": "about_mage", + "speaker": "疯狂法师", + "text": "我曾是冬堡学院的大法师。直到我发现了黑色星辰……它的低语让我疯狂。现在我只能在这里等待,等待有人能终结这场噩梦。", + "options": [ + { "id": "back", "text": "我明白了", "nextLineId": "start" } + ] + }, + "quest": { + "id": "quest", + "speaker": "疯狂法师", + "text": "你愿意帮我?黑色星辰就在暗光洞穴深处。去找到它……但要小心,它会诱惑你。你可以选择净化它,或者……保留它的力量。无论你选择什么,请结束我的痛苦。", + "options": [ + { "id": "accept", "text": "我来帮你", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "daedric_02_star" }] }, + { "id": "decline", "text": "这太危险了", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "疯狂法师", + "text": "谢谢你……去吧。在洞穴深处,你会找到一个祭坛。黑色星辰就在那里。做出你的选择……然后让我安息。", + "options": [ + { "id": "bye", "text": "我会做出正确的选择", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "疯狂法师", + "text": "我理解……没有人愿意面对那样的力量。但低语不会停止……永远不会……", + "options": [ + { "id": "bye", "text": "保重", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "疯狂法师", + "text": "黑色星辰在等待……" + } + }, + "startLineId": "start" + }, + "riverwood_merchant": { + "id": "riverwood_merchant", + "npcId": "riverwood_merchant", + "lines": { + "start": { + "id": "start", + "speaker": "溪木商人", + "text": "欢迎光临!我是溪木镇的商人。药水、材料、杂物,应有尽有!", + "options": [ + { "id": "shop", "text": "看看货物", "nextLineId": "shop" }, + { "id": "quest", "text": "你有什么麻烦吗?", "nextLineId": "quest", "conditions": [{ "type": "not", "conditions": [{ "type": "questCompleted", "value": "side_missing_cargo" }] }] }, + { "id": "bye", "text": "再见", "nextLineId": "end" } + ] + }, + "shop": { + "id": "shop", + "speaker": "溪木商人", + "text": "看看吧!药水、材料、食物,什么都有。", + "options": [ + { "id": "buy_health", "text": "买生命药水 (25金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 25 }, { "type": "giveItem", "value": { "id": "health_potion", "quantity": 1 } }] }, + { "id": "buy_iron_ore", "text": "买铁矿石 (10金币)", "nextLineId": "bought", "effects": [{ "type": "takeGold", "value": 10 }, { "type": "giveItem", "value": { "id": "iron_ore", "quantity": 1 } }] }, + { "id": "back", "text": "返回", "nextLineId": "start" } + ] + }, + "bought": { + "id": "bought", + "speaker": "溪木商人", + "text": "好买卖!还需要什么?", + "options": [ + { "id": "back", "text": "看看其他东西", "nextLineId": "shop" }, + { "id": "bye", "text": "谢谢,再见", "nextLineId": "end" } + ] + }, + "quest": { + "id": "quest", + "speaker": "溪木商人", + "text": "你来得正好!我有一批货物在运输途中被强盗劫走了。那些货物对我很重要。你能帮我找回来吗?", + "options": [ + { "id": "accept", "text": "我帮你找", "nextLineId": "quest_accepted", "effects": [{ "type": "startQuest", "value": "side_missing_cargo" }] }, + { "id": "decline", "text": "我现在没空", "nextLineId": "quest_declined" } + ] + }, + "quest_accepted": { + "id": "quest_accepted", + "speaker": "溪木商人", + "text": "太好了!那些货物应该在白漫城外的强盗营地里。小心那些强盗,他们人多势众。找到货物后带回来给我!", + "options": [ + { "id": "bye", "text": "我这就去", "nextLineId": "end" } + ] + }, + "quest_declined": { + "id": "quest_declined", + "speaker": "溪木商人", + "text": "好吧。但那批货物对我来说真的很重要……", + "options": [ + { "id": "bye", "text": "我会考虑的", "nextLineId": "end" } + ] + }, + "end": { + "id": "end", + "speaker": "溪木商人", + "text": "下次再来!" + } + }, + "startLineId": "start" } } } diff --git a/src/data/enemies/enemies.json b/src/data/enemies/enemies.json index 5558fff..8f1deef 100644 --- a/src/data/enemies/enemies.json +++ b/src/data/enemies/enemies.json @@ -378,6 +378,38 @@ "size": 28, "attackAnimation": { "type": "swing", "reach": 26, "duration": 200 } }, + "bandit_brute": { + "id": "bandit_brute", + "name": "强盗悍匪", + "level": 10, + "health": 95, + "stamina": 55, + "damage": 14, + "armor": 18, + "detectionRange": 150, + "attackRange": 48, + "attackSpeed": 0.9, + "aiBehavior": "melee", + "abilities": [ + { + "id": "brute_cleave", + "type": "attack", + "cooldown": 5500, + "condition": "always", + "damage": 20 + } + ], + "loot": { + "gold": { "min": 20, "max": 50 }, + "items": [ + { "id": "steel_sword", "chance": 0.12 }, + { "id": "health_potion", "chance": 0.35 } + ] + }, + "color": "#bb3333", + "size": 28, + "attackAnimation": { "type": "swing", "reach": 24, "duration": 180 } + }, "necromancer": { "id": "necromancer", "name": "死灵法师", @@ -424,6 +456,321 @@ "color": "#6622aa", "size": 24, "attackAnimation": { "type": "thrust", "reach": 20, "duration": 180 } + }, + "dragon": { + "id": "dragon", + "name": "龙", + "level": 15, + "health": 500, + "magicka": 200, + "stamina": 150, + "damage": 35, + "armor": 40, + "detectionRange": 300, + "attackRange": 100, + "attackSpeed": 0.6, + "aiBehavior": "mixed", + "abilities": [ + { + "id": "dragon_fire_breath", + "spellId": "dragon_fire_breath", + "type": "spell", + "cooldown": 4000, + "condition": "always" + }, + { + "id": "dragon_tail_swipe", + "spellId": "dragon_tail_swipe", + "type": "spell", + "cooldown": 5000, + "condition": "hp_below_60" + }, + { + "id": "dragon_frenzy", + "type": "buff", + "cooldown": 30000, + "condition": "hp_below_30", + "effect": "dragon_frenzy", + "effectDuration": 15000, + "effectMagnitude": 10 + } + ], + "loot": { + "gold": { "min": 200, "max": 500 }, + "items": [ + { "id": "dragon_bone", "chance": 1.0 }, + { "id": "dragon_scale", "chance": 0.8 }, + { "id": "dragon_sword", "chance": 0.15 } + ] + }, + "color": "#cc4400", + "size": 48, + "attackAnimation": { "type": "bite", "reach": 40, "duration": 250 } + }, + "dragon_ancient": { + "id": "dragon_ancient", + "name": "远古巨龙", + "level": 25, + "health": 800, + "magicka": 300, + "stamina": 200, + "damage": 50, + "armor": 55, + "detectionRange": 350, + "attackRange": 120, + "attackSpeed": 0.5, + "aiBehavior": "mixed", + "abilities": [ + { + "id": "ancient_fire_breath", + "spellId": "dragon_fire_breath", + "type": "spell", + "cooldown": 3000, + "condition": "always" + }, + { + "id": "ancient_frost_breath", + "spellId": "dragon_frost_breath", + "type": "spell", + "cooldown": 4000, + "condition": "hp_below_70" + }, + { + "id": "ancient_tail_swipe", + "spellId": "dragon_tail_swipe", + "type": "spell", + "cooldown": 4000, + "condition": "hp_below_50" + }, + { + "id": "ancient_dragon_frenzy", + "type": "buff", + "cooldown": 25000, + "condition": "hp_below_30", + "effect": "dragon_frenzy", + "effectDuration": 20000, + "effectMagnitude": 15 + } + ], + "loot": { + "gold": { "min": 500, "max": 1000 }, + "items": [ + { "id": "dragon_bone", "chance": 1.0 }, + { "id": "dragon_bone", "chance": 0.5 }, + { "id": "dragon_scale", "chance": 1.0 }, + { "id": "dragon_scale", "chance": 0.6 }, + { "id": "dragon_sword", "chance": 0.3 }, + { "id": "daedric_heart", "chance": 0.1 } + ] + }, + "color": "#880000", + "size": 56, + "attackAnimation": { "type": "bite", "reach": 48, "duration": 280 } + }, + "frost_troll": { + "id": "frost_troll", + "name": "冰霜巨魔", + "level": 12, + "health": 150, + "stamina": 60, + "damage": 20, + "armor": 22, + "detectionRange": 160, + "attackRange": 50, + "attackSpeed": 0.7, + "aiBehavior": "melee", + "abilities": [ + { + "id": "troll_frost_breath", + "spellId": "frost_breath", + "type": "spell", + "cooldown": 5000, + "condition": "always" + }, + { + "id": "troll_regen", + "type": "passive", + "cooldown": 0, + "condition": "on_spawn", + "effect": "regen", + "effectMagnitude": 3 + } + ], + "loot": { + "gold": { "min": 10, "max": 25 }, + "items": [ + { "id": "bear_pelt", "chance": 0.6 }, + { "id": "health_potion", "chance": 0.3 } + ] + }, + "color": "#5588aa", + "size": 36, + "attackAnimation": { "type": "slam", "reach": 32, "duration": 220 } + }, + "mudcrab": { + "id": "mudcrab", + "name": "泥蟹", + "level": 2, + "health": 25, + "stamina": 20, + "damage": 4, + "armor": 15, + "detectionRange": 80, + "attackRange": 25, + "attackSpeed": 1.0, + "aiBehavior": "melee", + "loot": { + "gold": { "min": 0, "max": 3 }, + "items": [ + { "id": "leather", "chance": 0.3 } + ] + }, + "color": "#886644", + "size": 16, + "attackAnimation": { "type": "bite", "reach": 12, "duration": 100 } + }, + "draugr_deathlord": { + "id": "draugr_deathlord", + "name": "尸鬼死亡领主", + "level": 22, + "health": 250, + "stamina": 60, + "magicka": 100, + "damage": 30, + "armor": 35, + "detectionRange": 180, + "attackRange": 55, + "attackSpeed": 0.85, + "aiBehavior": "mixed", + "abilities": [ + { + "id": "deathlord_ice_storm", + "spellId": "ice_storm", + "type": "spell", + "cooldown": 6000, + "condition": "always" + }, + { + "id": "deathlord_frost_breath", + "spellId": "frost_breath", + "type": "spell", + "cooldown": 4000, + "condition": "always" + }, + { + "id": "deathlord_rage", + "type": "buff", + "cooldown": 20000, + "condition": "hp_below_40", + "effect": "undead_rage", + "effectDuration": 15000, + "effectMagnitude": 10 + }, + { + "id": "deathlord_power_attack", + "type": "attack", + "cooldown": 6000, + "condition": "always", + "damage": 40, + "effect": "stun", + "effectDuration": 1500 + } + ], + "loot": { + "gold": { "min": 50, "max": 120 }, + "items": [ + { "id": "steel_sword", "chance": 0.2 }, + { "id": "ebony_ingot", "chance": 0.1 }, + { "id": "health_potion", "chance": 0.4 } + ] + }, + "color": "#334422", + "size": 32, + "attackAnimation": { "type": "swing", "reach": 30, "duration": 200 } + }, + "bandit_chief": { + "id": "bandit_chief", + "name": "强盗头目", + "level": 14, + "health": 140, + "stamina": 60, + "damage": 20, + "armor": 28, + "detectionRange": 170, + "attackRange": 50, + "attackSpeed": 0.9, + "aiBehavior": "mixed", + "abilities": [ + { + "id": "chief_battle_cry", + "type": "buff", + "cooldown": 20000, + "condition": "hp_below_50", + "effect": "battle_cry", + "effectDuration": 8000, + "effectMagnitude": 8 + }, + { + "id": "chief_power_attack", + "type": "attack", + "cooldown": 5000, + "condition": "always", + "damage": 30, + "effect": "stun", + "effectDuration": 1200 + } + ], + "loot": { + "gold": { "min": 50, "max": 120 }, + "items": [ + { "id": "steel_sword", "chance": 0.25 }, + { "id": "steel_chestplate", "chance": 0.1 }, + { "id": "health_potion", "chance": 0.5 } + ] + }, + "color": "#cc1111", + "size": 30, + "attackAnimation": { "type": "swing", "reach": 28, "duration": 200 } + }, + "ice_wraith": { + "id": "ice_wraith", + "name": "冰霜幽魂", + "level": 16, + "health": 90, + "magicka": 150, + "stamina": 20, + "damage": 12, + "armor": 10, + "detectionRange": 200, + "attackRange": 120, + "attackSpeed": 0.8, + "aiBehavior": "caster", + "abilities": [ + { + "id": "wraith_frost_bolt", + "spellId": "frostbite", + "type": "spell", + "cooldown": 2000, + "condition": "always" + }, + { + "id": "wraith_ice_storm", + "spellId": "ice_storm", + "type": "spell", + "cooldown": 8000, + "condition": "hp_below_60" + } + ], + "loot": { + "gold": { "min": 15, "max": 40 }, + "items": [ + { "id": "soul_gem", "chance": 0.3 }, + { "id": "magicka_potion", "chance": 0.3 } + ] + }, + "color": "#aaddff", + "size": 26, + "attackAnimation": { "type": "thrust", "reach": 22, "duration": 160 } } } } diff --git a/src/data/game-config.json b/src/data/game-config.json index 935f430..7a8c147 100644 --- a/src/data/game-config.json +++ b/src/data/game-config.json @@ -36,6 +36,144 @@ "magickaPerSecond": 3, "staminaPerSecond": 5, "restorationBonusPerSkill": 0.02 + }, + "player": { + "baseHealth": 100, + "baseMagicka": 50, + "baseStamina": 100, + "baseGold": 100, + "maxCarryWeight": 300, + "fistDamage": 4, + "fistSpeed": 1.4, + "movementSpeed": 200, + "startingSkills": { + "oneHanded": 20, "twoHanded": 15, "archery": 15, "block": 15, + "heavyArmor": 15, "lightArmor": 15, "smithing": 15, + "destruction": 15, "conjuration": 15, "illusion": 15, + "alteration": 15, "restoration": 15, "enchanting": 15, + "sneak": 15, "lockpicking": 15, "pickpocket": 15, + "speech": 15, "alchemy": 15 + }, + "startingItems": [ + { "id": "health_potion", "quantity": 3 }, + { "id": "iron_ingot", "quantity": 10 }, + { "id": "leather_strips", "quantity": 5 }, + { "id": "leather", "quantity": 3 }, + { "id": "blue_mountain_flower", "quantity": 5 }, + { "id": "wheat", "quantity": 3 }, + { "id": "salt_pile", "quantity": 5 } + ], + "startingSpells": ["flames", "healing", "conjure_familiar"], + "startingZone": "whiterun_exterior", + "startingQuest": "main_01_unbound" + }, + "ai": { + "chaseSpeed": 80, + "retreatSpeed": 100, + "patrolSpeed": 30 + }, + "ui": { + "itemTypeColors": { + "weapon": "#e06040", + "armor": "#4080c0", + "consumable": "#40a060", + "material": "#a08040", + "spell": "#7050c0", + "misc": "#888888" + }, + "questTypeLabels": { + "main": "主线", + "side": "支线", + "guild": "公会", + "daedric": "魔神", + "radiant": "辐射" + }, + "questTypeColors": { + "main": "#e06040", + "side": "#40a060", + "guild": "#4080c0", + "daedric": "#7050c0", + "radiant": "#a08040" + }, + "questStatusLabels": { + "active": "进行中", + "completed": "已完成", + "failed": "已失败" + }, + "questStatusColors": { + "active": "#ffd700", + "completed": "#40a060", + "failed": "#e06040" + }, + "magicSchoolLabels": { + "destruction": "毁灭", + "restoration": "恢复", + "conjuration": "召唤", + "illusion": "幻术", + "alteration": "变化" + }, + "magicSchoolColors": { + "destruction": "#e06040", + "restoration": "#40a060", + "conjuration": "#7050c0", + "illusion": "#c0a040", + "alteration": "#4080c0" + }, + "locationTypeColors": { + "city": "#ffd700", + "town": "#40a060", + "village": "#8bc34a", + "dungeon": "#e06040", + "camp": "#ff9800", + "landmark": "#4080c0" + }, + "materialColors": { + "iron": { "fill": "#8c7e6a", "stroke": "#6b5f4f" }, + "steel": { "fill": "#b0b0b0", "stroke": "#888888" }, + "leather": { "fill": "#8b6914", "stroke": "#6b4f10" }, + "corundum": { "fill": "#cd7f32", "stroke": "#a06020" }, + "orichalcum": { "fill": "#4a7a4a", "stroke": "#3a5a3a" }, + "moonstone": { "fill": "#7ab8a0", "stroke": "#5a9880" }, + "elven": { "fill": "#c8b050", "stroke": "#a89030" }, + "orcish": { "fill": "#5a7a3a", "stroke": "#4a5a2a" }, + "ebony": { "fill": "#2a2a2a", "stroke": "#1a1a1a" }, + "daedric": { "fill": "#8b0000", "stroke": "#5a0000" }, + "dragon": { "fill": "#4a6a8a", "stroke": "#3a5a7a" }, + "wood": { "fill": "#a0703c", "stroke": "#80502c" } + }, + "worldMap": { + "parchmentBase": "#2a2420", + "parchmentLight": "#352e28", + "fogAlpha": 0.85, + "roadColor": "#8a8070", + "gridOpacity": 0.08, + "playerMarkerColor": "#d4a843" + }, + "magicUI": { + "schoolSymbols": { + "destruction": "✦", + "restoration": "✚", + "illusion": "◉", + "conjuration": "☠", + "alteration": "◈" + }, + "showOnlyKnownSpells": true + }, + "skillTree": { + "starSize": 8, + "starGlowRadius": 16, + "lineWidth": 1.5, + "constellationLineColor": "rgba(212,168,67,0.4)", + "unlockedColor": "#d4a843", + "lockedColor": "#4a4a55", + "backgroundColor": "#0a0a14", + "starFieldDensity": 120 + }, + "inventory": { + "showWeight": true, + "showValue": true, + "sortBy": "name" + } } } } diff --git a/src/data/items/items.json b/src/data/items/items.json index cf2bee0..2a3dd36 100644 --- a/src/data/items/items.json +++ b/src/data/items/items.json @@ -270,6 +270,71 @@ "weight": 0.3, "value": 8, "description": "含有剧毒的毒囊" + }, + "golden_claw": { + "id": "golden_claw", + "name": "黄金龙爪", + "type": "quest", + "subtype": "key", + "weight": 0.5, + "value": 100, + "description": "刻有熊、鹰、狼图案的黄金龙爪,是打开荒瀑古坟大门的钥匙" + }, + "stolen_letter": { + "id": "stolen_letter", + "name": "偷来的信件", + "type": "quest", + "subtype": "document", + "weight": 0.1, + "value": 0, + "description": "一封从溪木商人处偷来的密信" + }, + "azorias_dagger": { + "id": "azorias_dagger", + "name": "梅法拉的剃刀", + "type": "weapon", + "subtype": "dagger", + "weight": 3, + "value": 5000, + "damage": 20, + "speed": 1.8, + "description": "传说中魔神梅法拉的匕首,能即刻杀死目标" + }, + "missing_cargo": { + "id": "missing_cargo", + "name": "失踪的货物", + "type": "quest", + "subtype": "misc", + "weight": 20, + "value": 100, + "description": "被强盗劫走的一箱货物" + }, + "ancient_tome": { + "id": "ancient_tome", + "name": "古代魔法书", + "type": "quest", + "subtype": "book", + "weight": 2, + "value": 200, + "description": "记载着失传魔法的古老书卷" + }, + "black_star": { + "id": "black_star", + "name": "黑色星辰", + "type": "quest", + "subtype": "gem", + "weight": 0.1, + "value": 5000, + "description": "被魔神污染的灵魂石,可以捕获任何灵魂" + }, + "blue_mountain_flower": { + "id": "blue_mountain_flower", + "name": "蓝色山花", + "type": "material", + "subtype": "ingredient", + "weight": 0.1, + "value": 5, + "description": "天际省常见的蓝色山花,炼金材料" } } } diff --git a/src/data/races/races.json b/src/data/races/races.json index 40df39b..1542c67 100644 --- a/src/data/races/races.json +++ b/src/data/races/races.json @@ -4,6 +4,7 @@ "id": "nord", "name": "Nord (诺德人)", "description": "天际省的原住民,强壮的战士种族", + "color": "#6fa8dc", "bonuses": { "twoHanded": 10, "heavyArmor": 5, "block": 5, "smithing": 5, "speech": 5 }, "baseStats": { "health": 120, "magicka": 40, "stamina": 100 }, "power": { "id": "battle_cry", "name": "战吼", "description": "恐惧附近的敌人", "cooldown": 60 }, @@ -13,6 +14,7 @@ "id": "dunmer", "name": "Dunmer (暗精灵)", "description": "来自晨风的神秘精灵,擅长毁灭魔法", + "color": "#c27ba0", "bonuses": { "destruction": 10, "lightArmor": 5, "alteration": 5, "illusion": 5, "alchemy": 5 }, "baseStats": { "health": 80, "magicka": 80, "stamina": 80 }, "power": { "id": "ancestors_wrath", "name": "祖先之怒", "description": "火焰斗篷伤害附近敌人", "cooldown": 60 }, @@ -22,6 +24,7 @@ "id": "altmer", "name": "Altmer (高精灵)", "description": "来自夏暮岛的高等精灵,魔法天赋极高", + "color": "#ffd966", "bonuses": { "enchanting": 10, "destruction": 5, "conjuration": 5, "illusion": 5, "alteration": 5 }, "baseStats": { "health": 70, "magicka": 100, "stamina": 70 }, "power": { "id": "highborn", "name": "高等精灵血脉", "description": "60秒内+50魔力", "cooldown": 60 }, @@ -31,6 +34,7 @@ "id": "argonian", "name": "Argonian (亚龙人)", "description": "来自黑沼泽的爬行种族,擅长潜行和开锁", + "color": "#93c47d", "bonuses": { "lockpicking": 10, "sneak": 5, "lightArmor": 5, "restoration": 5, "alteration": 5 }, "baseStats": { "health": 90, "magicka": 60, "stamina": 90 }, "power": { "id": "histskin", "name": "先祖之皮", "description": "60秒内10倍生命恢复", "cooldown": 60 }, @@ -41,6 +45,7 @@ "id": "khajiit", "name": "Khajiit (猫人)", "description": "来自艾斯维尔的猫形种族,天生潜行大师", + "color": "#e69138", "bonuses": { "sneak": 10, "pickpocket": 5, "lockpicking": 5, "archery": 5, "alchemy": 5 }, "baseStats": { "health": 85, "magicka": 60, "stamina": 95 }, "power": { "id": "claws", "name": "利爪", "description": "徒手伤害+15", "cooldown": 0 }, @@ -50,6 +55,7 @@ "id": "breton", "name": "Breton (布莱顿人)", "description": "来自高岩的人类精灵混血,魔法抗性极强", + "color": "#a4c2f4", "bonuses": { "conjuration": 10, "alteration": 5, "illusion": 5, "restoration": 5, "enchanting": 5 }, "baseStats": { "health": 80, "magicka": 90, "stamina": 70 }, "power": { "id": "dragonskin", "name": "龙皮", "description": "60秒内50%法术吸收", "cooldown": 60 }, @@ -59,6 +65,7 @@ "id": "imperial", "name": "Imperial (帝国人)", "description": "来自西罗帝尔的人类,擅长领导和交易", + "color": "#cc4125", "bonuses": { "restoration": 10, "heavyArmor": 5, "oneHanded": 5, "speech": 5, "smithing": 5 }, "baseStats": { "health": 100, "magicka": 60, "stamina": 80 }, "power": { "id": "voice_of_emperor", "name": "帝王之声", "description": "平静附近的NPC", "cooldown": 60 }, @@ -68,6 +75,7 @@ "id": "redguard", "name": "Redguard (红卫兵)", "description": "来自锤镇的战士种族,剑术精湛", + "color": "#ea4335", "bonuses": { "oneHanded": 10, "archery": 5, "block": 5, "heavyArmor": 5, "smithing": 5 }, "baseStats": { "health": 100, "magicka": 50, "stamina": 90 }, "power": { "id": "adrenaline_rush", "name": "肾上腺素激增", "description": "60秒内10倍耐力恢复", "cooldown": 60 }, @@ -77,6 +85,7 @@ "id": "orc", "name": "Orc (兽人)", "description": "来自沃古尔的强壮战士,狂暴时威力惊人", + "color": "#6aa84f", "bonuses": { "heavyArmor": 10, "oneHanded": 5, "twoHanded": 5, "block": 5, "smithing": 5 }, "baseStats": { "health": 110, "magicka": 40, "stamina": 90 }, "power": { "id": "berserker_rage", "name": "狂战士之怒", "description": "60秒内半伤双倍输出", "cooldown": 60 }, @@ -86,6 +95,7 @@ "id": "bosmer", "name": "Bosmer (木精灵)", "description": "来自艾尔默森林的精灵,弓箭和潜行大师", + "color": "#76a5af", "bonuses": { "archery": 10, "sneak": 5, "lightArmor": 5, "alchemy": 5, "pickpocket": 5 }, "baseStats": { "health": 80, "magicka": 60, "stamina": 90 }, "power": { "id": "command_animal", "name": "命令动物", "description": "控制附近的动物", "cooldown": 60 }, diff --git a/src/data/skills/perks.json b/src/data/skills/perks.json index f544d81..ccde0d2 100644 --- a/src/data/skills/perks.json +++ b/src/data/skills/perks.json @@ -14,7 +14,7 @@ "name": "毁灭", "perks": [ { "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": "destruction2", "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" }] } diff --git a/src/data/spells/spells.json b/src/data/spells/spells.json index a895d48..89f93e7 100644 --- a/src/data/spells/spells.json +++ b/src/data/spells/spells.json @@ -272,6 +272,45 @@ "level": 1, "description": "激发远古怒火,提升攻击力", "effects": [{ "type": "fortify", "attribute": "damage", "magnitude": 6, "duration": 10000 }] + }, + "dragon_fire_breath": { + "id": "dragon_fire_breath", + "name": "龙焰吐息", + "school": "destruction", + "type": "area", + "magickaCost": 0, + "magnitude": 35, + "duration": 0, + "cooldown": 3000, + "level": 15, + "description": "喷出灼热的龙焰,造成大范围火焰伤害", + "effects": [{ "type": "damage", "magnitude": 35 }, { "type": "burn", "magnitude": 5, "duration": 5000 }] + }, + "dragon_frost_breath": { + "id": "dragon_frost_breath", + "name": "龙霜吐息", + "school": "destruction", + "type": "area", + "magickaCost": 0, + "magnitude": 28, + "duration": 0, + "cooldown": 3000, + "level": 15, + "description": "喷出刺骨寒霜,造成冰霜伤害并减速", + "effects": [{ "type": "damage", "magnitude": 28 }, { "type": "slow", "magnitude": 0.5, "duration": 5000 }] + }, + "dragon_tail_swipe": { + "id": "dragon_tail_swipe", + "name": "龙尾横扫", + "school": "destruction", + "type": "target", + "magickaCost": 0, + "magnitude": 30, + "duration": 0, + "cooldown": 4000, + "level": 1, + "description": "以巨尾横扫,击退并伤害目标", + "effects": [{ "type": "damage", "magnitude": 30 }] } } } diff --git a/src/data/world/zones.json b/src/data/world/zones.json index 0fe38b7..5b8e1ba 100644 --- a/src/data/world/zones.json +++ b/src/data/world/zones.json @@ -4,191 +4,70 @@ "id": "whiterun", "name": "雪漫城", "description": "天际省的首府,一座繁华的城市", - "width": 25, - "height": 20, + "width": 60, + "height": 50, "tileSize": 32, "baseTile": 2, "borderTile": 6, "structures": [ - { "type": "floor", "tile": 5, "x": 3, "y": 3, "w": 6, "h": 4 }, - { "type": "floor", "tile": 5, "x": 10, "y": 8, "w": 7, "h": 5 }, - { "type": "floor", "tile": 5, "x": 18, "y": 14, "w": 5, "h": 4 } + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 12, "h": 8 }, + { "type": "floor", "tile": 5, "x": 22, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 38, "y": 5, "w": 14, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 20, "y": 18, "w": 15, "h": 10 }, + { "type": "floor", "tile": 5, "x": 42, "y": 20, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 35, "w": 10, "h": 6 }, + { "type": "floor", "tile": 5, "x": 25, "y": 35, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 42, "y": 35, "w": 12, "h": 6 }, + { "type": "wall", "tile": 6, "x": 18, "y": 5, "w": 1, "h": 8 }, + { "type": "wall", "tile": 6, "x": 33, "y": 5, "w": 1, "h": 8 }, + { "type": "wall", "tile": 6, "x": 14, "y": 20, "w": 1, "h": 6 }, + { "type": "wall", "tile": 6, "x": 36, "y": 18, "w": 1, "h": 10 }, + { "type": "water", "tile": 4, "x": 50, "y": 10, "w": 4, "h": 4 } ], "doors": [ - { "x": 12, "y": 0, "targetZone": "whiterun_exterior", "targetX": 12, "targetY": 28 } + { "x": 30, "y": 0, "targetZone": "whiterun_exterior", "targetX": 30, "targetY": 78 } ], "entities": [ - { "type": "npc", "id": "guard_01", "x": 12, "y": 3, "data": { "name": "守卫", "dialogue": "欢迎来到雪漫城", "script": "base-scripts:guard_patrol" } }, - { "type": "npc", "id": "merchant_01", "x": 5, "y": 4, "data": { "name": "商人", "dialogue": "看看我的货物", "shop": true } }, - { "type": "npc", "id": "blacksmith_01", "x": 5, "y": 5, "data": { "name": "铁匠", "dialogue": "需要打造什么?", "forge": true } } + { "type": "npc", "id": "guard_01", "x": 10, "y": 4, "data": { "name": "守卫队长", "dialogue": "欢迎来到雪漫城", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "guard_02", "x": 30, "y": 4, "data": { "name": "城市守卫", "dialogue": "保持秩序", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "guard_03", "x": 50, "y": 4, "data": { "name": "巡逻守卫", "dialogue": "一切平安", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "merchant_01", "x": 8, "y": 8, "data": { "name": " general 商人", "dialogue": "看看我的货物", "shop": true } }, + { "type": "npc", "id": "blacksmith_01", "x": 25, "y": 8, "data": { "name": "铁匠", "dialogue": "需要打造什么?", "forge": true } }, + { "type": "npc", "id": "alchemist_01", "x": 44, "y": 8, "data": { "name": "炼金术士", "dialogue": "各种药水应有尽有", "shop": true } }, + { "type": "npc", "id": "innkeeper_01", "x": 8, "y": 23, "data": { "name": "旅店老板", "dialogue": "来杯蜂蜜酒?", "shop": true } }, + { "type": "npc", "id": "court_wizard", "x": 25, "y": 23, "data": { "name": "宫廷法师", "dialogue": "魔法的奥秘无穷无尽", "shop": true } }, + { "type": "npc", "id": "thane_01", "x": 28, "y": 22, "data": { "name": "领主", "dialogue": "雪漫城需要忠诚的勇士" } }, + { "type": "npc", "id": "trainer_combat", "x": 46, "y": 24, "data": { "name": "战斗训练师", "dialogue": "我可以教你战斗技巧", "trainer": "oneHanded" } }, + { "type": "npc", "id": "trainer_magic", "x": 8, "y": 38, "data": { "name": "魔法训练师", "dialogue": "魔法需要天赋和练习", "trainer": "destruction" } }, + { "type": "npc", "id": "citizen_01", "x": 15, "y": 12, "data": { "name": "市民", "dialogue": "今天天气真好" } }, + { "type": "npc", "id": "citizen_02", "x": 35, "y": 15, "data": { "name": "商人妇", "dialogue": "需要买点什么吗?" } }, + { "type": "npc", "id": "whiterun_jarls", "x": 27, "y": 21, "data": { "name": "雪漫领主", "dialogue": "天际省需要英雄" } }, + { "type": "npc", "id": "whiterun_guard", "x": 18, "y": 10, "data": { "name": "城市守卫", "dialogue": "我会保护这座城市的", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "greymane_leader", "x": 44, "y": 37, "data": { "name": "灰鬃族长", "dialogue": "灰鬃家族历史悠久" } } ], "chests": [ - { "id": "chest_01", "x": 4, "y": 4, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }], "locked": false, "lockLevel": 0 } + { "id": "whiterun_chest_01", "x": 7, "y": 7, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 100 }], "locked": false, "lockLevel": 0 }, + { "id": "whiterun_chest_02", "x": 27, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "item", "id": "iron_ingot", "quantity": 5 }], "locked": false, "lockLevel": 0 }, + { "id": "whiterun_chest_03", "x": 45, "y": 38, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 2 }, { "type": "gold", "amount": 200 }], "locked": true, "lockLevel": 2 } ], - "spawnPoint": { "x": 12, "y": 10 } + "spawnPoint": { "x": 30, "y": 25 } }, "whiterun_exterior": { "id": "whiterun_exterior", "name": "雪漫城外", - "description": "雪漫城周围的平原", - "width": 30, - "height": 30, + "description": "雪漫城周围的广阔平原", + "width": 80, + "height": 80, "tileSize": 32, "baseTile": 1, "borderTile": 13, "structures": [ - { "type": "door", "tile": 7, "x": 10, "y": 26, "w": 5, "h": 3 }, - { "type": "wall", "tile": 0, "x": 18, "y": 5, "w": 5, "h": 4 } - ], - "procedural": { - "treeChance": 0.1, - "bushChance": 0.05, - "treeTile": 14, - "bushTile": 15 - }, - "doors": [ - { "x": 12, "y": 29, "targetZone": "whiterun", "targetX": 12, "targetY": 2 }, - { "x": 20, "y": 6, "targetZone": "bleakfalls_barrow", "targetX": 1, "targetY": 10 }, - { "x": 2, "y": 15, "targetZone": "riverwood", "targetX": 14, "targetY": 18 }, - { "x": 25, "y": 20, "targetZone": "darklight_cave", "targetX": 1, "targetY": 10 }, - { "x": 5, "y": 5, "targetZone": "ancient_ruins", "targetX": 1, "targetY": 15 } - ], - "entities": [ - { "type": "enemy", "id": "bandit_01", "x": 5, "y": 10, "data": { "type": "bandit" } }, - { "type": "enemy", "id": "wolf_01", "x": 20, "y": 15, "data": { "type": "wolf", "script": "base-scripts:pack_wolf" } }, - { "type": "enemy", "id": "wolf_02", "x": 22, "y": 16, "data": { "type": "wolf" } } - ], - "chests": [], - "spawnPoint": { "x": 12, "y": 25 } - }, - "bleakfalls_barrow": { - "id": "bleakfalls_barrow", - "name": "荒瀑古坟", - "description": "一座古老的诺德遗迹,据说藏有珍贵的宝物", - "width": 20, - "height": 20, - "tileSize": 32, - "baseTile": 3, - "borderTile": 6, - "structures": [ - { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 4, "h": 4 }, - { "type": "wall", "tile": 0, "x": 12, "y": 12, "w": 4, "h": 4 } - ], - "doors": [ - { "x": 0, "y": 10, "targetZone": "whiterun_exterior", "targetX": 19, "targetY": 7 } - ], - "entities": [ - { "type": "enemy", "id": "skeleton_01", "x": 8, "y": 6, "data": { "type": "skeleton" } }, - { "type": "enemy", "id": "skeleton_02", "x": 14, "y": 13, "data": { "type": "skeleton" } }, - { "type": "enemy", "id": "draugr_01", "x": 10, "y": 10, "data": { "type": "draugr", "script": "base-scripts:draugr_guard" } } - ], - "chests": [ - { "id": "barrow_chest_01", "x": 7, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "gold", "amount": 100 }], "locked": true, "lockLevel": 2 }, - { "id": "barrow_chest_02", "x": 14, "y": 14, "loot": [{ "type": "item", "id": "iron_ingot", "quantity": 5 }], "locked": false, "lockLevel": 0 } - ], - "spawnPoint": { "x": 2, "y": 10 } - }, - "riverwood": { - "id": "riverwood", - "name": "溪木镇", - "description": "一个宁静的河边小镇,以伐木业为生", - "width": 20, - "height": 20, - "tileSize": 32, - "baseTile": 1, - "borderTile": 14, - "structures": [ - { "type": "floor", "tile": 5, "x": 5, "y": 3, "w": 4, "h": 4 }, - { "type": "floor", "tile": 5, "x": 10, "y": 8, "w": 4, "h": 4 }, - { "type": "floor", "tile": 5, "x": 15, "y": 4, "w": 4, "h": 4 }, - { "type": "water", "tile": 4, "x": 2, "y": 14, "w": 3, "h": 3 } - ], - "doors": [ - { "x": 14, "y": 19, "targetZone": "whiterun_exterior", "targetX": 3, "targetY": 16 } - ], - "entities": [ - { "type": "npc", "id": "riverwood_merchant", "x": 6, "y": 4, "data": { "name": "溪木商人", "dialogue": "欢迎来到溪木镇", "shop": true } }, - { "type": "npc", "id": "riverwood_blacksmith", "x": 11, "y": 9, "data": { "name": "溪木铁匠", "dialogue": "需要打造什么?", "forge": true } }, - { "type": "npc", "id": "riverwood_elder", "x": 16, "y": 5, "data": { "name": "镇长", "dialogue": "溪木镇是个好地方", "script": "base-scripts:town_elder" } } - ], - "chests": [ - { "id": "riverwood_chest_01", "x": 6, "y": 5, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }, { "type": "gold", "amount": 50 }], "locked": false, "lockLevel": 0 } - ], - "spawnPoint": { "x": 10, "y": 15 } - }, - "darklight_cave": { - "id": "darklight_cave", - "name": "暗光洞穴", - "description": "一个阴暗的洞穴,据说有蜘蛛出没", - "width": 25, - "height": 20, - "tileSize": 32, - "baseTile": 3, - "borderTile": 6, - "structures": [ - { "type": "wall", "tile": 0, "x": 8, "y": 5, "w": 5, "h": 5 }, - { "type": "wall", "tile": 0, "x": 15, "y": 10, "w": 6, "h": 6 }, - { "type": "water", "tile": 4, "x": 3, "y": 12, "w": 4, "h": 5 } - ], - "doors": [ - { "x": 0, "y": 10, "targetZone": "whiterun_exterior", "targetX": 24, "targetY": 20 } - ], - "entities": [ - { "type": "enemy", "id": "spider_01", "x": 10, "y": 7, "data": { "type": "spider" } }, - { "type": "enemy", "id": "spider_02", "x": 18, "y": 12, "data": { "type": "frostbite_spider", "script": "base-scripts:frost_spider" } }, - { "type": "enemy", "id": "bandit_01", "x": 5, "y": 8, "data": { "type": "bandit" } } - ], - "chests": [ - { "id": "cave_chest_01", "x": 18, "y": 13, "loot": [{ "type": "item", "id": "spider_silk", "quantity": 3 }, { "type": "gold", "amount": 40 }], "locked": false, "lockLevel": 0 }, - { "id": "cave_chest_02", "x": 10, "y": 6, "loot": [{ "type": "item", "id": "health_potion", "quantity": 1 }], "locked": true, "lockLevel": 1 } - ], - "spawnPoint": { "x": 2, "y": 10 } - }, - "ancient_ruins": { - "id": "ancient_ruins", - "name": "古代遗迹", - "description": "一座被遗忘的古代遗迹,守护着强大的宝物", - "width": 25, - "height": 25, - "tileSize": 32, - "baseTile": 3, - "borderTile": 6, - "structures": [ - { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 5, "h": 5 }, - { "type": "wall", "tile": 0, "x": 15, "y": 5, "w": 5, "h": 5 }, - { "type": "wall", "tile": 0, "x": 5, "y": 15, "w": 5, "h": 5 }, - { "type": "wall", "tile": 0, "x": 15, "y": 15, "w": 5, "h": 5 }, - { "type": "campfire", "tile": 9, "x": 12, "y": 12, "w": 1, "h": 1 } - ], - "doors": [ - { "x": 0, "y": 15, "targetZone": "whiterun_exterior", "targetX": 6, "targetY": 6 } - ], - "entities": [ - { "type": "enemy", "id": "draugr_ruins_01", "x": 7, "y": 7, "data": { "type": "draugr" } }, - { "type": "enemy", "id": "draugr_ruins_02", "x": 17, "y": 7, "data": { "type": "draugr" } }, - { "type": "enemy", "id": "draugr_ruins_03", "x": 7, "y": 17, "data": { "type": "draugr_wight" } }, - { "type": "enemy", "id": "skeleton_ruins_01", "x": 12, "y": 8, "data": { "type": "skeleton" } }, - { "type": "enemy", "id": "skeleton_ruins_02", "x": 12, "y": 16, "data": { "type": "skeleton" } } - ], - "chests": [ - { "id": "ruins_chest_01", "x": 12, "y": 12, "loot": [{ "type": "item", "id": "steel_greatsword", "quantity": 1 }, { "type": "gold", "amount": 200 }], "locked": true, "lockLevel": 3 }, - { "id": "ruins_chest_02", "x": 7, "y": 8, "loot": [{ "type": "item", "id": "moonstone_ingot", "quantity": 2 }], "locked": false, "lockLevel": 0 }, - { "id": "ruins_chest_03", "x": 17, "y": 18, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 1 }, { "type": "gold", "amount": 80 }], "locked": true, "lockLevel": 2 } - ], - "spawnPoint": { "x": 2, "y": 15 } - }, - "skyrim_overworld": { - "id": "skyrim_overworld", - "name": "天际省 · 荒野", - "description": "天际省的广阔荒野,充满危险与机遇", - "width": 40, - "height": 40, - "tileSize": 32, - "baseTile": 1, - "borderTile": 13, - "structures": [ - { "type": "stone", "tile": 2, "x": 18, "y": 18, "w": 5, "h": 5 }, - { "type": "water", "tile": 4, "x": 5, "y": 10, "w": 4, "h": 5 } + { "type": "door", "tile": 7, "x": 28, "y": 76, "w": 5, "h": 3 }, + { "type": "water", "tile": 4, "x": 50, "y": 20, "w": 8, "h": 10 }, + { "type": "water", "tile": 4, "x": 10, "y": 55, "w": 6, "h": 8 }, + { "type": "stone", "tile": 2, "x": 35, "y": 35, "w": 6, "h": 6 }, + { "type": "dirt", "tile": 3, "x": 60, "y": 50, "w": 10, "h": 8 } ], "procedural": { "treeChance": 0.08, @@ -197,16 +76,648 @@ "bushTile": 15 }, "doors": [ - { "x": 0, "y": 20, "targetZone": "whiterun_exterior", "targetX": 20, "targetY": 0, "width": 2, "label": "通往白漫城外" } + { "x": 30, "y": 79, "targetZone": "whiterun", "targetX": 30, "targetY": 2 }, + { "x": 65, "y": 15, "targetZone": "bleakfalls_barrow", "targetX": 1, "targetY": 15 }, + { "x": 5, "y": 40, "targetZone": "riverwood", "targetX": 35, "targetY": 33 }, + { "x": 70, "y": 60, "targetZone": "darklight_cave", "targetX": 1, "targetY": 15 }, + { "x": 10, "y": 15, "targetZone": "ancient_ruins", "targetX": 1, "targetY": 17 }, + { "x": 40, "y": 0, "targetZone": "skyrim_overworld", "targetX": 20, "targetY": 78 }, + { "x": 0, "y": 30, "targetZone": "tundra", "targetX": 78, "targetY": 40 }, + { "x": 79, "y": 40, "targetZone": "reach", "targetX": 1, "targetY": 40 }, + { "x": 40, "y": 79, "targetZone": "rift", "targetX": 40, "targetY": 1 } ], "entities": [ - { "type": "enemy", "id": "wolf_over_01", "x": 10, "y": 10, "data": { "type": "wolf" } }, - { "type": "enemy", "id": "wolf_over_02", "x": 12, "y": 11, "data": { "type": "wolf" } }, - { "type": "enemy", "id": "bear_over_01", "x": 30, "y": 25, "data": { "type": "bear" } }, - { "type": "enemy", "id": "bandit_over_01", "x": 25, "y": 15, "data": { "type": "bandit_outlaw" } } + { "type": "enemy", "id": "bandit_ext_01", "x": 15, "y": 20, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "bandit_ext_02", "x": 18, "y": 22, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "bandit_ext_03", "x": 55, "y": 45, "data": { "type": "bandit_outlaw" } }, + { "type": "enemy", "id": "wolf_ext_01", "x": 60, "y": 30, "data": { "type": "wolf", "script": "base-scripts:pack_wolf" } }, + { "type": "enemy", "id": "wolf_ext_02", "x": 62, "y": 32, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "wolf_ext_03", "x": 63, "y": 31, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "bear_ext_01", "x": 25, "y": 50, "data": { "type": "bear" } }, + { "type": "enemy", "id": "spider_ext_01", "x": 68, "y": 55, "data": { "type": "spider" } }, + { "type": "enemy", "id": "skeleton_ext_01", "x": 45, "y": 25, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "bandit_ext_04", "x": 50, "y": 55, "data": { "type": "bandit_brute" } }, + { "type": "npc", "id": "companion_leader", "x": 25, "y": 40, "data": { "name": "战士公会首领", "dialogue": "证明你的战斗实力" } }, + { "type": "npc", "id": "whiterun_farmer", "x": 10, "y": 65, "data": { "name": "农场主", "dialogue": "附近的野兽总来糟蹋庄稼" } } ], - "chests": [], - "spawnPoint": { "x": 20, "y": 20 } + "chests": [ + { "id": "ext_chest_01", "x": 20, "y": 30, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }, { "type": "gold", "amount": 50 }], "locked": false, "lockLevel": 0 }, + { "id": "ext_chest_02", "x": 60, "y": 45, "loot": [{ "type": "item", "id": "steel_dagger", "quantity": 1 }], "locked": true, "lockLevel": 1 }, + { "id": "ext_chest_03", "x": 35, "y": 60, "loot": [{ "type": "item", "id": "leather_armor", "quantity": 1 }, { "type": "gold", "amount": 80 }], "locked": false, "lockLevel": 0 } + ], + "spawnPoint": { "x": 30, "y": 75 } + }, + "bleakfalls_barrow": { + "id": "bleakfalls_barrow", + "name": "荒瀑古坟", + "description": "一座古老的诺德遗迹,据说藏有珍贵的宝物", + "width": 35, + "height": 30, + "tileSize": 32, + "baseTile": 3, + "borderTile": 6, + "structures": [ + { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 20, "y": 5, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 5, "y": 18, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 20, "y": 18, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 12, "y": 12, "w": 3, "h": 3 }, + { "type": "water", "tile": 4, "x": 28, "y": 22, "w": 3, "h": 3 }, + { "type": "campfire", "tile": 9, "x": 16, "y": 14, "w": 1, "h": 1 } + ], + "doors": [ + { "x": 0, "y": 15, "targetZone": "whiterun_exterior", "targetX": 64, "targetY": 16 } + ], + "entities": [ + { "type": "enemy", "id": "skeleton_bar_01", "x": 8, "y": 7, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "skeleton_bar_02", "x": 23, "y": 7, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "draugr_bar_01", "x": 10, "y": 20, "data": { "type": "draugr", "script": "base-scripts:draugr_guard" } }, + { "type": "enemy", "id": "draugr_bar_02", "x": 24, "y": 20, "data": { "type": "draugr" } }, + { "type": "enemy", "id": "draugr_bar_03", "x": 16, "y": 13, "data": { "type": "draugr_wight" } }, + { "type": "enemy", "id": "skeleton_bar_03", "x": 14, "y": 8, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "skeleton_bar_04", "x": 22, "y": 12, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "deathlord_bar_01", "x": 16, "y": 22, "data": { "type": "draugr_deathlord" } } + ], + "chests": [ + { "id": "barrow_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "gold", "amount": 150 }], "locked": true, "lockLevel": 2 }, + { "id": "barrow_chest_02", "x": 24, "y": 7, "loot": [{ "type": "item", "id": "iron_ingot", "quantity": 8 }], "locked": false, "lockLevel": 0 }, + { "id": "barrow_chest_03", "x": 16, "y": 13, "loot": [{ "type": "item", "id": "steel_greatsword", "quantity": 1 }, { "type": "gold", "amount": 250 }], "locked": true, "lockLevel": 3 }, + { "id": "barrow_chest_04", "x": 8, "y": 20, "loot": [{ "type": "item", "id": "health_potion", "quantity": 4 }], "locked": false, "lockLevel": 0 } + ], + "spawnPoint": { "x": 2, "y": 15 } + }, + "riverwood": { + "id": "riverwood", + "name": "溪木镇", + "description": "一个宁静的河边小镇,以伐木业为生", + "width": 40, + "height": 35, + "tileSize": 32, + "baseTile": 1, + "borderTile": 14, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 18, "y": 5, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 30, "y": 5, "w": 6, "h": 5 }, + { "type": "floor", "tile": 5, "x": 5, "y": 18, "w": 6, "h": 5 }, + { "type": "floor", "tile": 5, "x": 15, "y": 16, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 30, "y": 18, "w": 6, "h": 5 }, + { "type": "water", "tile": 4, "x": 2, "y": 26, "w": 8, "h": 5 }, + { "type": "water", "tile": 4, "x": 12, "y": 28, "w": 6, "h": 4 } + ], + "doors": [ + { "x": 35, "y": 34, "targetZone": "whiterun_exterior", "targetX": 6, "targetY": 41 } + ], + "entities": [ + { "type": "npc", "id": "riverwood_merchant", "x": 8, "y": 7, "data": { "name": "溪木商人", "dialogue": "欢迎来到溪木镇", "shop": true } }, + { "type": "npc", "id": "riverwood_blacksmith", "x": 21, "y": 7, "data": { "name": "溪木铁匠", "dialogue": "需要打造什么?", "forge": true } }, + { "type": "npc", "id": "riverwood_elder", "x": 32, "y": 7, "data": { "name": "镇长", "dialogue": "溪木镇是个好地方", "script": "base-scripts:town_elder" } }, + { "type": "npc", "id": "riverwood_lumberjack", "x": 33, "y": 20, "data": { "name": "伐木工", "dialogue": "这些木材够用好一阵子了" } }, + { "type": "npc", "id": "riverwood_innkeeper", "x": 8, "y": 20, "data": { "name": "旅店老板", "dialogue": "来杯蜂蜜酒?", "shop": true } }, + { "type": "enemy", "id": "wolf_river_01", "x": 30, "y": 30, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "bandit_river_01", "x": 35, "y": 25, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "mudcrab_river_01", "x": 15, "y": 32, "data": { "type": "mudcrab" } }, + { "type": "npc", "id": "riverwood_alchemist", "x": 20, "y": 20, "data": { "name": "炼金术士", "dialogue": "我需要新鲜的草药来配药", "shop": true } } + ], + "chests": [ + { "id": "riverwood_chest_01", "x": 7, "y": 8, "loot": [{ "type": "item", "id": "health_potion", "quantity": 2 }, { "type": "gold", "amount": 60 }], "locked": false, "lockLevel": 0 }, + { "id": "riverwood_chest_02", "x": 20, "y": 8, "loot": [{ "type": "item", "id": "leather_armor", "quantity": 1 }], "locked": false, "lockLevel": 0 }, + { "id": "riverwood_chest_03", "x": 32, "y": 20, "loot": [{ "type": "item", "id": "iron_axe", "quantity": 1 }, { "type": "gold", "amount": 40 }], "locked": false, "lockLevel": 0 } + ], + "spawnPoint": { "x": 20, "y": 25 } + }, + "darklight_cave": { + "id": "darklight_cave", + "name": "暗光洞穴", + "description": "一个阴暗的洞穴,据说有蜘蛛出没", + "width": 35, + "height": 30, + "tileSize": 32, + "baseTile": 3, + "borderTile": 6, + "structures": [ + { "type": "wall", "tile": 0, "x": 8, "y": 5, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 22, "y": 5, "w": 6, "h": 6 }, + { "type": "wall", "tile": 0, "x": 8, "y": 18, "w": 6, "h": 6 }, + { "type": "wall", "tile": 0, "x": 20, "y": 18, "w": 8, "h": 6 }, + { "type": "water", "tile": 4, "x": 3, "y": 22, "w": 4, "h": 5 }, + { "type": "water", "tile": 4, "x": 28, "y": 10, "w": 3, "h": 4 } + ], + "doors": [ + { "x": 0, "y": 15, "targetZone": "whiterun_exterior", "targetX": 69, "targetY": 61 } + ], + "entities": [ + { "type": "enemy", "id": "spider_cave_01", "x": 12, "y": 7, "data": { "type": "spider" } }, + { "type": "enemy", "id": "spider_cave_02", "x": 25, "y": 7, "data": { "type": "frostbite_spider", "script": "base-scripts:frost_spider" } }, + { "type": "enemy", "id": "bandit_cave_01", "x": 12, "y": 20, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "bandit_cave_02", "x": 24, "y": 20, "data": { "type": "bandit_outlaw" } }, + { "type": "enemy", "id": "spider_cave_03", "x": 18, "y": 12, "data": { "type": "spider" } }, + { "type": "enemy", "id": "skeleton_cave_01", "x": 10, "y": 14, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "cave_frost_troll_01", "x": 20, "y": 15, "data": { "type": "frost_troll" } }, + { "type": "npc", "id": "mad_mage", "x": 28, "y": 15, "data": { "name": "疯狂法师", "dialogue": "黑暗星辰……它在呼唤我……" } } + ], + "chests": [ + { "id": "cave_chest_01", "x": 25, "y": 7, "loot": [{ "type": "item", "id": "spider_silk", "quantity": 5 }, { "type": "gold", "amount": 60 }], "locked": false, "lockLevel": 0 }, + { "id": "cave_chest_02", "x": 12, "y": 20, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 80 }], "locked": true, "lockLevel": 1 }, + { "id": "cave_chest_03", "x": 24, "y": 20, "loot": [{ "type": "item", "id": "steel_mace", "quantity": 1 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 2, "y": 15 } + }, + "ancient_ruins": { + "id": "ancient_ruins", + "name": "古代遗迹", + "description": "一座被遗忘的古代遗迹,守护着强大的宝物", + "width": 40, + "height": 35, + "tileSize": 32, + "baseTile": 3, + "borderTile": 6, + "structures": [ + { "type": "wall", "tile": 0, "x": 5, "y": 5, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 25, "y": 5, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 25, "y": 20, "w": 8, "h": 6 }, + { "type": "wall", "tile": 0, "x": 15, "y": 12, "w": 5, "h": 5 }, + { "type": "campfire", "tile": 9, "x": 18, "y": 14, "w": 1, "h": 1 }, + { "type": "water", "tile": 4, "x": 32, "y": 25, "w": 4, "h": 4 } + ], + "doors": [ + { "x": 0, "y": 17, "targetZone": "whiterun_exterior", "targetX": 11, "targetY": 16 } + ], + "entities": [ + { "type": "enemy", "id": "draugr_ruins_01", "x": 8, "y": 7, "data": { "type": "draugr" } }, + { "type": "enemy", "id": "draugr_ruins_02", "x": 28, "y": 7, "data": { "type": "draugr" } }, + { "type": "enemy", "id": "draugr_ruins_03", "x": 8, "y": 22, "data": { "type": "draugr_wight" } }, + { "type": "enemy", "id": "draugr_ruins_04", "x": 28, "y": 22, "data": { "type": "draugr" } }, + { "type": "enemy", "id": "skeleton_ruins_01", "x": 17, "y": 10, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "skeleton_ruins_02", "x": 17, "y": 18, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "necromancer_01", "x": 17, "y": 14, "data": { "type": "necromancer" } }, + { "type": "enemy", "id": "deathlord_ruins_01", "x": 17, "y": 5, "data": { "type": "draugr_deathlord" } }, + { "type": "enemy", "id": "ancient_dragon_ruins_01", "x": 17, "y": 3, "data": { "type": "dragon_ancient" } } + ], + "chests": [ + { "id": "ruins_chest_01", "x": 17, "y": 14, "loot": [{ "type": "item", "id": "steel_greatsword", "quantity": 1 }, { "type": "gold", "amount": 300 }], "locked": true, "lockLevel": 3 }, + { "id": "ruins_chest_02", "x": 8, "y": 8, "loot": [{ "type": "item", "id": "moonstone_ingot", "quantity": 3 }], "locked": false, "lockLevel": 0 }, + { "id": "ruins_chest_03", "x": 28, "y": 22, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 2 }, { "type": "gold", "amount": 120 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 2, "y": 17 } + }, + "skyrim_overworld": { + "id": "skyrim_overworld", + "name": "天际省 · 荒野", + "description": "天际省的广阔荒野,充满危险与机遇", + "width": 80, + "height": 80, + "tileSize": 32, + "baseTile": 1, + "borderTile": 13, + "structures": [ + { "type": "stone", "tile": 2, "x": 35, "y": 35, "w": 10, "h": 10 }, + { "type": "water", "tile": 4, "x": 10, "y": 20, "w": 8, "h": 10 }, + { "type": "water", "tile": 4, "x": 55, "y": 50, "w": 10, "h": 8 }, + { "type": "dirt", "tile": 3, "x": 20, "y": 60, "w": 12, "h": 8 }, + { "type": "stone", "tile": 2, "x": 60, "y": 15, "w": 8, "h": 6 } + ], + "procedural": { + "treeChance": 0.06, + "bushChance": 0.03, + "treeTile": 14, + "bushTile": 15 + }, + "doors": [ + { "x": 20, "y": 79, "targetZone": "whiterun_exterior", "targetX": 40, "targetY": 1 }, + { "x": 0, "y": 40, "targetZone": "tundra", "targetX": 78, "targetY": 40 }, + { "x": 79, "y": 40, "targetZone": "reach", "targetX": 1, "targetY": 40 }, + { "x": 40, "y": 0, "targetZone": "solitude", "targetX": 30, "targetY": 48 }, + { "x": 60, "y": 0, "targetZone": "windhelm", "targetX": 30, "targetY": 48 }, + { "x": 40, "y": 79, "targetZone": "riften", "targetX": 30, "targetY": 2 }, + { "x": 79, "y": 20, "targetZone": "shadowmere", "targetX": 1, "targetY": 40 }, + { "x": 0, "y": 60, "targetZone": "markarth", "targetX": 58, "targetY": 25 }, + { "x": 50, "y": 0, "targetZone": "winterhold_college", "targetX": 17, "targetY": 28 }, + { "x": 70, "y": 70, "targetZone": "rift", "targetX": 1, "targetY": 1 } + ], + "entities": [ + { "type": "enemy", "id": "wolf_ow_01", "x": 15, "y": 15, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "wolf_ow_02", "x": 17, "y": 16, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "bear_ow_01", "x": 30, "y": 50, "data": { "type": "cave_bear" } }, + { "type": "enemy", "id": "bandit_ow_01", "x": 50, "y": 30, "data": { "type": "bandit_outlaw" } }, + { "type": "enemy", "id": "bandit_ow_02", "x": 52, "y": 32, "data": { "type": "bandit_brute" } }, + { "type": "enemy", "id": "draugr_ow_01", "x": 25, "y": 45, "data": { "type": "draugr" } }, + { "type": "enemy", "id": "spider_ow_01", "x": 65, "y": 55, "data": { "type": "frostbite_spider" } }, + { "type": "enemy", "id": "skeleton_ow_01", "x": 40, "y": 25, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "wolf_ow_03", "x": 60, "y": 40, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "bandit_ow_03", "x": 35, "y": 60, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "dragon_ow_01", "x": 10, "y": 10, "data": { "type": "dragon" } }, + { "type": "enemy", "id": "frost_troll_ow_01", "x": 45, "y": 15, "data": { "type": "frost_troll" } }, + { "type": "enemy", "id": "mudcrab_ow_01", "x": 30, "y": 35, "data": { "type": "mudcrab" } }, + { "type": "enemy", "id": "bandit_chief_ow_01", "x": 53, "y": 31, "data": { "type": "bandit_chief" } } + ], + "chests": [ + { "id": "ow_chest_01", "x": 25, "y": 25, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 100 }], "locked": false, "lockLevel": 0 }, + { "id": "ow_chest_02", "x": 55, "y": 45, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "gold", "amount": 150 }], "locked": true, "lockLevel": 1 }, + { "id": "ow_chest_03", "x": 40, "y": 65, "loot": [{ "type": "item", "id": "elven_sword", "quantity": 1 }], "locked": true, "lockLevel": 3 } + ], + "spawnPoint": { "x": 40, "y": 40 } + }, + "windhelm": { + "id": "windhelm", + "name": "风盔城", + "description": "天际省最古老的城市,终年被冰雪覆盖", + "width": 60, + "height": 50, + "tileSize": 32, + "baseTile": 12, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 22, "y": 5, "w": 12, "h": 8 }, + { "type": "floor", "tile": 5, "x": 40, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 20, "y": 18, "w": 15, "h": 10 }, + { "type": "floor", "tile": 5, "x": 42, "y": 20, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 35, "w": 10, "h": 6 }, + { "type": "floor", "tile": 5, "x": 25, "y": 35, "w": 10, "h": 8 }, + { "type": "water", "tile": 4, "x": 48, "y": 12, "w": 5, "h": 5 } + ], + "doors": [ + { "x": 30, "y": 49, "targetZone": "skyrim_overworld", "targetX": 60, "targetY": 1 } + ], + "entities": [ + { "type": "npc", "id": "windhelm_guard_01", "x": 10, "y": 4, "data": { "name": "风盔守卫", "dialogue": "风盔城不欢迎外来者", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "windhelm_merchant", "x": 28, "y": 8, "data": { "name": "杂货商人", "dialogue": "风盔的货物应有尽有", "shop": true } }, + { "type": "npc", "id": "windhelm_blacksmith", "x": 44, "y": 8, "data": { "name": "风盔铁匠", "dialogue": "冰与火的锻造", "forge": true } }, + { "type": "npc", "id": "windhelm_jarl", "x": 28, "y": 22, "data": { "name": "风盔领主", "dialogue": "风盔是天际最古老的城市" } }, + { "type": "npc", "id": "windhelm_innkeeper", "x": 8, "y": 23, "data": { "name": "旅店老板", "dialogue": "来杯暖身的蜂蜜酒", "shop": true } }, + { "type": "enemy", "id": "bandit_wh_01", "x": 50, "y": 40, "data": { "type": "bandit" } } + ], + "chests": [ + { "id": "windhelm_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 120 }], "locked": false, "lockLevel": 0 }, + { "id": "windhelm_chest_02", "x": 28, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }], "locked": false, "lockLevel": 0 }, + { "id": "windhelm_chest_03", "x": 44, "y": 38, "loot": [{ "type": "item", "id": "elven_armor", "quantity": 1 }, { "type": "gold", "amount": 250 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 30, "y": 25 } + }, + "solitude": { + "id": "solitude", + "name": "独孤城", + "description": "天际省的省会,坐落在海边的悬崖之上", + "width": 60, + "height": 50, + "tileSize": 32, + "baseTile": 2, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 22, "y": 5, "w": 12, "h": 8 }, + { "type": "floor", "tile": 5, "x": 40, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 20, "y": 18, "w": 15, "h": 10 }, + { "type": "floor", "tile": 5, "x": 42, "y": 20, "w": 10, "h": 8 }, + { "type": "water", "tile": 4, "x": 50, "y": 35, "w": 6, "h": 8 } + ], + "doors": [ + { "x": 30, "y": 49, "targetZone": "skyrim_overworld", "targetX": 40, "targetY": 1 } + ], + "entities": [ + { "type": "npc", "id": "solitude_guard_01", "x": 10, "y": 4, "data": { "name": "独孤守卫", "dialogue": "帝国万岁", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "solitude_merchant", "x": 28, "y": 8, "data": { "name": "独孤商人", "dialogue": "最好的货物都在这里", "shop": true } }, + { "type": "npc", "id": "solitude_blacksmith", "x": 44, "y": 8, "data": { "name": "独孤铁匠", "dialogue": "帝国锻造技术", "forge": true } }, + { "type": "npc", "id": "solitude_jarl", "x": 28, "y": 22, "data": { "name": "独孤领主", "dialogue": "独孤是天际的骄傲" } }, + { "type": "npc", "id": "solitude_innkeeper", "x": 8, "y": 23, "data": { "name": "旅店老板", "dialogue": "来杯独孤特酿", "shop": true } }, + { "type": "npc", "id": "dark_brotherhood_agent", "x": 46, "y": 24, "data": { "name": "神秘人", "dialogue": "需要一些...特殊服务吗?" } } + ], + "chests": [ + { "id": "solitude_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 150 }], "locked": false, "lockLevel": 0 }, + { "id": "solitude_chest_02", "x": 28, "y": 7, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }, { "type": "item", "id": "elven_sword", "quantity": 1 }], "locked": false, "lockLevel": 0 }, + { "id": "solitude_chest_03", "x": 45, "y": 38, "loot": [{ "type": "item", "id": "ebony_ingot", "quantity": 2 }, { "type": "gold", "amount": 300 }], "locked": true, "lockLevel": 3 } + ], + "spawnPoint": { "x": 30, "y": 25 } + }, + "riften": { + "id": "riften", + "name": "裂谷城", + "description": "坐落在湖畔的城市,盗贼公会的故乡", + "width": 60, + "height": 50, + "tileSize": 32, + "baseTile": 5, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 2, "x": 5, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 2, "x": 22, "y": 5, "w": 12, "h": 8 }, + { "type": "floor", "tile": 2, "x": 40, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 2, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "floor", "tile": 2, "x": 20, "y": 18, "w": 15, "h": 10 }, + { "type": "floor", "tile": 2, "x": 42, "y": 20, "w": 10, "h": 8 }, + { "type": "water", "tile": 4, "x": 50, "y": 38, "w": 6, "h": 6 }, + { "type": "floor", "tile": 5, "x": 5, "y": 35, "w": 10, "h": 8 } + ], + "doors": [ + { "x": 30, "y": 0, "targetZone": "skyrim_overworld", "targetX": 40, "targetY": 78 }, + { "x": 10, "y": 39, "targetZone": "thieves_guild", "targetX": 17, "targetY": 1 } + ], + "entities": [ + { "type": "npc", "id": "riften_guard_01", "x": 10, "y": 4, "data": { "name": "裂谷守卫", "dialogue": "裂谷城欢迎你", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "riften_merchant", "x": 28, "y": 8, "data": { "name": "裂谷商人", "dialogue": "要买点什么?", "shop": true } }, + { "type": "npc", "id": "riften_blacksmith", "x": 44, "y": 8, "data": { "name": "裂谷铁匠", "dialogue": "最好的锻造", "forge": true } }, + { "type": "npc", "id": "riften_jarl", "x": 28, "y": 22, "data": { "name": "裂谷领主", "dialogue": "裂谷是天际的宝库" } }, + { "type": "npc", "id": "thieves_contact", "x": 8, "y": 38, "data": { "name": "神秘人", "dialogue": "如果你想加入我们,去地下水道" } }, + { "type": "npc", "id": "riften_innkeeper", "x": 8, "y": 23, "data": { "name": "旅店老板", "dialogue": "来杯裂谷蜜酒", "shop": true } } + ], + "chests": [ + { "id": "riften_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 120 }], "locked": false, "lockLevel": 0 }, + { "id": "riften_chest_02", "x": 44, "y": 7, "loot": [{ "type": "item", "id": "steel_dagger", "quantity": 2 }], "locked": false, "lockLevel": 0 }, + { "id": "riften_chest_03", "x": 8, "y": 38, "loot": [{ "type": "item", "id": "leather_armor", "quantity": 1 }, { "type": "gold", "amount": 200 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 30, "y": 25 } + }, + "markarth": { + "id": "markarth", + "name": "马卡斯城", + "description": "建在山地中的古老城市,以锻造闻名", + "width": 60, + "height": 50, + "tileSize": 32, + "baseTile": 2, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 22, "y": 5, "w": 12, "h": 8 }, + { "type": "floor", "tile": 5, "x": 40, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 5, "y": 20, "w": 8, "h": 6 }, + { "type": "floor", "tile": 5, "x": 20, "y": 18, "w": 15, "h": 10 }, + { "type": "floor", "tile": 5, "x": 42, "y": 20, "w": 10, "h": 8 }, + { "type": "stone", "tile": 2, "x": 15, "y": 35, "w": 15, "h": 10 } + ], + "doors": [ + { "x": 58, "y": 25, "targetZone": "skyrim_overworld", "targetX": 1, "targetY": 60 } + ], + "entities": [ + { "type": "npc", "id": "markarth_guard_01", "x": 10, "y": 4, "data": { "name": "马卡斯守卫", "dialogue": "马卡斯的石头永不磨灭", "script": "base-scripts:guard_patrol" } }, + { "type": "npc", "id": "markarth_merchant", "x": 28, "y": 8, "data": { "name": "马卡斯商人", "dialogue": "山中的珍宝", "shop": true } }, + { "type": "npc", "id": "markarth_blacksmith", "x": 44, "y": 8, "data": { "name": "马卡斯铁匠", "dialogue": "山地锻造技艺", "forge": true } }, + { "type": "npc", "id": "markarth_jarl", "x": 28, "y": 22, "data": { "name": "马卡斯领主", "dialogue": "马卡斯是最坚固的城市" } }, + { "type": "npc", "id": "markarth_innkeeper", "x": 8, "y": 23, "data": { "name": "旅店老板", "dialogue": "来杯山地烈酒", "shop": true } }, + { "type": "enemy", "id": "bandit_mk_01", "x": 50, "y": 40, "data": { "type": "bandit_brute" } } + ], + "chests": [ + { "id": "markarth_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 150 }], "locked": false, "lockLevel": 0 }, + { "id": "markarth_chest_02", "x": 28, "y": 7, "loot": [{ "type": "item", "id": "steel_greatsword", "quantity": 1 }, { "type": "item", "id": "corundum_ingot", "quantity": 5 }], "locked": false, "lockLevel": 0 }, + { "id": "markarth_chest_03", "x": 22, "y": 40, "loot": [{ "type": "item", "id": "orcish_sword", "quantity": 1 }, { "type": "gold", "amount": 280 }], "locked": true, "lockLevel": 3 } + ], + "spawnPoint": { "x": 30, "y": 25 } + }, + "winterhold_college": { + "id": "winterhold_college", + "name": "冬堡学院", + "description": "天际省最高等的魔法学府", + "width": 35, + "height": 30, + "tileSize": 32, + "baseTile": 2, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 20, "y": 5, "w": 10, "h": 8 }, + { "type": "floor", "tile": 5, "x": 10, "y": 18, "w": 15, "h": 8 }, + { "type": "water", "tile": 4, "x": 2, "y": 15, "w": 3, "h": 8 } + ], + "doors": [ + { "x": 17, "y": 29, "targetZone": "skyrim_overworld", "targetX": 50, "targetY": 1 } + ], + "entities": [ + { "type": "npc", "id": "archmage", "x": 17, "y": 20, "data": { "name": "院长", "dialogue": "欢迎来到冬堡学院" } }, + { "type": "npc", "id": "college_merchant", "x": 8, "y": 7, "data": { "name": "学院商人", "dialogue": "各种魔法材料", "shop": true } }, + { "type": "npc", "id": "destruction_teacher", "x": 24, "y": 7, "data": { "name": "毁灭法师", "dialogue": "我可以教你毁灭魔法", "trainer": "destruction" } }, + { "type": "npc", "id": "conjuration_teacher", "x": 15, "y": 21, "data": { "name": "召唤法师", "dialogue": "召唤术需要专注", "trainer": "conjuration" } }, + { "type": "npc", "id": "restoration_teacher", "x": 20, "y": 21, "data": { "name": "恢复法师", "dialogue": "恢复魔法是治疗的基础", "trainer": "restoration" } } + ], + "chests": [ + { "id": "college_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 5 }, { "type": "gold", "amount": 200 }], "locked": false, "lockLevel": 0 }, + { "id": "college_chest_02", "x": 24, "y": 7, "loot": [{ "type": "item", "id": "staff_fire", "quantity": 1 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 17, "y": 25 } + }, + "dark_brotherhood": { + "id": "dark_brotherhood", + "name": "黑暗兄弟会", + "description": "刺客公会的秘密据点", + "width": 35, + "height": 30, + "tileSize": 32, + "baseTile": 5, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 2, "x": 5, "y": 5, "w": 8, "h": 6 }, + { "type": "floor", "tile": 2, "x": 20, "y": 5, "w": 8, "h": 6 }, + { "type": "floor", "tile": 2, "x": 10, "y": 15, "w": 12, "h": 8 }, + { "type": "campfire", "tile": 9, "x": 16, "y": 18, "w": 1, "h": 1 } + ], + "doors": [ + { "x": 17, "y": 29, "targetZone": "skyrim_overworld", "targetX": 70, "targetY": 1 } + ], + "entities": [ + { "type": "npc", "id": "db_leader", "x": 16, "y": 17, "data": { "name": "黑暗兄弟会首领", "dialogue": "死亡是最好的礼物" } }, + { "type": "npc", "id": "db_trainer", "x": 8, "y": 7, "data": { "name": "刺客训练师", "dialogue": "我可以教你潜行技巧", "trainer": "sneak" } }, + { "type": "npc", "id": "db_merchant", "x": 24, "y": 7, "data": { "name": "刺客商人", "dialogue": "各种刺杀工具", "shop": true } } + ], + "chests": [ + { "id": "db_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "steel_dagger", "quantity": 2 }, { "type": "gold", "amount": 200 }], "locked": false, "lockLevel": 0 }, + { "id": "db_chest_02", "x": 16, "y": 17, "loot": [{ "type": "item", "id": "ebony_dagger", "quantity": 1 }, { "type": "gold", "amount": 500 }], "locked": true, "lockLevel": 4 } + ], + "spawnPoint": { "x": 17, "y": 25 } + }, + "thieves_guild": { + "id": "thieves_guild", + "name": "盗贼公会", + "description": "裂谷城地下的盗贼据点", + "width": 35, + "height": 30, + "tileSize": 32, + "baseTile": 3, + "borderTile": 6, + "structures": [ + { "type": "floor", "tile": 5, "x": 5, "y": 5, "w": 10, "h": 6 }, + { "type": "floor", "tile": 5, "x": 20, "y": 5, "w": 10, "h": 6 }, + { "type": "floor", "tile": 5, "x": 10, "y": 15, "w": 15, "h": 8 }, + { "type": "campfire", "tile": 9, "x": 17, "y": 18, "w": 1, "h": 1 }, + { "type": "water", "tile": 4, "x": 2, "y": 20, "w": 3, "h": 5 } + ], + "doors": [ + { "x": 17, "y": 0, "targetZone": "riften", "targetX": 10, "targetY": 38 } + ], + "entities": [ + { "type": "npc", "id": "tg_leader", "x": 17, "y": 17, "data": { "name": "盗贼公会首领", "dialogue": "欢迎来到盗贼公会" } }, + { "type": "npc", "id": "thief_leader", "x": 15, "y": 16, "data": { "name": "布林乔夫", "dialogue": "低调行事,不要惹麻烦" } }, + { "type": "npc", "id": "tg_trainer", "x": 8, "y": 7, "data": { "name": "扒窃训练师", "dialogue": "我可以教你扒窃技巧", "trainer": "pickpocket" } }, + { "type": "npc", "id": "tg_merchant", "x": 25, "y": 7, "data": { "name": "盗贼商人", "dialogue": "各种赃物", "shop": true } } + ], + "chests": [ + { "id": "tg_chest_01", "x": 8, "y": 7, "loot": [{ "type": "item", "id": "gold", "amount": 500 }], "locked": false, "lockLevel": 0 }, + { "id": "tg_chest_02", "x": 17, "y": 17, "loot": [{ "type": "item", "id": "elven_armor", "quantity": 1 }, { "type": "gold", "amount": 400 }], "locked": true, "lockLevel": 3 } + ], + "spawnPoint": { "x": 17, "y": 25 } + }, + "tundra": { + "id": "tundra", + "name": "冻土苔原", + "description": "北方的广袤雪原,寒风呼啸", + "width": 80, + "height": 80, + "tileSize": 32, + "baseTile": 12, + "borderTile": 13, + "structures": [ + { "type": "water", "tile": 4, "x": 30, "y": 30, "w": 10, "h": 8 }, + { "type": "stone", "tile": 2, "x": 50, "y": 20, "w": 6, "h": 6 }, + { "type": "dirt", "tile": 3, "x": 15, "y": 55, "w": 8, "h": 6 } + ], + "procedural": { + "treeChance": 0.04, + "bushChance": 0.02, + "treeTile": 14, + "bushTile": 15 + }, + "doors": [ + { "x": 79, "y": 40, "targetZone": "skyrim_overworld", "targetX": 1, "targetY": 40 }, + { "x": 1, "y": 20, "targetZone": "windhelm", "targetX": 58, "targetY": 25 } + ], + "entities": [ + { "type": "enemy", "id": "tundra_wolf_01", "x": 20, "y": 20, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "tundra_wolf_02", "x": 22, "y": 21, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "tundra_bear_01", "x": 50, "y": 50, "data": { "type": "cave_bear" } }, + { "type": "enemy", "id": "tundra_bandit_01", "x": 40, "y": 35, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "tundra_bandit_02", "x": 60, "y": 45, "data": { "type": "bandit_outlaw" } }, + { "type": "enemy", "id": "tundra_skeleton_01", "x": 35, "y": 60, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "tundra_frost_troll_01", "x": 25, "y": 40, "data": { "type": "frost_troll" } }, + { "type": "enemy", "id": "tundra_ice_wraith_01", "x": 65, "y": 25, "data": { "type": "ice_wraith" } }, + { "type": "enemy", "id": "tundra_mudcrab_01", "x": 45, "y": 55, "data": { "type": "mudcrab" } }, + { "type": "enemy", "id": "tundra_dragon_01", "x": 40, "y": 10, "data": { "type": "dragon" } } + ], + "chests": [ + { "id": "tundra_chest_01", "x": 35, "y": 35, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 120 }], "locked": false, "lockLevel": 0 }, + { "id": "tundra_chest_02", "x": 55, "y": 55, "loot": [{ "type": "item", "id": "steel_sword", "quantity": 1 }], "locked": true, "lockLevel": 1 } + ], + "spawnPoint": { "x": 78, "y": 40 } + }, + "reach": { + "id": "reach", + "name": "天际西省", + "description": "西部的荒野地带,强盗横行", + "width": 80, + "height": 80, + "tileSize": 32, + "baseTile": 3, + "borderTile": 13, + "structures": [ + { "type": "water", "tile": 4, "x": 20, "y": 40, "w": 8, "h": 8 }, + { "type": "stone", "tile": 2, "x": 55, "y": 30, "w": 6, "h": 6 }, + { "type": "dirt", "tile": 3, "x": 35, "y": 60, "w": 10, "h": 8 } + ], + "procedural": { + "treeChance": 0.05, + "bushChance": 0.03, + "treeTile": 14, + "bushTile": 15 + }, + "doors": [ + { "x": 1, "y": 40, "targetZone": "skyrim_overworld", "targetX": 79, "targetY": 40 }, + { "x": 58, "y": 79, "targetZone": "markarth", "targetX": 1, "targetY": 25 } + ], + "entities": [ + { "type": "enemy", "id": "reach_bandit_01", "x": 25, "y": 25, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "reach_bandit_02", "x": 27, "y": 27, "data": { "type": "bandit_outlaw" } }, + { "type": "enemy", "id": "reach_bandit_03", "x": 50, "y": 50, "data": { "type": "bandit_brute" } }, + { "type": "enemy", "id": "reach_wolf_01", "x": 40, "y": 30, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "reach_spider_01", "x": 60, "y": 60, "data": { "type": "spider" } }, + { "type": "enemy", "id": "reach_skeleton_01", "x": 30, "y": 50, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "reach_bandit_chief_01", "x": 28, "y": 26, "data": { "type": "bandit_chief" } }, + { "type": "enemy", "id": "reach_frost_troll_01", "x": 55, "y": 35, "data": { "type": "frost_troll" } } + ], + "chests": [ + { "id": "reach_chest_01", "x": 30, "y": 30, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 100 }], "locked": false, "lockLevel": 0 }, + { "id": "reach_chest_02", "x": 55, "y": 55, "loot": [{ "type": "item", "id": "steel_mace", "quantity": 1 }, { "type": "gold", "amount": 150 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 2, "y": 40 } + }, + "rift": { + "id": "rift", + "name": "裂谷森林", + "description": "南方的茂密森林,隐藏着古老的秘密", + "width": 80, + "height": 80, + "tileSize": 32, + "baseTile": 1, + "borderTile": 14, + "structures": [ + { "type": "water", "tile": 4, "x": 40, "y": 40, "w": 8, "h": 8 }, + { "type": "stone", "tile": 2, "x": 20, "y": 20, "w": 5, "h": 5 }, + { "type": "dirt", "tile": 3, "x": 60, "y": 60, "w": 8, "h": 8 } + ], + "procedural": { + "treeChance": 0.12, + "bushChance": 0.06, + "treeTile": 14, + "bushTile": 15 + }, + "doors": [ + { "x": 40, "y": 1, "targetZone": "skyrim_overworld", "targetX": 70, "targetY": 69 }, + { "x": 1, "y": 1, "targetZone": "riften", "targetX": 58, "targetY": 25 } + ], + "entities": [ + { "type": "enemy", "id": "rift_wolf_01", "x": 30, "y": 30, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "rift_wolf_02", "x": 32, "y": 32, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "rift_bear_01", "x": 50, "y": 50, "data": { "type": "bear" } }, + { "type": "enemy", "id": "rift_spider_01", "x": 60, "y": 30, "data": { "type": "frostbite_spider" } }, + { "type": "enemy", "id": "rift_bandit_01", "x": 25, "y": 55, "data": { "type": "bandit" } }, + { "type": "enemy", "id": "rift_draugr_01", "x": 55, "y": 20, "data": { "type": "draugr" } } + ], + "chests": [ + { "id": "rift_chest_01", "x": 35, "y": 35, "loot": [{ "type": "item", "id": "health_potion", "quantity": 3 }, { "type": "gold", "amount": 100 }], "locked": false, "lockLevel": 0 }, + { "id": "rift_chest_02", "x": 55, "y": 45, "loot": [{ "type": "item", "id": "elven_sword", "quantity": 1 }], "locked": true, "lockLevel": 2 } + ], + "spawnPoint": { "x": 40, "y": 2 } + }, + "shadowmere": { + "id": "shadowmere", + "name": "暗影沼泽", + "description": "东方的阴暗沼泽,充满神秘与危险", + "width": 80, + "height": 80, + "tileSize": 32, + "baseTile": 3, + "borderTile": 13, + "structures": [ + { "type": "water", "tile": 4, "x": 20, "y": 20, "w": 15, "h": 12 }, + { "type": "water", "tile": 4, "x": 50, "y": 50, "w": 12, "h": 10 }, + { "type": "water", "tile": 4, "x": 35, "y": 65, "w": 8, "h": 6 }, + { "type": "stone", "tile": 2, "x": 40, "y": 35, "w": 5, "h": 5 } + ], + "procedural": { + "treeChance": 0.06, + "bushChance": 0.04, + "treeTile": 14, + "bushTile": 15 + }, + "doors": [ + { "x": 1, "y": 40, "targetZone": "skyrim_overworld", "targetX": 79, "targetY": 20 }, + { "x": 79, "y": 15, "targetZone": "solitude", "targetX": 58, "targetY": 25 } + ], + "entities": [ + { "type": "enemy", "id": "shadow_spider_01", "x": 25, "y": 25, "data": { "type": "frostbite_spider" } }, + { "type": "enemy", "id": "shadow_spider_02", "x": 55, "y": 55, "data": { "type": "spider" } }, + { "type": "enemy", "id": "shadow_draugr_01", "x": 40, "y": 40, "data": { "type": "draugr_wight" } }, + { "type": "enemy", "id": "shadow_skeleton_01", "x": 30, "y": 60, "data": { "type": "skeleton" } }, + { "type": "enemy", "id": "shadow_necromancer_01", "x": 60, "y": 30, "data": { "type": "necromancer" } }, + { "type": "enemy", "id": "shadow_wolf_01", "x": 45, "y": 45, "data": { "type": "wolf" } }, + { "type": "enemy", "id": "shadow_ice_wraith_01", "x": 20, "y": 60, "data": { "type": "ice_wraith" } }, + { "type": "enemy", "id": "shadow_mudcrab_01", "x": 60, "y": 20, "data": { "type": "mudcrab" } } + ], + "chests": [ + { "id": "shadow_chest_01", "x": 40, "y": 35, "loot": [{ "type": "item", "id": "soul_gem", "quantity": 3 }, { "type": "gold", "amount": 200 }], "locked": false, "lockLevel": 0 }, + { "id": "shadow_chest_02", "x": 55, "y": 50, "loot": [{ "type": "item", "id": "ebony_ingot", "quantity": 2 }, { "type": "gold", "amount": 350 }], "locked": true, "lockLevel": 3 } + ], + "spawnPoint": { "x": 2, "y": 40 } } } } diff --git a/src/main.ts b/src/main.ts index 01cc644..0374507 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,9 @@ import './ui/UIManager'; import { modManager } from './mods/ModManager'; import { dataRegistry } from './data/DataRegistry'; import { titleMenuUI } from './ui/components/TitleMenuUI'; +import { CharacterCreationUI } from './ui/components/CharacterCreationUI'; import { eventBus } from './core/EventBus'; +import './systems/AudioManager'; injectGlobalTheme(); @@ -22,6 +24,11 @@ async function initGame(): Promise { // Listen for title menu actions eventBus.on('game:newGame', () => { + const creationUI = new CharacterCreationUI(); + creationUI.show(); + }); + + eventBus.on('game:start', () => { startGame(); }); @@ -30,7 +37,6 @@ async function initGame(): Promise { }); eventBus.on('game:loadMenu', () => { - // For now, just start game - full load menu would show save list startGame(); }); diff --git a/src/mods/ModTypes.ts b/src/mods/ModTypes.ts index 7b866bd..5afd365 100644 --- a/src/mods/ModTypes.ts +++ b/src/mods/ModTypes.ts @@ -24,6 +24,7 @@ export const MOD_DATA_DOMAINS = [ 'transforms', 'vampireStages', 'gameConfig', + 'zones', ] as const; export type ModDataDomain = (typeof MOD_DATA_DOMAINS)[number]; diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 4802754..9d813d1 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -17,7 +17,7 @@ import { dayNightSystem } from '../systems/DayNightSystem'; import { scriptSystem } from '../systems/ScriptSystem'; import { transformationSystem } from '../systems/TransformationSystem'; import { vampireSystem } from '../systems/VampireSystem'; -import { dataRegistry } from '../data/DataRegistry'; +import { dataRegistry, type EnemyData } from '../data/DataRegistry'; import { mapManager, type MapZone } from '../maps/MapManager'; import { combatUI } from '../ui/components/CombatUI'; import { uiManager } from '../ui/UIManager'; @@ -47,23 +47,40 @@ import { modManagerUI } from '../ui/components/ModManagerUI'; import { minimapHUD } from '../ui/components/MinimapHUD'; import { perkSystem } from '../systems/PerkSystem'; import { titleMenuUI } from '../ui/components/TitleMenuUI'; +import { questSystem } from '../systems/QuestSystem'; -// 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, -}; +// Material color maps — lazily built from game-config (Mod-overridable) +let _materialColors: Record | null = null; +let _armorStroke: Record | null = null; +function cssToHex(css: string): number { + return parseInt(css.replace('#', ''), 16); +} +function getMaterialColors(): Record { + if (!_materialColors) { + const mc = dataRegistry.getGameConfig().ui.materialColors; + _materialColors = Object.fromEntries(Object.entries(mc).map(([k, v]) => [k, cssToHex(v.fill)])); + } + return _materialColors; +} +function getArmorStroke(): Record { + if (!_armorStroke) { + const mc = dataRegistry.getGameConfig().ui.materialColors; + _armorStroke = Object.fromEntries(Object.entries(mc).map(([k, v]) => [k, cssToHex(v.stroke)])); + } + return _armorStroke; +} export class GameScene extends Phaser.Scene { - private player!: Phaser.GameObjects.Rectangle; + private playerGfx!: Phaser.GameObjects.Graphics; + private playerGfxX = 0; + private playerGfxY = 0; + private playerBodyColor = 0x2288cc; + private playerBodyStroke = 0x1a6699; + private playerAlpha = 1; + private playerDead = false; + private playerBlocking = false; + private envLayer!: Phaser.GameObjects.Graphics; + private lightingOverlay!: Phaser.GameObjects.Rectangle; private playerIndicator!: Phaser.GameObjects.Arc; private playerShadow!: Phaser.GameObjects.Ellipse; private leftHand!: Phaser.GameObjects.Arc; @@ -117,6 +134,17 @@ export class GameScene extends Phaser.Scene { this.cameras.main.setBackgroundColor('#1a1a2e'); this.createPlayer(); + + // Environment animation layer (water, fire, particles) + this.envLayer = this.add.graphics(); + this.envLayer.setDepth(500); + + // Day/night lighting overlay — full-screen semi-transparent rectangle + this.lightingOverlay = this.add.rectangle(0, 0, 9999, 9999, 0x000000, 0); + this.lightingOverlay.setOrigin(0, 0); + this.lightingOverlay.setDepth(2000); + this.lightingOverlay.setScrollFactor(0); + this.setupInput(); this.setupCamera(); this.setupEventListeners(); @@ -142,36 +170,35 @@ export class GameScene extends Phaser.Scene { 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); - inventorySystem.addItem(this.playerEntity, 'leather', 3); - inventorySystem.addItem(this.playerEntity, 'blue_mountain_flower', 5); - inventorySystem.addItem(this.playerEntity, 'wheat', 3); - inventorySystem.addItem(this.playerEntity, 'salt_pile', 5); - magicSystem.learnSpell(this.playerEntity, 'flames'); - magicSystem.learnSpell(this.playerEntity, 'healing'); - magicSystem.learnSpell(this.playerEntity, 'conjure_familiar'); - this.loadZone('whiterun_exterior'); + const pcfg = dataRegistry.getGameConfig().player; + for (const item of pcfg.startingItems) { + inventorySystem.addItem(this.playerEntity, item.id, item.quantity); + } + for (const spell of pcfg.startingSpells) { + magicSystem.learnSpell(this.playerEntity, spell); + } - // Auto-start first main quest - eventBus.emit('quest:started', { questId: 'main_01_unbound', player: this.playerEntity }); + this.loadZone(pcfg.startingZone); + + eventBus.emit('quest:started', { questId: pcfg.startingQuest, player: this.playerEntity }); } private createPlayer(): void { const startX = 400; const startY = 400; + this.playerGfxX = startX; + this.playerGfxY = startY; + // Player shadow const shadow = this.add.ellipse(startX, startY + 16, 28, 10, 0x000000, 0.3); shadow.setDepth(998); this.playerShadow = shadow; - // Player body — RPG character shape - this.player = this.add.rectangle(startX, startY, 24, 32, 0x2288cc); - this.player.setStrokeStyle(2, 0x1a6699); - this.player.setDepth(1000); + // Player multi-part character (Graphics) + this.playerGfx = this.add.graphics(); + this.playerGfx.setDepth(1000); // Selection ring this.playerIndicator = this.add.circle(startX, startY, 22, 0x00ff00, 0); @@ -194,28 +221,53 @@ export class GameScene extends Phaser.Scene { 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 }); - entityManager.addComponent(this.playerEntity.id, { type: 'magicka', current: 50, max: 50 }); - entityManager.addComponent(this.playerEntity.id, { type: 'stamina', current: 100, max: 100 }); - entityManager.addComponent(this.playerEntity.id, { type: 'level', level: 1, xp: 0, xpToNext: 100, perkPoints: 0 }); - entityManager.addComponent(this.playerEntity.id, { - type: 'skills', - oneHanded: 20, twoHanded: 15, archery: 15, block: 15, - heavyArmor: 15, lightArmor: 15, - destruction: 15, conjuration: 15, illusion: 15, alteration: 15, restoration: 15, enchanting: 15, - sneak: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15, smithing: 15, - }); - entityManager.addComponent(this.playerEntity.id, { type: 'weapon', id: 'fists', damage: 4, speed: 1.4 }); - entityManager.addComponent(this.playerEntity.id, { type: 'armor', rating: 0 }); - entityManager.addComponent(this.playerEntity.id, { type: 'blocking', isBlocking: false }); - entityManager.addComponent(this.playerEntity.id, { type: 'movement', speed: 200 }); - entityManager.addComponent(this.playerEntity.id, { type: 'statusEffects', active: [] }); - entityManager.addComponent(this.playerEntity.id, { type: 'legendary', skills: {} }); + // Use player entity from CharacterCreationUI if it exists, otherwise create one + const pcfg = dataRegistry.getGameConfig().player; + const existingPlayer = entityManager.getEntitiesByType('player')[0]; + if (existingPlayer) { + this.playerEntity = existingPlayer; + if (!entityManager.getComponent(this.playerEntity.id, 'position')) { + entityManager.addComponent(this.playerEntity.id, { type: 'position', x: startX, y: startY }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'weapon')) { + entityManager.addComponent(this.playerEntity.id, { type: 'weapon', id: 'fists', damage: pcfg.fistDamage, speed: pcfg.fistSpeed }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'armor')) { + entityManager.addComponent(this.playerEntity.id, { type: 'armor', rating: 0 }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'blocking')) { + entityManager.addComponent(this.playerEntity.id, { type: 'blocking', isBlocking: false }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'movement')) { + entityManager.addComponent(this.playerEntity.id, { type: 'movement', speed: pcfg.movementSpeed }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'statusEffects')) { + entityManager.addComponent(this.playerEntity.id, { type: 'statusEffects', active: [] }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'legendary')) { + entityManager.addComponent(this.playerEntity.id, { type: 'legendary', skills: {} }); + } + if (!entityManager.getComponent(this.playerEntity.id, 'inventory')) { + entityManager.addComponent(this.playerEntity.id, { type: 'inventory', items: [], gold: pcfg.baseGold, carryWeight: 0, maxCarryWeight: pcfg.maxCarryWeight }); + } + } else { + this.playerEntity = entityManager.createEntity('player'); + entityManager.addComponent(this.playerEntity.id, { type: 'position', x: startX, y: startY }); + entityManager.addComponent(this.playerEntity.id, { type: 'health', current: pcfg.baseHealth, max: pcfg.baseHealth }); + entityManager.addComponent(this.playerEntity.id, { type: 'magicka', current: pcfg.baseMagicka, max: pcfg.baseMagicka }); + entityManager.addComponent(this.playerEntity.id, { type: 'stamina', current: pcfg.baseStamina, max: pcfg.baseStamina }); + entityManager.addComponent(this.playerEntity.id, { type: 'level', level: 1, xp: 0, xpToNext: 100, perkPoints: 0 }); + entityManager.addComponent(this.playerEntity.id, { type: 'skills', ...pcfg.startingSkills }); + entityManager.addComponent(this.playerEntity.id, { type: 'weapon', id: 'fists', damage: pcfg.fistDamage, speed: pcfg.fistSpeed }); + entityManager.addComponent(this.playerEntity.id, { type: 'armor', rating: 0 }); + entityManager.addComponent(this.playerEntity.id, { type: 'blocking', isBlocking: false }); + entityManager.addComponent(this.playerEntity.id, { type: 'movement', speed: pcfg.movementSpeed }); + entityManager.addComponent(this.playerEntity.id, { type: 'statusEffects', active: [] }); + entityManager.addComponent(this.playerEntity.id, { type: 'legendary', skills: {} }); + entityManager.addComponent(this.playerEntity.id, { type: 'inventory', items: [], gold: pcfg.baseGold, carryWeight: 0, maxCarryWeight: pcfg.maxCarryWeight }); + } - this.playerEntity.sprite = this.player; - eventBus.emit('player:created', { entity: this.playerEntity }); + this.playerEntity.sprite = this.playerGfx; } private loadZone(zoneId: string, savedPosition?: { x: number; y: number }): void { @@ -232,8 +284,8 @@ export class GameScene extends Phaser.Scene { 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.playerGfxX = px; + this.playerGfxY = py; this.playerIndicator.x = px; this.playerIndicator.y = py; @@ -244,16 +296,7 @@ export class GameScene extends Phaser.Scene { 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); + compassHUD.showZoneName(zone.name || zoneId); this.cameras.main.fadeIn(300, 0, 0, 0); } @@ -272,49 +315,391 @@ export class GameScene extends Phaser.Scene { private renderZone(zone: MapZone): void { const tileSize = zone.tileSize; + const totalW = zone.width * tileSize; + const totalH = zone.height * tileSize; + + const canvas = document.createElement('canvas'); + canvas.width = totalW; + canvas.height = totalH; + const ctx = canvas.getContext('2d')!; + for (let y = 0; y < zone.height; y++) { for (let x = 0; x < zone.width; x++) { const tileId = zone.tiles[y]![x]!; - const tileData = mapManager.getTile(tileId); - if (!tileData) continue; - const tile = this.add.rectangle(x * tileSize + tileSize / 2, y * tileSize + tileSize / 2, tileSize, tileSize, tileData.color); - tile.setStrokeStyle(1, 0x222222, 0.15); - tile.setDepth(0); - this.mapTiles.push(tile); + const px = x * tileSize; + const py = y * tileSize; + this.drawTileDetail(ctx, tileId, px, py, tileSize, x, y); } } - // Grid overlay for reference - const totalW = zone.width * tileSize; - const totalH = zone.height * tileSize; - const gridGfx = this.add.graphics(); - gridGfx.lineStyle(1, 0xffffff, 0.04); + // Subtle grid overlay + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; for (let gx = 0; gx <= totalW; gx += tileSize * 4) { - gridGfx.lineBetween(gx, 0, gx, totalH); + ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, totalH); ctx.stroke(); } for (let gy = 0; gy <= totalH; gy += tileSize * 4) { - gridGfx.lineBetween(0, gy, totalW, gy); + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(totalW, gy); ctx.stroke(); } - gridGfx.setDepth(1); - this.mapTiles.push(gridGfx); + const key = 'zone_' + zone.id; + if (this.textures.exists(key)) this.textures.remove(key); + this.textures.addCanvas(key, canvas); + const mapSprite = this.add.image(0, 0, key).setOrigin(0, 0).setDepth(0); + this.mapTiles.push(mapSprite); + + // Chests — draw as proper chest shape for (const chest of zone.chests) { const cx = chest.x * tileSize + tileSize / 2; const cy = chest.y * tileSize + tileSize / 2; - - // Chest shadow - const shadow = this.add.ellipse(cx, cy + 10, 20, 8, 0x000000, 0.2); + const shadow = this.add.ellipse(cx, cy + 10, 22, 8, 0x000000, 0.25); shadow.setDepth(49); this.mapTiles.push(shadow); - - const s = this.add.rectangle(cx, cy, 20, 18, 0xffaa00); - s.setStrokeStyle(2, 0xcc8800); - s.setDepth(50); - this.mapTiles.push(s); + const g = this.add.graphics(); + g.setDepth(50); + // Chest body + g.fillStyle(0x8B4513, 1); + g.fillRoundedRect(cx - 10, cy - 4, 20, 14, 2); + // Chest lid + g.fillStyle(0xA0522D, 1); + g.fillRoundedRect(cx - 11, cy - 10, 22, 8, 3); + // Metal bands + g.lineStyle(1, 0x888888, 0.8); + g.lineBetween(cx - 10, cy - 6, cx + 10, cy - 6); + g.lineBetween(cx - 10, cy + 2, cx + 10, cy + 2); + // Lock + g.fillStyle(chest.locked ? 0xcc8800 : 0x666666, 1); + g.fillCircle(cx, cy - 6, 2); + this.mapTiles.push(g); containerSystem.createContainer(cx, cy, 'chest', chest.loot, chest.locked, chest.lockLevel); } } + // ── Seeded random for deterministic tile textures ── + private tileRand(x: number, y: number, seed: number): number { + let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0; + h = ((h ^ (h >> 13)) * 1103515245) | 0; + return ((h & 0x7fffffff) / 0x7fffffff); + } + + private drawTileDetail(ctx: CanvasRenderingContext2D, tileId: number, px: number, py: number, size: number, tx: number, ty: number): void { + const tileData = mapManager.getTile(tileId); + if (!tileData) return; + const baseHex = '#' + tileData.color.toString(16).padStart(6, '0'); + + switch (tileId) { + case 1: this.drawGrassTile(ctx, px, py, size, tx, ty, baseHex); break; + case 2: this.drawStoneTile(ctx, px, py, size, tx, ty, baseHex); break; + case 3: this.drawDirtTile(ctx, px, py, size, tx, ty, baseHex); break; + case 4: this.drawWaterTile(ctx, px, py, size, tx, ty, baseHex); break; + case 5: this.drawWoodTile(ctx, px, py, size, tx, ty, baseHex); break; + case 6: this.drawWallTile(ctx, px, py, size, tx, ty, baseHex); break; + case 9: this.drawCampfireTile(ctx, px, py, size); break; + case 12: this.drawSnowTile(ctx, px, py, size, tx, ty, baseHex); break; + case 13: this.drawRockTile(ctx, px, py, size, tx, ty, baseHex); break; + case 14: this.drawTreeTile(ctx, px, py, size, tx, ty); break; + case 15: this.drawBushTile(ctx, px, py, size, tx, ty); break; + default: + ctx.fillStyle = baseHex; + ctx.fillRect(px, py, size, size); + } + } + + private drawGrassTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Random grass variation + for (let i = 0; i < 8; i++) { + const rx = this.tileRand(tx, ty, i * 7) * s; + const ry = this.tileRand(tx, ty, i * 13 + 3) * s; + const bright = this.tileRand(tx, ty, i * 19) > 0.5; + ctx.fillStyle = bright ? 'rgba(60,140,40,0.3)' : 'rgba(30,80,20,0.25)'; + ctx.fillRect(px + rx, py + ry, 2, 2); + } + // Grass blades + for (let i = 0; i < 4; i++) { + const bx = this.tileRand(tx, ty, i * 31 + 50) * s; + const by = this.tileRand(tx, ty, i * 37 + 60) * s; + const len = 3 + this.tileRand(tx, ty, i * 41 + 70) * 4; + const angle = -Math.PI / 2 + (this.tileRand(tx, ty, i * 43 + 80) - 0.5) * 0.6; + ctx.strokeStyle = 'rgba(50,130,30,0.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px + bx, py + by); + ctx.lineTo(px + bx + Math.cos(angle) * len, py + by + Math.sin(angle) * len); + ctx.stroke(); + } + } + + private drawStoneTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Stone blocks + const rows = 3; + const cols = 2 + Math.floor(this.tileRand(tx, ty, 100) * 2); + for (let r = 0; r < rows; r++) { + const rowH = s / rows; + const ry = py + r * rowH; + const offset = (r % 2) * (s / cols / 2); + for (let c = 0; c < cols; c++) { + const cw = s / cols + (this.tileRand(tx, ty, r * 10 + c) - 0.5) * 3; + const cx = px + c * (s / cols) + offset; + const shade = this.tileRand(tx, ty, r * 20 + c + 200); + ctx.fillStyle = shade > 0.5 ? 'rgba(180,180,180,0.15)' : 'rgba(80,80,80,0.12)'; + ctx.fillRect(cx + 1, ry + 1, cw - 2, rowH - 2); + } + } + // Mortar lines + ctx.strokeStyle = 'rgba(40,40,40,0.2)'; + ctx.lineWidth = 1; + for (let r = 1; r < rows; r++) { + ctx.beginPath(); + ctx.moveTo(px, py + r * (s / rows)); + ctx.lineTo(px + s, py + r * (s / rows)); + ctx.stroke(); + } + } + + private drawDirtTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Dirt noise + for (let i = 0; i < 10; i++) { + const rx = this.tileRand(tx, ty, i * 11 + 300) * s; + const ry = this.tileRand(tx, ty, i * 17 + 310) * s; + const bright = this.tileRand(tx, ty, i * 23 + 320); + ctx.fillStyle = bright > 0.6 ? 'rgba(160,100,50,0.2)' : 'rgba(80,40,15,0.15)'; + ctx.fillRect(px + rx, py + ry, 1 + bright * 2, 1 + bright * 2); + } + // Small pebbles + for (let i = 0; i < 3; i++) { + const px2 = this.tileRand(tx, ty, i * 29 + 400) * s; + const py2 = this.tileRand(tx, ty, i * 31 + 410) * s; + const r = 1 + this.tileRand(tx, ty, i * 33 + 420); + ctx.fillStyle = 'rgba(100,80,60,0.3)'; + ctx.beginPath(); + ctx.arc(px + px2, py + py2, r, 0, Math.PI * 2); + ctx.fill(); + } + } + + private drawWaterTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Subtle wave lines + for (let i = 0; i < 3; i++) { + const wy = py + 6 + i * 10 + this.tileRand(tx, ty, i * 50) * 4; + ctx.strokeStyle = `rgba(120,180,255,${0.15 + this.tileRand(tx, ty, i * 60) * 0.1})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px, wy); + for (let dx = 0; dx <= s; dx += 4) { + const wave = Math.sin((px + dx) * 0.15 + i * 2) * 2; + ctx.lineTo(px + dx, wy + wave); + } + ctx.stroke(); + } + // Highlight spots + for (let i = 0; i < 2; i++) { + const hx = px + this.tileRand(tx, ty, i * 71 + 500) * s; + const hy = py + this.tileRand(tx, ty, i * 73 + 510) * s; + ctx.fillStyle = 'rgba(180,220,255,0.12)'; + ctx.beginPath(); + ctx.arc(hx, hy, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + + private drawWoodTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Plank lines (horizontal) + const plankH = s / 4; + ctx.strokeStyle = 'rgba(60,30,10,0.25)'; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + ctx.beginPath(); + ctx.moveTo(px, py + i * plankH); + ctx.lineTo(px + s, py + i * plankH); + ctx.stroke(); + } + // Vertical joints (staggered per plank) + for (let i = 0; i < 4; i++) { + const jx = px + (this.tileRand(tx, ty, i * 47 + 600) * 0.6 + 0.2) * s; + ctx.beginPath(); + ctx.moveTo(jx, py + i * plankH); + ctx.lineTo(jx, py + (i + 1) * plankH); + ctx.stroke(); + } + // Wood grain + for (let i = 0; i < 3; i++) { + const gy = py + this.tileRand(tx, ty, i * 53 + 650) * s; + ctx.strokeStyle = 'rgba(80,40,15,0.08)'; + ctx.beginPath(); + ctx.moveTo(px, gy); + ctx.lineTo(px + s, gy + (this.tileRand(tx, ty, i * 59 + 660) - 0.5) * 3); + ctx.stroke(); + } + } + + private drawWallTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Brick pattern + const brickH = s / 4; + const brickW = s / 2; + for (let r = 0; r < 4; r++) { + const offset = (r % 2) * (brickW / 2); + for (let c = -1; c < 3; c++) { + const bx = px + c * brickW + offset; + const by = py + r * brickH; + const shade = this.tileRand(tx, ty, r * 10 + c + 700); + ctx.fillStyle = shade > 0.5 ? 'rgba(120,120,120,0.1)' : 'rgba(40,40,40,0.1)'; + ctx.fillRect(bx + 1, by + 1, brickW - 2, brickH - 2); + } + } + ctx.strokeStyle = 'rgba(30,30,30,0.3)'; + ctx.lineWidth = 1; + for (let r = 1; r < 4; r++) { + ctx.beginPath(); + ctx.moveTo(px, py + r * brickH); + ctx.lineTo(px + s, py + r * brickH); + ctx.stroke(); + } + } + + private drawCampfireTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number): void { + // Ground base + ctx.fillStyle = '#3a2a1a'; + ctx.fillRect(px, py, s, s); + // Stone ring + ctx.fillStyle = '#666666'; + ctx.beginPath(); + ctx.ellipse(px + s / 2, py + s / 2 + 2, s * 0.35, s * 0.25, 0, 0, Math.PI * 2); + ctx.fill(); + // Inner dark + ctx.fillStyle = '#2a1a0a'; + ctx.beginPath(); + ctx.ellipse(px + s / 2, py + s / 2 + 2, s * 0.22, s * 0.15, 0, 0, Math.PI * 2); + ctx.fill(); + // Flames + const flames = [ + { x: 0, y: -3, r: 4, c: '#ff4400' }, + { x: -2, y: -1, r: 3, c: '#ff6600' }, + { x: 2, y: -2, r: 3.5, c: '#ff8800' }, + { x: 0, y: -5, r: 2, c: '#ffaa00' }, + ]; + for (const f of flames) { + ctx.fillStyle = f.c; + ctx.beginPath(); + ctx.arc(px + s / 2 + f.x, py + s / 2 + f.y, f.r, 0, Math.PI * 2); + ctx.fill(); + } + } + + private drawSnowTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Snow sparkle + for (let i = 0; i < 6; i++) { + const sx = this.tileRand(tx, ty, i * 61 + 800) * s; + const sy = this.tileRand(tx, ty, i * 67 + 810) * s; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillRect(px + sx, py + sy, 1, 1); + } + // Blue shadow patches + for (let i = 0; i < 2; i++) { + const bx = this.tileRand(tx, ty, i * 71 + 850) * s; + const by = this.tileRand(tx, ty, i * 73 + 860) * s; + ctx.fillStyle = 'rgba(150,160,200,0.1)'; + ctx.beginPath(); + ctx.ellipse(px + bx, py + by, 4, 3, 0, 0, Math.PI * 2); + ctx.fill(); + } + } + + private drawRockTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, tx: number, ty: number, base: string): void { + ctx.fillStyle = base; + ctx.fillRect(px, py, s, s); + // Rock facets + for (let i = 0; i < 4; i++) { + const rx = this.tileRand(tx, ty, i * 81 + 900) * s; + const ry = this.tileRand(tx, ty, i * 83 + 910) * s; + const shade = this.tileRand(tx, ty, i * 89 + 920); + ctx.fillStyle = shade > 0.5 ? 'rgba(150,150,150,0.15)' : 'rgba(50,50,50,0.12)'; + const rw = 3 + shade * 5; + const rh = 2 + shade * 4; + ctx.beginPath(); + ctx.moveTo(px + rx, py + ry - rh / 2); + ctx.lineTo(px + rx + rw, py + ry); + ctx.lineTo(px + rx, py + ry + rh / 2); + ctx.lineTo(px + rx - rw / 2, py + ry); + ctx.closePath(); + ctx.fill(); + } + } + + private drawTreeTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, _tx: number, _ty: number): void { + // Ground beneath tree + ctx.fillStyle = '#2d4a22'; + ctx.fillRect(px, py, s, s); + // Trunk + ctx.fillStyle = '#5a3a1a'; + ctx.fillRect(px + s / 2 - 3, py + s / 2, 6, s / 2); + ctx.fillStyle = '#4a2a10'; + ctx.fillRect(px + s / 2 - 1, py + s / 2, 2, s / 2); + // Canopy — overlapping circles + const cx = px + s / 2; + const cy = py + s / 2 - 2; + const canopyParts = [ + { x: 0, y: -4, r: 10, c: '#1a4a1a' }, + { x: -5, y: 0, r: 8, c: '#1f551f' }, + { x: 5, y: 0, r: 8, c: '#1f551f' }, + { x: 0, y: -7, r: 7, c: '#256625' }, + { x: -3, y: -6, r: 5, c: '#2d7a2d' }, + ]; + for (const p of canopyParts) { + ctx.fillStyle = p.c; + ctx.beginPath(); + ctx.arc(cx + p.x, cy + p.y, p.r, 0, Math.PI * 2); + ctx.fill(); + } + // Highlight + ctx.fillStyle = 'rgba(80,180,60,0.15)'; + ctx.beginPath(); + ctx.arc(cx - 2, cy - 5, 4, 0, Math.PI * 2); + ctx.fill(); + } + + private drawBushTile(ctx: CanvasRenderingContext2D, px: number, py: number, s: number, _tx: number, _ty: number): void { + // Ground + ctx.fillStyle = '#2a4a20'; + ctx.fillRect(px, py, s, s); + // Bush blobs + const cx = px + s / 2; + const cy = py + s / 2; + const parts = [ + { x: 0, y: 0, r: 8, c: '#225522' }, + { x: -4, y: 2, r: 6, c: '#2a6a2a' }, + { x: 4, y: -1, r: 7, c: '#1e4e1e' }, + { x: 0, y: -4, r: 5, c: '#308030' }, + ]; + for (const p of parts) { + ctx.fillStyle = p.c; + ctx.beginPath(); + ctx.arc(cx + p.x, cy + p.y, p.r, 0, Math.PI * 2); + ctx.fill(); + } + // Berry dots + ctx.fillStyle = 'rgba(180,40,40,0.4)'; + ctx.beginPath(); + ctx.arc(cx + 3, cy + 2, 1.5, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx - 2, cy - 3, 1.5, 0, Math.PI * 2); + ctx.fill(); + } + private spawnZoneEntities(zone: MapZone): void { const tileSize = zone.tileSize; for (const entityData of zone.entities) { @@ -329,9 +714,9 @@ export class GameScene extends Phaser.Scene { 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); - const enemySprite = this.add.rectangle(cx, cy, stats.size, stats.size, stats.color); - enemySprite.setStrokeStyle(2, 0xaa2222); + const enemySprite = this.add.graphics(); enemySprite.setDepth(100); + this.drawEnemyShape(enemySprite, stats, cx, cy); const enemy = entityManager.createEntity('enemy'); entityManager.addComponent(enemy.id, { type: 'position', x: cx, y: cy }); entityManager.addComponent(enemy.id, { type: 'health', current: stats.health, max: stats.health }); @@ -595,21 +980,38 @@ export class GameScene extends Phaser.Scene { } private setupCamera(): void { - this.cameras.main.startFollow(this.player, true, 0.08, 0.08); + this.cameras.main.startFollow(this.playerGfx, true, 0.08, 0.08); const minDim = Math.min(window.innerWidth, window.innerHeight); 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); + + // Mouse wheel zoom + this.input.on('wheel', (_pointer: Phaser.Input.Pointer, _gos: Phaser.GameObjects.GameObject[], _dx: number, dy: number) => { + const cam = this.cameras.main; + const delta = dy > 0 ? -0.1 : 0.1; + const newZoom = Math.max(0.4, Math.min(3.0, cam.zoom + delta)); + cam.setZoom(newZoom); + }); } + private eventCleanupFns: (() => void)[] = []; + private setupEventListeners(): void { - eventBus.on('entity:created', () => {}); - eventBus.on('entity:destroyed', () => {}); - eventBus.on('world:fastTravel', (data: { locationId: string }) => { + // Clean up previous listeners to prevent accumulation on scene restart + for (const fn of this.eventCleanupFns) fn(); + this.eventCleanupFns = []; + + const on = (event: string, handler: (...args: any[]) => void) => { + eventBus.on(event, handler); + this.eventCleanupFns.push(() => eventBus.off(event, handler)); + }; + + on('world:fastTravel', (data: { locationId: string }) => { this.loadZone(data.locationId); }); - eventBus.on('entity:killed', (data: { entity: Entity; killer: Entity }) => { + on('entity:killed', (data: { entity: Entity; killer: Entity }) => { if (data.killer.id === this.playerEntity.id) { const enemyType = entityManager.getComponent<{ name: string }>(data.entity.id, 'enemyType'); if (enemyType) this.showNotification(`${enemyType.name} 被击败`); @@ -618,18 +1020,18 @@ export class GameScene extends Phaser.Scene { this.handlePlayerDeath(); } }); - eventBus.on('player:levelUp', (data: { entityId: string; level: number }) => { + on('player:levelUp', (data: { entityId: string; level: number }) => { if (data.entityId === this.playerEntity.id) { levelUpUI.show(data.level); this.cameras.main.flash(500, 212, 168, 67); } }); - eventBus.on('combat:blockSuccess', (data: { entityId: string }) => { + on('combat:blockSuccess', (data: { entityId: string }) => { 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 }) => { + 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); } @@ -639,21 +1041,21 @@ export class GameScene extends Phaser.Scene { this.showPlayerHitFlash(); } }); - eventBus.on('ui:openCharacterInfo', () => { + on('ui:openCharacterInfo', () => { characterInfoUI.show(); }); - eventBus.on('ui:openSettings', () => { + on('ui:openSettings', () => { settingsUI.show(); }); - eventBus.on('ui:notification', (data: { text: string }) => { + on('ui:notification', (data: { text: string }) => { this.showNotification(data.text); }); - eventBus.on('game:returnToMenu', () => { + on('game:returnToMenu', () => { // Restart the scene to return to title this.scene.restart(); titleMenuUI.show(); }); - eventBus.on('container:unlocked', (data: { entity: Entity }) => { + on('container:unlocked', (data: { entity: Entity }) => { this.showNotification('锁已打开!'); // Auto-loot the container after unlocking const info = containerSystem.getContainerInfo(data.entity); @@ -693,8 +1095,8 @@ export class GameScene extends Phaser.Scene { // Sync sprite const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); if (pos) { - this.player.x = pos.x; - this.player.y = pos.y; + this.playerGfxX = pos.x; + this.playerGfxY = pos.y; this.playerIndicator.x = pos.x; this.playerIndicator.y = pos.y; if (this.playerShadow) { @@ -719,14 +1121,35 @@ export class GameScene extends Phaser.Scene { 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; + // Skip sync if attack animation is tweening the sprite + if ((enemy as any)._animating) return; + + const sprite = enemy.sprite as Phaser.GameObjects.Graphics; + const enemyType = entityManager.getComponent<{ id: string }>(enemy.id, 'enemyType'); + const stats = enemyType ? dataRegistry.getEnemy(enemyType.id) : undefined; + const flashWhite = (enemy as any)._hitFlash === true; + const isDead = (enemy as any)._dead === true; + const isLooted = (enemy as any)._looted === true; + const isSkeleton = (enemy as any)._skeleton === true; + if (stats) { + if (isSkeleton) { + const skelStats = { ...stats, color: 0xccccaa }; + this.drawEnemyShape(sprite, skelStats, ePos.x, ePos.y); + } else if (isLooted) { + const lootStats = { ...stats, color: 0x333333 }; + this.drawEnemyShape(sprite, lootStats, ePos.x, ePos.y); + } else if (isDead) { + const grayStats = { ...stats, color: 0x444444 }; + this.drawEnemyShape(sprite, grayStats, ePos.x, ePos.y); + } else { + this.drawEnemyShape(sprite, stats, ePos.x, ePos.y, flashWhite); + } + } // 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; + shadowComp.sprite.y = ePos.y + (stats?.size || 24) * 0.4; } } }); @@ -750,41 +1173,44 @@ export class GameScene extends Phaser.Scene { this.updateEnemyHealthBars(); } this.updatePlayerVisual(); + this.updateEnvironment(_time); } 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; + const px = this.playerGfxX; + const py = this.playerGfxY; - // Armor color from equipped chest piece — use dataRegistry for material + // Armor color from equipped chest piece 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]; + const fill = getMaterialColors()[mat]; + const stroke = getArmorStroke()[mat]; if (fill) { - this.player.setFillStyle(fill); - this.player.setStrokeStyle(2, stroke || 0x555555); + this.playerBodyColor = fill; + this.playerBodyStroke = stroke || 0x555555; } } else { - this.player.setFillStyle(0x2288cc); - this.player.setStrokeStyle(2, 0x1a6699); + this.playerBodyColor = 0x2288cc; + this.playerBodyStroke = 0x1a6699; } // Blocking override const isBlocking = entityManager.getComponent<{ isBlocking: boolean }>(this.playerEntity.id, 'blocking')?.isBlocking; + this.playerBlocking = isBlocking || false; if (isBlocking) { - this.player.setStrokeStyle(3, 0xffd700); - this.playerIndicator.setStrokeStyle(2, 0xffd700, 0.7); + this.playerIndicator.setStrokeStyle(3, 0xffd700, 0.7); } else { this.playerIndicator.setStrokeStyle(2, 0x00ff66, 0.3); } + this.redrawPlayer(); + // Hand indicators — orbit player based on facing angle const HAND_RADIUS = 14; const FIST_COLOR = 0xddb888; @@ -830,7 +1256,7 @@ export class GameScene extends Phaser.Scene { ); this.leftWeapon.setSize(4, wLen); this.leftWeapon.setRotation(angle + Math.PI / 2); - this.leftWeapon.setFillStyle(MATERIAL_COLORS[weaponMat] || 0xaaaaaa); + this.leftWeapon.setFillStyle(getMaterialColors()[weaponMat] || 0xaaaaaa); } else { this.leftHand.setFillStyle(FIST_COLOR); this.leftWeapon.setVisible(false); @@ -864,13 +1290,329 @@ export class GameScene extends Phaser.Scene { ); this.rightWeapon.setSize(wWid, wLen); this.rightWeapon.setRotation(angle + Math.PI / 2); - this.rightWeapon.setFillStyle(MATERIAL_COLORS[weaponMat] || 0xaaaaaa); + this.rightWeapon.setFillStyle(getMaterialColors()[weaponMat] || 0xaaaaaa); } else { this.rightHand.setFillStyle(FIST_COLOR); this.rightWeapon.setVisible(false); } } + private updateEnvironment(time: number): void { + if (!this.envLayer || !this.currentZone) return; + this.envLayer.clear(); + + const cam = this.cameras.main; + const vw = cam.width; + const vh = cam.height; + const scrollX = cam.scrollX; + const scrollY = cam.scrollY; + const tileSize = this.currentZone.tileSize; + const map = this.currentZone.tiles; + + // Only process tiles visible in camera viewport + const startCol = Math.max(0, Math.floor(scrollX / tileSize) - 1); + const endCol = Math.min(this.currentZone.width - 1, Math.ceil((scrollX + vw) / tileSize) + 1); + const startRow = Math.max(0, Math.floor(scrollY / tileSize) - 1); + const endRow = Math.min(this.currentZone.height - 1, Math.ceil((scrollY + vh) / tileSize) + 1); + + const t = time * 0.001; // seconds + + for (let row = startRow; row <= endRow; row++) { + for (let col = startCol; col <= endCol; col++) { + const tileId = map[row]?.[col] ?? 0; + const px = col * tileSize; + const py = row * tileSize; + + if (tileId === 4) { + // Water — animated ripple lines + this.envLayer.lineStyle(1, 0x88bbee, 0.35); + for (let i = 0; i < 3; i++) { + const waveY = py + tileSize * 0.2 + i * tileSize * 0.25; + this.envLayer.beginPath(); + for (let x = 0; x <= tileSize; x += 4) { + const wx = px + x; + const wy = waveY + Math.sin((x * 0.08) + t * 2.5 + row * 0.7 + i) * 2.5; + if (x === 0) this.envLayer.moveTo(wx, wy); + else this.envLayer.lineTo(wx, wy); + } + this.envLayer.strokePath(); + } + // Highlight shimmer + const shimmerX = px + ((t * 30 + col * 17) % tileSize); + const shimmerY = py + tileSize * 0.3 + Math.sin(t * 3 + row) * 3; + this.envLayer.fillStyle(0xffffff, 0.15); + this.envLayer.fillCircle(shimmerX, shimmerY, 2); + + } else if (tileId === 9) { + // Campfire — flickering particles + const fireX = px + tileSize / 2; + const fireBaseY = py + tileSize / 2 - 4; + for (let p = 0; p < 5; p++) { + const seed = col * 100 + row * 10 + p; + const phase = t * 4 + seed * 1.3; + const life = (phase % 1); + const pxp = fireX + Math.sin(phase * 2.7 + seed) * 4; + const pyp = fireBaseY - life * 12; + const size = (1 - life) * 2.5; + const alpha = (1 - life) * 0.7; + const fireColors = [0xff4400, 0xff8800, 0xffcc00]; + this.envLayer.fillStyle(fireColors[p % 3]!, alpha); + this.envLayer.fillCircle(pxp, pyp, size); + } + + } else if (tileId === 14) { + // Tree — subtle sway (canopy shift) + const swayX = Math.sin(t * 0.8 + col * 0.5) * 1.5; + const swayY = Math.cos(t * 0.6 + row * 0.5) * 0.8; + this.envLayer.fillStyle(0x2d7a2d, 0.15); + this.envLayer.fillCircle(px + tileSize / 2 + swayX, py + tileSize * 0.35 + swayY, tileSize * 0.3); + + } else if (tileId === 1 || tileId === 12) { + // Grass/Snow — ambient particles (fireflies in grass, snowflakes in snow) + if ((col + row) % 4 === 0) { + const seed = col * 31 + row * 17; + const phase = t * 0.7 + seed; + const life = (Math.sin(phase) + 1) * 0.5; + const particleX = px + tileSize * (0.2 + (seed % 60) / 100); + const particleY = py + tileSize * (0.3 + life * 0.4); + if (tileId === 1) { + // Grass — green firefly + this.envLayer.fillStyle(0xaaff44, 0.3 + life * 0.3); + this.envLayer.fillCircle(particleX, particleY, 1.2); + } else { + // Snow — white snowflake + this.envLayer.fillStyle(0xffffff, 0.2 + life * 0.2); + this.envLayer.fillCircle(particleX, particleY, 1); + } + } + } + } + } + } + + private redrawPlayer(): void { + this.playerGfx.x = this.playerGfxX; + this.playerGfx.y = this.playerGfxY; + this.playerGfx.clear(); + this.playerGfx.setAlpha(this.playerDead ? 0.5 : this.playerAlpha); + this.drawPlayerCharacter(this.playerGfx, 0, 0, this.facingAngle); + } + + private drawPlayerCharacter(g: Phaser.GameObjects.Graphics, px: number, py: number, angle: number): void { + const bodyColor = this.playerDead ? 0x664444 : this.playerBodyColor; + const bodyStroke = this.playerDead ? 0x443333 : this.playerBodyStroke; + const headOffset = 5; + + // Head position — toward facing direction + const headX = px + Math.cos(angle) * headOffset; + const headY = py + Math.sin(angle) * headOffset - 1; + + // ── Shadow ── + g.fillStyle(0x000000, 0.22); + g.fillEllipse(px, py + 14, 26, 9); + + // ── Body (shield behind body when blocking) ── + if (this.playerBlocking) { + const shieldX = px + Math.cos(angle) * 6; + const shieldY = py + Math.sin(angle) * 6; + g.fillStyle(0x888888, 0.9); + g.fillCircle(shieldX, shieldY, 8); + g.lineStyle(1.5, 0x666666, 1); + g.strokeCircle(shieldX, shieldY, 8); + } + + // Body — rounded rectangle torso + g.fillStyle(bodyColor, 1); + g.fillRoundedRect(px - 9, py - 7, 18, 17, 4); + g.lineStyle(1.5, bodyStroke, 1); + g.strokeRoundedRect(px - 9, py - 7, 18, 17, 4); + + // ── Belt ── + g.fillStyle(0x6b4226, 1); + g.fillRect(px - 8, py + 2, 16, 3); + g.fillStyle(0xdaa520, 1); + g.fillCircle(px, py + 3.5, 1.5); + + // ── Shoulders (plate armor accent) ── + g.fillStyle(bodyStroke, 0.8); + g.fillRoundedRect(px - 11, py - 8, 7, 5, 2); + g.fillRoundedRect(px + 4, py - 8, 7, 5, 2); + + // ── Head ── + g.fillStyle(0xddb888, 1); + g.fillCircle(headX, headY, 6); + g.lineStyle(1, 0xbb9966, 1); + g.strokeCircle(headX, headY, 6); + + // ── Hair (dark cap on top of head) ── + g.fillStyle(0x3a2a1a, 1); + g.beginPath(); + g.arc(headX, headY - 1, 6, Math.PI, 0, false); + g.fill(); + + // ── Eye dots ── + const eyeOffX = Math.cos(angle) * 2; + const eyeOffY = Math.sin(angle) * 2; + g.fillStyle(0x222222, 1); + g.fillCircle(headX + eyeOffX - 1.5, headY + eyeOffY - 0.5, 1); + g.fillCircle(headX + eyeOffX + 1.5, headY + eyeOffY - 0.5, 1); + } + + private drawEnemyShape(g: Phaser.GameObjects.Graphics, stats: EnemyData, cx: number, cy: number, flashWhite = false): void { + g.clear(); + const s = stats.size; + const color = flashWhite ? 0xffffff : stats.color; + const darker = Phaser.Display.Color.ValueToColor(color).darken(30).color; + const id = stats.id; + + // Shadow + g.fillStyle(0x000000, 0.2); + g.fillEllipse(cx, cy + s * 0.4, s * 0.8, s * 0.3); + + if (id.includes('wolf')) { + // Wolf — oval body + triangular ears + g.fillStyle(color, 1); + g.fillEllipse(cx, cy, s * 0.7, s * 0.5); + g.lineStyle(1.5, darker, 1); + g.strokeEllipse(cx, cy, s * 0.7, s * 0.5); + // Ears + g.fillStyle(darker, 1); + g.fillTriangle(cx - s * 0.25, cy - s * 0.25, cx - s * 0.15, cy - s * 0.45, cx - s * 0.05, cy - s * 0.25); + g.fillTriangle(cx + s * 0.05, cy - s * 0.25, cx + s * 0.15, cy - s * 0.45, cx + s * 0.25, cy - s * 0.25); + // Eyes + g.fillStyle(0xffcc00, 1); + g.fillCircle(cx - 3, cy - 2, 1.5); + g.fillCircle(cx + 3, cy - 2, 1.5); + + } else if (id.includes('bear') || id === 'cave_bear') { + // Bear — large round body + small round ears + g.fillStyle(color, 1); + g.fillCircle(cx, cy, s * 0.45); + g.lineStyle(1.5, darker, 1); + g.strokeCircle(cx, cy, s * 0.45); + // Ears + g.fillStyle(darker, 1); + g.fillCircle(cx - s * 0.3, cy - s * 0.35, s * 0.1); + g.fillCircle(cx + s * 0.3, cy - s * 0.35, s * 0.1); + // Snout + g.fillStyle(0x8B6914, 1); + g.fillCircle(cx, cy + 2, 4); + + } else if (id.includes('spider')) { + // Spider — round body + 8 legs + g.fillStyle(color, 1); + g.fillCircle(cx, cy, s * 0.3); + g.lineStyle(1, darker, 1); + g.strokeCircle(cx, cy, s * 0.3); + // Legs (4 each side) + g.lineStyle(1.5, color, 0.8); + for (let i = 0; i < 4; i++) { + const legAngle = (i - 1.5) * 0.35; + g.lineBetween(cx + Math.cos(legAngle - 0.8) * s * 0.3, cy + Math.sin(legAngle - 0.8) * s * 0.3, + cx + Math.cos(legAngle - 1.2) * s * 0.5, cy + Math.sin(legAngle - 1.2) * s * 0.5); + g.lineBetween(cx + Math.cos(-legAngle + 0.8) * s * 0.3, cy + Math.sin(-legAngle + 0.8) * s * 0.3, + cx + Math.cos(-legAngle + 1.2) * s * 0.5, cy + Math.sin(-legAngle + 1.2) * s * 0.5); + } + // Eyes + g.fillStyle(0xff0000, 1); + g.fillCircle(cx - 2, cy - 2, 1.5); + g.fillCircle(cx + 2, cy - 2, 1.5); + + } else if (id.includes('dragon')) { + // Dragon — large diamond body + wing triangles + g.fillStyle(color, 1); + g.beginPath(); + g.moveTo(cx, cy - s * 0.4); // top + g.lineTo(cx + s * 0.3, cy); // right + g.lineTo(cx, cy + s * 0.35); // bottom + g.lineTo(cx - s * 0.3, cy); // left + g.closePath(); + g.fill(); + g.lineStyle(2, darker, 1); + g.stroke(); + // Wings + g.fillStyle(darker, 0.7); + g.fillTriangle(cx - s * 0.3, cy - s * 0.05, cx - s * 0.55, cy - s * 0.3, cx - s * 0.15, cy - s * 0.25); + g.fillTriangle(cx + s * 0.3, cy - s * 0.05, cx + s * 0.55, cy - s * 0.3, cx + s * 0.15, cy - s * 0.25); + // Eyes + g.fillStyle(0xffaa00, 1); + g.fillCircle(cx - 4, cy - s * 0.2, 2); + g.fillCircle(cx + 4, cy - s * 0.2, 2); + + } else if (id.includes('wraith')) { + // Ice wraith — ethereal floating orb + crown + g.fillStyle(color, 0.6); + g.fillCircle(cx, cy, s * 0.4); + g.fillStyle(0xaaddff, 0.3); + g.fillCircle(cx, cy, s * 0.5); + g.lineStyle(1, 0xccddff, 0.8); + g.strokeCircle(cx, cy, s * 0.4); + // Crown spikes + g.fillStyle(0xffffff, 0.7); + for (let i = 0; i < 5; i++) { + const a = -Math.PI / 2 + (i - 2) * 0.4; + g.fillTriangle( + cx + Math.cos(a) * s * 0.35, cy + Math.sin(a) * s * 0.35, + cx + Math.cos(a - 0.12) * s * 0.5, cy + Math.sin(a - 0.12) * s * 0.5, + cx + Math.cos(a + 0.12) * s * 0.5, cy + Math.sin(a + 0.12) * s * 0.5 + ); + } + // Eyes + g.fillStyle(0x88ccff, 1); + g.fillCircle(cx - 3, cy - 2, 2); + g.fillCircle(cx + 3, cy - 2, 2); + + } else if (id.includes('mudcrab')) { + // Mudcrab — flat oval + pincers + g.fillStyle(color, 1); + g.fillEllipse(cx, cy, s * 0.8, s * 0.5); + g.lineStyle(1.5, darker, 1); + g.strokeEllipse(cx, cy, s * 0.8, s * 0.5); + // Pincers + g.fillStyle(darker, 1); + g.fillCircle(cx - s * 0.45, cy - 2, 3); + g.fillCircle(cx + s * 0.45, cy - 2, 3); + // Eyes on stalks + g.fillStyle(0x000000, 1); + g.fillCircle(cx - 4, cy - s * 0.25, 1.5); + g.fillCircle(cx + 4, cy - s * 0.25, 1.5); + + } else if (id.includes('troll')) { + // Frost troll — hulking body + horns + g.fillStyle(color, 1); + g.fillRoundedRect(cx - s * 0.35, cy - s * 0.35, s * 0.7, s * 0.7, 6); + g.lineStyle(2, darker, 1); + g.strokeRoundedRect(cx - s * 0.35, cy - s * 0.35, s * 0.7, s * 0.7, 6); + // Horns + g.fillStyle(0xcccccc, 1); + g.fillTriangle(cx - 6, cy - s * 0.35, cx - 3, cy - s * 0.55, cx, cy - s * 0.35); + g.fillTriangle(cx, cy - s * 0.35, cx + 3, cy - s * 0.55, cx + 6, cy - s * 0.35); + // Eyes + g.fillStyle(0x88ddff, 1); + g.fillCircle(cx - 5, cy - 5, 2); + g.fillCircle(cx + 5, cy - 5, 2); + + } else { + // Default humanoid — body + head + weapon hint + g.fillStyle(color, 1); + g.fillRoundedRect(cx - s * 0.35, cy - s * 0.3, s * 0.7, s * 0.7, 4); + g.lineStyle(1.5, darker, 1); + g.strokeRoundedRect(cx - s * 0.35, cy - s * 0.3, s * 0.7, s * 0.7, 4); + // Head + g.fillStyle(0xddb888, 1); + g.fillCircle(cx, cy - s * 0.35, s * 0.2); + g.lineStyle(1, 0xbb9966, 1); + g.strokeCircle(cx, cy - s * 0.35, s * 0.2); + // Belt + g.fillStyle(0x6b4226, 1); + g.fillRect(cx - s * 0.3, cy + s * 0.05, s * 0.6, 2); + // Eyes + g.fillStyle(0x222222, 1); + g.fillCircle(cx - 2, cy - s * 0.36, 1); + g.fillCircle(cx + 2, cy - s * 0.36, 1); + } + } + private playAttackSwing(hand: 'left' | 'right', isPower: boolean): void { const arc = hand === 'left' ? this.leftHand : this.rightHand; const weaponRect = hand === 'left' ? this.leftWeapon : this.rightWeapon; @@ -895,12 +1637,12 @@ export class GameScene extends Phaser.Scene { 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; + const midX = this.playerGfxX + Math.cos(midAngle) * SWING_REACH; + const midY = this.playerGfxY + 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 endX = this.playerGfxX + Math.cos(endAngle) * SWING_REACH; + const endY = this.playerGfxY + Math.sin(endAngle) * SWING_REACH; const FIST_PULSE_RADIUS = 8; @@ -962,8 +1704,8 @@ export class GameScene extends Phaser.Scene { // 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; + const targetX = this.playerGfxX + Math.cos(angle) * BLOCK_REACH; + const targetY = this.playerGfxY + Math.sin(angle) * BLOCK_REACH; if (this.leftSwingTween?.isPlaying()) this.leftSwingTween.stop(); @@ -978,8 +1720,8 @@ export class GameScene extends Phaser.Scene { private handlePlayerDeath(): void { this.isDead = true; - this.player.setFillStyle(0x664444); - this.player.setAlpha(0.5); + this.playerDead = true; + this.redrawPlayer(); const overlay = document.createElement('div'); overlay.id = 'death-screen'; @@ -1015,13 +1757,14 @@ export class GameScene extends Phaser.Scene { if (health) health.current = health.max; if (magicka) magicka.current = magicka.max; if (stamina) stamina.current = stamina.max; - this.player.setFillStyle(0x2288cc); - this.player.setAlpha(1); + this.playerDead = false; + this.playerAlpha = 1; + this.redrawPlayer(); statusEffectSystem.clearAll(this.playerEntity.id); if (this.currentZone) { const sx = this.currentZone.spawnPoint.x * this.currentZone.tileSize + this.currentZone.tileSize / 2; const sy = this.currentZone.spawnPoint.y * this.currentZone.tileSize + this.currentZone.tileSize / 2; - this.player.x = sx; this.player.y = sy; + this.playerGfxX = sx; this.playerGfxY = sy; this.playerIndicator.x = sx; this.playerIndicator.y = sy; if (this.playerShadow) { this.playerShadow.x = sx; this.playerShadow.y = sy + 16; } const pos = entityManager.getComponent<{ x: number; y: number }>(this.playerEntity.id, 'position'); @@ -1087,9 +1830,16 @@ export class GameScene extends Phaser.Scene { 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); + const corpseEntity = this.nearbyEntities.corpse; + lootUI.show('搜索尸体', lootEntries, corpseEntity); + // Mark as looted only after lootUI is closed (via event listener) + const markLooted = () => { + if (!lootUI.getIsOpen()) { + corpseSystem.searchCorpse(corpseEntity, this.playerEntity); + eventBus.off('ui:menuClosed', markLooted); + } + }; + eventBus.on('ui:menuClosed', markLooted); } else { this.showNotification('尸体已经被搜刮过了'); } @@ -1218,12 +1968,12 @@ export class GameScene extends Phaser.Scene { this.weaponDrawn = !this.weaponDrawn; } - // Number keys 1-8 for quickbar + // Number keys 1-8 for favorites 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); + favoritesUI.useFavorite(i + 1); return; } } @@ -1250,6 +2000,7 @@ export class GameScene extends Phaser.Scene { if (pauseMenuUI.getIsOpen()) { pauseMenuUI.hide(); return; } if (modManagerUI.getIsOpen()) { modManagerUI.hide(); return; } if (settingsUI.getIsOpen()) { settingsUI.hide(); return; } + if (levelUpUI.getIsOpen()) { levelUpUI.hide(); return; } if (skillTreeUI.getIsOpen()) { skillTreeUI.toggle(); return; } if (magicUI.getIsOpen()) { magicUI.toggle(); return; } if (worldMapUI.getIsOpen()) { worldMapUI.toggle(); return; } @@ -1265,46 +2016,6 @@ export class GameScene extends Phaser.Scene { 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; @@ -1316,6 +2027,28 @@ export class GameScene extends Phaser.Scene { const pos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); const inventory = inventorySystem.getInventory(player); + // Save quest state + const activeQuests = questSystem.getActiveQuests(); + const completedQuests = questSystem.getCompletedQuests(); + const questState: Record = {}; + for (const q of activeQuests) { + questState[q.id] = { + status: 'active', + objectives: q.objectives.map(o => ({ id: o.id, completed: o.completed, currentCount: o.currentCount || 0 })), + currentObjective: q.currentObjective, + }; + } + for (const q of completedQuests) { + questState[q.id] = { status: 'completed', objectives: q.objectives.map(o => ({ id: o.id, completed: true, currentCount: o.count || 1 })), currentObjective: -1 }; + } + + // Save map discovery + const discovered = worldMapUI.getDiscoveredLocations(); + const mapStates: Record = {}; + for (const loc of discovered) { + mapStates[loc.id] = true; + } + await saveManager.saveGame('快速存档', { playTime: 0, character: { @@ -1338,10 +2071,10 @@ export class GameScene extends Phaser.Scene { maxCarryWeight: inventory.maxCarryWeight, } : undefined, worldState: { currentZone: this.currentZone?.id || 'whiterun_exterior' }, - quests: {}, + quests: questState, factions: {}, npcs: {}, - mapStates: {}, + mapStates, }); this.showNotification('游戏已快速保存'); } catch (e) { @@ -1417,9 +2150,24 @@ export class GameScene extends Phaser.Scene { } } + // Restore quest state + if (saveData.quests) { + for (const [questId, state] of Object.entries(saveData.quests as Record)) { + questSystem.restoreQuestState(questId, state.status as 'active' | 'completed', state.objectives, state.currentObjective); + } + } + + // Restore map discovery + if (saveData.mapStates) { + for (const [locId, discovered] of Object.entries(saveData.mapStates as Record)) { + if (discovered) worldMapUI.discoverLocation(locId); + } + } + this.isDead = false; - this.player.setFillStyle(0x2288cc); - this.player.setAlpha(1); + this.playerDead = false; + this.playerAlpha = 1; + this.redrawPlayer(); this.showNotification('游戏已快速读取'); } catch (e) { this.showNotification('读取失败!'); @@ -1460,30 +2208,22 @@ export class GameScene extends Phaser.Scene { private applyDayNightTint(): void { const tint = dayNightSystem.getSkyTint(); if (tint.a > 0) { - this.cameras.main.setBackgroundColor( - Phaser.Display.Color.GetColor( - Math.round(tint.r * tint.a), - Math.round(tint.g * tint.a), - Math.round(tint.b * tint.a) - ) + this.lightingOverlay.setFillStyle( + Phaser.Display.Color.GetColor(tint.r, tint.g, tint.b), + tint.a ); } else { - this.cameras.main.setBackgroundColor('#1a1a2e'); + this.lightingOverlay.setFillStyle(0x000000, 0); } } private showHitFlash(entity: Entity): void { - const sprite = entity.sprite as Phaser.GameObjects.Rectangle | undefined; - if (!sprite) return; + if (!entity.sprite) return; - // Flash white briefly - sprite.setFillStyle(0xffffff); + // Flash white briefly via flag — sync loop redraws with flashWhite + (entity as any)._hitFlash = true; 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); - } + (entity as any)._hitFlash = false; }); // Show damage number via CombatUI event @@ -1494,7 +2234,7 @@ export class GameScene extends Phaser.Scene { } private playEnemyAttackAnimation(enemy: Entity): void { - const sprite = enemy.sprite as Phaser.GameObjects.Rectangle | undefined; + const sprite = enemy.sprite as Phaser.GameObjects.Graphics; if (!sprite) return; const enemyType = entityManager.getComponent<{ id: string }>(enemy.id, 'enemyType'); @@ -1523,6 +2263,8 @@ export class GameScene extends Phaser.Scene { const restX = sprite.x; const restY = sprite.y; + (enemy as any)._animating = true; + if (type === 'swing') { // Swing: scale pulse + slight rotation const origScaleX = sprite.scaleX; @@ -1543,6 +2285,7 @@ export class GameScene extends Phaser.Scene { ease: 'Back.easeOut', }, ], + onComplete: () => { (enemy as any)._animating = false; }, }); } else if (type === 'bite') { // Bite: quick lunge forward + snap back @@ -1562,6 +2305,7 @@ export class GameScene extends Phaser.Scene { ease: 'Back.easeOut', }, ], + onComplete: () => { (enemy as any)._animating = false; }, }); } else if (type === 'thrust') { // Thrust: slow push forward, fast return @@ -1581,6 +2325,7 @@ export class GameScene extends Phaser.Scene { ease: 'Quad.easeOut', }, ], + onComplete: () => { (enemy as any)._animating = false; }, }); } else { // Default lunge: fast forward, slow return @@ -1600,24 +2345,19 @@ export class GameScene extends Phaser.Scene { ease: 'Back.easeOut', }, ], + onComplete: () => { (enemy as any)._animating = false; }, }); } } private showPlayerHitFlash(): void { - this.player.setFillStyle(0xff4444); + const savedColor = this.playerBodyColor; + this.playerBodyColor = 0xff4444; + this.redrawPlayer(); 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); - } + this.playerBodyColor = savedColor; + this.redrawPlayer(); }); } diff --git a/src/systems/AISystem.ts b/src/systems/AISystem.ts index 4eb8016..e81a2a1 100644 --- a/src/systems/AISystem.ts +++ b/src/systems/AISystem.ts @@ -1,7 +1,8 @@ import { entityManager, type Entity } from '../core/EntityManager'; import { combatSystem } from './CombatSystem'; import { corpseSystem } from './CorpseSystem'; -import type { EnemyAbility } from '../data/DataRegistry'; +import { mapManager } from '../maps/MapManager'; +import { dataRegistry, type EnemyAbility } from '../data/DataRegistry'; export type AIState = 'idle' | 'chase' | 'attack' | 'retreat' | 'patrol' | 'stunned'; @@ -96,15 +97,16 @@ export class AISystem { if (distanceToPlayer <= ai.attackRange) { ai.state = 'attack'; } else { - const speed = 80; + const speed = dataRegistry.getGameConfig().ai.chaseSpeed; const playerPos = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); if (playerPos) { const dx = playerPos.x - pos.x; const dy = playerPos.y - pos.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist > 0) { - pos.x += (dx / dist) * speed * (delta / 1000); - pos.y += (dy / dist) * speed * (delta / 1000); + const newX = pos.x + (dx / dist) * speed * (delta / 1000); + const newY = pos.y + (dy / dist) * speed * (delta / 1000); + this.applyWalkability(pos, newX, newY); } } } @@ -137,15 +139,16 @@ export class AISystem { break; } this.tryRetreatAbility(enemy, ai, player, hpRatio); - const retreatSpeed = 100; + const retreatSpeed = dataRegistry.getGameConfig().ai.retreatSpeed; const pp = entityManager.getComponent<{ x: number; y: number }>(player.id, 'position'); if (pp) { const dx = pos.x - pp.x; const dy = pos.y - pp.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist > 0) { - pos.x += (dx / dist) * retreatSpeed * (delta / 1000); - pos.y += (dy / dist) * retreatSpeed * (delta / 1000); + const newX = pos.x + (dx / dist) * retreatSpeed * (delta / 1000); + const newY = pos.y + (dy / dist) * retreatSpeed * (delta / 1000); + this.applyWalkability(pos, newX, newY); } } break; @@ -247,9 +250,33 @@ export class AISystem { return; } - const patrolSpeed = 30; - pos.x += (dx / dist) * patrolSpeed * (delta / 1000); - pos.y += (dy / dist) * patrolSpeed * (delta / 1000); + const patrolSpeed = dataRegistry.getGameConfig().ai.patrolSpeed; + const newX = pos.x + (dx / dist) * patrolSpeed * (delta / 1000); + const newY = pos.y + (dy / dist) * patrolSpeed * (delta / 1000); + this.applyWalkability(pos, newX, newY); + } + + private applyWalkability(pos: { x: number; y: number }, newX: number, newY: number): void { + const zone = mapManager.getCurrentZone(); + if (!zone) { pos.x = newX; pos.y = newY; return; } + + const tileSize = zone.tileSize; + const tileOldX = Math.floor(pos.x / tileSize); + const tileOldY = Math.floor(pos.y / tileSize); + const tileNewX = Math.floor(newX / tileSize); + const tileNewY = Math.floor(newY / tileSize); + + const canMoveX = tileNewX !== tileOldX ? mapManager.isWalkable(tileNewX, tileOldY) : true; + const canMoveY = tileNewY !== tileOldY ? mapManager.isWalkable(tileOldX, tileNewY) : true; + + if (canMoveX) pos.x = newX; + if (canMoveY) pos.y = newY; + + // Clamp to zone bounds + const maxX = zone.width * tileSize; + const maxY = zone.height * tileSize; + pos.x = Math.max(12, Math.min(maxX - 12, pos.x)); + pos.y = Math.max(12, Math.min(maxY - 12, pos.y)); } setEnemyAI(entity: Entity, config: Partial): void { diff --git a/src/systems/AudioManager.ts b/src/systems/AudioManager.ts new file mode 100644 index 0000000..d4a747c --- /dev/null +++ b/src/systems/AudioManager.ts @@ -0,0 +1,182 @@ +import { Howl, Howler } from 'howler'; +import { eventBus } from '../core/EventBus'; + +export type SoundCategory = 'sfx' | 'music' | 'ambient' | 'ui'; + +interface SoundDef { + src: string[]; + category: SoundCategory; + volume?: number; + loop?: boolean; + sprite?: Record; +} + +const SOUND_DEFS: Record = { + // Combat + 'combat.hit': { src: ['audio/sfx/hit.mp3'], category: 'sfx', volume: 0.6 }, + 'combat.slash': { src: ['audio/sfx/slash.mp3'], category: 'sfx', volume: 0.5 }, + 'combat.block': { src: ['audio/sfx/block.mp3'], category: 'sfx', volume: 0.5 }, + 'combat.crit': { src: ['audio/sfx/crit.mp3'], category: 'sfx', volume: 0.7 }, + 'combat.death': { src: ['audio/sfx/death.mp3'], category: 'sfx', volume: 0.6 }, + 'combat.fireball': { src: ['audio/sfx/fireball.mp3'], category: 'sfx', volume: 0.5 }, + 'combat.ice_shard': { src: ['audio/sfx/ice_shard.mp3'], category: 'sfx', volume: 0.5 }, + 'combat.heal': { src: ['audio/sfx/heal.mp3'], category: 'sfx', volume: 0.4 }, + 'combat.miss': { src: ['audio/sfx/miss.mp3'], category: 'sfx', volume: 0.3 }, + 'combat.shield_bash': { src: ['audio/sfx/shield_bash.mp3'], category: 'sfx', volume: 0.5 }, + + // UI + 'ui.click': { src: ['audio/ui/click.mp3'], category: 'ui', volume: 0.3 }, + 'ui.open': { src: ['audio/ui/open.mp3'], category: 'ui', volume: 0.3 }, + 'ui.close': { src: ['audio/ui/close.mp3'], category: 'ui', volume: 0.25 }, + 'ui.equip': { src: ['audio/ui/equip.mp3'], category: 'ui', volume: 0.4 }, + 'ui.unequip': { src: ['audio/ui/unequip.mp3'], category: 'ui', volume: 0.35 }, + 'ui.pickup': { src: ['audio/ui/pickup.mp3'], category: 'ui', volume: 0.4 }, + 'ui.quest_complete': { src: ['audio/ui/quest_complete.mp3'], category: 'ui', volume: 0.6 }, + 'ui.level_up': { src: ['audio/ui/level_up.mp3'], category: 'ui', volume: 0.7 }, + 'ui.coins': { src: ['audio/ui/coins.mp3'], category: 'ui', volume: 0.4 }, + + // Ambient + 'ambient.wind': { src: ['audio/ambient/wind.mp3'], category: 'ambient', volume: 0.2, loop: true }, + 'ambient.cave_drip': { src: ['audio/ambient/cave_drip.mp3'], category: 'ambient', volume: 0.15, loop: true }, + 'ambient.forest': { src: ['audio/ambient/forest.mp3'], category: 'ambient', volume: 0.2, loop: true }, + + // Music + 'music.explore': { src: ['audio/music/explore.mp3'], category: 'music', volume: 0.3, loop: true }, + 'music.combat': { src: ['audio/music/combat.mp3'], category: 'music', volume: 0.35, loop: true }, + 'music.menu': { src: ['audio/music/menu.mp3'], category: 'music', volume: 0.4, loop: true }, + 'music.city': { src: ['audio/music/city.mp3'], category: 'music', volume: 0.3, loop: true }, + 'music.dungeon': { src: ['audio/music/dungeon.mp3'], category: 'music', volume: 0.25, loop: true }, +}; + +export class AudioManager { + private static instance: AudioManager; + private sounds: Map = new Map(); + private volumes: Record = { sfx: 0.7, music: 0.6, ambient: 0.5, ui: 0.5 }; + private masterVolume = 0.8; + private enabled = true; + private currentMusic: string | null = null; + + static getInstance(): AudioManager { + if (!AudioManager.instance) { + AudioManager.instance = new AudioManager(); + } + return AudioManager.instance; + } + + constructor() { + this.setupEventListeners(); + } + + private setupEventListeners(): void { + eventBus.on('combat:hit', (data: { isCritical?: boolean }) => { + if (data.isCritical) this.play('combat.crit'); + else this.play('combat.hit'); + }); + eventBus.on('entity:killed', () => this.play('combat.death')); + eventBus.on('combat:blockSuccess', () => this.play('combat.block')); + eventBus.on('combat:beforeAttack', (data: { isPowerAttack?: boolean }) => { + if (data.isPowerAttack) this.play('combat.slash'); + }); + + eventBus.on('item:pickup', () => this.play('ui.pickup')); + eventBus.on('item:used', () => this.play('ui.equip')); + eventBus.on('inventory:updated', () => this.play('ui.click')); + + eventBus.on('quest:completed', () => this.play('ui.quest_complete')); + eventBus.on('level:up', () => this.play('ui.level_up')); + + eventBus.on('ui:open', () => this.play('ui.open')); + eventBus.on('ui:close', () => this.play('ui.close')); + } + + private getSound(id: string): Howl | null { + if (this.sounds.has(id)) return this.sounds.get(id)!; + + const def = SOUND_DEFS[id]; + if (!def) return null; + + const howl = new Howl({ + src: def.src, + volume: 0, + loop: def.loop || false, + preload: false, + onloaderror: () => { + this.sounds.delete(id); + }, + }); + + this.sounds.set(id, howl); + return howl; + } + + play(id: string, volumeOverride?: number): void { + if (!this.enabled) return; + + const howl = this.getSound(id); + if (!howl) return; + + const def = SOUND_DEFS[id]; + const categoryVol = this.volumes[def?.category || 'sfx']; + const baseVol = def?.volume ?? 0.5; + const finalVol = (volumeOverride ?? baseVol) * categoryVol * this.masterVolume; + + howl.volume(finalVol); + howl.play(); + } + + playMusic(id: string): void { + if (this.currentMusic === id) return; + this.stopMusic(); + this.currentMusic = id; + const howl = this.getSound(id); + if (howl) { + const def = SOUND_DEFS[id]; + const vol = (def?.volume ?? 0.3) * this.volumes.music * this.masterVolume; + howl.volume(vol); + howl.play(); + } + } + + stopMusic(): void { + if (this.currentMusic) { + const howl = this.sounds.get(this.currentMusic); + if (howl) howl.stop(); + this.currentMusic = null; + } + } + + stopAll(): void { + Howler.stop(); + } + + setMasterVolume(v: number): void { + this.masterVolume = Math.max(0, Math.min(1, v / 100)); + this.updateAllVolumes(); + } + + setCategoryVolume(category: SoundCategory, v: number): void { + this.volumes[category] = Math.max(0, Math.min(1, v / 100)); + this.updateAllVolumes(); + } + + private updateAllVolumes(): void { + this.sounds.forEach((howl, id) => { + const def = SOUND_DEFS[id]; + if (!def) return; + const catVol = this.volumes[def.category]; + const baseVol = def.volume ?? 0.5; + howl.volume(baseVol * catVol * this.masterVolume); + }); + } + + setEnabled(on: boolean): void { + this.enabled = on; + if (!on) this.stopAll(); + } + + isPlaying(id: string): boolean { + return this.sounds.get(id)?.playing() ?? false; + } +} + +export const audioManager = AudioManager.getInstance(); diff --git a/src/systems/CombatSystem.ts b/src/systems/CombatSystem.ts index d39f047..7928107 100644 --- a/src/systems/CombatSystem.ts +++ b/src/systems/CombatSystem.ts @@ -233,10 +233,9 @@ export class CombatSystem { private handleDeath(target: Entity, killer: Entity): void { eventBus.emit('entity:killed', { entity: target, killer }); - const targetSprite = target.sprite as Phaser.GameObjects.Rectangle | undefined; - if (targetSprite) { - targetSprite.setFillStyle(0x444444); - targetSprite.setAlpha(0.7); + if (target.sprite) { + (target as any)._dead = true; + if ('setAlpha' in target.sprite) (target.sprite as any).setAlpha(0.7); } const targetAI = entityManager.getComponent<{ state: string }>(target.id, 'ai'); diff --git a/src/systems/CorpseSystem.ts b/src/systems/CorpseSystem.ts index 1982857..a4521c8 100644 --- a/src/systems/CorpseSystem.ts +++ b/src/systems/CorpseSystem.ts @@ -65,10 +65,10 @@ export class CorpseSystem { corpse.state = 'looted'; corpse.decayTime = this.lootedDecayTime; - const sprite = entity.sprite as Phaser.GameObjects.Rectangle | undefined; + const sprite = entity.sprite; if (sprite) { - sprite.setFillStyle(0x333333); - sprite.setAlpha(0.5); + (entity as any)._looted = true; + if ('setAlpha' in sprite) (sprite as any).setAlpha(0.5); } eventBus.emit('corpse:searched', { entity, searcher, loot: corpse.loot }); @@ -147,10 +147,10 @@ export class CorpseSystem { corpse.decayTime = 60000; corpse.createdAt = Date.now(); eventBus.emit('corpse:decayed', { entity, state: 'skeleton' }); - const sprite1 = entity.sprite as Phaser.GameObjects.Rectangle | undefined; + const sprite1 = entity.sprite; if (sprite1) { - sprite1.setFillStyle(0xccccaa); - sprite1.setAlpha(0.3); + (entity as any)._skeleton = true; + if ('setAlpha' in sprite1) (sprite1 as any).setAlpha(0.3); } break; case 'skeleton': diff --git a/src/systems/DialogueSystem.ts b/src/systems/DialogueSystem.ts index 38fa728..42e4eda 100644 --- a/src/systems/DialogueSystem.ts +++ b/src/systems/DialogueSystem.ts @@ -1,6 +1,7 @@ import { eventBus } from '../core/EventBus'; import { entityManager, type Entity } from '../core/EntityManager'; import { dataRegistry } from '../data/DataRegistry'; +import { questSystem } from './QuestSystem'; export interface DialogueLine { id: string; @@ -26,8 +27,9 @@ export interface DialogueOption { } export interface DialogueCondition { - type: 'hasItem' | 'questActive' | 'questCompleted' | 'skillCheck' | 'gold' | 'level' | 'faction'; - value: any; + type: 'hasItem' | 'questActive' | 'questCompleted' | 'skillCheck' | 'gold' | 'level' | 'faction' | 'not'; + value?: any; + conditions?: DialogueCondition[]; } export interface DialogueEffect { @@ -172,6 +174,18 @@ export class DialogueSystem { if (skillValue < condition.value.difficulty) return false; break; } + case 'questActive': { + if (!questSystem.isQuestActive(condition.value)) return false; + break; + } + case 'questCompleted': { + if (!questSystem.isQuestCompleted(condition.value)) return false; + break; + } + case 'not': { + if (condition.conditions && this.checkConditions(condition.conditions, playerEntity)) return false; + break; + } } } return true; diff --git a/src/systems/GroundItemSystem.ts b/src/systems/GroundItemSystem.ts index 3f5cddf..f958b9f 100644 --- a/src/systems/GroundItemSystem.ts +++ b/src/systems/GroundItemSystem.ts @@ -59,13 +59,13 @@ export class GroundItemSystem { canPickup: true, } as GroundItem); - const sprite = (sourceEntity as any).scene?.add?.rectangle?.( - pos.x + offsetX, - pos.y + offsetY, - 12, - 12, - 0xffff00 - ); + const sprite = (sourceEntity as any).scene?.add?.graphics?.() as Phaser.GameObjects.Graphics | null; + if (sprite) { + const ix = pos.x + offsetX; + const iy = pos.y + offsetY; + const itemData = dataRegistry.getItem(itemId); + this.drawGroundItemShape(sprite, itemData, ix, iy, itemId); + } if (sprite) { groundEntity.sprite = sprite; } @@ -174,13 +174,84 @@ export class GroundItemSystem { } removeItem(itemEntity: Entity): void { - const sprite = itemEntity.sprite as Phaser.GameObjects.Rectangle | undefined; - if (sprite) { - sprite.destroy(); + const sprite = itemEntity.sprite; + if (sprite && 'destroy' in sprite) { + (sprite as any).destroy(); } entityManager.destroyEntity(itemEntity.id); this.items.delete(itemEntity.id); } + + private drawGroundItemShape(g: Phaser.GameObjects.Graphics, itemData: ReturnType, ix: number, iy: number, itemId: string): void { + g.clear(); + + const itemType = itemData?.type ?? ''; + const material = itemData?.material ?? ''; + + // Material-based color + const matColors: Record = { + iron: 0x8a8a8a, steel: 0xb0b0b0, leather: 0x8b5e3c, chainmail: 0x999999, + elven: 0x44aa66, dwarven: 0xddaa44, orcish: 0x6b7b3a, ebony: 0x2a2a3a, dragon: 0x3366aa, glass: 0x88ddcc, + }; + const baseColor = matColors[material] ?? 0xdddd44; + + if (itemType.startsWith('weapon') || itemType === 'dagger' || itemType === 'two_handed_sword') { + // Weapon — elongated blade shape + g.fillStyle(baseColor, 1); + g.fillRect(ix - 1.5, iy - 7, 3, 12); + g.fillStyle(0x6b4226, 1); + g.fillRect(ix - 3, iy + 3, 6, 3); + } else if (itemType.startsWith('armor') || itemType === 'shield') { + // Armor — rounded rectangle + g.fillStyle(baseColor, 1); + g.fillRoundedRect(ix - 5, iy - 5, 10, 10, 2); + g.lineStyle(1, 0x555555, 0.6); + g.strokeRoundedRect(ix - 5, iy - 5, 10, 10, 2); + } else if (itemType === 'potion' || itemType === 'poison') { + // Potion — bottle shape + g.fillStyle(itemType === 'poison' ? 0x884488 : 0x44aa44, 0.9); + g.fillCircle(ix, iy + 1, 4); + g.fillStyle(0xcccccc, 1); + g.fillRect(ix - 1.5, iy - 5, 3, 4); + } else if (itemId.includes('gold') || itemId === 'gold') { + // Gold — shiny coin + g.fillStyle(0xdaa520, 1); + g.fillCircle(ix, iy, 4); + g.fillStyle(0xffd700, 0.6); + g.fillCircle(ix - 1, iy - 1, 1.5); + } else if (itemId.includes('key') || itemId.includes('claw')) { + // Key/claw — diamond shape + g.fillStyle(0xdaa520, 1); + g.beginPath(); + g.moveTo(ix, iy - 5); + g.lineTo(ix + 4, iy); + g.lineTo(ix, iy + 5); + g.lineTo(ix - 4, iy); + g.closePath(); + g.fill(); + } else if (itemType === 'book' || itemType === 'scroll') { + // Book/scroll — rectangle with line + g.fillStyle(0x8b4513, 1); + g.fillRect(ix - 4, iy - 5, 8, 10); + g.fillStyle(0xddccaa, 0.5); + g.fillRect(ix - 3, iy - 3, 6, 1); + g.fillRect(ix - 3, iy - 1, 6, 1); + } else if (itemType === 'ingredient' || itemType === 'material') { + // Ingredient — small organic blob + g.fillStyle(0x66aa44, 0.9); + g.fillCircle(ix, iy, 3.5); + g.fillStyle(0x88cc66, 0.5); + g.fillCircle(ix + 1, iy - 1, 2); + } else { + // Default — small glowing square + g.fillStyle(baseColor, 0.9); + g.fillRoundedRect(ix - 4, iy - 4, 8, 8, 1); + } + + // Subtle glow underneath + g.fillStyle(0xffffaa, 0.12); + g.fillCircle(ix, iy + 2, 8); + } } export const groundItemSystem = GroundItemSystem.getInstance(); diff --git a/src/systems/InventorySystem.ts b/src/systems/InventorySystem.ts index 7558128..911364b 100644 --- a/src/systems/InventorySystem.ts +++ b/src/systems/InventorySystem.ts @@ -58,12 +58,13 @@ export class InventorySystem { const existing = entityManager.getComponent(entity.id, 'inventory'); if (existing) return; + const pcfg = dataRegistry.getGameConfig().player; entityManager.addComponent(entity.id, { type: 'inventory', items: [], - gold: 100, + gold: pcfg.baseGold, carryWeight: 0, - maxCarryWeight: 300, + maxCarryWeight: pcfg.maxCarryWeight, }); } diff --git a/src/systems/MagicSystem.ts b/src/systems/MagicSystem.ts index c15878f..e3cb3d6 100644 --- a/src/systems/MagicSystem.ts +++ b/src/systems/MagicSystem.ts @@ -373,6 +373,20 @@ export class MagicSystem { return Array.from(this.spells.values()).filter((s) => s.school === school); } + getKnownSpellsBySchool(entity: Entity, school: MagicSchool): Spell[] { + const knownSpells = entityManager.getComponent<{ spells: string[] }>(entity.id, 'knownSpells'); + if (!knownSpells) return []; + return Array.from(this.spells.values()).filter( + (s) => s.school === school && knownSpells.spells.includes(s.id) + ); + } + + getKnownSpells(entity: Entity): Spell[] { + const knownSpells = entityManager.getComponent<{ spells: string[] }>(entity.id, 'knownSpells'); + if (!knownSpells) return []; + return Array.from(this.spells.values()).filter((s) => knownSpells.spells.includes(s.id)); + } + getAllSpells(): Spell[] { return Array.from(this.spells.values()); } diff --git a/src/systems/MovementSystem.ts b/src/systems/MovementSystem.ts index 6fc6b26..fcafb85 100644 --- a/src/systems/MovementSystem.ts +++ b/src/systems/MovementSystem.ts @@ -1,6 +1,7 @@ import { entityManager, type Entity } from '../core/EntityManager'; import { perkSystem } from './PerkSystem'; import { inventorySystem } from './InventorySystem'; +import { mapManager } from '../maps/MapManager'; export interface MovementInput { up: boolean; @@ -54,8 +55,35 @@ export class MovementSystem { vy *= 0.707; } - pos.x += vx * (delta / 1000); - pos.y += vy * (delta / 1000); + const dt = delta / 1000; + const newX = pos.x + vx * dt; + const newY = pos.y + vy * dt; + + // Walkability check — per-axis for wall sliding + const zone = mapManager.getCurrentZone(); + if (zone) { + const tileSize = zone.tileSize; + const tileYOld = Math.floor(pos.y / tileSize); + const tileXOld = Math.floor(pos.x / tileSize); + + let canMoveX = true; + let canMoveY = true; + + if (vx !== 0) canMoveX = mapManager.isWalkable(Math.floor((pos.x + vx * dt) / tileSize), tileYOld); + if (vy !== 0) canMoveY = mapManager.isWalkable(tileXOld, Math.floor((pos.y + vy * dt) / tileSize)); + + if (canMoveX) pos.x = newX; + if (canMoveY) pos.y = newY; + + // Clamp to zone bounds + const maxX = zone.width * tileSize; + const maxY = zone.height * tileSize; + pos.x = Math.max(12, Math.min(maxX - 12, pos.x)); + pos.y = Math.max(12, Math.min(maxY - 12, pos.y)); + } else { + pos.x = newX; + pos.y = newY; + } } moveEntity(entity: Entity, targetX: number, targetY: number, speed: number, delta: number): boolean { diff --git a/src/systems/PerkSystem.ts b/src/systems/PerkSystem.ts index 9f0ffff..f041254 100644 --- a/src/systems/PerkSystem.ts +++ b/src/systems/PerkSystem.ts @@ -31,18 +31,46 @@ class PerkSystem { }); } - private onPerkUnlocked(entity: Entity, perkId: string): void { + private onPerkUnlocked(entity: Entity, perkId: string): boolean { const perks = entityManager.getComponent(entity.id, 'perks'); - if (!perks) return; + if (!perks) return false; const allPerks = dataRegistry.getAllPerkTrees().flatMap((t) => t.perks); const perk = allPerks.find((p) => p.id === perkId); - if (!perk) return; + if (!perk) return false; + + // Check maxRank + const currentRank = perks.rankedPerks[perkId] || 0; + if (currentRank >= perk.maxRank) { + eventBus.emit('perk:failed', { entityId: entity.id, perkId, reason: 'maxRank' }); + return false; + } + + // Check prerequisites + if (perk.requires && perk.requires.length > 0) { + for (const reqId of perk.requires) { + if (!perks.unlocked.includes(reqId)) { + eventBus.emit('perk:failed', { entityId: entity.id, perkId, reason: 'prerequisite', missingPerk: reqId }); + return false; + } + } + } + + // Check skill level requirement + if (perk.skillLevel > 0) { + const skills = entityManager.getComponent>(entity.id, 'skills'); + const currentLevel = skills?.[perk.skill] ?? 0; + if (currentLevel < perk.skillLevel) { + eventBus.emit('perk:failed', { entityId: entity.id, perkId, reason: 'skillLevel', required: perk.skillLevel, current: currentLevel }); + return false; + } + } if (!perks.unlocked.includes(perkId)) { perks.unlocked.push(perkId); } - perks.rankedPerks[perkId] = (perks.rankedPerks[perkId] || 0) + 1; + perks.rankedPerks[perkId] = currentRank + 1; + return true; } hasPerk(entity: Entity, perkId: string): boolean { diff --git a/src/systems/QuestSystem.ts b/src/systems/QuestSystem.ts index ddf29b1..24d7ae9 100644 --- a/src/systems/QuestSystem.ts +++ b/src/systems/QuestSystem.ts @@ -416,6 +416,33 @@ export class QuestSystem { return Array.from(this.quests.values()); } + restoreQuestState(questId: string, status: QuestStatus, objectives?: { id: string; completed: boolean; currentCount: number }[], currentObjective?: number): void { + const quest = this.quests.get(questId); + if (!quest) return; + + quest.status = status; + if (currentObjective !== undefined) quest.currentObjective = currentObjective; + + if (objectives) { + for (const savedObj of objectives) { + const obj = quest.objectives.find(o => o.id === savedObj.id); + if (obj) { + obj.completed = savedObj.completed; + obj.currentCount = savedObj.currentCount; + } + } + } + + if (status === 'active' && !this.activeQuests.includes(questId)) { + this.activeQuests.push(questId); + } else if (status === 'completed') { + this.activeQuests = this.activeQuests.filter(id => id !== questId); + if (!this.completedQuests.includes(questId)) { + this.completedQuests.push(questId); + } + } + } + resetForTests(): void { this.activeQuests = []; this.completedQuests = []; diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts index b3bc02d..5bdc744 100644 --- a/src/ui/UIManager.ts +++ b/src/ui/UIManager.ts @@ -1,9 +1,11 @@ import { eventBus } from '../core/EventBus'; import { entityManager } from '../core/EntityManager'; import { inventorySystem } from '../systems/InventorySystem'; +import { mapManager } from '../maps/MapManager'; +import { dataRegistry } from '../data/DataRegistry'; import { T, FONT } from './theme'; -type InventoryTab = 'all' | 'weapon' | 'armor' | 'consumable' | 'material'; +type InventoryTab = 'all' | 'weapon' | 'armor' | 'consumable' | 'material' | 'misc'; export class UIManager { private static instance: UIManager; @@ -170,16 +172,17 @@ export class UIManager { top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 420px; - max-height: 520px; + width: 680px; + max-height: 560px; background: linear-gradient(180deg, rgba(22,18,14,0.96), rgba(14,10,8,0.96)); - border: 1px solid ${T.borderDark}; + border: 1px solid ${T.borderBronze}; border-radius: 4px; z-index: 300; display: none; pointer-events: auto; overflow: hidden; font-family: ${FONT.body}; + box-shadow: 0 0 40px rgba(0,0,0,0.5); `; this.container.appendChild(panel); return panel; @@ -270,6 +273,7 @@ export class UIManager { const level = entityManager.getComponent<{ level: number }>(player.id, 'level'); const levelText = level ? `等级 ${level.level}` : ''; + const overweight = inventory.carryWeight > inventory.maxCarryWeight; const tabs: { id: InventoryTab; label: string }[] = [ { id: 'all', label: '全部' }, @@ -277,22 +281,41 @@ export class UIManager { { id: 'armor', label: '护甲' }, { id: 'consumable', label: '消耗品' }, { id: 'material', label: '材料' }, + { id: 'misc', label: '杂物' }, ]; const filteredItems = this.currentInventoryTab === 'all' ? inventory.items : inventory.items.filter(i => i.type === this.currentInventoryTab); - const overweight = inventory.carryWeight > inventory.maxCarryWeight; + // Equipment slots + const equipSlots: { id: string; label: string; icon: string }[] = [ + { id: 'head', label: '头部', icon: '🪖' }, + { id: 'chest', label: '身体', icon: '🦺' }, + { id: 'hands', label: '手部', icon: '🧤' }, + { id: 'feet', label: '脚部', icon: '👢' }, + { id: 'rightHand', label: '右手', icon: '⚔' }, + { id: 'leftHand', label: '左手', icon: '🛡' }, + { id: 'ring', label: '戒指', icon: '💍' }, + { id: 'necklace', label: '项链', icon: '📿' }, + ]; + + const equippedItems = inventory.items.filter(i => i.equipped); + const getEquippedForSlot = (slotId: string) => { + return equippedItems.find(i => i.slot === slotId); + }; this.inventoryPanel.innerHTML = ` -
+ +

物品

${levelText ? `${levelText}` : ''}
- +
+ +
${tabs.map(t => ` `).join('')}
-
- 物品 ${filteredItems.length} - - ${inventory.gold} 金币 - | - 负重 ${inventory.carryWeight.toFixed(1)}/${inventory.maxCarryWeight} - -
-
- ${filteredItems.length === 0 ? `
空空如也
` : ''} + + +
+ +
+
装备栏
+
+ ${equipSlots.map(slot => { + const equipped = getEquippedForSlot(slot.id); + return ` +
+
${slot.icon}
+
+ ${equipped ? equipped.name : slot.label} +
+
+ `; + }).join('')} +
+ +
+
+ ${inventory.gold} 金币 +
+
+ 负重 ${inventory.carryWeight.toFixed(1)}/${inventory.maxCarryWeight} + ${overweight ? ' ⚠' : ''} +
+
+
+ + +
+
+ 物品 ${filteredItems.length} +
+
+ ${filteredItems.length === 0 ? `
空空如也
` : ''} +
+
`; + // Tab click handlers 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; - } + }); + + // Equipment slot click handlers + const slotElements = this.inventoryPanel.querySelectorAll('.equip-slot'); + slotElements.forEach(el => { + el.addEventListener('mouseenter', () => { + (el as HTMLElement).style.borderColor = T.borderBronze; }); - btn.addEventListener('mouseleave', () => { - if ((btn as HTMLElement).dataset.tab !== this.currentInventoryTab) { - (btn as HTMLElement).style.color = T.textMuted; + el.addEventListener('mouseleave', () => { + const slotId = (el as HTMLElement).dataset.slot; + const equipped = getEquippedForSlot(slotId!); + (el as HTMLElement).style.borderColor = equipped ? T.borderBronze : T.borderDark; + }); + el.addEventListener('click', () => { + const slotId = (el as HTMLElement).dataset.slot; + const equipped = getEquippedForSlot(slotId!); + if (equipped) { + inventorySystem.unequipItem(player, equipped.id); } }); }); + // Render item list const itemsContainer = document.getElementById('inventory-items'); if (itemsContainer) { + const typeColors = dataRegistry.getGameConfig().ui.itemTypeColors; 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; + display: flex; justify-content: space-between; align-items: center; + padding: 6px 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.name}${item.equipped ? ` [E]` : ''}
-
- ${item.quantity > 1 ? `x${item.quantity} ` : ''}${item.weight} · ${item.value}金 +
+ ${item.quantity > 1 ? `x${item.quantity} ` : ''}${item.weight}kg · ${item.value}金
-
- ${item.type === 'consumable' ? `` : ''} - ${item.type === 'weapon' || item.type === 'armor' ? `` : ''} +
+ ${item.type === 'consumable' ? `` : ''} + ${item.type === 'weapon' || item.type === 'armor' ? `` : ''}
`; - itemEl.addEventListener('mouseenter', () => { + // Hover: show tooltip + itemEl.addEventListener('mouseenter', (e) => { itemEl.style.borderColor = T.borderBronze; itemEl.style.background = 'rgba(255,255,255,0.04)'; + this.showItemTooltip(item, e.clientX, e.clientY); }); itemEl.addEventListener('mouseleave', () => { itemEl.style.borderColor = T.borderDark; itemEl.style.background = 'rgba(255,255,255,0.02)'; + this.hideItemTooltip(); }); const useBtn = itemEl.querySelector('.use-btn'); if (useBtn) { useBtn.addEventListener('click', (e) => { e.stopPropagation(); - inventorySystem.useItem(player, item.id); + const result = inventorySystem.useItem(player, item.id); + if (result) { + eventBus.emit('ui:notification', { text: `使用了 ${item.name}` }); + } else { + eventBus.emit('ui:notification', { text: `无法使用 ${item.name}` }); + } }); } @@ -399,7 +467,18 @@ export class UIManager { if (item.equipped) { inventorySystem.unequipItem(player, item.id); } else { - inventorySystem.equipItem(player, item.id, 'rightHand'); + // Determine slot from item type and slot property + let slot = 'rightHand'; + if (item.type === 'armor') { + const armorData = dataRegistry.getArmor(item.id); + const subtypeToSlot: Record = { + helmet: 'head', chest: 'chest', gauntlets: 'hands', boots: 'feet', shield: 'leftHand', + }; + slot = subtypeToSlot[armorData?.subtype || ''] || 'chest'; + } else if (item.type === 'weapon') { + slot = 'rightHand'; + } + inventorySystem.equipItem(player, item.id, slot as any); } }); } @@ -413,6 +492,107 @@ export class UIManager { }); } + private itemTooltipEl: HTMLDivElement | null = null; + + private showItemTooltip(item: any, mouseX: number, mouseY: number): void { + this.hideItemTooltip(); + + // Look up canonical ItemData from dataRegistry for stats + const itemData = dataRegistry.getItem(item.id); + const armorData = dataRegistry.getArmor(item.id); + const damage = itemData?.damage; + const armorValue = armorData?.armor; + const material = itemData?.material || armorData?.material; + + // Find currently equipped item in the same slot for comparison + const player = entityManager.getEntitiesByType('player')[0]; + const inv = player ? inventorySystem.getInventory(player) : null; + let compDamage: number | undefined; + let compArmor: number | undefined; + if (inv) { + const equippedSameType = inv.items.find(i => i.equipped && i.type === item.type && i.id !== item.id); + if (equippedSameType) { + const eqItemData = dataRegistry.getItem(equippedSameType.id); + const eqArmorData = dataRegistry.getArmor(equippedSameType.id); + compDamage = eqItemData?.damage; + compArmor = eqArmorData?.armor; + } + } + + const tooltip = document.createElement('div'); + tooltip.className = 'oes-tooltip-item'; + + // Position: flip if near viewport edge + let left = mouseX + 16; + let top = mouseY - 10; + tooltip.style.cssText = ` + position: fixed; left: ${left}px; top: ${top}px; + z-index: 400; max-width: 260px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; border-radius: 3px; + padding: 12px 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.6); + pointer-events: none; + `; + + const typeLabels: Record = { + weapon: '武器', armor: '护甲', consumable: '消耗品', material: '材料', misc: '杂物', + }; + + const compArrow = (current: number, equipped: number | undefined) => { + if (equipped == null) return ''; + const diff = current - equipped; + if (diff > 0) return ` ▲+${diff}`; + if (diff < 0) return ` ▼${diff}`; + return ''; + }; + + let statsHtml = ''; + if (item.type === 'weapon' && damage != null) { + statsHtml = `
伤害: ${damage}${compArrow(damage, compDamage)}
`; + } + if (item.type === 'armor' && armorValue != null) { + statsHtml = `
护甲: ${armorValue}${compArrow(armorValue, compArmor)}
`; + } + + let effectsHtml = ''; + if (item.effects && item.effects.length > 0) { + effectsHtml = ` +
+
效果
+ ${item.effects.map((e: any) => ` +
+ ${e.type}: ${e.magnitude}${e.duration ? ` (${e.duration / 1000}s)` : ''} +
+ `).join('')} +
+ `; + } + + tooltip.innerHTML = ` +
${item.name}
+
+ ${typeLabels[item.type] || item.type}${material ? ` · ${material}` : ''} +
+ ${statsHtml} + ${item.description ? `
${item.description}
` : ''} + ${effectsHtml} +
+ 重量: ${item.weight} + 价值: ${item.value}金 +
+ `; + + document.body.appendChild(tooltip); + this.itemTooltipEl = tooltip; + } + + private hideItemTooltip(): void { + if (this.itemTooltipEl) { + this.itemTooltipEl.remove(); + this.itemTooltipEl = null; + } + } + private setupEventListeners(): void { eventBus.on('entity:killed', (data: { entity: any }) => { const name = data.entity.type === 'enemy' ? '敌人' : '实体'; @@ -420,7 +600,7 @@ export class UIManager { }); eventBus.on('game:initialized', () => { - this.showNotification('Welcome to OES-WEB'); + this.showNotification('欢迎来到 OES-WEB'); }); eventBus.on('inventory:updated', () => { @@ -435,16 +615,8 @@ export class UIManager { } updateZoneDisplay(zoneId: string): void { - const zoneNames: Record = { - whiterun: '白漫城', - whiterun_exterior: '白漫城 · 外围', - riverwood: '溪木镇', - bleakfalls_barrow: '荒瀑古坟', - darklight_cave: '暗光洞穴', - ancient_ruins: '古代遗迹', - skyrim_overworld: '天际省 · 荒野', - }; - this.zoneDisplay.textContent = zoneNames[zoneId] || zoneId; + const zone = mapManager.getZone(zoneId); + this.zoneDisplay.textContent = zone?.name || zoneId; } private healthTextEl: HTMLElement | null = null; diff --git a/src/ui/components/CharacterCreationUI.ts b/src/ui/components/CharacterCreationUI.ts index aee5692..0ca98a2 100644 --- a/src/ui/components/CharacterCreationUI.ts +++ b/src/ui/components/CharacterCreationUI.ts @@ -165,24 +165,13 @@ export class CharacterCreationUI { text-align: center; `; - const raceColors: Record = { - nord: '#6fa8dc', - dunmer: '#c27ba0', - altmer: '#ffd966', - argonian:'#93c47d', - khajiit: '#e69138', - breton: '#a4c2f4', - imperial: '#cc4125', - redguard:'#ea4335', - orc: '#6aa84f', - bosmer: '#76a5af', - }; + const raceColor = race.color || '#888888'; const icon = document.createElement('div'); icon.style.cssText = ` width: 44px; height: 44px; - background: ${raceColors[race.id] || '#666'}; + background: ${raceColor}; border-radius: 50%; margin: 0 auto 8px; display: flex; @@ -192,7 +181,7 @@ export class CharacterCreationUI { font-family: ${FONT.title}; color: #000; font-weight: bold; - box-shadow: 0 0 8px ${raceColors[race.id] || '#666'}33; + box-shadow: 0 0 8px ${raceColor}33; `; icon.textContent = race.name.charAt(0); card.appendChild(icon); @@ -290,18 +279,15 @@ export class CharacterCreationUI { current: this.selectedRace.baseStats.stamina, max: this.selectedRace.baseStats.stamina, }); - entityManager.addComponent(player.id, { - type: 'skills', - oneHanded: 15, twoHanded: 15, archery: 15, block: 15, heavyArmor: 15, smithing: 15, - destruction: 15, conjuration: 15, illusion: 15, restoration: 15, alteration: 15, enchanting: 15, - sneak: 15, lightArmor: 15, lockpicking: 15, pickpocket: 15, speech: 15, alchemy: 15, - }); + const pcfg = dataRegistry.getGameConfig().player; + entityManager.addComponent(player.id, { type: 'skills', ...pcfg.startingSkills }); entityManager.addComponent(player.id, { type: 'level', level: 1, perkPoints: 0, xp: 0 }); - entityManager.addComponent(player.id, { type: 'inventory', items: [], gold: 100, weight: 0, maxWeight: 300 }); + entityManager.addComponent(player.id, { type: 'inventory', items: [], gold: pcfg.baseGold, carryWeight: 0, maxCarryWeight: pcfg.maxCarryWeight }); entityManager.addComponent(player.id, { type: 'equipment', head: null, chest: null, hands: null, feet: null, shield: null, ring: null, necklace: null, weapon: null }); entityManager.addComponent(player.id, { type: 'name', value: this.playerName }); eventBus.emit('player:created', { entity: player }); this.hide(); + eventBus.emit('game:start'); } } diff --git a/src/ui/components/CharacterInfoUI.ts b/src/ui/components/CharacterInfoUI.ts index 46ab30e..079a00d 100644 --- a/src/ui/components/CharacterInfoUI.ts +++ b/src/ui/components/CharacterInfoUI.ts @@ -2,6 +2,7 @@ import { T, FONT } from '../theme'; import { eventBus } from '../../core/EventBus'; import { entityManager } from '../../core/EntityManager'; import { inventorySystem } from '../../systems/InventorySystem'; +import { dataRegistry } from '../../data/DataRegistry'; export class CharacterInfoUI { private static instance: CharacterInfoUI; @@ -124,6 +125,29 @@ export class CharacterInfoUI { 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); + // Equipped items + const equippedTitle = document.createElement('div'); + equippedTitle.style.cssText = `font-size: 12px; color: ${T.textGold}; margin: 12px 0 6px; font-family: ${FONT.title};`; + equippedTitle.textContent = '已装备'; + left.appendChild(equippedTitle); + + const equippedItems = inv ? inv.items.filter(i => i.equipped) : []; + if (equippedItems.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = `font-size: 11px; color: ${T.textDim}; padding: 4px 0;`; + empty.textContent = '无'; + left.appendChild(empty); + } else { + for (const item of equippedItems) { + const row = document.createElement('div'); + row.style.cssText = `display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid ${T.borderDark};`; + const typeColors = dataRegistry.getGameConfig().ui.itemTypeColors; + const color = typeColors[item.type] || T.textMuted; + row.innerHTML = `${item.name}${item.slot || item.type}`; + left.appendChild(row); + } + } + // Right: Skills const right = document.createElement('div'); right.style.cssText = `flex: 1;`; @@ -133,15 +157,10 @@ export class CharacterInfoUI { 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: '锻造', - }; + const skillNameMap = Object.fromEntries(dataRegistry.getAllSkills().map((s) => [s.id, s.name])); if (skills) { - for (const [key, name] of Object.entries(skillNames)) { + for (const [key, name] of Object.entries(skillNameMap)) { 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;`; @@ -149,7 +168,7 @@ export class CharacterInfoUI { ${name}
-
+
${val}
diff --git a/src/ui/components/CompassHUD.ts b/src/ui/components/CompassHUD.ts index 29ff0d3..b9df17a 100644 --- a/src/ui/components/CompassHUD.ts +++ b/src/ui/components/CompassHUD.ts @@ -17,7 +17,7 @@ export class CompassHUD { private levelBadge: HTMLDivElement; private directionLabel: HTMLDivElement; private zoneLabel: HTMLDivElement; - private updateTimer: ReturnType | null = null; + private rafId: number = 0; static getInstance(): CompassHUD { if (!CompassHUD.instance) { @@ -145,9 +145,9 @@ export class CompassHUD { } stop(): void { - if (this.updateTimer) { - clearInterval(this.updateTimer); - this.updateTimer = null; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = 0; } } @@ -222,13 +222,18 @@ export class CompassHUD { case 'door': return T.goldAccent; case 'enemy': return T.danger; case 'npc': return '#4488cc'; + case 'poi': return '#d4a843'; default: return T.textMuted; } } private startUpdate(): void { - if (this.updateTimer) return; - this.updateTimer = setInterval(() => this.autoUpdate(), 250); + if (this.rafId) return; + const loop = () => { + this.autoUpdate(); + this.rafId = requestAnimationFrame(loop); + }; + this.rafId = requestAnimationFrame(loop); } private autoUpdate(): void { @@ -278,42 +283,38 @@ export class CompassHUD { markers.push({ id: npc.id, label: name?.name || 'NPC', type: 'npc', angle: a }); } - // Door markers from current zone + // Door and chest markers from current zone try { const currentZone = mapManager.getCurrentZone?.(); - if (currentZone?.doors) { + if (currentZone) { 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 }); + if (currentZone.doors) { + 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 }); + } + } + if (currentZone.chests) { + for (const chest of currentZone.chests) { + const cx = chest.x * tileSize + tileSize / 2; + const cy = chest.y * tileSize + tileSize / 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 if mapManager not ready */ } - // 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); } diff --git a/src/ui/components/FavoritesUI.ts b/src/ui/components/FavoritesUI.ts index 7b1403a..26641d7 100644 --- a/src/ui/components/FavoritesUI.ts +++ b/src/ui/components/FavoritesUI.ts @@ -3,6 +3,7 @@ import { eventBus } from '../../core/EventBus'; import { entityManager, type Entity } from '../../core/EntityManager'; import { inventorySystem } from '../../systems/InventorySystem'; import { magicSystem } from '../../systems/MagicSystem'; +import { dataRegistry } from '../../data/DataRegistry'; interface FavoriteEntry { id: string; @@ -67,7 +68,10 @@ export class FavoritesUI { } addToFavorites(id: string, name: string, type: FavoriteEntry['type']): void { - if (this.favorites.length >= 8) return; + if (this.favorites.length >= 8) { + eventBus.emit('ui:notification', { text: '收藏栏已满 (最多8个)' }); + return; + } if (this.favorites.some(f => f.id === id)) return; this.favorites.push({ id, name, type, slot: this.favorites.length + 1 }); this.saveFavorites(); @@ -89,9 +93,18 @@ export class FavoritesUI { switch (fav.type) { case 'weapon': - case 'armor': inventorySystem.equipItem(player, fav.id, 'rightHand'); break; + case 'armor': { + // Look up armor subtype to determine correct equip slot + const armorData = dataRegistry.getArmor(fav.id); + const subtypeToSlot: Record = { + helmet: 'head', chest: 'chest', gauntlets: 'hands', boots: 'feet', shield: 'leftHand', + }; + const slot = (subtypeToSlot[armorData?.subtype || ''] || 'chest') as any; + inventorySystem.equipItem(player, fav.id, slot); + break; + } case 'spell': { // Find nearest enemy to cast spell on const enemies = entityManager.getEntitiesByType('enemy'); @@ -166,7 +179,7 @@ export class FavoritesUI { 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)) { + for (const item of equipable) { const isFav = this.favorites.some(f => f.id === item.id); const btn = document.createElement('button'); btn.className = 'oes-btn'; @@ -208,9 +221,7 @@ export class FavoritesUI { cursor: pointer; transition: border-color 0.12s; `; - const typeColors: Record = { - weapon: '#e06040', armor: '#4080c0', spell: '#7050c0', consumable: '#40a060', - }; + const typeColors = dataRegistry.getGameConfig().ui.itemTypeColors; row.innerHTML = `
diff --git a/src/ui/components/LootUI.ts b/src/ui/components/LootUI.ts index 0f7215b..c39a50c 100644 --- a/src/ui/components/LootUI.ts +++ b/src/ui/components/LootUI.ts @@ -2,6 +2,7 @@ import { T, FONT } from '../theme'; import { eventBus } from '../../core/EventBus'; import { entityManager, type Entity } from '../../core/EntityManager'; import { inventorySystem } from '../../systems/InventorySystem'; +import { dataRegistry } from '../../data/DataRegistry'; interface LootEntry { id: string; @@ -121,9 +122,7 @@ export class LootUI { cursor: pointer; transition: border-color 0.12s, background 0.12s; `; - const typeColors: Record = { - weapon: '#e06040', armor: '#4080c0', consumable: '#40a060', material: '#a08040', misc: '#888', - }; + const typeColors = dataRegistry.getGameConfig().ui.itemTypeColors; const color = typeColors[item.type] || T.textDim; row.innerHTML = ` @@ -157,6 +156,23 @@ export class LootUI { } panel.appendChild(list); + + // Weight/value summary footer + if (this.lootItems.length > 0) { + const totalWeight = this.lootItems.reduce((s, i) => s + i.weight * i.quantity, 0); + const totalValue = this.lootItems.reduce((s, i) => s + i.value * i.quantity, 0); + const footer = document.createElement('div'); + footer.style.cssText = ` + padding: 8px 20px; border-top: 1px solid ${T.borderDark}; + display: flex; justify-content: space-between; font-size: 11px; + `; + footer.innerHTML = ` + 总重量: ${totalWeight.toFixed(1)} + 总价值: ${totalValue} 金 + `; + panel.appendChild(footer); + } + this.container.appendChild(panel); } @@ -165,7 +181,9 @@ export class LootUI { if (!player) return; inventorySystem.addItem(player, item.id, item.quantity); - this.lootItems = this.lootItems.filter(i => i.id !== item.id); + // Remove only the first matching entry (by reference), not all with same ID + const idx = this.lootItems.indexOf(item); + if (idx >= 0) this.lootItems.splice(idx, 1); eventBus.emit('inventory:updated'); eventBus.emit('ui:notification', { text: `获得了 ${item.name} x${item.quantity}` }); diff --git a/src/ui/components/MagicUI.ts b/src/ui/components/MagicUI.ts index f78fed4..7dc84e1 100644 --- a/src/ui/components/MagicUI.ts +++ b/src/ui/components/MagicUI.ts @@ -1,5 +1,6 @@ import { entityManager } from '../../core/EntityManager'; import { magicSystem, type Spell, type Shout, type MagicSchool } from '../../systems/MagicSystem'; +import { dataRegistry } from '../../data/DataRegistry'; import { T, FONT, goldTitleStyle } from '../theme'; type MagicTab = MagicSchool | 'shouts'; @@ -52,38 +53,61 @@ export class MagicUI { } private render(): void { - const tabs: { id: MagicTab; label: string; color: string }[] = [ - { id: 'destruction', label: '毁灭', color: '#e04040' }, - { id: 'restoration', label: '恢复', color: '#e0e060' }, - { id: 'illusion', label: '幻术', color: '#c060e0' }, - { id: 'conjuration', label: '召唤', color: '#6080e0' }, - { id: 'alteration', label: '变化', color: '#60c0a0' }, - { id: 'shouts', label: '龙吼', color: T.textGold }, + const cfg = dataRegistry.getGameConfig().ui; + const schools: { id: MagicTab; label: string; color: string; symbol: string }[] = [ + ...Object.entries(cfg.magicSchoolLabels).map(([id, label]) => ({ + id: id as MagicTab, + label, + color: cfg.magicSchoolColors[id] || T.textMuted, + symbol: cfg.magicUI.schoolSymbols[id] || '✦', + })), + { id: 'shouts', label: '龙吼', color: T.textGold, symbol: 'Dragon' }, ]; + const currentSchool = schools.find(s => s.id === this.currentTab); + this.container.innerHTML = `

魔法

-
- ${tabs.map((t) => ` - - `).join('')} -
-
-
-
法术列表
+ +
+ +
+ ${schools.map(s => ` + +
${s.label}
+ `).join('')} +
+ + +
+
+ ${currentSchool?.label || ''} · 已学法术 +
+ +
-
+

选择一个法术查看详情

@@ -93,7 +117,9 @@ export class MagicUI { this.renderSpellList(); document.getElementById('close-magic')?.addEventListener('click', () => this.hide()); - document.querySelectorAll('.magic-tab').forEach((btn) => { + + // School symbol click handlers + document.querySelectorAll('.magic-school-btn').forEach(btn => { btn.addEventListener('click', () => { this.currentTab = (btn as HTMLElement).getAttribute('data-tab') as MagicTab; this.selectedSpell = null; @@ -111,6 +137,9 @@ export class MagicUI { const playerEntity = entityManager.getEntitiesByType('player')[0]; if (!playerEntity) return; + const cfg = dataRegistry.getGameConfig().ui; + const schoolColor = cfg.magicSchoolColors[this.currentTab] || T.textMuted; + if (this.currentTab === 'shouts') { const shouts = magicSystem.getAllShouts(); if (shouts.length === 0) { @@ -140,12 +169,8 @@ export class MagicUI { return; } - const school = this.currentTab as MagicSchool; - const spells = magicSystem.getSpellsBySchool(school); - const schoolColors: Record = { - destruction: '#e04040', restoration: '#e0e060', illusion: '#c060e0', conjuration: '#6080e0', alteration: '#60c0a0', - }; - const color = schoolColors[school] || T.textMuted; + // Only show known spells for this school + const spells = magicSystem.getKnownSpellsBySchool(playerEntity, this.currentTab as MagicSchool); if (spells.length === 0) { list.innerHTML = `

该学派没有已学法术

`; @@ -158,12 +183,13 @@ export class MagicUI { padding: 8px 10px; background: rgba(255,255,255,0.02); border: 1px solid ${T.borderDark}; border-radius: 3px; cursor: pointer; transition: border-color 0.15s; + border-left: 3px solid ${schoolColor}; `; item.innerHTML = ` -
${spell.name}
+
${spell.name}
消耗: ${spell.magickaCost} · 威力: ${spell.magnitude}
`; - item.addEventListener('mouseenter', () => { item.style.borderColor = color; }); + item.addEventListener('mouseenter', () => { item.style.borderColor = schoolColor; }); item.addEventListener('mouseleave', () => { item.style.borderColor = T.borderDark; }); item.addEventListener('click', () => { this.selectedSpell = spell; @@ -183,6 +209,7 @@ export class MagicUI { if (this.selectedType === 'shout') { const shout = this.selectedSpell as Shout; + const isShoutEquipped = entityManager.getComponent<{ activeShout?: string }>(playerEntity.id, 'equippedSpells')?.activeShout === shout.id; details.innerHTML = `

${shout.name}

@@ -192,7 +219,7 @@ export class MagicUI {
词语
${shout.words.map((w, i) => ` -
+
${w}
`).join('')} @@ -200,76 +227,112 @@ export class MagicUI {
效果
- ${shout.effects.map((e) => ` + ${shout.effects.map(e => `
${e.type} ${e.magnitude}${e.duration ? ` (${e.duration / 1000}s)` : ''}
`).join('')}
-
冷却: ${shout.cooldown}s
+
冷却: ${shout.cooldown}s
+ `; + document.getElementById('equip-shout-btn')?.addEventListener('click', () => { + const equipped = entityManager.getComponent<{ activeShout?: string }>(playerEntity.id, 'equippedSpells') || { type: 'equippedSpells' }; + entityManager.addComponent(playerEntity.id, { ...equipped, type: 'equippedSpells', activeShout: shout.id }); + this.showNotification(`${shout.name} 设为当前龙吼`); + this.renderSpellDetails(); + }); return; } const spell = this.selectedSpell as Spell; const magicka = entityManager.getComponent<{ current: number; max: number }>(playerEntity.id, 'magicka'); const hasEnough = (magicka?.current ?? 0) >= spell.magickaCost; + const schoolLabels: Record = { + destruction: '毁灭', restoration: '恢复', illusion: '幻术', + conjuration: '召唤', alteration: '变化', + }; + const typeLabels: Record = { + self: '自身', target: '目标', area: '范围', ranged: '远程', + }; + const schoolColor = dataRegistry.getGameConfig().ui.magicSchoolColors[spell.school] || T.textMuted; + + // Check current hand assignments + const equipped = entityManager.getComponent<{ left?: string; right?: string }>(playerEntity.id, 'equippedSpells'); + const isLeftEquipped = equipped?.left === spell.id; + const isRightEquipped = equipped?.right === spell.id; details.innerHTML = `

${spell.name}

-
- ${spell.school === 'destruction' ? '毁灭' : spell.school === 'restoration' ? '恢复' : spell.school === 'illusion' ? '幻术' : spell.school === 'conjuration' ? '召唤' : '变化'} - · ${spell.type === 'self' ? '自身' : spell.type === 'target' ? '目标' : spell.type === 'area' ? '范围' : '远程'} +
+ ${schoolLabels[spell.school] || spell.school} · ${typeLabels[spell.type] || spell.type}
+
-
+
魔力消耗
${spell.magickaCost}
+ ${magicka ? `
${Math.round(magicka.current)}/${magicka.max}
` : ''}
-
+
威力
${spell.magnitude}
-
+
冷却
${spell.cooldown / 1000}s
+

${spell.description}

+
效果
- ${spell.effects.map((e) => ` + ${spell.effects.map(e => `
${e.type} ${e.magnitude}${e.duration ? ` (${e.duration / 1000}s)` : ''}
`).join('')}
+
- - - + +
+ ${!hasEnough ? `
魔力不足
` : ''} `; document.getElementById('equip-left-btn')?.addEventListener('click', () => { - entityManager.addComponent(playerEntity.id, { type: 'equippedSpells', left: spell.id, right: (entityManager.getComponent<{ right?: string }>(playerEntity.id, 'equippedSpells')?.right) || '' }); - this.showNotification(`${spell.name} 装备到左手`); - }); - document.getElementById('equip-right-btn')?.addEventListener('click', () => { - entityManager.addComponent(playerEntity.id, { type: 'equippedSpells', left: (entityManager.getComponent<{ left?: string }>(playerEntity.id, 'equippedSpells')?.left) || '', right: spell.id }); - this.showNotification(`${spell.name} 装备到右手`); - }); - if (hasEnough) { - document.getElementById('cast-btn')?.addEventListener('click', () => { - if (magicSystem.castSpell(playerEntity, spell.id)) { - this.showNotification(`施放了 ${spell.name}`); - } + const current = entityManager.getComponent<{ left?: string; right?: string }>(playerEntity.id, 'equippedSpells'); + entityManager.addComponent(playerEntity.id, { + type: 'equippedSpells', + left: spell.id, + ...(current?.right ? { right: current.right } : {}), }); - } + this.showNotification(`${spell.name} 装备到左手`); + this.renderSpellDetails(); + }); + + document.getElementById('equip-right-btn')?.addEventListener('click', () => { + const current = entityManager.getComponent<{ left?: string; right?: string }>(playerEntity.id, 'equippedSpells'); + entityManager.addComponent(playerEntity.id, { + type: 'equippedSpells', + right: spell.id, + ...(current?.left ? { left: current.left } : {}), + }); + this.showNotification(`${spell.name} 装备到右手`); + this.renderSpellDetails(); + }); } private showNotification(text: string): void { diff --git a/src/ui/components/MinimapHUD.ts b/src/ui/components/MinimapHUD.ts index 5eee535..927cd97 100644 --- a/src/ui/components/MinimapHUD.ts +++ b/src/ui/components/MinimapHUD.ts @@ -2,6 +2,7 @@ import { T } from '../theme'; import { entityManager } from '../../core/EntityManager'; import { mapManager } from '../../maps/MapManager'; import { corpseSystem } from '../../systems/CorpseSystem'; +import { worldMapUI } from './WorldMapUI'; export class MinimapHUD { private static instance: MinimapHUD; @@ -9,7 +10,7 @@ export class MinimapHUD { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private size = 140; - private updateTimer: ReturnType | null = null; + private rafId: number = 0; private colorCache = new Map(); static getInstance(): MinimapHUD { @@ -28,8 +29,8 @@ export class MinimapHUD { left: 16px; width: ${this.size}px; height: ${this.size}px; - pointer-events: none; z-index: 110; + cursor: pointer; `; // Outer frame @@ -41,6 +42,7 @@ export class MinimapHUD { 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); + transition: border-color 0.15s; `; this.canvas = document.createElement('canvas'); @@ -51,6 +53,11 @@ export class MinimapHUD { frame.appendChild(this.canvas); this.container.appendChild(frame); + + frame.addEventListener('click', () => worldMapUI.toggle()); + frame.addEventListener('mouseenter', () => { frame.style.borderColor = T.textGold; }); + frame.addEventListener('mouseleave', () => { frame.style.borderColor = T.borderBronze; }); + this.startUpdate(); } @@ -62,15 +69,19 @@ export class MinimapHUD { } stop(): void { - if (this.updateTimer) { - clearInterval(this.updateTimer); - this.updateTimer = null; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = 0; } } private startUpdate(): void { - if (this.updateTimer) return; - this.updateTimer = setInterval(() => this.render(), 500); + if (this.rafId) return; + const loop = () => { + this.render(); + this.rafId = requestAnimationFrame(loop); + }; + this.rafId = requestAnimationFrame(loop); } private render(): void { @@ -90,7 +101,9 @@ export class MinimapHUD { if (!zone) return; const tileSize = zone.tileSize; - const scale = 0.3; + // Adaptive scale: smaller for larger zones so the minimap shows meaningful area + const maxDim = Math.max(zone.width, zone.height); + const scale = maxDim <= 40 ? 0.3 : maxDim <= 60 ? 0.2 : 0.15; const centerX = w / 2; const centerY = h / 2; diff --git a/src/ui/components/QuestJournalUI.ts b/src/ui/components/QuestJournalUI.ts index 0f2ee96..ca5f160 100644 --- a/src/ui/components/QuestJournalUI.ts +++ b/src/ui/components/QuestJournalUI.ts @@ -1,5 +1,6 @@ import { eventBus } from '../../core/EventBus'; import { questSystem, type Quest, type QuestObjective } from '../../systems/QuestSystem'; +import { dataRegistry } from '../../data/DataRegistry'; import { T, FONT, goldTitleStyle } from '../theme'; type QuestFilter = 'all' | 'main' | 'side' | 'guild' | 'daedric' | 'radiant'; @@ -36,6 +37,7 @@ export class QuestJournalUI { this.container.style.display = 'block'; this.render(); document.body.appendChild(this.container); + eventBus.emit('ui:menuOpened', { menu: 'questJournal' }); } hide(): void { @@ -43,6 +45,7 @@ export class QuestJournalUI { this.isOpen = false; this.container.style.display = 'none'; this.container.remove(); + eventBus.emit('ui:menuClosed', { menu: 'questJournal' }); } toggle(): void { @@ -60,13 +63,12 @@ export class QuestJournalUI { } private render(): void { + const cfg = dataRegistry.getGameConfig().ui; const filters: { id: QuestFilter; label: string; color: string }[] = [ { id: 'all', label: '全部', color: T.textGold }, - { id: 'main', label: '主线', color: '#e0c060' }, - { id: 'side', label: '支线', color: '#60a0e0' }, - { id: 'guild', label: '公会', color: '#e08040' }, - { id: 'daedric', label: '魔神', color: '#c040c0' }, - { id: 'radiant', label: '辐射', color: '#80c080' }, + ...Object.entries(cfg.questTypeLabels).map(([id, label]) => ({ + id: id as QuestFilter, label, color: cfg.questTypeColors[id] || T.textMuted, + })), ]; const quests = this.getFilteredQuests(); @@ -124,9 +126,7 @@ export class QuestJournalUI { return; } - const typeColors: Record = { - main: '#e0c060', side: '#60a0e0', guild: '#e08040', daedric: '#c040c0', radiant: '#80c080', - }; + const typeColors = dataRegistry.getGameConfig().ui.questTypeColors; for (const quest of quests) { const item = document.createElement('div'); @@ -164,13 +164,9 @@ export class QuestJournalUI { const details = document.getElementById('quest-details'); if (!details) return; - const typeLabels: Record = { - main: '主线任务', side: '支线任务', guild: '公会任务', daedric: '魔神任务', radiant: '辐射任务', - }; - const statusLabels: Record = { active: '进行中', completed: '已完成', failed: '已失败', inactive: '未激活' }; - const statusColors: Record = { - active: T.success, completed: T.textMuted, failed: T.danger, inactive: T.textDim, - }; + const typeLabels = dataRegistry.getGameConfig().ui.questTypeLabels; + const statusLabels = dataRegistry.getGameConfig().ui.questStatusLabels; + const statusColors = dataRegistry.getGameConfig().ui.questStatusColors; details.innerHTML = `
diff --git a/src/ui/components/QuickbarHUD.ts b/src/ui/components/QuickbarHUD.ts index 924ebfd..dce4362 100644 --- a/src/ui/components/QuickbarHUD.ts +++ b/src/ui/components/QuickbarHUD.ts @@ -1,6 +1,6 @@ import { T, FONT } from '../theme'; -import { entityManager } from '../../core/EntityManager'; -import { inventorySystem } from '../../systems/InventorySystem'; +import { dataRegistry } from '../../data/DataRegistry'; +import { favoritesUI } from './FavoritesUI'; export class QuickbarHUD { private static instance: QuickbarHUD; @@ -26,7 +26,6 @@ export class QuickbarHUD { display: flex; gap: 3px; z-index: 120; - pointer-events: none; `; for (let i = 0; i < 8; i++) { @@ -65,6 +64,11 @@ export class QuickbarHUD { slot.appendChild(keyLabel); slot.appendChild(icon); + // Click handler for mouse interaction + const idx = i; + slot.addEventListener('click', () => { + favoritesUI.useFavorite(idx + 1); + }); this.slots.push(slot); this.container.appendChild(slot); } @@ -92,64 +96,22 @@ export class QuickbarHUD { } 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 }); - } - } + const favorites = favoritesUI.getFavorites(); for (let i = 0; i < 8; i++) { const slot = this.slots[i]; if (!slot) continue; const icon = slot.querySelector('.qb-icon') as HTMLDivElement | null; + const fav = favorites.find(f => f.slot === i + 1); - if (i < quickItems.length) { - const item = quickItems[i]!; + if (fav) { 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; + icon.textContent = this.getItemIcon(fav.id, fav.type); + const typeColors = dataRegistry.getGameConfig().ui.itemTypeColors; + icon.style.color = typeColors[fav.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 = ''; @@ -157,8 +119,6 @@ export class QuickbarHUD { } slot.style.borderColor = T.borderIron; slot.style.background = 'rgba(10,10,15,0.7)'; - const qty = slot.querySelector('.qb-qty'); - if (qty) qty.textContent = ''; } } } diff --git a/src/ui/components/SettingsUI.ts b/src/ui/components/SettingsUI.ts index b699e99..bbfa594 100644 --- a/src/ui/components/SettingsUI.ts +++ b/src/ui/components/SettingsUI.ts @@ -1,5 +1,6 @@ import { T, FONT } from '../theme'; import { eventBus } from '../../core/EventBus'; +import { audioManager } from '../../systems/AudioManager'; interface GameSettings { masterVolume: number; @@ -114,9 +115,9 @@ export class SettingsUI { // 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; }); + this.addSlider(content, '主音量', this.settings.masterVolume, (v) => { this.settings.masterVolume = v; audioManager.setMasterVolume(v); }); + this.addSlider(content, '音效', this.settings.sfxVolume, (v) => { this.settings.sfxVolume = v; audioManager.setCategoryVolume('sfx', v); }); + this.addSlider(content, '音乐', this.settings.musicVolume, (v) => { this.settings.musicVolume = v; audioManager.setCategoryVolume('music', v); }); // Gameplay section this.addSection(content, '游戏'); @@ -147,6 +148,8 @@ export class SettingsUI { ['X', '魔法'], ['Q', '龙吼'], ['T', '变形'], + ['Z', '收藏'], + ['R', '拔出/收起武器'], ['1-8', '快捷栏'], ['F5', '快速保存'], ['F9', '快速读取'], @@ -169,6 +172,7 @@ export class SettingsUI { resetBtn.textContent = '恢复默认设置'; resetBtn.addEventListener('click', () => { this.settings = { ...defaultSettings }; + this.saveSettings(); this.render(); }); content.appendChild(resetBtn); @@ -207,6 +211,7 @@ export class SettingsUI { const v = parseInt(slider.value); valDisplay.textContent = `${v}%`; onChange(v); + this.saveSettings(); }); parent.appendChild(row); } @@ -264,6 +269,7 @@ export class SettingsUI { knob.style.left = value ? 'auto' : '1px'; knob.style.right = value ? '1px' : 'auto'; onChange(value); + this.saveSettings(); }); row.appendChild(lbl); diff --git a/src/ui/components/SkillTreeUI.ts b/src/ui/components/SkillTreeUI.ts index 2861101..d935d56 100644 --- a/src/ui/components/SkillTreeUI.ts +++ b/src/ui/components/SkillTreeUI.ts @@ -1,13 +1,26 @@ import { eventBus } from '../../core/EventBus'; import { entityManager } from '../../core/EntityManager'; import { dataRegistry, type PerkData } from '../../data/DataRegistry'; -import { T, FONT, goldTitleStyle } from '../theme'; +import { perkSystem } from '../../systems/PerkSystem'; +import { T, FONT } from '../theme'; + +interface LayoutNode { + perk: PerkData; + x: number; + y: number; +} export class SkillTreeUI { private static instance: SkillTreeUI; private container: HTMLDivElement; private isOpen: boolean = false; private currentSkill: string | null = null; + private layoutNodes: LayoutNode[] = []; + private hoveredPerk: PerkData | null = null; + private tooltipEl: HTMLDivElement | null = null; + private animTime: number = 0; + private animFrame: number | null = null; + private bgStars: { x: number; y: number; r: number; brightness: number; phase: number }[] = []; static getInstance(): SkillTreeUI { if (!SkillTreeUI.instance) SkillTreeUI.instance = new SkillTreeUI(); @@ -26,6 +39,21 @@ export class SkillTreeUI { font-family: ${FONT.body}; color: ${T.textLight}; `; + this.generateBgStars(); + } + + private generateBgStars(): void { + this.bgStars = []; + const density = dataRegistry.getGameConfig().ui.skillTree.starFieldDensity; + for (let i = 0; i < density; i++) { + this.bgStars.push({ + x: Math.random(), + y: Math.random(), + r: 0.3 + Math.random() * 1.2, + brightness: 0.3 + Math.random() * 0.7, + phase: Math.random() * Math.PI * 2, + }); + } } show(): void { @@ -34,6 +62,7 @@ export class SkillTreeUI { this.container.style.display = 'flex'; this.render(); document.body.appendChild(this.container); + this.startAnimation(); } hide(): void { @@ -41,6 +70,8 @@ export class SkillTreeUI { this.isOpen = false; this.container.style.display = 'none'; this.container.remove(); + this.removeTooltip(); + this.stopAnimation(); } toggle(): void { @@ -51,16 +82,33 @@ export class SkillTreeUI { return this.isOpen; } + private startAnimation(): void { + this.stopAnimation(); + const tick = () => { + this.animTime = Date.now(); + this.drawConstellationCanvas(); + this.animFrame = requestAnimationFrame(tick); + }; + this.animFrame = requestAnimationFrame(tick); + } + + private stopAnimation(): void { + if (this.animFrame !== null) { + cancelAnimationFrame(this.animFrame); + this.animFrame = null; + } + } + private render(): void { this.container.innerHTML = ''; - /* ── Header ───────────────────────────────── */ + // Header const header = document.createElement('div'); header.style.cssText = ` padding: 16px 24px; text-align: center; border-bottom: 1px solid ${T.borderBronze}; - background: linear-gradient(180deg, rgba(26,24,36,0.8) 0%, transparent 100%); + background: linear-gradient(180deg, rgba(10,10,20,0.9) 0%, transparent 100%); `; header.innerHTML = `
@@ -73,185 +121,567 @@ export class SkillTreeUI { `; this.container.appendChild(header); - /* ── Content ──────────────────────────────── */ + // Main content const content = document.createElement('div'); - content.style.cssText = `display: flex; height: calc(100% - 70px);`; + content.style.cssText = `display: flex; flex-direction: column; height: calc(100% - 70px);`; - /* Sidebar */ - const sidebar = document.createElement('div'); - sidebar.style.cssText = ` - width: 180px; - padding: 16px 12px; - border-right: 1px solid ${T.borderDark}; - overflow-y: auto; - background: rgba(0,0,0,0.2); + // Constellation canvas area + const canvasArea = document.createElement('div'); + canvasArea.style.cssText = ` + flex: 1; position: relative; overflow: hidden; + background: radial-gradient(ellipse at center, #0e0c1e 0%, #0a0a14 100%); + `; + canvasArea.innerHTML = ``; + content.appendChild(canvasArea); + + // Bottom skill selector bar + const skillBar = document.createElement('div'); + skillBar.style.cssText = ` + height: 80px; + border-top: 1px solid ${T.borderBronze}; + background: rgba(10,10,20,0.9); + display: flex; + align-items: center; + padding: 0 20px; + gap: 4px; + overflow-x: auto; `; - const categories = [ - { name: '战斗', skills: ['oneHanded', 'twoHanded', 'archery', 'block', 'heavyArmor', 'smithing'] }, - { name: '魔法', skills: ['destruction', 'conjuration', 'illusion', 'restoration', 'alteration', 'enchanting'] }, - { name: '潜行', skills: ['sneak', 'lightArmor', 'lockpicking', 'pickpocket', 'speech', 'alchemy'] }, - ]; + const allSkills = dataRegistry.getAllSkills(); + const categoryLabels: Record = { combat: '战斗', magic: '魔法', stealth: '潜行' }; + const categories = (['combat', 'magic', 'stealth'] as const); - const skillNames: Record = { - oneHanded: '单手武器', twoHanded: '双手武器', archery: '弓箭', - block: '格挡', heavyArmor: '重甲', smithing: '锻造', - destruction: '毁灭', conjuration: '召唤', illusion: '幻术', - restoration: '恢复', alteration: '变化', enchanting: '附魔', - sneak: '潜行', lightArmor: '轻甲', lockpicking: '开锁', - pickpocket: '扒窃', speech: '口才', alchemy: '炼金', - }; - - categories.forEach((cat) => { - const catTitle = document.createElement('div'); - catTitle.textContent = cat.name; - catTitle.style.cssText = ` - color: ${T.textGold}; - font-weight: 600; - margin: 14px 0 8px; - font-size: 12px; - letter-spacing: 2px; - text-transform: uppercase; - font-family: ${FONT.title}; + for (const cat of categories) { + const catLabel = document.createElement('div'); + catLabel.textContent = categoryLabels[cat] || ''; + catLabel.style.cssText = ` + color: ${T.textGold}; font-size: 10px; font-weight: 600; + letter-spacing: 1px; text-transform: uppercase; + writing-mode: vertical-rl; text-orientation: mixed; + padding: 0 6px; flex-shrink: 0; `; - sidebar.appendChild(catTitle); + skillBar.appendChild(catLabel); - cat.skills.forEach((skillId) => { - const skillBtn = document.createElement('button'); - skillBtn.textContent = skillNames[skillId] || skillId; - skillBtn.style.cssText = ` - display: block; - width: 100%; - padding: 7px 10px; - margin: 2px 0; - background: transparent; - border: 1px solid transparent; - border-radius: 3px; - color: ${T.textMuted}; - cursor: pointer; - text-align: left; - font-size: 13px; - font-family: ${FONT.body}; + const catSkills = allSkills.filter(s => s.category === cat); + for (const skill of catSkills) { + const btn = document.createElement('button'); + const isActive = this.currentSkill === skill.id; + btn.style.cssText = ` + width: 50px; height: 56px; flex-shrink: 0; + background: ${isActive ? 'rgba(212,168,67,0.1)' : 'transparent'}; + border: 1px solid ${isActive ? T.borderGold : 'transparent'}; + border-radius: 3px; cursor: pointer; + display: flex; flex-direction: column; + align-items: center; justify-content: center; transition: all 0.15s; `; - skillBtn.addEventListener('mouseenter', () => { - skillBtn.style.background = 'rgba(255,255,255,0.04)'; - skillBtn.style.color = T.textLight; - skillBtn.style.borderColor = T.borderDark; + btn.innerHTML = ` +
${this.getSkillIcon(skill.id)}
+
${skill.name}
+ `; + btn.addEventListener('mouseenter', () => { + if (!isActive) btn.style.borderColor = T.borderBronze; }); - skillBtn.addEventListener('mouseleave', () => { - skillBtn.style.background = 'transparent'; - skillBtn.style.color = T.textMuted; - skillBtn.style.borderColor = 'transparent'; + btn.addEventListener('mouseleave', () => { + if (!isActive) btn.style.borderColor = 'transparent'; }); - skillBtn.addEventListener('click', () => this.selectSkill(skillId)); - sidebar.appendChild(skillBtn); - }); - }); - content.appendChild(sidebar); + btn.addEventListener('click', () => this.selectSkill(skill.id)); + skillBar.appendChild(btn); + } - /* Tree panel */ - const treePanel = document.createElement('div'); - treePanel.id = 'tree-panel'; - treePanel.style.cssText = ` - flex: 1; - padding: 30px; - display: flex; - flex-direction: column; - align-items: center; - `; - treePanel.innerHTML = `

选择一个技能查看天赋树

`; - content.appendChild(treePanel); + // Separator + const sep = document.createElement('div'); + sep.style.cssText = `width: 1px; height: 40px; background: ${T.borderDark}; flex-shrink: 0; margin: 0 4px;`; + skillBar.appendChild(sep); + } + content.appendChild(skillBar); this.container.appendChild(content); this.updatePerkPoints(); document.getElementById('close-skill-tree')?.addEventListener('click', () => this.hide()); + + // Setup canvas click handler + setTimeout(() => this.setupCanvasInteraction(), 0); + } + + private getSkillIcon(skillId: string): string { + const icons: Record = { + oneHanded: '⚔', twoHanded: '🗡', archery: '🏹', block: '🛡', + heavyArmor: '⚙', lightArmor: '🥠', smithing: '🔨', + destruction: '🔥', conjuration: '👻', illusion: '👁', + alteration: '◈', restoration: '✚', enchanting: '✨', + sneak: '🏹', lockpicking: '🔐', pickpocket: '🤲', + speech: '💬', alchemy: '⚗', + }; + return icons[skillId] || '★'; } private selectSkill(skillId: string): void { this.currentSkill = skillId; - const treePanel = document.getElementById('tree-panel'); - if (!treePanel) return; + this.layoutNodes = []; + this.hoveredPerk = null; + this.removeTooltip(); const treeData = dataRegistry.getPerkTree(skillId); - if (!treeData) { - treePanel.innerHTML = `

该技能天赋树尚未实现

`; - return; + if (treeData) { + this.layoutNodes = this.computeConstellationLayout(treeData.perks); } - treePanel.innerHTML = ` -

${treeData.name}

-
- `; + this.render(); + this.drawConstellationCanvas(); + } - const perkGrid = document.getElementById('perk-grid'); - if (!perkGrid) return; + private computeConstellationLayout(perks: PerkData[]): LayoutNode[] { + if (perks.length === 0) return []; - treeData.perks.forEach((perk) => { - perkGrid.appendChild(this.createPerkNode(perk)); + const canvasArea = this.container.querySelector('#constellation-canvas'); + const canvasW = canvasArea ? (canvasArea as HTMLCanvasElement).width || 800 : 800; + const canvasH = canvasArea ? (canvasArea as HTMLCanvasElement).height || 400 : 400; + + const nodes: LayoutNode[] = []; + const placed = new Map(); + + // Build dependency graph + const roots = perks.filter(p => !p.requires || p.requires.length === 0); + const children = new Map(); + for (const perk of perks) { + if (perk.requires) { + for (const reqId of perk.requires) { + if (!children.has(reqId)) children.set(reqId, []); + children.get(reqId)!.push(perk); + } + } + } + + // BFS layout from roots + const queue: { perk: PerkData; depth: number; parentId?: string }[] = []; + const depthCount = new Map(); + + for (const root of roots) { + queue.push({ perk: root, depth: 0 }); + } + + const visited = new Set(); + + while (queue.length > 0) { + const { perk, depth } = queue.shift()!; + if (visited.has(perk.id)) continue; + visited.add(perk.id); + + const count = depthCount.get(depth) || 0; + depthCount.set(depth, count + 1); + + // Position: spread horizontally within depth level + const totalAtDepth = this.countNodesAtDepth(perk.id, depth, roots, children, perks); + const spacing = canvasW / (totalAtDepth + 1); + const x = spacing * (count + 1); + const y = 60 + depth * 90; + + const node: LayoutNode = { perk, x: Math.max(40, Math.min(canvasW - 40, x)), y: Math.min(canvasH - 60, y) }; + nodes.push(node); + placed.set(perk.id, node); + + // Add children + const childPerks = children.get(perk.id) || []; + for (const child of childPerks) { + if (!visited.has(child.id)) { + queue.push({ perk: child, depth: depth + 1, parentId: perk.id }); + } + } + } + + // Handle orphaned perks (not reachable from roots) + for (const perk of perks) { + if (!visited.has(perk.id)) { + const count = depthCount.get(2) || 0; + depthCount.set(2, count + 1); + const x = (count + 1) * (canvasW / (perks.length - visited.size + 1)); + const node: LayoutNode = { perk, x: Math.max(40, Math.min(canvasW - 40, x)), y: canvasH - 80 }; + nodes.push(node); + placed.set(perk.id, node); + } + } + + return nodes; + } + + private countNodesAtDepth(_perkId: string, _depth: number, _roots: PerkData[], _children: Map, _allPerks: PerkData[]): number { + // Count actual nodes at this depth level from the BFS + let count = 0; + for (const p of _allPerks) { + if (!p.requires || p.requires.length === 0) { + if (_depth === 0) count++; + } else { + // Simple heuristic: depth = max depth of parents + 1 + let maxParentDepth = -1; + for (const reqId of p.requires) { + const parent = _allPerks.find(pp => pp.id === reqId); + if (parent) { + const parentDepth = !parent.requires || parent.requires.length === 0 ? 0 : 1; + maxParentDepth = Math.max(maxParentDepth, parentDepth); + } + } + if (maxParentDepth + 1 === _depth) count++; + } + } + return Math.max(5, count || _allPerks.length); + } + + private setupCanvasInteraction(): void { + const canvas = document.getElementById('constellation-canvas') as HTMLCanvasElement; + if (!canvas) return; + + canvas.addEventListener('mousemove', (e) => { + const rect = canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) * (canvas.width / rect.width); + const my = (e.clientY - rect.top) * (canvas.height / rect.height); + + let found: PerkData | null = null; + for (const node of this.layoutNodes) { + const dx = mx - node.x; + const dy = my - node.y; + if (Math.sqrt(dx * dx + dy * dy) <= 20) { + found = node.perk; + break; + } + } + + if (found !== this.hoveredPerk) { + this.hoveredPerk = found; + if (found) { + this.showTooltip(found, e.clientX, e.clientY); + } else { + this.removeTooltip(); + } + } + }); + + canvas.addEventListener('mouseleave', () => { + this.hoveredPerk = null; + this.removeTooltip(); + }); + + canvas.addEventListener('click', (e) => { + const rect = canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) * (canvas.width / rect.width); + const my = (e.clientY - rect.top) * (canvas.height / rect.height); + + for (const node of this.layoutNodes) { + const dx = mx - node.x; + const dy = my - node.y; + if (Math.sqrt(dx * dx + dy * dy) <= 20) { + this.tryUnlockPerk(node.perk); + break; + } + } }); } - private createPerkNode(perk: PerkData): HTMLDivElement { - const node = document.createElement('div'); - node.style.cssText = ` - padding: 14px 24px; - background: rgba(255,255,255,0.04); - border: 1px solid ${T.borderIron}; - border-radius: 3px; - cursor: pointer; - transition: all 0.15s; - min-width: 220px; - text-align: center; + private showTooltip(perk: PerkData, mouseX: number, mouseY: number): void { + this.removeTooltip(); + + const player = entityManager.getEntitiesByType('player')[0]; + const perksComp = player ? entityManager.getComponent<{ unlocked: string[]; rankedPerks: Record }>(player.id, 'perks') : null; + const level = player ? entityManager.getComponent<{ perkPoints: number }>(player.id, 'level') : null; + + const isUnlocked = perksComp?.unlocked.includes(perk.id) ?? false; + const currentRank = perksComp?.rankedPerks[perk.id] ?? 0; + const hasPerkPoints = (level?.perkPoints ?? 0) > 0; + + // Check prerequisites + let prereqMet = true; + let missingPrereqs: string[] = []; + if (perk.requires && perk.requires.length > 0) { + for (const reqId of perk.requires) { + if (!perksComp?.unlocked.includes(reqId)) { + prereqMet = false; + missingPrereqs.push(reqId); + } + } + } + + const atMaxRank = currentRank >= perk.maxRank; + + // Check skill level requirement + const skills = player ? entityManager.getComponent>(player.id, 'skills') : null; + const skillLevel = skills?.[perk.skill] ?? 0; + const skillMet = perk.skillLevel <= 0 || skillLevel >= perk.skillLevel; + + const tooltip = document.createElement('div'); + tooltip.className = 'oes-tooltip-item'; + tooltip.style.cssText = ` + position: fixed; left: ${mouseX + 16}px; top: ${mouseY - 10}px; + z-index: 600; max-width: 260px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${isUnlocked ? T.borderGold : T.borderBronze}; + border-radius: 3px; padding: 12px 16px; + box-shadow: 0 4px 16px rgba(0,0,0,0.6); + pointer-events: none; `; - node.innerHTML = ` -
${perk.name}
-
${perk.description}
-
等级: ${perk.rank}/${perk.maxRank}
+ const skillNameMap = Object.fromEntries(dataRegistry.getAllSkills().map(s => [s.id, s.name])); + const skillName = skillNameMap[perk.skill] || perk.skill; + + tooltip.innerHTML = ` +
+ ${perk.name} +
+
+ ${perk.description} +
+
+ 等级: ${currentRank}/${perk.maxRank} + ${atMaxRank ? ' (已满)' : ''} +
+ ${perk.skillLevel > 0 ? ` +
+ ${skillName} 需要 ${perk.skillLevel} 级 (当前: ${skillLevel}) +
+ ` : ''} + ${!prereqMet ? ` +
+ 需要前置天赋: ${missingPrereqs.map(id => { + const allPerks = dataRegistry.getAllPerkTrees().flatMap(t => t.perks); + const p = allPerks.find(pp => pp.id === id); + return p ? p.name : id; + }).join(', ')} +
+ ` : ''} + ${!hasPerkPoints && !isUnlocked ? ` +
+ 没有可用天赋点 +
+ ` : ''} `; - node.addEventListener('mouseenter', () => { - node.style.borderColor = T.borderGold; - node.style.background = 'rgba(212,168,67,0.06)'; - }); - node.addEventListener('mouseleave', () => { - node.style.borderColor = T.borderIron; - node.style.background = 'rgba(255,255,255,0.04)'; - }); - node.addEventListener('click', () => this.unlockPerk(perk)); - - return node; + document.body.appendChild(tooltip); + this.tooltipEl = tooltip; } - private unlockPerk(perk: PerkData): void { + private removeTooltip(): void { + if (this.tooltipEl) { + this.tooltipEl.remove(); + this.tooltipEl = null; + } + } + + private tryUnlockPerk(perk: PerkData): void { const player = entityManager.getEntitiesByType('player')[0]; if (!player) return; const level = entityManager.getComponent<{ level: number; perkPoints: number }>(player.id, 'level'); - if (!level || level.perkPoints <= 0) { - alert('没有可用的天赋点!'); + if (!level) return; + + // Check perk points + if (level.perkPoints <= 0) { + this.showNotification('没有可用的天赋点!'); return; } + // Check maxRank via perkSystem + const currentRank = perkSystem.getPerkRank(player, perk.id); + if (currentRank >= perk.maxRank) { + this.showNotification('该天赋已达到最大等级!'); + return; + } + + // Check prerequisites + if (perk.requires && perk.requires.length > 0) { + for (const reqId of perk.requires) { + if (!perkSystem.hasPerk(player, reqId)) { + const allPerks = dataRegistry.getAllPerkTrees().flatMap(t => t.perks); + const reqPerk = allPerks.find(p => p.id === reqId); + this.showNotification(`需要先解锁: ${reqPerk?.name || reqId}`); + return; + } + } + } + + // Check skill level requirement + if (perk.skillLevel > 0) { + const skills = entityManager.getComponent>(player.id, 'skills'); + const currentLevel = skills?.[perk.skill] ?? 0; + if (currentLevel < perk.skillLevel) { + const skillNameMap = Object.fromEntries(dataRegistry.getAllSkills().map(s => [s.id, s.name])); + const skillName = skillNameMap[perk.skill] || perk.skill; + this.showNotification(`${skillName} 需要 ${perk.skillLevel} 级 (当前: ${currentLevel})`); + return; + } + } + + // All checks passed — consume point and unlock level.perkPoints -= 1; eventBus.emit('perk:unlocked', { entityId: player.id, perkId: perk.id }); this.updatePerkPoints(); + this.showNotification(`解锁了 ${perk.name}!`); + // Refresh layout if (this.currentSkill) { this.selectSkill(this.currentSkill); } } + private drawConstellationCanvas(): void { + const canvas = document.getElementById('constellation-canvas') as HTMLCanvasElement; + if (!canvas) return; + + // Set canvas size to match display + const rect = canvas.getBoundingClientRect(); + if (canvas.width !== Math.floor(rect.width) || canvas.height !== Math.floor(rect.height)) { + canvas.width = Math.floor(rect.width); + canvas.height = Math.floor(rect.height); + // Recompute layout for new size + if (this.currentSkill) { + const treeData = dataRegistry.getPerkTree(this.currentSkill); + if (treeData) { + this.layoutNodes = this.computeConstellationLayout(treeData.perks); + } + } + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const w = canvas.width; + const h = canvas.height; + const cfg = dataRegistry.getGameConfig().ui.skillTree; + + // Deep space background + const bg = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w * 0.6); + bg.addColorStop(0, '#0e0c1e'); + bg.addColorStop(1, cfg.backgroundColor); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + + // Nebulae (subtle colored clouds) + const nebulae = [ + { x: w * 0.3, y: h * 0.4, r: 120, color: '40,20,80' }, + { x: w * 0.7, y: h * 0.3, r: 100, color: '20,40,80' }, + { x: w * 0.5, y: h * 0.7, r: 90, color: '60,20,30' }, + ]; + for (const n of nebulae) { + const grad = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.r); + grad.addColorStop(0, `rgba(${n.color},0.15)`); + grad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + } + + // Background stars + const time = this.animTime * 0.001; + for (const star of this.bgStars) { + const flicker = Math.sin(time * 0.5 + star.phase) * 0.2 + 0.8; + ctx.fillStyle = `rgba(255,255,240,${star.brightness * flicker})`; + ctx.beginPath(); + ctx.arc(star.x * w, star.y * h, star.r, 0, Math.PI * 2); + ctx.fill(); + } + + if (this.layoutNodes.length === 0) { + // No tree selected — show hint + ctx.fillStyle = T.textDim; + ctx.font = `14px "${FONT.body}"`; + ctx.textAlign = 'center'; + ctx.fillText('选择一个技能查看星座天赋树', w / 2, h / 2); + return; + } + + const player = entityManager.getEntitiesByType('player')[0]; + const perksComp = player ? entityManager.getComponent<{ unlocked: string[]; rankedPerks: Record }>(player.id, 'perks') : null; + + // Draw constellation lines (prerequisite connections) + ctx.lineWidth = cfg.lineWidth; + for (const node of this.layoutNodes) { + if (!node.perk.requires) continue; + for (const reqId of node.perk.requires) { + const parentNode = this.layoutNodes.find(n => n.perk.id === reqId); + if (!parentNode) continue; + + const isUnlocked = perksComp?.unlocked.includes(node.perk.id) ?? false; + const isParentUnlocked = perksComp?.unlocked.includes(reqId) ?? false; + const bothUnlocked = isUnlocked && isParentUnlocked; + + ctx.strokeStyle = bothUnlocked ? cfg.unlockedColor + '80' : cfg.lockedColor + '40'; + ctx.lineWidth = bothUnlocked ? cfg.lineWidth + 0.5 : cfg.lineWidth; + + ctx.beginPath(); + ctx.moveTo(parentNode.x, parentNode.y); + ctx.lineTo(node.x, node.y); + ctx.stroke(); + } + } + + // Draw star nodes + for (const node of this.layoutNodes) { + const isUnlocked = perksComp?.unlocked.includes(node.perk.id) ?? false; + const currentRank = perksComp?.rankedPerks[node.perk.id] ?? 0; + const isHovered = this.hoveredPerk?.id === node.perk.id; + const size = isHovered ? cfg.starSize + 3 : (isUnlocked ? cfg.starSize : cfg.starSize - 2); + + if (isUnlocked) { + // Glow + const glow = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, cfg.starGlowRadius); + glow.addColorStop(0, cfg.unlockedColor + '40'); + glow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(node.x, node.y, cfg.starGlowRadius, 0, Math.PI * 2); + ctx.fill(); + + // Star + ctx.fillStyle = cfg.unlockedColor; + ctx.beginPath(); + ctx.arc(node.x, node.y, size, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = '#f0e8d8'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } else { + // Locked star + ctx.fillStyle = cfg.lockedColor; + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.arc(node.x, node.y, size, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.strokeStyle = cfg.lockedColor; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Rank indicator (small dots below star) + if (node.perk.maxRank > 1) { + const dotsY = node.y + size + 8; + const dotSpacing = 6; + const totalWidth = (node.perk.maxRank - 1) * dotSpacing; + const startX = node.x - totalWidth / 2; + for (let i = 0; i < node.perk.maxRank; i++) { + ctx.fillStyle = i < currentRank ? cfg.unlockedColor : cfg.lockedColor + '60'; + ctx.beginPath(); + ctx.arc(startX + i * dotSpacing, dotsY, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + private updatePerkPoints(): void { const player = entityManager.getEntitiesByType('player')[0]; const pointsEl = document.getElementById('perk-points'); if (!player || !pointsEl) return; - const level = entityManager.getComponent<{ perkPoints: number }>(player.id, 'level'); pointsEl.textContent = `天赋点: ${level?.perkPoints || 0}`; } + + private showNotification(text: string): void { + const toast = document.createElement('div'); + toast.className = 'oes-toast'; + toast.textContent = text; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2600); + } } export const skillTreeUI = SkillTreeUI.getInstance(); diff --git a/src/ui/components/WorldMapUI.ts b/src/ui/components/WorldMapUI.ts index 3f3fd7e..a5f3145 100644 --- a/src/ui/components/WorldMapUI.ts +++ b/src/ui/components/WorldMapUI.ts @@ -1,4 +1,6 @@ import { eventBus } from '../../core/EventBus'; +import { mapManager } from '../../maps/MapManager'; +import { dataRegistry } from '../../data/DataRegistry'; import { T, FONT, goldTitleStyle, panelStyle } from '../theme'; export interface MapLocation { @@ -17,6 +19,9 @@ export class WorldMapUI { private isOpen: boolean = false; private locations: MapLocation[] = []; private selectedLocation: MapLocation | null = null; + private animTime: number = 0; + private animFrame: number | null = null; + private starSeed: number[] = []; static getInstance(): WorldMapUI { if (!WorldMapUI.instance) { @@ -37,31 +42,65 @@ export class WorldMapUI { font-family: ${FONT.body}; color: ${T.textLight}; `; + // Pre-generate star random seed for parchment texture + for (let i = 0; i < 200; i++) this.starSeed.push(Math.random()); this.initializeLocations(); } private initializeLocations(): void { - this.locations = [ - { id: 'whiterun', name: '雪漫城', description: '天际省的首府', x: 400, y: 300, discovered: true, type: 'city' }, - { id: 'whiterun_exterior', name: '雪漫城外', description: '雪漫城周围的平原', x: 400, y: 350, discovered: true, type: 'landmark' }, - { id: 'bleakfalls_barrow', name: '荒瀑古坟', description: '古老的诺德遗迹', x: 500, y: 250, discovered: false, type: 'dungeon' }, - { id: 'riverwood', name: '河木镇', description: '宁静的河边小镇', x: 300, y: 400, discovered: false, type: 'town' }, - { id: 'windhelm', name: '风盔城', description: '古老的诺德城市', x: 600, y: 200, discovered: false, type: 'city' }, - { id: 'solitude', name: '独孤城', description: '帝国在天际的首都', x: 200, y: 150, discovered: false, type: 'city' }, - { id: 'riften', name: '裂谷城', description: '盗贼公会的据点', x: 550, y: 450, discovered: false, type: 'city' }, - { id: 'markarth', name: '马卡斯城', description: '古老的矮人城市', x: 150, y: 350, discovered: false, type: 'city' }, - { id: 'dark_brotherhood', name: '黑暗兄弟会', description: '暗杀组织的藏身处', x: 350, y: 200, discovered: false, type: 'dungeon' }, - { id: 'college_of_winterhold', name: '冬堡学院', description: '魔法学院', x: 550, y: 100, discovered: false, type: 'landmark' }, - { id: 'thieves_guild', name: '盗贼公会', description: '盗贼的地下总部', x: 560, y: 440, discovered: false, type: 'dungeon' }, - ]; + const layout: Record = { + skyrim_overworld: { x: 350, y: 250, type: 'landmark' }, + whiterun_exterior: { x: 350, y: 300, type: 'landmark' }, + whiterun: { x: 350, y: 330, type: 'city' }, + solitude: { x: 200, y: 80, type: 'city' }, + winterhold_college: { x: 450, y: 60, type: 'landmark' }, + windhelm: { x: 580, y: 80, type: 'city' }, + markarth: { x: 100, y: 380, type: 'city' }, + reach: { x: 150, y: 280, type: 'landmark' }, + shadowmere: { x: 600, y: 250, type: 'landmark' }, + riften: { x: 500, y: 420, type: 'city' }, + rift: { x: 420, y: 460, type: 'landmark' }, + riverwood: { x: 250, y: 400, type: 'town' }, + tundra: { x: 350, y: 130, type: 'landmark' }, + bleakfalls_barrow: { x: 450, y: 180, type: 'dungeon' }, + darklight_cave: { x: 550, y: 350, type: 'dungeon' }, + ancient_ruins: { x: 250, y: 200, type: 'dungeon' }, + dark_brotherhood: { x: 200, y: 130, type: 'dungeon' }, + thieves_guild: { x: 520, y: 440, type: 'dungeon' }, + }; + + this.locations = []; + for (const [id, pos] of Object.entries(layout)) { + const zone = mapManager.getZone(id); + this.locations.push({ + id, + name: zone?.name || id, + description: zone?.description || '', + discovered: id === 'skyrim_overworld' || id === 'whiterun_exterior' || id === 'whiterun', + ...pos, + }); + } } show(): void { if (this.isOpen) return; this.isOpen = true; + // Preserve discovery state + const prevDiscovered = new Set(this.locations.filter(l => l.discovered).map(l => l.id)); + this.initializeLocations(); + for (const loc of this.locations) { + if (prevDiscovered.has(loc.id)) loc.discovered = true; + } + // Auto-discover current zone + const currentZone = mapManager.getCurrentZone(); + if (currentZone) { + const loc = this.locations.find(l => l.id === currentZone.id); + if (loc) loc.discovered = true; + } this.container.style.display = 'block'; this.render(); document.body.appendChild(this.container); + this.startAnimation(); } hide(): void { @@ -69,14 +108,11 @@ export class WorldMapUI { this.isOpen = false; this.container.style.display = 'none'; this.container.remove(); + this.stopAnimation(); } toggle(): void { - if (this.isOpen) { - this.hide(); - } else { - this.show(); - } + if (this.isOpen) this.hide(); else this.show(); } getIsOpen(): boolean { @@ -85,28 +121,37 @@ export class WorldMapUI { discoverLocation(locationId: string): void { const location = this.locations.find((l) => l.id === locationId); - if (location) { - location.discovered = true; + if (location) location.discovered = true; + } + + private startAnimation(): void { + this.stopAnimation(); + const tick = () => { + this.animTime = Date.now(); + this.renderMapCanvas(); + this.animFrame = requestAnimationFrame(tick); + }; + this.animFrame = requestAnimationFrame(tick); + } + + private stopAnimation(): void { + if (this.animFrame !== null) { + cancelAnimationFrame(this.animFrame); + this.animFrame = null; } } private render(): void { this.container.innerHTML = `
-

世界地图

- -
-
- -
已发现地点
@@ -116,139 +161,279 @@ export class WorldMapUI {
`; - this.renderMap(); + this.renderMapCanvas(); this.renderLocationList(); + this.renderLocationDetails(); document.getElementById('close-map')?.addEventListener('click', () => this.hide()); + + // Canvas click handler + const canvas = document.getElementById('map-canvas') as HTMLCanvasElement; + if (canvas) { + canvas.onclick = (e) => { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) * (canvas.width / rect.width); + const y = (e.clientY - rect.top) * (canvas.height / rect.height); + for (const location of this.locations) { + if (!location.discovered) continue; + const dx = x - location.x; + const dy = y - location.y; + if (Math.sqrt(dx * dx + dy * dy) <= 18) { + this.selectedLocation = location; + this.renderLocationList(); + this.renderLocationDetails(); + break; + } + } + }; + } } - private renderMap(): void { + private renderMapCanvas(): void { const canvas = document.getElementById('map-canvas') as HTMLCanvasElement; if (!canvas) return; - const ctx = canvas.getContext('2d'); if (!ctx) return; + const cfg = dataRegistry.getGameConfig().ui.worldMap; + const w = canvas.width; + const h = canvas.height; - /* Background */ - const grad = ctx.createRadialGradient(350, 250, 50, 350, 250, 400); - grad.addColorStop(0, '#1a1828'); - grad.addColorStop(1, '#0e0c16'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, canvas.width, canvas.height); + // === Parchment background === + // Base gradient + const baseGrad = ctx.createLinearGradient(0, 0, w, h); + baseGrad.addColorStop(0, cfg.parchmentBase); + baseGrad.addColorStop(0.5, cfg.parchmentLight); + baseGrad.addColorStop(1, cfg.parchmentBase); + ctx.fillStyle = baseGrad; + ctx.fillRect(0, 0, w, h); - /* Grid */ - ctx.strokeStyle = 'rgba(74,106,30,0.08)'; - ctx.lineWidth = 1; - for (let x = 0; x < canvas.width; x += 50) { - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, canvas.height); - ctx.stroke(); - } - for (let y = 0; y < canvas.height; y += 50) { - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(canvas.width, y); - ctx.stroke(); + // Noise texture (paper grain) + for (let i = 0; i < this.starSeed.length; i++) { + const rx = this.starSeed[i]! * w; + const ry = ((this.starSeed[i]! * 7.3 + 0.3) % 1) * h; + const alpha = 0.03 + this.starSeed[i]! * 0.05; + ctx.fillStyle = `rgba(255,255,240,${alpha})`; + ctx.fillRect(rx, ry, 2, 2); } - /* Roads */ + // Vignette (dark edges) + const vignette = ctx.createRadialGradient(w / 2, h / 2, w * 0.25, w / 2, h / 2, w * 0.55); + vignette.addColorStop(0, 'rgba(0,0,0,0)'); + vignette.addColorStop(1, 'rgba(0,0,0,0.4)'); + ctx.fillStyle = vignette; + ctx.fillRect(0, 0, w, h); + + // Faint grid lines + ctx.strokeStyle = `rgba(138,128,112,${cfg.gridOpacity})`; + ctx.lineWidth = 0.5; + for (let x = 0; x < w; x += 50) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); + } + for (let y = 0; y < h; y += 50) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); + } + + // === Roads (curved bezier) === const roads = [ { from: 'whiterun', to: 'whiterun_exterior' }, + { from: 'whiterun_exterior', to: 'skyrim_overworld' }, + { from: 'skyrim_overworld', to: 'solitude' }, + { from: 'skyrim_overworld', to: 'windhelm' }, + { from: 'skyrim_overworld', to: 'riften' }, + { from: 'skyrim_overworld', to: 'markarth' }, + { from: 'skyrim_overworld', to: 'tundra' }, + { from: 'skyrim_overworld', to: 'shadowmere' }, { from: 'whiterun_exterior', to: 'bleakfalls_barrow' }, - { from: 'whiterun', to: 'riverwood' }, - { from: 'whiterun', to: 'windhelm' }, - { from: 'whiterun', to: 'solitude' }, - { from: 'whiterun', to: 'riften' }, - { from: 'whiterun', to: 'markarth' }, + { from: 'whiterun_exterior', to: 'riverwood' }, + { from: 'whiterun_exterior', to: 'darklight_cave' }, + { from: 'whiterun_exterior', to: 'ancient_ruins' }, + { from: 'whiterun_exterior', to: 'reach' }, + { from: 'whiterun_exterior', to: 'rift' }, + { from: 'tundra', to: 'windhelm' }, + { from: 'shadowmere', to: 'solitude' }, + { from: 'riften', to: 'thieves_guild' }, + { from: 'solitude', to: 'dark_brotherhood' }, + { from: 'skyrim_overworld', to: 'winterhold_college' }, ]; - ctx.strokeStyle = 'rgba(138,128,112,0.3)'; - ctx.lineWidth = 2; + ctx.strokeStyle = cfg.roadColor; + ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); + ctx.globalAlpha = 0.5; for (const road of roads) { - const from = this.locations.find((l) => l.id === road.from); - const to = this.locations.find((l) => l.id === road.to); - if (from && to && from.discovered && to.discovered) { - ctx.beginPath(); - ctx.moveTo(from.x, from.y); - ctx.lineTo(to.x, to.y); - ctx.stroke(); - } + const from = this.locations.find(l => l.id === road.from); + const to = this.locations.find(l => l.id === road.to); + if (!from || !to || !from.discovered || !to.discovered) continue; + + // Bezier curve with random control point offset + const mx = (from.x + to.x) / 2; + const my = (from.y + to.y) / 2; + const dx = to.x - from.x; + const dy = to.y - from.y; + const cpx = mx - dy * 0.15; + const cpy = my + dx * 0.15; + + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.quadraticCurveTo(cpx, cpy, to.x, to.y); + ctx.stroke(); } ctx.setLineDash([]); + ctx.globalAlpha = 1; - /* Locations */ + // === Locations === for (const location of this.locations) { - if (!location.discovered) continue; + if (!location.discovered) { + // Fog of war: draw fog circle over undiscovered + ctx.fillStyle = `rgba(10,10,15,${cfg.fogAlpha})`; + ctx.beginPath(); + ctx.arc(location.x, location.y, 25, 0, Math.PI * 2); + ctx.fill(); + continue; + } const color = this.getLocationColor(location.type); const isSelected = this.selectedLocation?.id === location.id; - /* Glow for selected */ + // Glow for selected if (isSelected) { ctx.fillStyle = color + '30'; ctx.beginPath(); - ctx.arc(location.x, location.y, 16, 0, Math.PI * 2); + ctx.arc(location.x, location.y, 18, 0, Math.PI * 2); ctx.fill(); } - /* Dot */ - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(location.x, location.y, isSelected ? 7 : 5, 0, Math.PI * 2); - ctx.fill(); + // Location icon based on type + this.drawLocationIcon(ctx, location, color, isSelected); - ctx.strokeStyle = '#f0e8d8'; - ctx.lineWidth = isSelected ? 2 : 1; - ctx.stroke(); - - /* Label */ + // Label ctx.fillStyle = isSelected ? '#f0e8d8' : '#a09888'; ctx.font = `${isSelected ? 'bold ' : ''}11px "${FONT.body}"`; ctx.textAlign = 'center'; - ctx.fillText(location.name, location.x, location.y + 18); + ctx.fillText(location.name, location.x, location.y + 22); } - /* Click handler */ - canvas.onclick = (e) => { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + // === Player marker === + const currentZone = mapManager.getCurrentZone(); + if (currentZone) { + const playerLoc = this.locations.find(l => l.id === currentZone.id); + if (playerLoc && playerLoc.discovered) { + const pulse = Math.sin(this.animTime * 0.004) * 0.3 + 0.7; + const markerColor = cfg.playerMarkerColor; - for (const location of this.locations) { - if (!location.discovered) continue; - const dx = x - location.x; - const dy = y - location.y; - if (Math.sqrt(dx * dx + dy * dy) <= 15) { - this.selectedLocation = location; - this.renderMap(); - this.renderLocationDetails(); - break; - } + // Pulse ring + ctx.strokeStyle = markerColor; + ctx.globalAlpha = pulse * 0.4; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(playerLoc.x, playerLoc.y, 12 + pulse * 4, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + + // Diamond shape + ctx.fillStyle = markerColor; + ctx.beginPath(); + ctx.moveTo(playerLoc.x, playerLoc.y - 7); + ctx.lineTo(playerLoc.x + 5, playerLoc.y); + ctx.lineTo(playerLoc.x, playerLoc.y + 7); + ctx.lineTo(playerLoc.x - 5, playerLoc.y); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = '#f0e8d8'; + ctx.lineWidth = 1; + ctx.stroke(); } - }; + } + } + + private drawLocationIcon(ctx: CanvasRenderingContext2D, loc: MapLocation, color: string, isSelected: boolean): void { + const x = loc.x; + const y = loc.y; + const r = isSelected ? 7 : 5; + + ctx.fillStyle = color; + ctx.strokeStyle = '#f0e8d8'; + ctx.lineWidth = isSelected ? 2 : 1; + + switch (loc.type) { + case 'city': { + // Castle: triangle roof + rectangle body + ctx.beginPath(); + ctx.moveTo(x, y - r - 3); + ctx.lineTo(x + r, y - 1); + ctx.lineTo(x - r, y - 1); + ctx.closePath(); + ctx.fill(); + ctx.fillRect(x - r + 1, y - 1, r * 2 - 2, r + 2); + ctx.strokeRect(x - r, y - 1, r * 2, r + 3); + break; + } + case 'dungeon': { + // Skull-like: circle + cross + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + // Eyes + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(x - 3, y - 2, 2, 2); + ctx.fillRect(x + 1, y - 2, 2, 2); + break; + } + case 'landmark': { + // Star shape + ctx.beginPath(); + for (let i = 0; i < 5; i++) { + const angle = (i * 72 - 90) * Math.PI / 180; + const outerX = x + Math.cos(angle) * r; + const outerY = y + Math.sin(angle) * r; + const innerAngle = ((i * 72 + 36) - 90) * Math.PI / 180; + const innerX = x + Math.cos(innerAngle) * (r * 0.4); + const innerY = y + Math.sin(innerAngle) * (r * 0.4); + if (i === 0) ctx.moveTo(outerX, outerY); + else ctx.lineTo(outerX, outerY); + ctx.lineTo(innerX, innerY); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + break; + } + case 'camp': { + // Fire: triangle + ctx.beginPath(); + ctx.moveTo(x, y - r - 2); + ctx.lineTo(x + r, y + r); + ctx.lineTo(x - r, y + r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + break; + } + case 'town': + case 'village': + default: { + // Simple circle + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + break; + } + } } private getLocationColor(type: MapLocation['type']): string { - const colors: Record = { - city: T.textGold, - town: T.enchanting, - village: T.success, - dungeon: T.danger, - camp: T.smithing, - landmark: '#8060c0', - }; - return colors[type] || '#ffffff'; + return dataRegistry.getGameConfig().ui.locationTypeColors[type] || '#ffffff'; } private renderLocationList(): void { const list = document.getElementById('location-list'); if (!list) return; - list.innerHTML = ''; - const discovered = this.locations.filter((l) => l.discovered); + const discovered = this.locations.filter(l => l.discovered); for (const location of discovered) { const item = document.createElement('div'); const isSelected = this.selectedLocation?.id === location.id; @@ -274,7 +459,6 @@ export class WorldMapUI { this.selectedLocation = location; this.renderLocationList(); this.renderLocationDetails(); - this.renderMap(); }); list.appendChild(item); } @@ -282,38 +466,82 @@ export class WorldMapUI { private renderLocationDetails(): void { const details = document.getElementById('location-details'); - if (!details || !this.selectedLocation) { - if (details) details.innerHTML = `

点击地图或列表选择地点

`; + if (!details) return; + + if (!this.selectedLocation) { + details.innerHTML = `

点击地图或列表选择地点

`; return; } const loc = this.selectedLocation; + const typeLabels: Record = { + city: '城市', town: '城镇', village: '村庄', + dungeon: '地牢', camp: '营地', landmark: '地标', + }; + const currentZoneId = mapManager.getCurrentZone()?.id; + const isCurrentLocation = loc.id === currentZoneId; + details.innerHTML = `
${loc.name}
${loc.description}
-
类型: ${loc.type}
+
类型: ${typeLabels[loc.type] || loc.type}
- + ${isCurrentLocation + ? `
你已在此处
` + : `` + } `; - document.getElementById('fast-travel-btn')?.addEventListener('click', () => { - this.fastTravel(loc.id); - }); + if (!isCurrentLocation) { + document.getElementById('fast-travel-btn')?.addEventListener('click', () => { + this.fastTravel(loc.id); + }); + } } private fastTravel(locationId: string): void { - eventBus.emit('world:fastTravel', { locationId }); - this.hide(); + const loc = this.locations.find(l => l.id === locationId); + const name = loc?.name || locationId; + // Confirmation overlay + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; inset: 0; background: rgba(5,5,10,0.85); z-index: 600; + display: flex; align-items: center; justify-content: center; + font-family: ${FONT.body}; color: ${T.textLight}; + `; + overlay.innerHTML = ` +
+
确认快速旅行
+
+ 确认快速旅行到 ${name}? +
+
+ + +
+
+ `; + document.body.appendChild(overlay); + overlay.querySelector('#ft-confirm')?.addEventListener('click', () => { + overlay.remove(); + eventBus.emit('world:fastTravel', { locationId }); + this.hide(); + }); + overlay.querySelector('#ft-cancel')?.addEventListener('click', () => overlay.remove()); } getDiscoveredLocations(): MapLocation[] { - return this.locations.filter((l) => l.discovered); + return this.locations.filter(l => l.discovered); } isLocationDiscovered(locationId: string): boolean { - const location = this.locations.find((l) => l.id === locationId); - return location?.discovered || false; + return this.locations.find(l => l.id === locationId)?.discovered || false; } } diff --git a/src/ui/theme.ts b/src/ui/theme.ts index e9e91ec..8ec6fa5 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -45,6 +45,16 @@ export const T = { magic: '#7050c0', danger: '#c83030', success: '#40a060', + + /* 羊皮纸 */ + parchmentBg: '#2a2420', + parchmentLight:'#352e28', + + /* 星座 */ + starGold: '#d4a843', + starLocked: '#4a4a55', + skyDark: '#0a0a14', + skyDeep: '#0e0c1e', } as const; /* ── 字体 ──────────────────────────────────────── */ @@ -290,6 +300,28 @@ export function injectGlobalTheme(): void { z-index: 1000; max-width: 280px; } + + /* ── Parchment panel ─────────────────────────── */ + .oes-parchment { + background: linear-gradient(135deg, ${T.parchmentBg} 0%, ${T.parchmentLight} 100%); + border: 2px solid ${T.borderBronze}; + border-radius: 4px; + box-shadow: 0 0 20px rgba(0,0,0,0.6), inset 0 0 30px rgba(0,0,0,0.3); + } + + /* ── Item tooltip ────────────────────────────── */ + .oes-tooltip-item { + padding: 12px 16px; + background: linear-gradient(180deg, rgba(22,18,14,0.97), rgba(14,10,8,0.97)); + border: 1px solid ${T.borderBronze}; + border-radius: 3px; + color: ${T.textLight}; + font-size: 13px; + box-shadow: 0 4px 16px rgba(0,0,0,0.6); + pointer-events: none; + z-index: 1000; + max-width: 300px; + } `; document.head.appendChild(style); } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..859c95a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 3000, + host: true, + }, +});