// ======================================================== // MYTHIC DEPTHS v3.0 — High Fantasy Mystery Dungeon RPG // Larger Map (72x52), Zero-to-Hero Starter, Header Materials, // Clean 1-Click Inventory, Auto-Save & Mobile Touch Controls // ======================================================== const MAP_W = 72; const MAP_H = 52; const MAX_FLOORS = 10; let tileSize = window.innerWidth < 640 ? 38 : 44; // --- Sound Synthesizer (Web Audio API) --- let audioCtx = null; let soundEnabled = true; function initAudio() { if (!audioCtx) { const AudioContext = window.AudioContext || window.webkitAudioContext; if (AudioContext) audioCtx = new AudioContext(); } } function playSound(type) { if (!soundEnabled) return; initAudio(); if (!audioCtx) return; try { if (audioCtx.state === "suspended") audioCtx.resume(); const now = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); if (type === "step") { osc.type = "sine"; osc.frequency.setValueAtTime(135, now); osc.frequency.exponentialRampToValueAtTime(60, now + 0.045); gain.gain.setValueAtTime(0.04, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.045); osc.start(now); osc.stop(now + 0.045); } else if (type === "hit") { osc.type = "sawtooth"; osc.frequency.setValueAtTime(230, now); osc.frequency.exponentialRampToValueAtTime(65, now + 0.11); gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.11); osc.start(now); osc.stop(now + 0.11); } else if (type === "shoot") { osc.type = "triangle"; osc.frequency.setValueAtTime(520, now); osc.frequency.exponentialRampToValueAtTime(160, now + 0.10); gain.gain.setValueAtTime(0.13, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.10); osc.start(now); osc.stop(now + 0.10); } else if (type === "spell") { osc.type = "sine"; osc.frequency.setValueAtTime(360, now); osc.frequency.linearRampToValueAtTime(760, now + 0.15); gain.gain.setValueAtTime(0.14, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18); osc.start(now); osc.stop(now + 0.18); } else if (type === "potion" || type === "craft") { osc.type = "triangle"; osc.frequency.setValueAtTime(330, now); osc.frequency.linearRampToValueAtTime(580, now + 0.12); gain.gain.setValueAtTime(0.12, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22); osc.start(now); osc.stop(now + 0.22); } else if (type === "chest") { osc.type = "triangle"; osc.frequency.setValueAtTime(440, now); osc.frequency.setValueAtTime(587, now + 0.09); gain.gain.setValueAtTime(0.14, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); osc.start(now); osc.stop(now + 0.25); } else if (type === "stairs" || type === "levelup") { [330, 415, 493, 659].forEach((freq, idx) => { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.type = "triangle"; o.connect(g); g.connect(audioCtx.destination); o.frequency.setValueAtTime(freq, now + idx * 0.07); g.gain.setValueAtTime(0.12, now + idx * 0.07); g.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.07 + 0.25); o.start(now + idx * 0.07); o.stop(now + idx * 0.07 + 0.25); }); } else if (type === "gameover") { osc.type = "sawtooth"; osc.frequency.setValueAtTime(210, now); osc.frequency.exponentialRampToValueAtTime(55, now + 0.55); gain.gain.setValueAtTime(0.16, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.55); osc.start(now); osc.stop(now + 0.55); } } catch (e) {} } function toggleSound() { soundEnabled = !soundEnabled; const icon = document.getElementById("icon-sound"); if (icon) icon.textContent = soundEnabled ? "🔊" : "🔇"; showToast(soundEnabled ? "Sound aktiviert" : "Sound stummgeschaltet"); } function cycleZoom() { const sizes = [32, 38, 46, 54]; const idx = sizes.indexOf(tileSize); tileSize = sizes[(idx + 1) % sizes.length]; showToast(`🔍 Karten-Zoom: ${tileSize}px`); } // --- 3 Selectable 10-Floor Dungeons --- const DUNGEONS_DB = { catacombs: { id: "catacombs", name: "Vergessene Katakomben", icon: "🕯️", recLevel: 1, diffMult: 1.0, goldMult: 1.0, bossId: "boss_goblin", midBossId: "midboss_orc", desc: "10 Ebenen unter der Feste Grimhold. Ideal für den Einstieg (Stufe 1–6).", wallTint: null, floorTint: null }, frost_caverns: { id: "frost_caverns", name: "Frostklamm-Kavernen", icon: "❄️", recLevel: 4, diffMult: 1.38, goldMult: 1.5, bossId: "boss_frostwyrm", midBossId: "golem", desc: "10 vereiste Tiefen voller Mana-Kristalle & Frostbestien (+50% Gold & Kristall-Beute, empf. ab St. 4).", wallTint: "#1e3a5f", floorTint: "#0f223d" }, magma_sanctum: { id: "magma_sanctum", name: "Glutkern-Sanktum", icon: "🌋", recLevel: 7, diffMult: 1.80, goldMult: 2.0, bossId: "boss_ignis", midBossId: "boss_goblin", desc: "10 glühende Vulkankammern voller Feueressenz & Mythril (+100% Gold, reich an 🔥 & 🔷, empf. ab St. 7).", wallTint: "#541b1b", floorTint: "#2b0e0e" } }; // --- 10 Dungeon Zones --- const ZONES = [ { floor: 1, name: "Verlassener Schacht", desc: "Sammle Holz, Kräuter, Wandfackeln (🔥) und Erz für deine Ausrüstung.", wall: "#2e2540", floor: "#1b1728" }, { floor: 2, name: "Goblin-Minen", desc: "Kleine Plünderer bewachen reiche Eisenerz-Adern.", wall: "#35283c", floor: "#1f1826" }, { floor: 3, name: "Kristall-Grotten", desc: "Leuchtende Mana-Kristalle und klappernde Skelett-Schützen.", wall: "#1b3152", floor: "#111f36" }, { floor: 4, name: "Spinnen-Katakomben", desc: "Dichte Netze und wertvolle Spinnenseide in dunklen Kammern.", wall: "#3f202a", floor: "#23141b" }, { floor: 5, name: "Wachstube des Aufsehers", desc: "Zwischenboss-Ebene! Der Kriegsherr versperrt den Weg.", wall: "#472518", floor: "#26150e" }, { floor: 6, name: "Tiefe Zwergenschmiede", desc: "Uralte Ambosse und seltene Mythril-Einschlüsse im Gestein.", wall: "#2c3540", floor: "#171d24" }, { floor: 7, name: "Schamanen-Heiligtum", desc: "Feueressenzen und arkane Rituale durchdringen die Luft.", wall: "#3d1e4d", floor: "#21102b" }, { floor: 8, name: "Golem-Kammern", desc: "Massive Kristall-Golems bewachen uralte Schätze.", wall: "#193b47", floor: "#0e2229" }, { floor: 9, name: "Vorhalle der Glut", desc: "Die Hitze des tiefsten Kerkers kündigt den Herrscher an.", wall: "#4d1919", floor: "#290d0d" }, { floor: 10, name: "Thronsaal des Endbosses", desc: "Endkampf auf Ebene 10! Bezwinge den Herrscher des Dungeons!", wall: "#4a1233", floor: "#29081d" } ]; // --- Full RPG Paperdoll Equipment Slots (7 Rüstung & Relikte + genau 1 Waffe für Nah- oder Fernkampf) --- const EQUIP_SLOT_META = [ { key: "helmet", side: "left", label: "Kopf / Hut", emptyIcon: "🪖" }, { key: "armor", side: "left", label: "Brust / Rüstung", emptyIcon: "👕" }, { key: "pants", side: "left", label: "Beine / Hose", emptyIcon: "👖" }, { key: "boots", side: "left", label: "Füße / Schuhe", emptyIcon: "👢" }, { key: "weapon", side: "right", label: "Waffe (Nah- oder Fernkampf)", emptyIcon: "⚔️" }, { key: "shield", side: "right", label: "Nebenhand / Schild", emptyIcon: "🛡️" }, { key: "ring", side: "right", label: "Relikt 1: Ring", emptyIcon: "💍" }, { key: "amulet", side: "right", label: "Relikt 2: Amulett", emptyIcon: "📿" } ]; const ALL_EQUIP_SLOTS = EQUIP_SLOT_META.map(s => s.key); const MATERIAL_META = { iron_ore: { id: "iron_ore", name: "Eisenerz", icon: "🪨" }, wood_branch: { id: "wood_branch", name: "Eschenholz", icon: "🪵" }, healing_herb: { id: "healing_herb", name: "Blutkraut", icon: "🌿" }, mana_shard: { id: "mana_shard", name: "Kristall", icon: "💎" }, spider_silk: { id: "spider_silk", name: "Spinnenseide", icon: "🕸️" }, monster_bone: { id: "monster_bone", name: "Knochen", icon: "🦴" }, fire_essence: { id: "fire_essence", name: "Feueressenz", icon: "🔥" }, mythril_ingot: { id: "mythril_ingot", name: "Mythril", icon: "🔷" } }; const MATERIAL_KEYS = Object.keys(MATERIAL_META); // --- Items Database (Includes Helmet, Armor, Pants, Boots, Weapon, Ranged, Shield, Ring, Amulet & Consumables) --- const ITEMS = { // === WORN STARTER WEAPONS === rusty_sword: { id: "rusty_sword", name: "Rostiges Kurzschwert", type: "weapon", icon: "🗡️", atk: 5, desc: "+5 Nahkampf (Abgenutzte Startwaffe)", rarity: "common" }, apprentice_wand: { id: "apprentice_wand", name: "Alter Lehrstab", type: "ranged", icon: "🪄", rangedAtk: 6, range: 4, ammoType: "mana", manaCost: 2, projIcon: "✨", desc: "+6 Magie-Schuss (4 Felder, 2 MP)", rarity: "common" }, branch_bow: { id: "branch_bow", name: "Ast-Kurzbogen", type: "ranged", icon: "🏹", rangedAtk: 6, range: 4, ammoType: "arrow", projIcon: "🏹", desc: "+6 Fernkampf (4 Felder, 1 Pfeil)", rarity: "common" }, // === CRAFTABLE & DROPPED MELEE WEAPONS === dagger: { id: "dagger", name: "Jagddolch", type: "weapon", icon: "🗡️", atk: 9, crit: 0.12, desc: "+9 Nahkampf, +12% Krit-Chance", rarity: "common" }, iron_sword: { id: "iron_sword", name: "Eisenschwert", type: "weapon", icon: "⚔️", atk: 14, desc: "+14 Solider Nahkampf-Schaden", rarity: "common" }, spear: { id: "spear", name: "Kriegsspeer", type: "weapon", icon: "🔱", atk: 16, reach: 2, desc: "+16 Nahkampf, 2 Felder Reichweite!", rarity: "rare" }, bone_blade: { id: "bone_blade", name: "Knochenbrecher-Klinge", type: "weapon", icon: "🦴", atk: 21, crit: 0.12, desc: "+21 Nahkampf, +12% Krit-Chance", rarity: "rare" }, battle_axe: { id: "battle_axe", name: "Henkersaxt", type: "weapon", icon: "🪓", atk: 26, desc: "+26 Wuchtiger Nahkampf-Schaden", rarity: "rare" }, mythril_sword: { id: "mythril_sword", name: "Mythril-Runenschwert", type: "weapon", icon: "✨", atk: 34, crit: 0.20, lifesteal: 0.15, desc: "+34 Angriff, +20% Crit, 15% Lebensraub", rarity: "legendary" }, // === RANGED WEAPONS === throwing_belt: { id: "throwing_belt", name: "Wurfmesser-Gurt", type: "ranged", icon: "🗡️", rangedAtk: 11, range: 4, ammoType: "arrow", projIcon: "🗡️", desc: "+11 Fernkampf (4 Felder)", rarity: "common" }, short_bow: { id: "short_bow", name: "Eschen-Jagdbogen", type: "ranged", icon: "🏹", rangedAtk: 13, range: 4, ammoType: "arrow", projIcon: "🏹", desc: "+13 Fernkampf (4 Felder)", rarity: "common" }, elven_bow: { id: "elven_bow", name: "Elfen-Langbogen", type: "ranged", icon: "🏹", rangedAtk: 18, range: 5, crit: 0.12, ammoType: "arrow", projIcon: "🏹", desc: "+18 Fernkampf, +12% Crit (5 Felder)", rarity: "rare" }, composite_bow: { id: "composite_bow", name: "Komposit-Kriegsbogen", type: "ranged", icon: "🏹", rangedAtk: 23, range: 5, crit: 0.15, ammoType: "arrow", projIcon: "🏹", desc: "+23 Fernkampf, +15% Crit (5 Felder)", rarity: "epic" }, heavy_crossbow: { id: "heavy_crossbow", name: "Zwergen-Armbrust", type: "ranged", icon: "🎯", rangedAtk: 30, range: 5, crit: 0.20, ammoType: "arrow", projIcon: "🔩", desc: "+30 Durchschlag, +20% Crit (5 Felder)", rarity: "epic" }, fire_staff: { id: "fire_staff", name: "Feuerfunken-Stab", type: "ranged", icon: "🪄", rangedAtk: 16, range: 5, ammoType: "mana", manaCost: 3, spellPwr: 0.25, projIcon: "🔥", desc: "+16 Magie-Schuss (5 Felder, 3 MP), +25% Magie", rarity: "rare" }, archmage_staff: { id: "archmage_staff", name: "Sternensplitter-Stab", type: "ranged", icon: "🪄", rangedAtk: 26, range: 6, ammoType: "mana", manaCost: 4, maxMp: 30, spellPwr: 0.45, projIcon: "⚡", desc: "+26 Arkan-Schuss (6 Felder), +30 MP, +45% Magie", rarity: "legendary" }, // === HELMETS (KOPF / HUT) === leather_cap: { id: "leather_cap", name: "Lederkappe", type: "helmet", icon: "🧢", def: 2, crit: 0.05, desc: "+2 Rüstung, +5% Krit-Chance", rarity: "common" }, iron_helm: { id: "iron_helm", name: "Eisen-Visierhelm", type: "helmet", icon: "🪖", def: 5, maxHp: 12, desc: "+5 Rüstung, +12 Max HP", rarity: "common" }, arcane_circlet: { id: "arcane_circlet", name: "Diadem der Weisheit", type: "helmet", icon: "👑", def: 3, maxMp: 25, spellPwr: 0.15, desc: "+3 Rüstung, +25 Max MP, +15% Magie", rarity: "rare" }, bone_crown: { id: "bone_crown", name: "Knochenkrone", type: "helmet", icon: "💀", def: 6, atk: 6, maxHp: 20, desc: "+6 Rüstung, +6 Angriff, +20 Max HP", rarity: "epic" }, // === ARMOR (BRUST / RÜSTUNG) === hide_vest: { id: "hide_vest", name: "Einfaches Pelzwams", type: "armor", icon: "🦺", def: 3, maxHp: 8, desc: "+3 Rüstung, +8 Max HP", rarity: "common" }, leather_armor: { id: "leather_armor", name: "Gehärteter Lederharnisch", type: "armor", icon: "👕", def: 6, maxHp: 12, desc: "+6 Rüstung, +12 Max HP", rarity: "common" }, mage_robe: { id: "mage_robe", name: "Robe des Arkanisten", type: "armor", icon: "🥋", def: 5, maxMp: 30, spellPwr: 0.20, desc: "+5 Rüstung, +30 Max MP, +20% Magie", rarity: "rare" }, silk_tunic: { id: "silk_tunic", name: "Spinnenseiden-Gewand", type: "armor", icon: "🕸️", def: 9, maxMp: 25, crit: 0.10, desc: "+9 Rüstung, +25 Max MP, +10% Crit", rarity: "epic" }, plate_armor: { id: "plate_armor", name: "Ritter-Plattenrüstung", type: "armor", icon: "🦾", def: 13, maxHp: 28, desc: "+13 Schwere Rüstung, +28 Max HP", rarity: "rare" }, mythril_mail: { id: "mythril_mail", name: "Mythril-Schuppenpanzer", type: "armor", icon: "🛡️", def: 19, maxHp: 45, crit: 0.10, desc: "+19 Rüstung, +45 Max HP, +10% Crit", rarity: "legendary" }, // === PANTS (BEINE / HOSE) === cloth_trousers: { id: "cloth_trousers", name: "Leinenhose des Spähers", type: "pants", icon: "👖", def: 2, maxHp: 8, desc: "+2 Rüstung, +8 Max HP", rarity: "common" }, leather_pants: { id: "leather_pants", name: "Verstärkte Jagdhose", type: "pants", icon: "👖", def: 4, maxHp: 15, crit: 0.04, desc: "+4 Rüstung, +15 Max HP, +4% Crit", rarity: "common" }, iron_greaves: { id: "iron_greaves", name: "Eisen-Beinschienen", type: "pants", icon: "👖", def: 7, maxHp: 24, desc: "+7 Rüstung, +24 Max HP", rarity: "rare" }, mythril_leggings: { id: "mythril_leggings", name: "Mythril-Beinplatten", type: "pants", icon: "👖", def: 12, maxHp: 40, crit: 0.08, desc: "+12 Rüstung, +40 Max HP, +8% Crit", rarity: "legendary" }, // === BOOTS (FÜSSE / SCHUHE) === traveler_boots: { id: "traveler_boots", name: "Robuste Wanderstiefel", type: "boots", icon: "👢", def: 2, maxHp: 10, crit: 0.04, desc: "+2 Rüstung, +10 Max HP, +4% Crit", rarity: "common" }, iron_sabatons: { id: "iron_sabatons", name: "Gepanzerte Eisenstiefel", type: "boots", icon: "👢", def: 5, maxHp: 18, desc: "+5 Rüstung, +18 Max HP", rarity: "rare" }, shadow_boots: { id: "shadow_boots", name: "Schattenläufer-Stiefel", type: "boots", icon: "👢", def: 6, atk: 4, crit: 0.10, desc: "+6 Rüstung, +4 ATK, +10% Crit", rarity: "epic" }, flame_striders: { id: "flame_striders", name: "Glutläufer-Stiefel", type: "boots", icon: "👢", def: 9, atk: 6, maxHp: 30, crit: 0.08, desc: "+9 Rüstung, +6 ATK, +30 HP, +8% Crit", rarity: "legendary" }, // === SHIELDS & OFFHAND === wood_shield: { id: "wood_shield", name: "Holzschild", type: "shield", icon: "🪵", def: 3, desc: "+3 Leichte Schild-Abwehr", rarity: "common" }, buckler: { id: "buckler", name: "Eisen-Rundschild", type: "shield", icon: "🛡️", def: 5, desc: "+5 Solide Schild-Abwehr", rarity: "common" }, quiver: { id: "quiver", name: "Elfen-Pfeilköcher", type: "shield", icon: "🏹", rangedAtk: 6, crit: 0.08, desc: "+6 Fernkampf-Schaden, +8% Crit, spart Pfeile", rarity: "rare" }, focus_orb: { id: "focus_orb", name: "Kristall-Fokusorb", type: "shield", icon: "🔮", maxMp: 25, spellPwr: 0.25, desc: "+25 Max MP, +25% Zauberkraft", rarity: "rare" }, tower_shield: { id: "tower_shield", name: "Turmschild der Feste", type: "shield", icon: "🛡️", def: 11, maxHp: 25, desc: "+11 Rüstung, +25 Max HP", rarity: "epic" }, // === RELIC 1: RINGS === bone_ring: { id: "bone_ring", name: "Knochenring der Kraft", type: "ring", icon: "💍", atk: 4, maxHp: 10, desc: "+4 Angriff, +10 Max HP", rarity: "common" }, ruby_ring: { id: "ruby_ring", name: "Rubin-Ring des Lebens", type: "ring", icon: "💍", maxHp: 35, desc: "+35 Maximale Lebenspunkte", rarity: "rare" }, sapphire_ring: { id: "sapphire_ring", name: "Saphir-Ring des Geistes", type: "ring", icon: "💎", maxMp: 35, desc: "+35 Maximales Mana", rarity: "rare" }, amulet_sun: { id: "amulet_sun", name: "Sonnen-Siegelring", type: "ring", icon: "☀️", lightRadius: 3, atk: 5, desc: "+3 Sichtweite im Dunkeln, +5 Angriff", rarity: "epic" }, vampire_ring: { id: "vampire_ring", name: "Blutring des Vampirs", type: "ring", icon: "🩸", lifesteal: 0.20, crit: 0.10, desc: "20% Lebensraub bei Treffern, +10% Crit", rarity: "legendary" }, // === RELIC 2: AMULETS & TALISMANS === bone_talisman: { id: "bone_talisman", name: "Knochen-Talisman", type: "amulet", icon: "📿", atk: 3, maxHp: 15, crit: 0.04, desc: "Relikt: +3 ATK, +15 Max HP, +4% Crit", rarity: "common" }, ward_amulet: { id: "ward_amulet", name: "Runen-Schutzamulett", type: "amulet", icon: "📿", def: 4, maxHp: 22, maxMp: 15, desc: "Relikt: +4 Rüstung, +22 HP, +15 MP", rarity: "rare" }, ember_amulet: { id: "ember_amulet", name: "Glutherz-Amulett", type: "amulet", icon: "🔥", atk: 7, maxMp: 25, spellPwr: 0.22, desc: "Relikt: +7 ATK, +25 MP, +22% Magie", rarity: "epic" }, eye_of_grimhold: { id: "eye_of_grimhold", name: "Auge von Grimhold", type: "amulet", icon: "🧿", atk: 9, def: 5, maxHp: 35, crit: 0.10, lifesteal: 0.10, desc: "Relikt: +9 ATK, +5 Rüst, +35 HP, +10% Lebensraub", rarity: "legendary" }, // === CONSUMABLES & THROWABLES === health_potion: { id: "health_potion", name: "Heiltrank", type: "consumable", icon: "🧪", heal: 55, desc: "Stellt sofort 55 Lebenspunkte her", rarity: "common" }, mana_potion: { id: "mana_potion", name: "Mana-Elixier", type: "consumable", icon: "🍶", mana: 45, desc: "Füllt sofort 45 Mana-Punkte auf", rarity: "common" }, titan_elixir: { id: "titan_elixir", name: "Elixier der Titanen", type: "consumable", icon: "🍷", permHp: 18, permAtk: 3, desc: "Permanent +18 Max HP & +3 Angriff (+Vollheilung)!", rarity: "legendary" }, throwing_knife: { id: "throwing_knife", name: "Wurfmesser", type: "consumable", icon: "🗡️", throwDmg: 24, range: 5, projIcon: "🗡️", desc: "Wirft eine Klinge auf das nächste Ziel (24 Schaden)", rarity: "common" }, fire_bomb: { id: "fire_bomb", name: "Alchemie-Feuerbombe", type: "consumable", icon: "💣", bombDmg: 40, range: 4, projIcon: "💣", desc: "38–40 Flächen-Feuerschaden auf Gegner", rarity: "rare" }, spider_net: { id: "spider_net", name: "Spinnenseiden-Fangnetz", type: "consumable", icon: "🕸️", stunTurns: 3, range: 5, projIcon: "🕸️", desc: "Fesselt ein sichtbares Monster für 3 Runden!", rarity: "rare" }, scroll_fire: { id: "scroll_fire", name: "Schriftrolle: Feuersbrunst", type: "consumable", icon: "📜", spell: "fireball", desc: "Verbrennt alle Gegner im Umkreis (45 Schaden)", rarity: "rare" }, scroll_teleport: { id: "scroll_teleport", name: "Schriftrolle: Fluchtportal", type: "consumable", icon: "📜", spell: "teleport", desc: "Teleportiert dich sofort in einen sicheren Raum", rarity: "rare" }, scroll_map: { id: "scroll_map", name: "Schriftrolle: Hellsicht", type: "consumable", icon: "📜", spell: "map", desc: "Deckt die gesamte aktuelle Ebene auf", rarity: "rare" } }; // --- Crafting Recipes (Includes Pants, Boots, Amulets & Starter Gear) --- const CRAFT_RECIPES = [ // Starter Gear (Floor 1-2 craftables) { id: "c_wood_shield", category: "starter", name: "Holzschild zimmern", icon: "🪵", resultItem: "wood_shield", resultCount: 1, desc: "Nebenhand: +3 Rüstung.", cost: { wood_branch: 2 } }, { id: "c_leather_cap", category: "starter", name: "Lederkappe nähen", icon: "🧢", resultItem: "leather_cap", resultCount: 1, desc: "Hut/Helm: +2 Rüstung, +5% Crit.", cost: { healing_herb: 1, monster_bone: 1 } }, { id: "c_hide_vest", category: "starter", name: "Einfaches Pelzwams", icon: "🦺", resultItem: "hide_vest", resultCount: 1, desc: "Brust: +3 Rüstung, +8 Max HP.", cost: { wood_branch: 1, monster_bone: 1 } }, { id: "c_cloth_trousers", category: "starter", name: "Leinenhose nähen", icon: "👖", resultItem: "cloth_trousers", resultCount: 1, desc: "Hose: +2 Rüstung, +8 Max HP.", cost: { wood_branch: 1, healing_herb: 1 } }, { id: "c_traveler_boots", category: "starter", name: "Robuste Wanderstiefel", icon: "👢", resultItem: "traveler_boots", resultCount: 1, desc: "Schuhe: +2 Rüstung, +10 HP, +4% Crit.", cost: { wood_branch: 1, monster_bone: 1 } }, { id: "c_iron_sword", category: "starter", name: "Eisenschwert schmieden", icon: "⚔️", resultItem: "iron_sword", resultCount: 1, desc: "Nahkampf: +14 Angriff.", cost: { iron_ore: 2, wood_branch: 1 } }, { id: "c_short_bow", category: "starter", name: "Eschen-Jagdbogen", icon: "🏹", resultItem: "short_bow", resultCount: 1, desc: "Fernkampf: +13 Schaden (4 Felder).", cost: { wood_branch: 2, healing_herb: 1 } }, { id: "c_iron_helm", category: "starter", name: "Eisen-Visierhelm", icon: "🪖", resultItem: "iron_helm", resultCount: 1, desc: "Hut/Helm: +5 Rüstung, +12 Max HP.", cost: { iron_ore: 2 } }, { id: "c_leather_pants", category: "starter", name: "Verstärkte Jagdhose", icon: "👖", resultItem: "leather_pants", resultCount: 1, desc: "Hose: +4 Rüstung, +15 HP, +4% Crit.", cost: { spider_silk: 1, monster_bone: 1 } }, { id: "c_iron_sabatons", category: "starter", name: "Gepanzerte Eisenstiefel", icon: "👢", resultItem: "iron_sabatons", resultCount: 1, desc: "Schuhe: +5 Rüstung, +18 Max HP.", cost: { iron_ore: 2, wood_branch: 1 } }, { id: "c_buckler", category: "starter", name: "Eisen-Rundschild", icon: "🛡️", resultItem: "buckler", resultCount: 1, desc: "Nebenhand: +5 Rüstung.", cost: { iron_ore: 2, wood_branch: 1 } }, { id: "c_bone_ring", category: "starter", name: "Knochenring schnitzen", icon: "💍", resultItem: "bone_ring", resultCount: 1, desc: "Relikt 1 (Ring): +4 Angriff, +10 Max HP.", cost: { monster_bone: 2 } }, { id: "c_bone_talisman", category: "starter", name: "Knochen-Talisman", icon: "📿", resultItem: "bone_talisman", resultCount: 1, desc: "Relikt 2 (Amulett): +3 ATK, +15 HP, +4% Crit.", cost: { monster_bone: 2, mana_shard: 1 } }, // Ammo & Throwables { id: "c_arrows_wood", category: "ammo", name: "10x Pfeile schnitzen", icon: "🏹", resultArrows: 10, desc: "+10 Pfeile für Bögen & Armbrüste.", cost: { wood_branch: 1 } }, { id: "c_arrows_bone", category: "ammo", name: "15x Knochenpfeile", icon: "🏹", resultArrows: 15, desc: "+15 Pfeile aus Monsterknochen.", cost: { monster_bone: 1, wood_branch: 1 } }, { id: "c_knives", category: "ammo", name: "3x Wurfmesser schmieden", icon: "🗡️", resultItem: "throwing_knife", resultCount: 3, desc: "Wurfwaffe (24 Schaden, 5 Felder).", cost: { iron_ore: 1, wood_branch: 1 } }, { id: "c_nets", category: "ammo", name: "2x Spinnenseiden-Fangnetz", icon: "🕸️", resultItem: "spider_net", resultCount: 2, desc: "Fesselt Gegner für 3 Runden.", cost: { spider_silk: 1 } }, // Alchemy { id: "c_hp_pot", category: "alchemy", name: "Heiltrank brauen", icon: "🧪", resultItem: "health_potion", resultCount: 1, desc: "Stellt 55 HP wieder her.", cost: { healing_herb: 2 } }, { id: "c_mp_pot", category: "alchemy", name: "Mana-Elixier brauen", icon: "🍶", resultItem: "mana_potion", resultCount: 1, desc: "Stellt 45 MP wieder her.", cost: { healing_herb: 1, mana_shard: 1 } }, { id: "c_fire_bomb", category: "alchemy", name: "2x Feuerbombe mischen", icon: "💣", resultItem: "fire_bomb", resultCount: 2, desc: "Flächen-Explosion (40 Schaden).", cost: { fire_essence: 1, iron_ore: 1 } }, { id: "c_titan", category: "alchemy", name: "Elixier der Titanen", icon: "🍷", resultItem: "titan_elixir", resultCount: 1, desc: "Permanent +18 Max HP & +3 Angriff!", cost: { healing_herb: 2, mana_shard: 2, fire_essence: 1 } }, // Master Smithing { id: "c_spear", category: "master", name: "Kriegsspeer (2 Felder)", icon: "🔱", resultItem: "spear", resultCount: 1, desc: "+16 Nahkampf, stößt 2 Felder weit!", cost: { iron_ore: 2, wood_branch: 2 } }, { id: "c_comp_bow", category: "master", name: "Komposit-Kriegsbogen", icon: "🏹", resultItem: "composite_bow", resultCount: 1, desc: "+23 Fernkampf, +15% Crit (5 Felder).", cost: { wood_branch: 2, spider_silk: 2 } }, { id: "c_crossbow", category: "master", name: "Zwergen-Armbrust", icon: "🎯", resultItem: "heavy_crossbow", resultCount: 1, desc: "+30 Fernkampf, +20% Crit (5 Felder).", cost: { iron_ore: 3, wood_branch: 2 } }, { id: "c_fire_staff", category: "master", name: "Feuerfunken-Stab", icon: "🪄", resultItem: "fire_staff", resultCount: 1, desc: "+16 Magie-Schuss (5 Felder), +25% Magie.", cost: { wood_branch: 2, fire_essence: 1, mana_shard: 1 } }, { id: "c_bone_blade", category: "master", name: "Knochenbrecher-Klinge", icon: "🦴", resultItem: "bone_blade", resultCount: 1, desc: "+21 Nahkampf, +12% Crit.", cost: { iron_ore: 2, monster_bone: 2 } }, { id: "c_bone_crown", category: "master", name: "Knochenkrone", icon: "💀", resultItem: "bone_crown", resultCount: 1, desc: "Helm: +6 Def, +6 Atk, +20 HP.", cost: { monster_bone: 3, mana_shard: 1 } }, { id: "c_silk_tunic", category: "master", name: "Spinnenseiden-Gewand", icon: "🕸️", resultItem: "silk_tunic", resultCount: 1, desc: "Brust: +9 Def, +25 MP, +10% Crit.", cost: { spider_silk: 3, mana_shard: 1 } }, { id: "c_plate_armor", category: "master", name: "Ritter-Plattenrüstung", icon: "🦾", resultItem: "plate_armor", resultCount: 1, desc: "Brust: +13 Rüstung, +28 Max HP.", cost: { iron_ore: 4, monster_bone: 1 } }, { id: "c_iron_greaves", category: "master", name: "Eisen-Beinschienen", icon: "👖", resultItem: "iron_greaves", resultCount: 1, desc: "Hose: +7 Rüstung, +24 Max HP.", cost: { iron_ore: 3, monster_bone: 1 } }, { id: "c_shadow_boots", category: "master", name: "Schattenläufer-Stiefel", icon: "👢", resultItem: "shadow_boots", resultCount: 1, desc: "Schuhe: +6 Rüstung, +4 ATK, +10% Crit.", cost: { spider_silk: 2, fire_essence: 1 } }, { id: "c_ember_amulet", category: "master", name: "Glutherz-Amulett", icon: "🔥", resultItem: "ember_amulet", resultCount: 1, desc: "Amulett: +7 ATK, +25 MP, +22% Magie.", cost: { fire_essence: 2, mana_shard: 2 } }, { id: "c_quiver", category: "master", name: "Elfen-Pfeilköcher", icon: "🏹", resultItem: "quiver", resultCount: 1, desc: "Nebenhand: +6 Fernkampf, spart Pfeile.", cost: { spider_silk: 2, wood_branch: 1 } }, { id: "c_mythril_bar", category: "master", name: "Mythril-Barren schmelzen", icon: "🔷", resultMat: "mythril_ingot", resultCount: 1, desc: "Edles Metall aus Erz & Kristall.", cost: { iron_ore: 3, mana_shard: 2 } }, { id: "c_mythril_sword", category: "master", name: "Mythril-Runenschwert", icon: "✨", resultItem: "mythril_sword", resultCount: 1, desc: "+34 Angriff, +20% Crit, 15% Lebensraub!", cost: { mythril_ingot: 2, fire_essence: 1 } }, { id: "c_mythril_mail", category: "master", name: "Mythril-Schuppenpanzer", icon: "🛡️", resultItem: "mythril_mail", resultCount: 1, desc: "+19 Rüstung, +45 Max HP, +10% Crit!", cost: { mythril_ingot: 2, iron_ore: 2 } }, { id: "c_mythril_leggings", category: "master", name: "Mythril-Beinplatten", icon: "👖", resultItem: "mythril_leggings", resultCount: 1, desc: "Hose: +12 Rüstung, +40 Max HP, +8% Crit!", cost: { mythril_ingot: 2, spider_silk: 2 } }, { id: "c_flame_striders", category: "master", name: "Glutläufer-Stiefel", icon: "👢", resultItem: "flame_striders", resultCount: 1, desc: "Schuhe: +9 Rüstung, +6 ATK, +30 HP!", cost: { mythril_ingot: 1, fire_essence: 2 } }, { id: "c_eye_grimhold", category: "master", name: "Auge von Grimhold", icon: "🧿", resultItem: "eye_of_grimhold", resultCount: 1, desc: "Amulett: +9 ATK, +5 Def, +35 HP, +10% Lebensraub!", cost: { mythril_ingot: 2, fire_essence: 2, mana_shard: 2 } }, { id: "c_vampire_ring", category: "master", name: "Blutring des Vampirs", icon: "🩸", resultItem: "vampire_ring", resultCount: 1, desc: "Ring: 20% Lebensraub, +10% Crit.", cost: { mana_shard: 2, healing_herb: 2, fire_essence: 1 } } ]; const SKILLS_DB = { ranged_shot: { id: "ranged_shot", name: "Fernkampf-Schuss", icon: "🎯", mpCost: 0, desc: "Feuert deine angelegte Fernkampfwaffe auf ein sichtbares Ziel." }, whirlwind: { id: "whirlwind", name: "Wirbelwind-Hieb", icon: "⚔️", mpCost: 6, desc: "Trifft alle angrenzenden Feinde mit 125% Nahkampfschaden." }, shield_bash: { id: "shield_bash", name: "Schildstoß", icon: "🛡️", mpCost: 7, desc: "Schmettert einen nahen Gegner nieder (Schaden + 2 Runden Betäubung)." }, power_snipe: { id: "power_snipe", name: "Meisterschuss", icon: "🏹", mpCost: 9, desc: "Präzisionsschuss mit +65% Fernkampfschaden." }, thorn_trap: { id: "thorn_trap", name: "Dornenfalle", icon: "🪤", mpCost: 7, desc: "Legt eine Falle auf dein Feld (30 Schaden + 2 Runden Fesselung)." }, magic_missile: { id: "magic_missile", name: "Arkanpfeil", icon: "⚡", mpCost: 7, desc: "Zielsuchendes Arkan-Projektil auf ein Monster (bis 6 Felder)." }, fireball: { id: "fireball", name: "Feuerball-Nova", icon: "🔥", mpCost: 15, desc: "Feurige Explosion auf alle Gegner im Umkreis von 3.5 Feldern." }, frost_nova: { id: "frost_nova", name: "Frostnova", icon: "❄️", mpCost: 13, desc: "Friert alle Gegner im Umkreis von 3 Feldern für 2 Runden ein." } }; const MONSTER_TYPES = { rat: { name: "Höhlenratte", icon: "🐀", hp: 16, atk: 6, def: 0, xp: 14, gold: 7, drops: ["healing_herb", "monster_bone", "wood_branch"] }, slime: { name: "Grünschleim", icon: "🟢", hp: 26, atk: 8, def: 1, xp: 20, gold: 11, drops: ["healing_herb", "mana_shard"] }, goblin: { name: "Goblin-Sammler", icon: "👺", hp: 34, atk: 10, def: 1, xp: 26, gold: 15, drops: ["iron_ore", "wood_branch"] }, skel_archer: { name: "Skelett-Schütze", icon: "🏹", hp: 40, atk: 13, def: 2, ranged: 5, projIcon: "🏹", xp: 36, gold: 20, drops: ["wood_branch", "monster_bone"] }, skeleton: { name: "Skelett-Krieger", icon: "💀", hp: 52, atk: 15, def: 3, xp: 42, gold: 24, drops: ["monster_bone", "iron_ore"] }, spider: { name: "Höhlen-Spinne", icon: "🕷️", hp: 48, atk: 14, def: 2, xp: 46, gold: 26, drops: ["spider_silk", "healing_herb"] }, orc: { name: "Ork-Plünderer", icon: "👹", hp: 88, atk: 20, def: 4, xp: 70, gold: 42, drops: ["iron_ore", "monster_bone", "fire_essence"] }, shaman: { name: "Goblin-Schamane", icon: "🧙‍♂️", hp: 68, atk: 19, def: 3, ranged: 5, projIcon: "🔥", xp: 80, gold: 50, drops: ["fire_essence", "fire_essence", "mana_shard"] }, fire_elemental: { name: "Feuer-Elementar", icon: "🔥", hp: 75, atk: 21, def: 2, ranged: 4, projIcon: "🔥", xp: 95, gold: 55, drops: ["fire_essence", "fire_essence", "fire_essence"] }, magma_slime: { name: "Magma-Schleim", icon: "🌋", hp: 58, atk: 17, def: 3, xp: 65, gold: 40, drops: ["fire_essence", "iron_ore"] }, golem: { name: "Kristall-Golem", icon: "🗿", hp: 145, atk: 26, def: 8, xp: 130, gold: 85, drops: ["iron_ore", "mana_shard", "mythril_ingot"] }, midboss_orc: { name: "Ork-Kriegsherr Grak", icon: "👹", hp: 210, atk: 25, def: 6, xp: 220, gold: 160, isMidBoss: true, drops: ["mythril_ingot", "iron_ore"] }, boss_goblin: { name: "Goblin-König Kraxx", icon: "👑", hp: 380, atk: 32, def: 8, ranged: 5, projIcon: "🔥", xp: 500, gold: 400, isBoss: true, drops: ["mythril_ingot", "fire_essence"] }, boss_frostwyrm: { name: "Frostwyrm Vaelgor", icon: "🐉", hp: 460, atk: 36, def: 10, ranged: 5, projIcon: "❄️", xp: 750, gold: 650, isBoss: true, drops: ["mythril_ingot", "mana_shard"] }, boss_ignis: { name: "Feuerfürst Ignis", icon: "👿", hp: 560, atk: 42, def: 12, ranged: 6, projIcon: "🔥", xp: 1100, gold: 950, isBoss: true, drops: ["mythril_ingot", "fire_essence"] } }; // ================= GAME STATE ================= let currentDungeonId = "catacombs"; let inTown = false; let savedDungeonFloorBeforeTown = 1; let currentFloor = 1; let mapGrid = []; let dungeonRooms = []; let fovMap = []; let monsters = []; let floorItems = []; let floorTraps = []; let projectiles = []; let floatingNumbers = []; let stairsDown = { x: -1, y: -1 }; let stairsUp = { x: -1, y: -1 }; let floorCache = {}; let hasActiveRun = false; let currentTownShopTab = "buy_gear"; let currentInventoryFilter = "all"; let currentTownShopFilter = "all"; const ITEM_FILTER_CATEGORIES = [ { id: "all", label: "Alle", icon: "✨" }, { id: "weapon", label: "Waffe", icon: "⚔️" }, { id: "helmet", label: "Hut", icon: "🪖" }, { id: "armor", label: "Rüstung", icon: "👕" }, { id: "pants", label: "Hose", icon: "👖" }, { id: "boots", label: "Schuhe", icon: "👢" }, { id: "shield", label: "Schild", icon: "🛡️" }, { id: "relic", label: "Relikt", icon: "💍" }, { id: "consumable", label: "Tränke", icon: "🧪" } ]; let onlinePlayers = []; let chatMessages = []; let aimMode = { active: false, targetIndex: 0, validTargets: [], pendingSkillOrItem: null }; let currentCraftFilter = "all"; let selectedShortcutSlot = 0; let awaitingKeybindTarget = null; // Persistent Client ID for Server Auto-Save let clientId = localStorage.getItem("mythic_client_id"); if (!clientId) { clientId = "cid_" + Math.random().toString(36).substring(2, 11) + "_" + Date.now(); try { localStorage.setItem("mythic_client_id", clientId); } catch (e) {} } let hero = { playerName: localStorage.getItem("mythic_player_name") || "Abenteurer", classId: "knight", name: "Klingensänger", icon: "⚔️", x: 5, y: 5, level: 1, xp: 0, xpNeeded: 100, statPoints: 0, spentPoints: { hp: 0, mana: 0, str: 0 }, hp: 110, maxHp: 110, baseMaxHp: 110, mp: 25, maxMp: 25, baseMaxMp: 25, baseAtk: 10, baseDef: 1, baseCrit: 0.08, baseSpellPwr: 1.0, arrows: 0, gold: 0, turns: 0, kills: 0, score: 0, house: { owned: false, tier: 0, chest: [] }, materials: { iron_ore: 0, wood_branch: 0, healing_herb: 0, mana_shard: 0, spider_silk: 0, monster_bone: 0, fire_essence: 0, mythril_ingot: 0 }, equipped: { helmet: null, armor: null, pants: null, boots: null, weapon: null, shield: null, ring: null, amulet: null }, inventory: [] }; // Bag capacity: strictly 5 slots at level 1, growing by +2 slots each level up! function getMaxBagSlots() { return Math.min(30, 5 + Math.max(0, ((hero.level || 1) - 1) * 2)); } // Class-specific attribute scaling per stat point const CLASS_STAT_GROWTH = { knight: { hp: { amount: 15, label: "+15 Max HP", desc: "+15 Lebenspunkte" }, mana: { amount: 5, label: "+5 Max MP", desc: "+5 Mana" }, str: { amount: 2, label: "+2 Angriff", desc: "+2 Nahkampf-Angriff" } }, mage: { hp: { amount: 6, label: "+6 Max HP", desc: "+6 Lebenspunkte" }, mana: { amount: 15, label: "+15 Max MP", desc: "+15 Mana" }, str: { amount: 1, label: "+1 ATK & +5% Magie", desc: "+1 Angriff & +5% Zaubermacht" } }, ranger: { hp: { amount: 10, label: "+10 Max HP", desc: "+10 Lebenspunkte" }, mana: { amount: 8, label: "+8 Max MP", desc: "+8 Mana" }, str: { amount: 2, label: "+2 Schaden & +0.5% Krit", desc: "+2 Schaden & +0.5% Krit-Chance" } } }; function spendAttributePoint(statType) { if (!hero.statPoints || hero.statPoints <= 0) { showToast("Keine freien Attributspunkte verfügbar!"); return; } hero.statPoints--; if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; hero.spentPoints[statType] = (hero.spentPoints[statType] || 0) + 1; const cid = hero.classId || "knight"; const growth = CLASS_STAT_GROWTH[cid] || CLASS_STAT_GROWTH.knight; if (statType === "hp") { const gain = growth.hp.amount; hero.baseMaxHp += gain; hero.hp = Math.min(hero.maxHp + gain, hero.hp + gain); playSound("potion"); showToast(`❤️ Lebenskraft gesteigert (+${gain} Max HP)!`); } else if (statType === "mana") { const gain = growth.mana.amount; hero.baseMaxMp += gain; hero.mp = Math.min(hero.maxMp + gain, hero.mp + gain); playSound("spell"); showToast(`✨ Willenskraft gesteigert (+${gain} Max MP)!`); } else if (statType === "str") { const gain = growth.str.amount; hero.baseAtk += gain; if (cid === "mage") hero.baseSpellPwr = (hero.baseSpellPwr || 1.0) + 0.05; if (cid === "ranger") hero.baseCrit = (hero.baseCrit || 0.08) + 0.005; playSound("hit"); showToast(`💪 Stärke gesteigert (${growth.str.label})!`); } recalcHeroStats(); updateHUD(); updateInventoryUI(); saveGameProgress(); } const DEFAULT_QUICKSLOTS = { knight: [ { type: "skill", id: "whirlwind", key: "1" }, { type: "skill", id: "shield_bash", key: "2" }, { type: "skill", id: "ranged_shot", key: "3" }, { type: "item", id: "health_potion", key: "4" }, { type: "item", id: "throwing_knife", key: "5" }, { type: "item", id: "scroll_teleport", key: "6" } ], mage: [ { type: "skill", id: "magic_missile", key: "1" }, { type: "skill", id: "fireball", key: "2" }, { type: "skill", id: "frost_nova", key: "3" }, { type: "item", id: "mana_potion", key: "4" }, { type: "item", id: "health_potion", key: "5" }, { type: "item", id: "scroll_teleport", key: "6" } ], ranger: [ { type: "skill", id: "ranged_shot", key: "1" }, { type: "skill", id: "power_snipe", key: "2" }, { type: "skill", id: "thorn_trap", key: "3" }, { type: "item", id: "health_potion", key: "4" }, { type: "item", id: "spider_net", key: "5" }, { type: "item", id: "fire_bomb", key: "6" } ] }; let quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS.knight)); let sysKeybinds = { ranged: "f", wait: " ", inv: "i", craft: "c" }; try { const savedSys = localStorage.getItem("mythic_sys_keys_v3"); if (savedSys) sysKeybinds = { ...sysKeybinds, ...JSON.parse(savedSys) }; } catch (e) {} let canvas = null; let ctx = null; let camera = { x: 0, y: 0 }; let animFrameId = null; let torchFlicker = 0; // ================= AUTO-SAVE & RESTORE SYSTEM ================= let serverSaveTimeout = null; function getFloorCacheKey(floorNum = currentFloor, dId = currentDungeonId) { return `${dId || "catacombs"}_${floorNum}`; } function saveCurrentFloorToCache() { if (inTown || !mapGrid || mapGrid.length === 0) return; floorCache[getFloorCacheKey(currentFloor, currentDungeonId)] = { mapGrid, fovMap, dungeonRooms, monsters, floorItems, floorTraps, stairsDown: { ...stairsDown }, stairsUp: { ...stairsUp } }; } function saveGameProgress() { if (!hasActiveRun || hero.hp <= 0) return; saveCurrentFloorToCache(); const stateObj = { v: 3, balanceVer: 2, currentDungeonId, inTown, savedDungeonFloorBeforeTown, currentFloor, hero, mapGrid, fovMap, dungeonRooms, monsters, floorItems, floorTraps, stairsDown, stairsUp, floorCache, quickslots }; try { const serialized = JSON.stringify(stateObj); localStorage.setItem("mythic_saved_run_v3", serialized); const badge = document.getElementById("hud-save-badge"); if (badge) { badge.textContent = "💾 Gespeichert"; badge.classList.remove("hidden"); } // Debounced Server Sync clearTimeout(serverSaveTimeout); serverSaveTimeout = setTimeout(() => { const token = localStorage.getItem("grimhold_token"); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; fetch(`/api/dungeon/save-active?client_id=${encodeURIComponent(clientId)}`, { method: "POST", headers, body: JSON.stringify({ client_id: clientId, hero_class: hero.name, run_state: serialized }) }).catch(() => {}); }, 1000); } catch (e) {} } function clearSavedGameProgress() { hasActiveRun = false; floorCache = {}; try { localStorage.removeItem("mythic_saved_run_v3"); } catch (e) {} } function restoreFromStateObject(st) { if (!st || st.v !== 3 || !st.hero || st.hero.hp <= 0) return false; currentDungeonId = st.currentDungeonId || "catacombs"; inTown = Boolean(st.inTown); savedDungeonFloorBeforeTown = st.savedDungeonFloorBeforeTown || st.currentFloor || 1; currentFloor = st.currentFloor || 1; hero = st.hero; if (!hero.highestFloor || hero.highestFloor < currentFloor) { hero.highestFloor = currentFloor; } if (hero.classId === "mage") { hero.icon = "🔮"; hero.name = "Arkanist"; } else if (hero.classId === "ranger") { hero.icon = "🏹"; hero.name = "Waldläufer"; } else { hero.icon = "⚔️"; hero.name = "Klingensänger"; } if (!hero.materials) { hero.materials = { iron_ore: 0, wood_branch: 0, healing_herb: 0, mana_shard: 0, spider_silk: 0, monster_bone: 0, fire_essence: 0, mythril_ingot: 0 }; } if (!hero.equipped) hero.equipped = {}; ALL_EQUIP_SLOTS.forEach(k => { if (hero.equipped[k] === undefined) hero.equipped[k] = null; }); // Retroactive Attribute Points Calculation: // (hero.level - 1) * 3 total earned points minus already spent points if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; if (hero.statPoints === undefined || hero.statPoints === null) hero.statPoints = 0; const totalEarnedPoints = Math.max(0, ((hero.level || 1) - 1) * 3); const totalSpentPoints = (hero.spentPoints.hp || 0) + (hero.spentPoints.mana || 0) + (hero.spentPoints.str || 0); if (hero.statPoints + totalSpentPoints < totalEarnedPoints) { hero.statPoints = totalEarnedPoints - totalSpentPoints; } mapGrid = st.mapGrid; fovMap = st.fovMap; dungeonRooms = st.dungeonRooms || []; floorCache = st.floorCache || {}; stairsDown = st.stairsDown || { x: -1, y: -1 }; stairsUp = st.stairsUp || { x: -1, y: -1 }; // Migrate old numeric floorCache keys (e.g. floorCache[5]) to dungeon-prefixed keys Object.keys(floorCache).forEach(k => { if (/^\d+$/.test(k)) { floorCache[`catacombs_${k}`] = floorCache[k]; } }); // Ensure existing saves on any Dungeon Floor >= 1 have a Stairs Up tile in start room! if (!inTown && currentFloor >= 1 && dungeonRooms.length > 0) { const sr = dungeonRooms[0]; if (stairsUp.x < 0 || mapGrid[stairsUp.y]?.[stairsUp.x]?.type !== "stairs_up") { const ux = sr.cx; const uy = sr.cy; stairsUp = { x: ux, y: uy }; if (mapGrid[uy]?.[ux]) mapGrid[uy][ux] = { type: "stairs_up" }; } } const needsRebalance = !st.balanceVer || st.balanceVer < 2; monsters = (st.monsters || []).map(m => { const proto = MONSTER_TYPES[m.typeId]; if (proto) { m.icon = proto.icon; m.name = proto.name; m.projIcon = proto.projIcon || "🔥"; if (needsRebalance && m.hp > 0) { const fresh = createMonster(m.typeId, m.x, m.y); const hpRatio = m.maxHp > 0 ? (m.hp / m.maxHp) : 1; m.maxHp = fresh.maxHp; m.hp = Math.max(1, Math.round(fresh.maxHp * hpRatio)); m.atk = fresh.atk; m.def = fresh.def; } } return m; }); floorItems = st.floorItems || []; floorTraps = st.floorTraps || []; if (st.quickslots) quickslots = st.quickslots; hasActiveRun = true; recalcHeroStats(); computeFOV(); centerCameraOnHero(); updateHUD(); updateInventoryUI(); return true; } async function tryLoadSavedRun() { const token = localStorage.getItem("grimhold_token"); // 1. If logged into an account, check Server Account Save first! if (token) { try { const res = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`, { headers: { "Authorization": `Bearer ${token}` } }); const data = await res.json(); if (data && data.has_saved_run && data.run_state) { const parsed = JSON.parse(data.run_state); if (restoreFromStateObject(parsed)) { showToast(`☁️ Konto-Spielstand geladen (Ebene ${currentFloor}, Stufe ${hero.level})!`); return true; } } } catch (e) {} } // 2. Check localStorage (instant) try { const raw = localStorage.getItem("mythic_saved_run_v3"); if (raw) { const parsed = JSON.parse(raw); if (restoreFromStateObject(parsed)) { showToast(`💾 Spielstand geladen (Ebene ${currentFloor}, Stufe ${hero.level})!`); return true; } } } catch (e) {} // 3. Fallback: Check Server SQLite backup by clientId try { const res = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`); const data = await res.json(); if (data && data.has_saved_run && data.run_state) { const parsed = JSON.parse(data.run_state); if (restoreFromStateObject(parsed)) { showToast(`💾 Server-Spielstand wiederhergestellt (Ebene ${currentFloor})!`); return true; } } } catch (e) {} return false; } // ================= TOWN HUB ("STADT GRIMHOLD-FESTE") & MULTI-DUNGEON GENERATOR ================= function toggleTownOrDungeon() { if (!hasActiveRun || isGameOver) return; if (inTown) { leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); } else { enterTownHub(); } } function enterTownHub() { if (!hasActiveRun || isGameOver) return; if (!inTown) { saveCurrentFloorToCache(); savedDungeonFloorBeforeTown = Math.max(1, currentFloor); } inTown = true; projectiles = []; floatingNumbers = []; monsters = []; floorItems = []; floorTraps = []; dungeonRooms = []; cancelAimMode(); mapGrid = []; fovMap = []; for (let y = 0; y < MAP_H; y++) { mapGrid[y] = []; fovMap[y] = []; for (let x = 0; x < MAP_W; x++) { mapGrid[y][x] = { type: "wall", hasTorch: false }; fovMap[y][x] = { explored: false, visible: false }; } } // Carve a lively 22x14 Town Square in the center of the map const tx0 = 8, ty0 = 5, tw = 20, th = 13; for (let y = ty0; y < ty0 + th; y++) { for (let x = tx0; x < tx0 + tw; x++) { mapGrid[y][x] = { type: "floor" }; fovMap[y][x] = { explored: true, visible: true }; } } // Reveal outer border walls of town for (let y = ty0 - 1; y <= ty0 + th; y++) { for (let x = tx0 - 1; x <= tx0 + tw; x++) { if (fovMap[y]?.[x]) { fovMap[y][x].explored = true; fovMap[y][x].visible = true; } } } // Decorative wall torches around town [tx0 + 3, tx0 + 9, tx0 + 16].forEach(wx => { if (mapGrid[ty0 - 1]?.[wx]) mapGrid[ty0 - 1][wx].hasTorch = true; }); const cx = tx0 + Math.floor(tw / 2); const cy = ty0 + Math.floor(th / 2); // Town Interactive Buildings (Fixed Positions) mapGrid[ty0 + 2][cx] = { type: "town_portal" }; // 🌀 Weltportal (Dungeon-Auswahl) mapGrid[cy + 1][cx + 5] = { type: "anvil" }; // ⚒️ Zentrale Stadt-Schmiede (Standard-Kosten) mapGrid[cy + 2][cx - 6] = { type: "player_house" }; // 🏡 Spieler-Grundstück & Eigenheim mapGrid[ty0 + th - 2][cx] = { type: "stairs_down" }; // 🪜 Direkt zurück in den aktuellen Dungeon // Dynamic Town NPCs (Wander freely on the square!) initTownNpcs(cx, cy); clearInterval(townNpcTimer); townNpcTimer = setInterval(updateTownNpcs, 2800); stairsDown = { x: cx, y: ty0 + th - 2 }; stairsUp = { x: -1, y: -1 }; hero.x = cx; hero.y = cy + 2; playSound("stairs"); centerCameraOnHero(); updateHUD(); saveGameProgress(); syncMultiplayerPresence(); showFloorBanner(0, "Stadt Grimhold-Feste", "Sichere Zuflucht • Händler Balthasar, Schmied Thorgar, Eigenheim & Weltportal"); logCombat(`🏘️ Willkommen in der Stadt Grimhold-Feste! Sprich mit 🧔 Händler Balthasar, 🧝‍♀️ Heilerin Seraphina, besuche dein 🏡 Eigenheim oder wähle am 🌀 Weltportal deinen Dungeon.`); } let townNpcTimer = null; let townNpcs = []; function initTownNpcs(cx, cy) { townNpcs = [ { id: "merchant", type: "npc_merchant", name: "Händler Balthasar", icon: "🧔", homeX: cx - 4, homeY: cy - 1, x: cx - 4, y: cy - 1 }, { id: "smith", type: "npc_smith", name: "Meister-Schmied Thorgar", icon: "👨‍🏭", homeX: cx + 4, homeY: cy - 1, x: cx + 4, y: cy - 1 }, { id: "healer", type: "npc_healer", name: "Heilerin Seraphina", icon: "🧝‍♀️", homeX: cx, homeY: cy, x: cx, y: cy }, { id: "citizen1", type: "npc_citizen", name: "Bürger Aldous", icon: "🧑", homeX: cx - 2, homeY: cy + 3, x: cx - 2, y: cy + 3 }, { id: "citizen2", type: "npc_citizen", name: "Wache Roderic", icon: "💂", homeX: cx + 2, homeY: cy + 3, x: cx + 2, y: cy + 3 } ]; } function updateTownNpcs() { if (!inTown || townNpcs.length === 0) return; const dirs = [{ dx: 0, dy: 1 }, { dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 0 }]; townNpcs.forEach(npc => { const d = dirs[Math.floor(Math.random() * dirs.length)]; if (d.dx === 0 && d.dy === 0) return; const nx = npc.x + d.dx; const ny = npc.y + d.dy; if (Math.hypot(nx - npc.homeX, ny - npc.homeY) <= 2.6 && isWalkable(nx, ny)) { if (mapGrid[ny]?.[nx]?.type === "floor" && !(nx === hero.x && ny === hero.y)) { const blocked = townNpcs.some(o => o.id !== npc.id && o.x === nx && o.y === ny); if (!blocked) { npc.x = nx; npc.y = ny; } } } }); } function leaveTownToDungeon(targetDungeonId = currentDungeonId, targetFloor = savedDungeonFloorBeforeTown || 1) { clearInterval(townNpcTimer); inTown = false; currentDungeonId = targetDungeonId || "catacombs"; const f = Math.max(1, Math.min(MAX_FLOORS, targetFloor || 1)); playSound("stairs"); generateDungeonFloor(f, "down"); syncMultiplayerPresence(); } function generateDungeonFloor(floorNum, arrivalMode = "down") { if (!inTown && mapGrid && mapGrid.length > 0 && currentFloor !== floorNum) { saveCurrentFloorToCache(); } inTown = false; currentFloor = floorNum; savedDungeonFloorBeforeTown = floorNum; if (!hero.highestFloor || floorNum > hero.highestFloor) { hero.highestFloor = floorNum; } projectiles = []; floatingNumbers = []; cancelAimMode(); const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const cacheKey = getFloorCacheKey(floorNum, currentDungeonId); // Check if this floor was already visited in this dungeon! if (floorCache[cacheKey] && arrivalMode !== "fresh") { const cached = floorCache[cacheKey]; mapGrid = cached.mapGrid; fovMap = cached.fovMap; dungeonRooms = cached.dungeonRooms; monsters = cached.monsters; floorItems = cached.floorItems; floorTraps = cached.floorTraps; stairsDown = cached.stairsDown || { x: -1, y: -1 }; stairsUp = cached.stairsUp || { x: -1, y: -1 }; // Ensure Floor 1 also has stairs_up leading to Town Hub if (dungeonRooms.length > 0 && (stairsUp.x < 0 || mapGrid[stairsUp.y]?.[stairsUp.x]?.type !== "stairs_up")) { stairsUp = { x: dungeonRooms[0].cx, y: dungeonRooms[0].cy }; mapGrid[stairsUp.y][stairsUp.x] = { type: "stairs_up" }; } if (arrivalMode === "up" && stairsDown.x >= 0) { hero.x = stairsDown.x; hero.y = stairsDown.y; } else if (arrivalMode === "down" && stairsUp.x >= 0) { hero.x = stairsUp.x; hero.y = stairsUp.y; } else if (dungeonRooms.length > 0) { hero.x = dungeonRooms[0].cx; hero.y = dungeonRooms[0].cy; } const aliveCount = monsters.filter(m => m.hp > 0).length; if (aliveCount < 3 && dungeonRooms.length > 2) { const r = dungeonRooms[Math.floor(Math.random() * (dungeonRooms.length - 1)) + 1]; if (Math.hypot(r.cx - hero.x, r.cy - hero.y) > 6) { monsters.push(createMonster(chooseMonsterType(floorNum), r.cx, r.cy)); } } recalcHeroStats(); computeFOV(); centerCameraOnHero(); updateHUD(); saveGameProgress(); const zone = ZONES[Math.min(floorNum - 1, ZONES.length - 1)]; showFloorBanner(floorNum, `${dCfg.icon} ${dCfg.name} — ${zone.name}`, "Bereits erkundete Ebene"); return; } monsters = []; floorItems = []; floorTraps = []; dungeonRooms = []; mapGrid = []; fovMap = []; for (let y = 0; y < MAP_H; y++) { mapGrid[y] = []; fovMap[y] = []; for (let x = 0; x < MAP_W; x++) { mapGrid[y][x] = { type: "wall", hasTorch: false }; fovMap[y][x] = { explored: false, visible: false }; } } const numRoomAttempts = 34; const minRoomSize = 5; const maxRoomSize = 11; for (let i = 0; i < numRoomAttempts; i++) { const rw = Math.floor(Math.random() * (maxRoomSize - minRoomSize + 1)) + minRoomSize; const rh = Math.floor(Math.random() * (maxRoomSize - minRoomSize + 1)) + minRoomSize; const rx = Math.floor(Math.random() * (MAP_W - rw - 4)) + 2; const ry = Math.floor(Math.random() * (MAP_H - rh - 4)) + 2; const newRoom = { x: rx, y: ry, w: rw, h: rh, cx: Math.floor(rx + rw / 2), cy: Math.floor(ry + rh / 2) }; let overlaps = false; for (const r of dungeonRooms) { if (rx - 1 < r.x + r.w && rx + rw + 1 > r.x && ry - 1 < r.y + r.h && ry + rh + 1 > r.y) { overlaps = true; break; } } if (!overlaps) { for (let y = ry; y < ry + rh; y++) { for (let x = rx; x < rx + rw; x++) { mapGrid[y][x] = { type: "floor" }; } } dungeonRooms.push(newRoom); } } if (dungeonRooms.length < 8) { return generateDungeonFloor(floorNum, arrivalMode); } for (let i = 0; i < dungeonRooms.length - 1; i++) { carveCorridor(dungeonRooms[i].cx, dungeonRooms[i].cy, dungeonRooms[i + 1].cx, dungeonRooms[i + 1].cy); } carveCorridor(dungeonRooms[dungeonRooms.length - 1].cx, dungeonRooms[dungeonRooms.length - 1].cy, dungeonRooms[0].cx, dungeonRooms[0].cy); // Place harvestable wall torches (🔥 Feueressenz!) on room walls dungeonRooms.forEach(r => { if (Math.random() < 0.72 && mapGrid[r.y - 1]?.[r.cx]?.type === "wall") mapGrid[r.y - 1][r.cx].hasTorch = true; if (Math.random() < 0.50 && mapGrid[r.y + r.h]?.[r.cx]?.type === "wall") mapGrid[r.y + r.h][r.cx].hasTorch = true; }); // Start Room: Player + Stairs Up (Floor 1 -> Town Hub, Floor >= 2 -> Floor - 1) + Anvil const startRoom = dungeonRooms[0]; hero.x = startRoom.cx; hero.y = startRoom.cy; stairsUp = { x: startRoom.cx, y: startRoom.cy }; mapGrid[stairsUp.y][stairsUp.x] = { type: "stairs_up" }; if (floorNum === 1 && isWalkable(startRoom.x + startRoom.w - 2, startRoom.y + 1)) { mapGrid[startRoom.y + 1][startRoom.x + startRoom.w - 2] = { type: "node_wood" }; } // Place 1 Damaged Dungeon Forge in one of the explored rooms (+20% Materialkosten) if (dungeonRooms.length > 2) { const fRoom = dungeonRooms[1]; if (isWalkable(fRoom.cx, fRoom.cy)) { mapGrid[fRoom.cy][fRoom.cx] = { type: "forge_damaged" }; } } // Place Stairs Down in furthest room const endRoom = dungeonRooms[dungeonRooms.length - 1]; if (floorNum < MAX_FLOORS) { stairsDown = { x: endRoom.cx, y: endRoom.cy }; mapGrid[stairsDown.y][stairsDown.x] = { type: "stairs_down" }; } else { stairsDown = { x: -1, y: -1 }; } if (arrivalMode === "up" && stairsDown.x >= 0) { hero.x = stairsDown.x; hero.y = stairsDown.y; } // Populate Rooms const monsterSpawnRooms = dungeonRooms.slice(1); monsterSpawnRooms.forEach((r, idx) => { const isFinalBoss = (floorNum === MAX_FLOORS && idx === monsterSpawnRooms.length - 1); const isMidBoss = (floorNum === 5 && idx === monsterSpawnRooms.length - 1); if (isFinalBoss) { monsters.push(createMonster(dCfg.bossId || "boss_goblin", r.cx, r.cy)); monsters.push(createMonster("shaman", r.cx - 2, r.cy)); monsters.push(createMonster("golem", r.cx + 2, r.cy)); return; } if (isMidBoss) { monsters.push(createMonster(dCfg.midBossId || "midboss_orc", r.cx, r.cy)); monsters.push(createMonster("shaman", r.cx - 2, r.cy)); monsters.push(createMonster("skel_archer", r.cx + 2, r.cy)); return; } const count = Math.floor(Math.random() * 2) + 1 + Math.floor(floorNum / 2.2); for (let c = 0; c < count; c++) { const mx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); const my = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); if (isWalkable(mx, my) && !(mx === stairsDown.x && my === stairsDown.y) && !getMonsterAt(mx, my)) { monsters.push(createMonster(chooseMonsterType(floorNum), mx, my)); } } // Chests if (Math.random() < 0.50) { const cx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); const cy = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); if (isWalkable(cx, cy) && mapGrid[cy][cx].type === "floor" && !getMonsterAt(cx, cy)) { mapGrid[cy][cx] = { type: "chest" }; } } // Resource Nodes const nodeCount = 1 + (Math.random() < 0.70 ? 1 : 0); for (let n = 0; n < nodeCount; n++) { const nx = r.x + 1 + Math.floor(Math.random() * (r.w - 2)); const ny = r.y + 1 + Math.floor(Math.random() * (r.h - 2)); if (isWalkable(nx, ny) && mapGrid[ny][nx].type === "floor" && !getMonsterAt(nx, ny)) { const roll = Math.random(); const nodeType = roll < 0.30 ? "node_ore" : (roll < 0.58 ? "node_wood" : (roll < 0.82 ? "node_herb" : "node_crystal")); mapGrid[ny][nx] = { type: nodeType }; } } }); recalcHeroStats(); computeFOV(); centerCameraOnHero(); updateHUD(); saveGameProgress(); const zone = ZONES[Math.min(floorNum - 1, ZONES.length - 1)]; showFloorBanner(floorNum, `${dCfg.icon} ${dCfg.name} — ${zone.name}`, zone.desc); } function centerCameraOnHero() { const rectW = canvas ? canvas.getBoundingClientRect().width : window.innerWidth; const rectH = canvas ? canvas.getBoundingClientRect().height : window.innerHeight; camera.x = hero.x * tileSize + tileSize / 2 - (rectW > 0 ? rectW / 2 : 400); camera.y = hero.y * tileSize + tileSize / 2 - (rectH > 0 ? rectH / 2 : 300); } function carveCorridor(x1, y1, x2, y2) { let cx = x1, cy = y1; while (cx !== x2) { if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; cx += (x2 > cx) ? 1 : -1; } while (cy !== y2) { if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; cy += (y2 > cy) ? 1 : -1; } if (mapGrid[cy][cx].type === "wall") mapGrid[cy][cx] = { type: "floor" }; } function chooseMonsterType(floor) { const r = Math.random(); if (currentDungeonId === "frost_caverns") { if (floor <= 2) return r < 0.35 ? "slime" : (r < 0.70 ? "skel_archer" : "skeleton"); if (floor <= 6) return r < 0.30 ? "skel_archer" : (r < 0.65 ? "spider" : "golem"); return r < 0.35 ? "shaman" : (r < 0.70 ? "orc" : "golem"); } if (currentDungeonId === "magma_sanctum") { if (floor <= 3) return r < 0.35 ? "orc" : (r < 0.75 ? "shaman" : "spider"); return r < 0.30 ? "orc" : (r < 0.65 ? "shaman" : "golem"); } if (floor === 1) return r < 0.50 ? "rat" : (r < 0.82 ? "slime" : "goblin"); if (floor === 2) return r < 0.25 ? "rat" : (r < 0.58 ? "slime" : (r < 0.85 ? "goblin" : "skel_archer")); if (floor <= 4) return r < 0.20 ? "goblin" : (r < 0.50 ? "skel_archer" : (r < 0.78 ? "skeleton" : "spider")); if (floor <= 7) return r < 0.22 ? "spider" : (r < 0.48 ? "skeleton" : (r < 0.76 ? "orc" : "shaman")); return r < 0.32 ? "orc" : (r < 0.65 ? "shaman" : "golem"); } function chooseRandomDrop(floor) { const roll = Math.random(); if (roll < 0.28) return "health_potion"; if (roll < 0.42) return "mana_potion"; if (roll < 0.52) return "throwing_knife"; if (roll < 0.60) return "scroll_teleport"; if (roll < 0.66) return "scroll_map"; const gearPool = floor <= 2 ? ["dagger", "iron_sword", "short_bow", "leather_cap", "hide_vest", "cloth_trousers", "traveler_boots", "wood_shield", "buckler", "bone_ring", "bone_talisman"] : (floor <= 5 ? ["spear", "elven_bow", "iron_helm", "leather_armor", "leather_pants", "iron_greaves", "iron_sabatons", "arcane_circlet", "mage_robe", "ruby_ring", "sapphire_ring", "ward_amulet", "quiver"] : ["battle_axe", "composite_bow", "heavy_crossbow", "fire_staff", "plate_armor", "mythril_leggings", "shadow_boots", "flame_striders", "tower_shield", "focus_orb", "amulet_sun", "ember_amulet", "eye_of_grimhold", "archmage_staff"]); return gearPool[Math.floor(Math.random() * gearPool.length)]; } function createMonster(typeId, x, y) { const proto = MONSTER_TYPES[typeId] || MONSTER_TYPES.goblin; const f = Math.max(1, currentFloor); const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const dMult = dCfg.diffMult || 1.0; const gMult = dCfg.goldMult || 1.0; const hpScale = (1 + (f - 1) * 0.24 + Math.pow(Math.max(0, f - 2), 1.35) * 0.14) * dMult; const atkScale = (1 + (f - 1) * 0.20 + Math.max(0, f - 3) * 0.11) * Math.pow(dMult, 0.85); const defBonus = Math.floor((f - 1) * 0.9 * dMult); const hp = Math.round(proto.hp * hpScale); return { id: Math.random().toString(), typeId, name: proto.name, icon: proto.icon, x, y, hp, maxHp: hp, atk: Math.round(proto.atk * atkScale), def: (proto.def || 0) + defBonus, ranged: proto.ranged || 0, projIcon: proto.projIcon || "🔥", xp: Math.round(proto.xp * (1 + (f - 1) * 0.12) * dMult), gold: Math.round(proto.gold * (1 + (f - 1) * 0.12) * gMult), drops: proto.drops || ["iron_ore"], isBoss: proto.isBoss || false, isMidBoss: proto.isMidBoss || false, alerted: false, stunnedTurns: 0 }; } let isGameOver = false; let selectedClassId = "knight"; let miniCanvas = null; let miniCtx = null; // ================= STATS, FOV & COMBAT ENGINE ================= function getHeroCombatStats() { let atk = hero.baseAtk; let def = hero.baseDef; let maxHp = hero.baseMaxHp; let maxMp = hero.baseMaxMp; let crit = hero.baseCrit || 0.08; let rangedAtk = 0; let range = 4; let spellPwr = hero.baseSpellPwr || 1.0; let lifesteal = 0; let lightBonus = 0; if (!hero.equipped) { hero.equipped = {}; } ALL_EQUIP_SLOTS.forEach(slot => { const entry = hero.equipped[slot]; if (!entry) return; const itemId = typeof entry === "string" ? entry : entry.id; const lvl = typeof entry === "object" ? (entry.upgrade || 0) : 0; const it = ITEMS[itemId]; if (!it) return; if (slot === "weapon") { if (it.type === "ranged" || it.rangedAtk > 0) { rangedAtk = (it.rangedAtk || 0) + lvl * 3; if (it.range && it.range > range) range = it.range; if (it.spellPwr) spellPwr += it.spellPwr + lvl * 0.05; // Ranged weapon does not increase base melee atk (bow in melee deals fixed 1 ATK + knockback) } else { if (it.atk) atk += it.atk + lvl * 2; } } else { if (it.atk) atk += it.atk + lvl * 2; } if (it.def) def += it.def + lvl * 2; if (it.maxHp) maxHp += it.maxHp + lvl * 8; if (it.maxMp) maxMp += it.maxMp + lvl * 5; if (it.crit) crit += it.crit + lvl * 0.02; if (it.spellPwr && slot !== "weapon") spellPwr += it.spellPwr + lvl * 0.05; if (it.lifesteal) lifesteal += it.lifesteal; if (it.lightRadius) lightBonus += it.lightRadius; }); return { atk, def, maxHp, maxMp, crit, rangedAtk, range, spellPwr, lifesteal, lightBonus }; } function recalcHeroStats() { const s = getHeroCombatStats(); hero.maxHp = s.maxHp; hero.maxMp = s.maxMp; if (hero.hp > hero.maxHp) hero.hp = hero.maxHp; if (hero.mp > hero.maxMp) hero.mp = hero.maxMp; } function computeFOV() { if (inTown) { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (fovMap[y]?.[x] && mapGrid[y]?.[x]?.type !== "wall") { fovMap[y][x].visible = true; fovMap[y][x].explored = true; } } } return; } const s = getHeroCombatStats(); const radius = 8.5 + (s.lightBonus || 0); for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (fovMap[y]?.[x]) fovMap[y][x].visible = false; } } for (let angle = 0; angle < 360; angle += 1.5) { const rad = angle * (Math.PI / 180); const dx = Math.cos(rad); const dy = Math.sin(rad); let ox = hero.x + 0.5; let oy = hero.y + 0.5; for (let step = 0; step < radius; step++) { const tx = Math.floor(ox); const ty = Math.floor(oy); if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) break; fovMap[ty][tx].visible = true; fovMap[ty][tx].explored = true; if (mapGrid[ty][tx].type === "wall") break; ox += dx * 0.85; oy += dy * 0.85; } } // Wall Torches cast a persistent illumination radius (~2.8 tiles), permanently revealing fog of war! for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (mapGrid[y]?.[x]?.hasTorch) { for (let dy = -3; dy <= 3; dy++) { for (let dx = -3; dx <= 3; dx++) { if (Math.hypot(dx, dy) <= 2.8) { const tx = x + dx; const ty = y + dy; if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H && fovMap[ty]?.[tx]) { fovMap[ty][tx].visible = true; fovMap[ty][tx].explored = true; } } } } } } } } function isWalkable(x, y) { if (x < 0 || x >= MAP_W || y < 0 || y >= MAP_H) return false; return mapGrid[y]?.[x]?.type !== "wall"; } function getMonsterAt(x, y) { return monsters.find(m => m.x === x && m.y === y && m.hp > 0); } function spawnProjectile(sx, sy, tx, ty, icon, color = "#fbbf24") { projectiles.push({ x: sx * tileSize + tileSize / 2, y: sy * tileSize + tileSize / 2, tx: tx * tileSize + tileSize / 2, ty: ty * tileSize + tileSize / 2, icon, color, progress: 0 }); } // ================= PLAYER ACTIONS & HARVESTING ================= function handleDirectionalMove(dx, dy) { if (isGameOver || !hasActiveRun) return; if (aimMode.active) cancelAimMode(); const nx = hero.x + dx; const ny = hero.y + dy; if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H) return; // Town NPC Collision Check (Dynamic NPCs) if (inTown && townNpcs.length > 0) { const npc = townNpcs.find(n => n.x === nx && n.y === ny); if (npc) { if (npc.type === "npc_merchant") { openTownShopModal(); return; } if (npc.type === "npc_smith") { openCraftingModal(); return; } if (npc.type === "npc_healer") { interactWithHealerNpc(nx, ny); return; } if (npc.type === "npc_citizen") { showToast(`${npc.icon} ${npc.name}: "Sei gegrüßt in der Grimhold-Feste, Abenteurer!"`); return; } } } const targetMonster = getMonsterAt(nx, ny); if (targetMonster) { executePlayerMelee(targetMonster); endPlayerTurn(); return; } // Spear 2-tile reach check const wepEntry = hero.equipped?.weapon; const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; if (!inTown && wepId && ITEMS[wepId]?.reach === 2 && isWalkable(nx, ny)) { const reachMonster = getMonsterAt(nx + dx, ny + dy); if (reachMonster) { executePlayerMelee(reachMonster); endPlayerTurn(); return; } } const tile = mapGrid[ny][nx]; if (tile.type === "wall") { // Wall Torches are persistent light sources, not harvestable! return; } // Town Buildings & Interactive Objects if (tile.type === "player_house") { openPlayerHouseModal(); return; } if (tile.type === "forge_damaged") { openCraftingModal(); return; } if (tile.type === "npc_merchant") { openTownShopModal(); return; } if (tile.type === "npc_smith" || tile.type === "anvil") { openCraftingModal(); return; } if (tile.type === "npc_healer") { interactWithHealerNpc(nx, ny); return; } if (tile.type === "town_portal") { openDungeonsModal(); return; } if (tile.type.startsWith("node_")) { harvestResourceNode(nx, ny, tile.type); endPlayerTurn(); return; } hero.x = nx; hero.y = ny; playSound("step"); if (tile.type === "chest") { openChestAt(nx, ny); } checkLootPickup(nx, ny); if (tile.type === "stairs_down") { if (inTown) { leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); } else { descendStairs(); } return; } if (tile.type === "stairs_up") { ascendStairs(); return; } if (inTown) { saveGameProgress(); } else { endPlayerTurn(); } } function interactWithHealerNpc(tx, ty) { if (hero.hp >= hero.maxHp && hero.mp >= hero.maxMp) { showToast("🧝‍♀️ Heilerin Seraphina: Deine Lebenskraft und dein Mana sind bereits voll!"); return; } const cost = 15; if (hero.gold < cost) { hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.4)); hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.4)); playSound("potion"); updateHUD(); saveGameProgress(); showToast("🧝‍♀️ Seraphina spendet dir einen kostenlosen Not-Segen (+40% LP/MP)!"); return; } hero.gold -= cost; hero.hp = hero.maxHp; hero.mp = hero.maxMp; playSound("levelup"); spawnFloatingText(tx, ty, "✨ Vollheilung!", "#34d399"); updateHUD(); saveGameProgress(); logCombat(`🧝‍♀️ Heilerin Seraphina heilt deine Wunden und dein Mana vollständig (-${cost}💰)!`); } function addMaterial(matKey, qty = 1) { if (!hero.materials) hero.materials = {}; hero.materials[matKey] = (hero.materials[matKey] || 0) + qty; updateHUD(); } function harvestResourceNode(x, y, nodeType) { playSound("hit"); mapGrid[y][x] = { type: "floor" }; let matId = "iron_ore"; let qty = 1 + (Math.random() < 0.65 ? 1 : 0); if (nodeType === "node_ore") { matId = (currentFloor >= 4 && Math.random() < 0.30) ? "mythril_ingot" : "iron_ore"; } else if (nodeType === "node_wood") { matId = "wood_branch"; qty = 2 + (Math.random() < 0.5 ? 1 : 0); } else if (nodeType === "node_herb") { matId = "healing_herb"; } else if (nodeType === "node_crystal") { matId = (currentDungeonId === "magma_sanctum" && Math.random() < 0.55) ? "fire_essence" : "mana_shard"; } addMaterial(matId, qty); const meta = MATERIAL_META[matId] || { name: matId, icon: "📦" }; spawnFloatingText(x, y, `+${qty} ${meta.icon}`, "#6ee7b7"); logCombat(`⛏️ Abgebaut: ${qty}x ${meta.icon} ${meta.name}!`); } function executePlayerMelee(monster) { const wepEntry = hero.equipped?.weapon; const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; const isRangedWep = wepId && (ITEMS[wepId]?.type === "ranged" || ITEMS[wepId]?.rangedAtk > 0); // RANGED WEAPON IN MELEE RANGE: Deals minimal 1 ATK damage + Knocks enemy back 1 tile! if (isRangedWep) { const dmg = 1; monster.hp -= dmg; monster.alerted = true; playSound("hit"); const kx = Math.sign(monster.x - hero.x); const ky = Math.sign(monster.y - hero.y); const destX = monster.x + kx; const destY = monster.y + ky; let knocked = false; if (isWalkable(destX, destY) && !getMonsterAt(destX, destY) && !(destX === hero.x && destY === hero.y)) { monster.x = destX; monster.y = destY; knocked = true; } spawnFloatingText(monster.x, monster.y, knocked ? "💥 1 + Rückstoß!" : "💥 1", "#fbbf24"); logCombat(`🏹 Bogen-Nahkampf: Du stößt ${monster.name} mit dem Bogen zurück (1 Schaden${knocked ? " + 1 Feld Rückstoß" : ""})!`); if (monster.hp <= 0) handleMonsterDeath(monster); return; } const stats = getHeroCombatStats(); const isCrit = Math.random() < stats.crit; const rawDmg = stats.atk + Math.floor(Math.random() * 4) - 1; const dmg = Math.max(1, Math.round((isCrit ? rawDmg * 1.65 : rawDmg) - (monster.def || 0))); monster.hp -= dmg; monster.alerted = true; playSound("hit"); if (stats.lifesteal > 0) { const heal = Math.max(1, Math.round(dmg * stats.lifesteal)); hero.hp = Math.min(hero.maxHp, hero.hp + heal); } spawnFloatingText(monster.x, monster.y, isCrit ? `💥 ${dmg}!` : `-${dmg}`, isCrit ? "#fbbf24" : "#f87171"); logCombat(`⚔️ Du triffst ${monster.name} für ${dmg} Schaden.`); if (monster.hp <= 0) handleMonsterDeath(monster); } function openChestAt(x, y) { mapGrid[y][x] = { type: "floor" }; playSound("chest"); const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const goldAmt = Math.round((Math.floor(Math.random() * 25) + 12 * currentFloor) * (dCfg.goldMult || 1)); hero.gold += goldAmt; const dropId = chooseRandomDrop(currentFloor); addItemToInventory(dropId, 1); const bonusMat = ["iron_ore", "wood_branch", "healing_herb", "mana_shard", "fire_essence"][Math.floor(Math.random() * 5)]; addMaterial(bonusMat, 2); spawnFloatingText(x, y, `📦 +${goldAmt}G`, "#fbbf24"); logCombat(`📦 Truhe: +${goldAmt}G, ${ITEMS[dropId]?.name || dropId} & +2 ${MATERIAL_META[bonusMat].icon}!`); } function checkLootPickup(x, y) { for (let i = floorItems.length - 1; i >= 0; i--) { const fi = floorItems[i]; if (fi.x === x && fi.y === y) { if (fi.matKey || MATERIAL_META[fi.itemId]) { const mk = fi.matKey || fi.itemId; addMaterial(mk, fi.qty || 1); const m = MATERIAL_META[mk]; spawnFloatingText(x, y, `+${fi.qty || 1} ${m.icon}`, "#34d399"); logCombat(`👜 Gesammelt: ${fi.qty || 1}x ${m.icon} ${m.name}.`); floorItems.splice(i, 1); } else if (addItemToInventory(fi.itemId, fi.qty || 1)) { logCombat(`🎒 Aufgehoben: ${ITEMS[fi.itemId]?.name || fi.itemId}.`); floorItems.splice(i, 1); } else { showToast("Beutel voll! Öffne das Inventar.", "#ef4444"); } } } } function playerWaitTurn() { if (isGameOver || !hasActiveRun) return; if (aimMode.active) cancelAimMode(); const curTile = mapGrid[hero.y]?.[hero.x]; if (curTile?.type === "stairs_down") { if (inTown) leaveTownToDungeon(currentDungeonId, savedDungeonFloorBeforeTown || 1); else descendStairs(); return; } if (curTile?.type === "stairs_up") { ascendStairs(); return; } if (hero.mp < hero.maxMp) hero.mp = Math.min(hero.maxMp, hero.mp + 1); spawnFloatingText(hero.x, hero.y, "⏳ +1 MP", "#94a3b8"); if (!inTown) endPlayerTurn(); } function descendStairs() { if (currentFloor >= MAX_FLOORS) return; playSound("stairs"); const nextFloor = currentFloor + 1; const cacheKey = getFloorCacheKey(nextFloor, currentDungeonId); const isFirstVisit = !floorCache[cacheKey] && nextFloor > (hero.highestFloor || 1); if (isFirstVisit) { hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.12)); hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.20)); logCombat(`🔽 Du steigst hinab auf Ebene ${nextFloor} (+12% LP regeneriert).`); } else { logCombat(`🔽 Du steigst wieder hinab auf Ebene ${nextFloor}.`); } generateDungeonFloor(nextFloor, "down"); syncMultiplayerPresence(); } function ascendStairs() { if (currentFloor <= 1) { enterTownHub(); return; } playSound("stairs"); const prevFloor = currentFloor - 1; logCombat(`🔼 Du kletterst zurück nach oben auf Ebene ${prevFloor}.`); generateDungeonFloor(prevFloor, "up"); syncMultiplayerPresence(); } // ================= RANGED COMBAT & SKILLS ================= function getVisibleMonstersInRange(maxRange) { return monsters.filter(m => { if (m.hp <= 0) return false; if (!fovMap[m.y]?.[m.x]?.visible) return false; const dist = Math.hypot(m.x - hero.x, m.y - hero.y); return dist <= maxRange; }).sort((a, b) => Math.hypot(a.x - hero.x, a.y - hero.y) - Math.hypot(b.x - hero.x, b.y - hero.y)); } function triggerRangedAttackOrAim() { if (isGameOver || !hasActiveRun) return; const wepEntry = hero.equipped?.weapon || hero.equipped?.ranged; const wepId = typeof wepEntry === "string" ? wepEntry : wepEntry?.id; const wepProto = wepId ? ITEMS[wepId] : null; const usesMana = (wepProto && wepProto.ammoType === "mana") || (!wepProto && hero.classId === "mage"); if (!usesMana && (hero.arrows || 0) <= 0) { cancelAimMode(); openNoArrowsModal(); return; } const s = getHeroCombatStats(); const maxR = Math.max(5.5, s.range + 1); if (aimMode.active) { const targets = getVisibleMonstersInRange(maxR); const chosen = targets[aimMode.targetIndex] || targets[0]; if (chosen) { fireRangedAtMonster(chosen); cancelAimMode(); return; } } const targets = getVisibleMonstersInRange(maxR); if (targets.length === 0) { showToast("Kein sichtbares Ziel in Fernkampfreichweite!"); return; } aimMode.active = true; aimMode.validTargets = targets; aimMode.targetIndex = 0; const banner = document.getElementById("aim-mode-banner"); const txt = document.getElementById("aim-mode-text"); if (banner) banner.classList.remove("hidden"); if (txt) txt.textContent = `🎯 Ziel: ${targets[0].name} (Nochmal drücken oder antippen zum Feuern!)`; } function cycleAimTarget() { const targets = getVisibleMonstersInRange(6.5); if (targets.length === 0) { cancelAimMode(); return; } aimMode.validTargets = targets; aimMode.targetIndex = (aimMode.targetIndex + 1) % targets.length; const txt = document.getElementById("aim-mode-text"); if (txt) txt.textContent = `🎯 Ziel: ${targets[aimMode.targetIndex].name}`; } function cancelAimMode() { aimMode.active = false; aimMode.validTargets = []; aimMode.targetIndex = 0; document.getElementById("aim-mode-banner")?.classList.add("hidden"); } function fireRangedAtMonster(monster, bonusMult = 1.0) { const stats = getHeroCombatStats(); const rangedEntry = hero.equipped?.weapon || hero.equipped?.ranged; const rangedId = typeof rangedEntry === "string" ? rangedEntry : rangedEntry?.id; const rangedItem = rangedId ? ITEMS[rangedId] : null; const usesMana = (rangedItem && rangedItem.ammoType === "mana") || (!rangedItem && hero.classId === "mage"); if (usesMana) { const mpCost = rangedItem ? (rangedItem.manaCost || 2) : 2; if (hero.mp < mpCost) { showToast(`Nicht genug Mana (${mpCost} MP benötigt)!`); return false; } hero.mp -= mpCost; spawnProjectile(hero.x, hero.y, monster.x, monster.y, rangedItem?.projIcon || "✨", "#38bdf8"); } else { if ((hero.arrows || 0) <= 0) { cancelAimMode(); openNoArrowsModal(); return false; } // Elven quiver 35% chance to save arrow const shEntry = hero.equipped?.shield; const shId = typeof shEntry === "string" ? shEntry : shEntry?.id; if (!(shId === "quiver" && Math.random() < 0.35)) { hero.arrows--; } spawnProjectile(hero.x, hero.y, monster.x, monster.y, rangedItem?.projIcon || "🏹", "#fbbf24"); } playSound("shoot"); const isCrit = Math.random() < stats.crit; const baseDmg = (stats.rangedAtk > 0 ? stats.rangedAtk + Math.round(stats.atk * 0.45) : Math.round(stats.atk * 0.85)) * bonusMult; const dmg = Math.max(2, Math.round((isCrit ? baseDmg * 1.6 : baseDmg) - Math.floor((monster.def || 0) * 0.5))); monster.hp -= dmg; monster.alerted = true; spawnFloatingText(monster.x, monster.y, isCrit ? `🎯 ${dmg}!` : `🏹 -${dmg}`, isCrit ? "#fbbf24" : "#34d399"); logCombat(`🏹 Fernkampf auf ${monster.name}: ${dmg} Schaden!`); if (monster.hp <= 0) handleMonsterDeath(monster); endPlayerTurn(); return true; } function executeSkillById(skillId) { if (isGameOver || !hasActiveRun) return; const sk = SKILLS_DB[skillId]; if (!sk) return; if (skillId === "ranged_shot") { triggerRangedAttackOrAim(); return; } if (hero.mp < sk.mpCost) { showToast(`Nicht genug Mana für ${sk.name} (${sk.mpCost} MP)!`); return; } const stats = getHeroCombatStats(); if (skillId === "whirlwind") { hero.mp -= sk.mpCost; playSound("hit"); let hits = 0; monsters.forEach(m => { if (m.hp > 0 && Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1) { const dmg = Math.max(3, Math.round(stats.atk * 1.3)); m.hp -= dmg; hits++; spawnFloatingText(m.x, m.y, `⚔️ -${dmg}`, "#fbbf24"); if (m.hp <= 0) handleMonsterDeath(m); } }); spawnFloatingText(hero.x, hero.y, "⚔️ WIRBELWIND", "#fbbf24"); logCombat(`⚔️ Wirbelwind-Hieb trifft ${hits} Gegner!`); endPlayerTurn(); } else if (skillId === "shield_bash") { const adj = monsters.find(m => m.hp > 0 && Math.abs(m.x - hero.x) <= 1.5 && Math.abs(m.y - hero.y) <= 1.5); if (!adj) { showToast("Kein Gegner direkt neben dir für Schildstoß!"); return; } hero.mp -= sk.mpCost; playSound("hit"); const dmg = Math.max(3, Math.round(stats.atk * 1.1 + stats.def)); adj.hp -= dmg; adj.stunnedTurns = 2; spawnFloatingText(adj.x, adj.y, `🛡️ BETÄUBT -${dmg}`, "#38bdf8"); logCombat(`🛡️ Schildstoß betäubt ${adj.name} für 2 Runden (-${dmg} LP)!`); if (adj.hp <= 0) handleMonsterDeath(adj); endPlayerTurn(); } else if (skillId === "power_snipe") { const targets = getVisibleMonstersInRange(6.5); if (targets.length === 0) { showToast("Kein sichtbares Ziel in Reichweite!"); return; } hero.mp -= sk.mpCost; fireRangedAtMonster(targets[0], 1.65); } else if (skillId === "thorn_trap") { hero.mp -= sk.mpCost; floorTraps.push({ x: hero.x, y: hero.y, dmg: Math.round(stats.atk * 1.5 + 14) }); playSound("chest"); spawnFloatingText(hero.x, hero.y, "🪤 Dornenfalle", "#34d399"); logCombat(`🪤 Du legst eine Dornenfalle auf dein Feld.`); endPlayerTurn(); } else if (skillId === "magic_missile") { const targets = getVisibleMonstersInRange(6.5); if (targets.length === 0) { showToast("Kein sichtbarer Gegner in Reichweite!"); return; } hero.mp -= sk.mpCost; playSound("spell"); const t = targets[0]; spawnProjectile(hero.x, hero.y, t.x, t.y, "⚡", "#38bdf8"); const dmg = Math.round((stats.atk + 10) * stats.spellPwr); t.hp -= dmg; t.alerted = true; spawnFloatingText(t.x, t.y, `⚡ -${dmg}`, "#38bdf8"); logCombat(`⚡ Arkanpfeil trifft ${t.name} für ${dmg} Schaden!`); if (t.hp <= 0) handleMonsterDeath(t); endPlayerTurn(); } else if (skillId === "fireball") { hero.mp -= sk.mpCost; playSound("spell"); let count = 0; monsters.forEach(m => { if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 3.8) { const dmg = Math.round((stats.atk * 1.3 + 12) * stats.spellPwr); m.hp -= dmg; count++; spawnFloatingText(m.x, m.y, `🔥 -${dmg}`, "#f97316"); if (m.hp <= 0) handleMonsterDeath(m); } }); spawnFloatingText(hero.x, hero.y, "🔥 FEUERBALL-NOVA", "#f97316"); logCombat(`🔥 Feuerball-Nova verbrennt ${count} Gegner!`); endPlayerTurn(); } else if (skillId === "frost_nova") { hero.mp -= sk.mpCost; playSound("spell"); let count = 0; monsters.forEach(m => { if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 3.5) { m.stunnedTurns = 2; const dmg = Math.max(4, Math.round(stats.atk * 0.8 * stats.spellPwr)); m.hp -= dmg; count++; spawnFloatingText(m.x, m.y, `❄️ FROST -${dmg}`, "#38bdf8"); if (m.hp <= 0) handleMonsterDeath(m); } }); logCombat(`❄️ Frostnova friert ${count} Gegner für 2 Runden ein!`); endPlayerTurn(); } } function useQuickSlot(slotIdx) { if (isGameOver || !hasActiveRun) return; const entry = quickslots[slotIdx]; if (!entry || !entry.id) { openShortcutsModal(); return; } if (entry.type === "skill") { executeSkillById(entry.id); } else if (entry.type === "item") { const invIdx = hero.inventory.findIndex(s => s.id === entry.id); if (invIdx === -1) { showToast(`Kein ${ITEMS[entry.id]?.name || "Gegenstand"} im Rucksack!`); return; } useInventoryItem(invIdx); } } // ================= MONSTER TURN AI ================= function endPlayerTurn() { hero.turns = (hero.turns || 0) + 1; for (const m of monsters) { if (m.hp <= 0) continue; if (m.stunnedTurns > 0) { m.stunnedTurns--; continue; } const dist = Math.hypot(m.x - hero.x, m.y - hero.y); if (fovMap[m.y]?.[m.x]?.visible && dist <= 9.0) { m.alerted = true; } if (!m.alerted) continue; // Shaman special: 30% chance to heal a wounded ally in range if (m.typeId === "shaman" && Math.random() < 0.30) { const woundedAlly = monsters.find(a => a.hp > 0 && a.hp < a.maxHp * 0.75 && Math.hypot(a.x - m.x, a.y - m.y) <= 4.5); if (woundedAlly) { const healAmt = Math.round(14 + currentFloor * 3); woundedAlly.hp = Math.min(woundedAlly.maxHp, woundedAlly.hp + healAmt); spawnFloatingText(woundedAlly.x, woundedAlly.y, `💚 +${healAmt}`, "#34d399"); logCombat(`🧙‍♂️ ${m.name} heilt ${woundedAlly.name} um +${healAmt} LP!`); continue; } } if (Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1 && (m.x === hero.x || m.y === hero.y)) { executeMonsterMelee(m); } else if (m.ranged > 0 && fovMap[m.y]?.[m.x]?.visible && dist <= m.ranged && Math.random() < 0.72) { executeMonsterRanged(m); } else { moveMonsterTowardsHero(m); } if (hero.hp <= 0) { triggerGameOver(false, m.name); return; } } recalcHeroStats(); computeFOV(); updateHUD(); saveGameProgress(); } function executeMonsterMelee(m) { const stats = getHeroCombatStats(); let atkPower = m.atk + Math.floor(Math.random() * 4); let specialNote = ""; // Orc / Boss Berserk below 60% HP if ((m.typeId === "orc" || m.isMidBoss || m.isBoss) && m.hp < m.maxHp * 0.60) { atkPower = Math.round(atkPower * 1.30); specialNote = " (🔥 Kampfrausch!)"; } const minFloorDmg = Math.max(2, Math.floor(currentFloor * 0.85)); let dmg = Math.max(minFloorDmg, atkPower - Math.floor(stats.def * 0.55)); // Spider Poison Bite if (m.typeId === "spider" && Math.random() < 0.35) { const poisonDmg = 3 + Math.floor(currentFloor * 0.8); dmg += poisonDmg; specialNote = ` (+${poisonDmg} 🕷️ Gift!)`; } hero.hp -= dmg; playSound("hit"); spawnFloatingText(hero.x, hero.y, `-${dmg}`, "#ef4444"); logCombat(`🩸 ${m.name} trifft dich für ${dmg} Schaden${specialNote}.`); } function executeMonsterRanged(m) { const stats = getHeroCombatStats(); spawnProjectile(m.x, m.y, hero.x, hero.y, m.projIcon || "🏹", "#f87171"); const minFloorDmg = Math.max(2, Math.floor(currentFloor * 0.75)); const defFactor = m.typeId === "shaman" ? 0.30 : 0.48; // Shaman magic pierces armor const dmg = Math.max(minFloorDmg, Math.round(m.atk * 0.90) - Math.floor(stats.def * defFactor)); hero.hp -= dmg; playSound("shoot"); spawnFloatingText(hero.x, hero.y, `-${dmg}`, "#fb923c"); logCombat(`${m.projIcon || "🏹"} ${m.name} trifft dich aus der Distanz (-${dmg} LP)!`); } function moveMonsterTowardsHero(m) { const dx = Math.sign(hero.x - m.x); const dy = Math.sign(hero.y - m.y); const candidates = []; // Primary cardinal steps prioritized by greater distance axis if (Math.abs(hero.x - m.x) >= Math.abs(hero.y - m.y)) { if (dx !== 0) candidates.push({ x: m.x + dx, y: m.y }); if (dy !== 0) candidates.push({ x: m.x, y: m.y + dy }); } else { if (dy !== 0) candidates.push({ x: m.x, y: m.y + dy }); if (dx !== 0) candidates.push({ x: m.x + dx, y: m.y }); } // Diagonal flanking if cardinal path is blocked if (dx !== 0 && dy !== 0) { candidates.push({ x: m.x + dx, y: m.y + dy }); } for (const pos of candidates) { if (pos.x === hero.x && pos.y === hero.y) { executeMonsterMelee(m); return; } if (isWalkable(pos.x, pos.y) && !getMonsterAt(pos.x, pos.y)) { m.x = pos.x; m.y = pos.y; const trapIdx = floorTraps.findIndex(t => t.x === m.x && t.y === m.y); if (trapIdx !== -1) { const trap = floorTraps[trapIdx]; m.hp -= trap.dmg; m.stunnedTurns = 2; floorTraps.splice(trapIdx, 1); spawnFloatingText(m.x, m.y, `🪤 -${trap.dmg}!`, "#34d399"); if (m.hp <= 0) handleMonsterDeath(m); } return; } } } function handleMonsterDeath(m) { m.hp = 0; hero.kills = (hero.kills || 0) + 1; hero.xp += m.xp; hero.gold += m.gold; if (m.drops && m.drops.length > 0 && Math.random() < 0.78) { const dropKey = m.drops[Math.floor(Math.random() * m.drops.length)]; if (MATERIAL_META[dropKey]) { addMaterial(dropKey, 1); spawnFloatingText(m.x, m.y - 0.3, `+1 ${MATERIAL_META[dropKey].icon}`, "#6ee7b7"); } else if (ITEMS[dropKey]) { floorItems.push({ x: m.x, y: m.y, itemId: dropKey, qty: 1 }); } } else if (Math.random() < 0.25) { floorItems.push({ x: m.x, y: m.y, itemId: chooseRandomDrop(currentFloor), qty: 1 }); } logCombat(`💀 ${m.name} besiegt! (+${m.xp} XP, +${m.gold}G)`); checkLevelUp(); if (m.isBoss) { triggerGameOver(true, m.name); } } function checkLevelUp() { while (hero.xp >= hero.xpNeeded) { hero.xp -= hero.xpNeeded; hero.level++; hero.xpNeeded = Math.round(hero.xpNeeded * 1.48); // 3 Attribute points per Level-Up! hero.statPoints = (hero.statPoints || 0) + 3; recalcHeroStats(); hero.hp = Math.min(hero.maxHp, hero.hp + Math.round(hero.maxHp * 0.30)); hero.mp = Math.min(hero.maxMp, hero.mp + Math.round(hero.maxMp * 0.35)); playSound("levelup"); spawnFloatingText(hero.x, hero.y, `⭐ STUFE ${hero.level}! (+3 Punkte)`, "#fbbf24"); showToast(`⭐ STUFENAUFSTIEG! Du bist jetzt Stufe ${hero.level}! (+3 Attributspunkte & +2 Beutelplätze)`); } } // ================= CLEAN 1-CLICK INVENTORY & PAPERDOLL ================= function addItemToInventory(itemId, qty = 1) { if (MATERIAL_META[itemId]) { addMaterial(itemId, qty); return true; } const proto = ITEMS[itemId]; if (!proto) return false; if (proto.type === "consumable") { const existing = hero.inventory.find(s => s.id === itemId); if (existing) { existing.qty += qty; updateHUD(); updateInventoryUI(); return true; } } const maxSlots = getMaxBagSlots(); if (hero.inventory.length >= maxSlots) { showToast(`Beutel voll (${hero.inventory.length}/${maxSlots} Plätze)!`); return false; } hero.inventory.push({ id: itemId, qty, upgrade: 0 }); updateHUD(); updateInventoryUI(); return true; } function useInventoryItem(bagIdx) { const entry = hero.inventory[bagIdx]; if (!entry) return; const proto = ITEMS[entry.id]; if (!proto) return; // Equip Gear directly with 1 Click (Single Weapon Slot for Melee OR Ranged!) const isWeaponType = (proto.type === "weapon" || proto.type === "ranged"); const targetSlot = isWeaponType ? "weapon" : proto.type; if (ALL_EQUIP_SLOTS.includes(targetSlot)) { const slot = targetSlot; if (!hero.equipped) hero.equipped = {}; const prev = hero.equipped[slot]; hero.equipped[slot] = { id: entry.id, qty: 1, upgrade: entry.upgrade || 0 }; hero.inventory.splice(bagIdx, 1); if (prev) { const prevObj = typeof prev === "string" ? { id: prev, qty: 1, upgrade: 0 } : prev; hero.inventory.push(prevObj); } playSound("chest"); recalcHeroStats(); updateHUD(); updateInventoryUI(); saveGameProgress(); showToast(`🛡️ ${proto.name} angelegt!`); return; } // Use Consumable if (proto.type === "consumable") { playSound("potion"); if (proto.heal) { hero.hp = Math.min(hero.maxHp, hero.hp + proto.heal); spawnFloatingText(hero.x, hero.y, `+${proto.heal} ❤️`, "#10b981"); } if (proto.mana) { hero.mp = Math.min(hero.maxMp, hero.mp + proto.mana); spawnFloatingText(hero.x, hero.y, `+${proto.mana} 🔮`, "#38bdf8"); } if (proto.permHp || proto.permAtk) { hero.baseMaxHp += (proto.permHp || 0); hero.baseAtk += (proto.permAtk || 0); recalcHeroStats(); hero.hp = hero.maxHp; spawnFloatingText(hero.x, hero.y, `🍷 +${proto.permHp} HP / +${proto.permAtk} ATK!`, "#fbbf24"); } if (proto.throwDmg) { const targets = getVisibleMonstersInRange(proto.range || 5); if (targets.length === 0) { showToast("Kein sichtbarer Gegner für Wurfmesser!"); return; } const t = targets[0]; spawnProjectile(hero.x, hero.y, t.x, t.y, proto.projIcon || "🗡️", "#cbd5e1"); t.hp -= proto.throwDmg; spawnFloatingText(t.x, t.y, `🗡️ -${proto.throwDmg}`, "#f87171"); if (t.hp <= 0) handleMonsterDeath(t); } if (proto.bombDmg) { const targets = getVisibleMonstersInRange(proto.range || 4); if (targets.length === 0) { showToast("Kein Gegner in Reichweite für die Feuerbombe!"); return; } const main = targets[0]; spawnProjectile(hero.x, hero.y, main.x, main.y, "💣", "#f97316"); monsters.forEach(m => { if (m.hp > 0 && Math.abs(m.x - main.x) <= 1 && Math.abs(m.y - main.y) <= 1) { m.hp -= proto.bombDmg; spawnFloatingText(m.x, m.y, `💣 -${proto.bombDmg}`, "#f97316"); if (m.hp <= 0) handleMonsterDeath(m); } }); } if (proto.stunTurns) { const targets = getVisibleMonstersInRange(proto.range || 5); if (targets.length === 0) { showToast("Kein Gegner in Reichweite für das Fangnetz!"); return; } const t = targets[0]; spawnProjectile(hero.x, hero.y, t.x, t.y, "🕸️", "#e2e8f0"); t.stunnedTurns = proto.stunTurns; spawnFloatingText(t.x, t.y, `🕸️ GEFESSELT (${proto.stunTurns}R)`, "#c084fc"); } if (proto.spell === "fireball") { monsters.forEach(m => { if (m.hp > 0 && Math.hypot(m.x - hero.x, m.y - hero.y) <= 4.5) { m.hp -= 45; spawnFloatingText(m.x, m.y, "🔥 -45", "#ef4444"); if (m.hp <= 0) handleMonsterDeath(m); } }); } if (proto.spell === "teleport") { if (dungeonRooms.length > 0) { const r = dungeonRooms[Math.floor(Math.random() * dungeonRooms.length)]; hero.x = r.cx; hero.y = r.cy; centerCameraOnHero(); spawnFloatingText(hero.x, hero.y, "✨ TELEPORT!", "#a855f7"); } } if (proto.spell === "map") { for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (mapGrid[y][x].type !== "wall") fovMap[y][x].explored = true; } } showToast("🗺️ Gesamte Ebene aufgedeckt!"); } entry.qty--; if (entry.qty <= 0) hero.inventory.splice(bagIdx, 1); updateHUD(); updateInventoryUI(); if (!inTown) endPlayerTurn(); } } function unequipSlot(slotName) { const eq = hero.equipped?.[slotName]; if (!eq) return; const maxSlots = getMaxBagSlots(); if (hero.inventory.length >= maxSlots) { showToast(`Beutel ist voll (${hero.inventory.length}/${maxSlots})! Mache Platz.`); return; } const eqObj = typeof eq === "string" ? { id: eq, qty: 1, upgrade: 0 } : eq; hero.inventory.push(eqObj); hero.equipped[slotName] = null; recalcHeroStats(); updateHUD(); updateInventoryUI(); saveGameProgress(); } function upgradeGearItem(slotName) { const raw = hero.equipped?.[slotName]; if (!raw) return; const entry = typeof raw === "string" ? (hero.equipped[slotName] = { id: raw, qty: 1, upgrade: 0 }) : raw; const proto = ITEMS[entry.id]; if (!proto) return; const nextLvl = (entry.upgrade || 0) + 1; const goldCost = 35 * nextLvl; const oreCost = nextLvl; if (hero.gold < goldCost || (hero.materials.iron_ore || 0) < oreCost) { showToast(`Benötigt: ${goldCost} Gold & ${oreCost}x 🪨 Eisenerz!`); return; } hero.gold -= goldCost; hero.materials.iron_ore -= oreCost; entry.upgrade = nextLvl; playSound("craft"); recalcHeroStats(); updateHUD(); updateInventoryUI(); saveGameProgress(); showToast(`⚒️ ${proto.name} auf +${nextLvl} verbessert!`); } function getItemSalvageReward(entry) { if (!entry || !ITEMS[entry.id]) return { gold: 5, mats: {} }; const proto = ITEMS[entry.id]; const qty = entry.qty || 1; const upg = entry.upgrade || 0; const mats = {}; const recipe = CRAFT_RECIPES.find(r => r.resultItem === entry.id); if (recipe && recipe.cost) { for (const [mKey, cVal] of Object.entries(recipe.cost)) { const refund = Math.max(1, Math.floor((cVal * qty) / 2)); mats[mKey] = (mats[mKey] || 0) + refund; } } else { if (["weapon", "helmet", "armor", "pants", "boots", "shield"].includes(proto.type)) { if (entry.id.includes("mythril")) mats.mythril_ingot = 1 * qty; else if (entry.id.includes("leather") || entry.id.includes("cloth") || entry.id.includes("wood") || entry.id.includes("rusty") || entry.id.includes("traveler")) mats.wood_branch = 1 * qty; else mats.iron_ore = 1 * qty; } else if (proto.type === "ranged") { mats.wood_branch = 1 * qty; } else if (proto.type === "ring" || proto.type === "amulet") { mats.mana_shard = 1 * qty; } else if (proto.type === "consumable") { if (proto.heal) mats.healing_herb = 1 * qty; else if (proto.mana) mats.mana_shard = 1 * qty; else mats.iron_ore = 1 * qty; } } if (upg > 0) { mats.iron_ore = (mats.iron_ore || 0) + upg; } const baseGold = (proto.atk || 0) * 2 + (proto.rangedAtk || 0) * 2 + (proto.def || 0) * 3 + (proto.maxHp || 0) * 0.5 + (proto.heal || 0) * 0.3 + (proto.mana || 0) * 0.4; const gold = Math.max(6, Math.round((baseGold + 8 + upg * 18) * qty)); return { gold, mats }; } function salvageBagItem(bagIdx) { const entry = hero.inventory[bagIdx]; if (!entry) return; const proto = ITEMS[entry.id]; const reward = getItemSalvageReward(entry); hero.inventory.splice(bagIdx, 1); hero.gold += reward.gold; const matParts = [`+${reward.gold}💰`]; for (const [mKey, count] of Object.entries(reward.mats)) { if (count > 0) { addMaterial(mKey, count); const meta = MATERIAL_META[mKey] || { icon: "📦", name: mKey }; matParts.push(`+${count}${meta.icon}`); } } playSound("pickup"); updateHUD(); updateInventoryUI(); saveGameProgress(); showToast(`♻️ ${proto ? proto.name : "Gegenstand"} zerlegt/verkauft (${matParts.join(" ")})!`); } function dropBagItem(bagIdx) { salvageBagItem(bagIdx); } function pinItemToShortcut(itemId) { const emptyIdx = quickslots.findIndex(s => !s || !s.id); const targetIdx = emptyIdx !== -1 ? emptyIdx : 3; quickslots[targetIdx] = { type: "item", id: itemId, key: quickslots[targetIdx]?.key || `${targetIdx + 1}` }; renderQuickslotsBar(); saveGameProgress(); showToast(`📌 Auf Schnellzugriff-Slot ${targetIdx + 1} gelegt!`); } function getItemFilterCategory(proto) { if (!proto) return "other"; if (proto.type === "weapon" || proto.type === "ranged") return "weapon"; if (proto.type === "helmet") return "helmet"; if (proto.type === "armor") return "armor"; if (proto.type === "pants") return "pants"; if (proto.type === "boots") return "boots"; if (proto.type === "shield") return "shield"; if (proto.type === "ring" || proto.type === "amulet") return "relic"; if (proto.type === "consumable") return "consumable"; return "other"; } function getItemSlotLabel(proto) { if (!proto) return "GEGENSTAND"; if (proto.type === "weapon") return "⚔️ WAFFE (NAHKAMPF)"; if (proto.type === "ranged") return "🏹 WAFFE (FERNKAMPF)"; if (proto.type === "helmet") return "🪖 KOPF / HUT"; if (proto.type === "armor") return "👕 BRUST / RÜSTUNG"; if (proto.type === "pants") return "👖 BEINE / HOSE"; if (proto.type === "boots") return "👢 FÜSSE / SCHUHE"; if (proto.type === "shield") return "🛡️ NEBENHAND / SCHILD"; if (proto.type === "ring") return "💍 RELIKT 1: RING"; if (proto.type === "amulet") return "📿 RELIKT 2: AMULETT"; if (proto.type === "consumable") { if (proto.heal) return "🧪 HEILTRANK"; if (proto.mana) return "🔮 MANA-ELIXIER"; if (proto.bombDmg) return "💣 ALCHEMIE-BOMBE"; if (proto.throwDmg) return "🗡️ WURFMESSER"; if (proto.permHp) return "🍷 TITANEN-ELIXIER"; return "📜 SCHRIFTROLLE / VERBRAUCH"; } return "GEGENSTAND"; } function formatItemStatsBadge(proto, upgrade = 0) { if (!proto) return ""; const parts = []; if (proto.atk) parts.push(`⚔️ +${proto.atk + upgrade * 2} ATK`); if (proto.rangedAtk) parts.push(`🏹 +${proto.rangedAtk + upgrade * 3} Fern`); if (proto.def) parts.push(`🛡️ +${proto.def + upgrade * 2} Rüst`); if (proto.maxHp) parts.push(`❤️ +${proto.maxHp + upgrade * 8} HP`); if (proto.maxMp) parts.push(`🔮 +${proto.maxMp + upgrade * 5} MP`); if (proto.crit) parts.push(`🎯 +${Math.round((proto.crit + upgrade * 0.02) * 100)}% Crit`); if (proto.spellPwr) parts.push(`⚡ +${Math.round((proto.spellPwr + upgrade * 0.05) * 100)}% Magie`); if (proto.lifesteal) parts.push(`🩸 +${Math.round(proto.lifesteal * 100)}% Raub`); if (proto.lightRadius) parts.push(`☀️ +${proto.lightRadius} Sicht`); if (proto.heal) parts.push(`💚 Heilt ${proto.heal} HP`); if (proto.mana) parts.push(`💙 +${proto.mana} MP`); if (proto.permHp) parts.push(`❤️ Perm +${proto.permHp} HP`); if (proto.permAtk) parts.push(`⚔️ Perm +${proto.permAtk} ATK`); if (proto.throwDmg) parts.push(`🗡️ ${proto.throwDmg} Schaden`); if (proto.bombDmg) parts.push(`💣 ${proto.bombDmg} Flächenschaden`); return parts.join(" · ") || proto.desc || ""; } function formatItemStatsBadgesHtml(proto, upg = 0) { if (!proto) return ""; const badges = []; if (proto.atk) badges.push(`⚔️ +${proto.atk + upg * 2} ATK`); if (proto.rangedAtk) badges.push(`🏹 +${proto.rangedAtk + upg * 3} Fern`); if (proto.def) badges.push(`🛡️ +${proto.def + upg * 2} Rüst`); if (proto.maxHp) badges.push(`❤️ +${proto.maxHp + upg * 8} HP`); if (proto.maxMp) badges.push(`🔮 +${proto.maxMp + upg * 5} MP`); if (proto.crit) badges.push(`🎯 +${Math.round((proto.crit + upg * 0.02) * 100)}% Crit`); if (proto.spellPwr) badges.push(`⚡ +${Math.round((proto.spellPwr + upg * 0.05) * 100)}% Magie`); if (proto.lifesteal) badges.push(`🩸 +${Math.round(proto.lifesteal * 100)}% Raub`); if (proto.lightRadius) badges.push(`☀️ +${proto.lightRadius} Sicht`); if (proto.heal) badges.push(`💚 Heilt ${proto.heal} HP`); if (proto.mana) badges.push(`💙 +${proto.mana} MP`); if (proto.permHp) badges.push(`❤️ Perm +${proto.permHp} HP`); if (proto.permAtk) badges.push(`⚔️ Perm +${proto.permAtk} ATK`); if (proto.throwDmg) badges.push(`🗡️ ${proto.throwDmg} Schaden`); if (proto.bombDmg) badges.push(`💣 ${proto.bombDmg} Explosion`); if (badges.length === 0) { if (proto.desc) return `${proto.desc}`; return `Basis-Gegenstand`; } return badges.join(" "); } function renderSingleEquipSlotCard(sm) { const raw = hero.equipped?.[sm.key]; const eq = typeof raw === "string" ? { id: raw, qty: 1, upgrade: 0 } : raw; if (!eq || !ITEMS[eq.id]) { return `
${sm.emptyIcon}
${sm.label}
Nicht angelegt
`; } const proto = ITEMS[eq.id]; const upg = eq.upgrade || 0; const nextUpg = upg + 1; const canUpg = hero.gold >= 35 * nextUpg && (hero.materials.iron_ore || 0) >= nextUpg; return `
${proto.icon}
${sm.label}
${proto.name} ${upg > 0 ? `+${upg}` : ""}
${formatItemStatsBadgesHtml(proto, upg)}
`; } function getSlotEquippedIcon(slotKey, fallback) { const raw = hero.equipped?.[slotKey]; const eq = typeof raw === "string" ? { id: raw } : raw; return (eq && ITEMS[eq.id]) ? ITEMS[eq.id].icon : fallback; } function updateInventoryUI() { const s = getHeroCombatStats(); const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; setTxt("stat-melee-atk", s.atk); setTxt("stat-ranged-atk", s.rangedAtk > 0 ? `+${s.rangedAtk}` : "—"); setTxt("stat-total-def", s.def); setTxt("stat-crit-pct", `${Math.round(s.crit * 100)}%`); setTxt("stat-spell-pwr", `${Math.round(s.spellPwr * 100)}%`); setTxt("stat-arrows-count", hero.arrows); const maxSlots = getMaxBagSlots(); setTxt("inv-capacity-text", `${hero.inventory.length} / ${maxSlots} Plätze`); // Ensure retroactive Stat Points are computed if player leveled up before v5 if (!hero.spentPoints) hero.spentPoints = { hp: 0, mana: 0, str: 0 }; const earnedStatPoints = Math.max(0, ((hero.level || 1) - 1) * 3); const spentStatPoints = (hero.spentPoints.hp || 0) + (hero.spentPoints.mana || 0) + (hero.spentPoints.str || 0); if ((hero.statPoints || 0) + spentStatPoints < earnedStatPoints) { hero.statPoints = earnedStatPoints - spentStatPoints; } // 1. Top Character Avatar & Level Card (Mobile & Desktop) const charCard = document.getElementById("character-avatar-card"); if (charCard) { const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; charCard.innerHTML = `
${getSlotEquippedIcon("helmet", "🙂")}
${getSlotEquippedIcon("weapon", "👊")}
${getSlotEquippedIcon("armor", hero.icon)}
${getSlotEquippedIcon("shield", "🛡️")}
${getSlotEquippedIcon("ring", "💍")}
${getSlotEquippedIcon("pants", "👖")}
${getSlotEquippedIcon("amulet", "📿")}
${getSlotEquippedIcon("boots", "👢")}
${dCfg.icon} ${inTown ? "Stadt Grimhold-Feste" : `${dCfg.name} (E${currentFloor})`}
${hero.playerName}
${hero.icon} ${hero.name} • Stufe ${hero.level}
Erfahrung: ${hero.xp}/${hero.xpNeeded} XP
Beutel-Plätze
${hero.inventory.length} / ${maxSlots}
+2 Plätze pro Stufe
`; } // 2. Permanent Skill & Attribute Points Banner const statBanner = document.getElementById("stat-points-banner"); if (statBanner) { statBanner.classList.remove("hidden"); const pts = hero.statPoints || 0; const growth = CLASS_STAT_GROWTH[hero.classId || "knight"] || CLASS_STAT_GROWTH.knight; setTxt("stat-points-count", pts); const badge = document.getElementById("stat-points-count-badge"); if (badge) { badge.className = pts > 0 ? "font-mono text-[11px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/60 animate-pulse font-bold" : "font-mono text-[11px] px-2 py-0.5 rounded bg-stone-900 text-stone-400 border border-stone-800"; } const statusEl = document.getElementById("stat-banner-status"); if (statusEl) { statusEl.innerHTML = pts > 0 ? `⭐ ${pts} Attributspunkt${pts > 1 ? "e" : ""} verfügbar!` : `Attributs-Verteilung (0 freie Punkte • +3 bei Level-Up)`; } const hpSpent = hero.spentPoints?.hp || 0; const manaSpent = hero.spentPoints?.mana || 0; const strSpent = hero.spentPoints?.str || 0; const statContainer = document.getElementById("stat-buttons-container"); if (statContainer) { statContainer.innerHTML = `
❤️ Lebenskraft (HP)
${hero.maxHp} HP (+${hpSpent} vergeben, +${growth.hp.val}/Pkt)
✨ Willenskraft (MP)
${hero.maxMp} MP (+${manaSpent} vergeben, +${growth.mana.val}/Pkt)
💪 Stärke / Macht
${hero.baseAtk} ATK (+${strSpent} vergeben, +${growth.str.val}/Pkt)
`; } } // 3. Render Visual RPG Character Paperdoll Slots (8 Slots in 2 columns: 4 left body, 4 right weapon/relics) const pdContainer = document.getElementById("paperdoll-slots-container"); if (pdContainer) { pdContainer.innerHTML = `
${EQUIP_SLOT_META.map(renderSingleEquipSlotCard).join("")}
`; } // 2. Render Unified Backpack List with Category Filters renderInventoryFilters(); const bagList = document.getElementById("backpack-list"); if (!bagList) return; const houseOwned = Boolean(hero.house && hero.house.owned); let listHtml = ""; const filteredInventory = hero.inventory .map((entry, idx) => ({ entry, idx })) .filter(({ entry }) => { if (currentInventoryFilter === "all") return true; const proto = ITEMS[entry.id]; return getItemFilterCategory(proto) === currentInventoryFilter; }); if (hero.inventory.length === 0) { listHtml = `
Dein Beutel ist leer. Sammle Kräuter, Erz & Beute von Monstern oder besuche 🪙 Händler Balthasar!
`; } else if (filteredInventory.length === 0) { const activeCat = ITEM_FILTER_CATEGORIES.find(c => c.id === currentInventoryFilter); listHtml = `
Keine Gegenstände in der Kategorie ${activeCat ? activeCat.label : currentInventoryFilter} im Beutel vorhanden.
`; } else { listHtml = filteredInventory.map(({ entry, idx }) => { return renderBackpackItemCard(entry, idx, houseOwned, inTown); }).join(""); } // Render visual empty slot placeholders up to maxSlots (only on 'all' view) if (currentInventoryFilter === "all") { const emptySlotsCount = Math.max(0, maxSlots - hero.inventory.length); if (emptySlotsCount > 0 && hero.inventory.length > 0) { const emptyPlaceholders = Array.from({ length: Math.min(emptySlotsCount, 4) }, (_, i) => `
📦 Freier Beutel-Platz ${hero.inventory.length + i + 1} Leer
`).join(""); listHtml += emptyPlaceholders; } } bagList.innerHTML = listHtml; } function renderInventoryFilters() { const container = document.getElementById("inv-filter-bar"); if (!container) return; const counts = { all: hero.inventory.length }; ITEM_FILTER_CATEGORIES.forEach(c => { if (c.id !== "all") { counts[c.id] = hero.inventory.filter(entry => { const p = ITEMS[entry.id]; return getItemFilterCategory(p) === c.id; }).length; } }); container.innerHTML = ITEM_FILTER_CATEGORIES.map(c => { const isAct = currentInventoryFilter === c.id; const cnt = counts[c.id] || 0; return ` `; }).join(""); } function setInventoryFilter(cat) { currentInventoryFilter = cat; updateInventoryUI(); } function renderBackpackItemCard(entry, idx, houseOwned, inTown) { const proto = ITEMS[entry.id]; if (!proto) return ""; const isEquip = ALL_EQUIP_SLOTS.includes(proto.type === "ranged" ? "weapon" : proto.type); const upg = entry.upgrade || 0; const salvage = getItemSalvageReward(entry); const matIcons = Object.entries(salvage.mats) .filter(([, c]) => c > 0) .map(([k, c]) => `+${c}${MATERIAL_META[k]?.icon || "📦"}`) .join(" "); const salvageLabel = `♻️ +${salvage.gold}G${matIcons ? " " + matIcons : ""}`; const slotLabel = getItemSlotLabel(proto); return `
${proto.icon} ${entry.qty > 1 ? `x${entry.qty}` : ""}
${slotLabel}
${proto.name} ${upg > 0 ? `+${upg}` : ""}
${formatItemStatsBadgesHtml(proto, upg)}
${proto.desc ? `
${proto.desc}
` : ""}
${houseOwned && inTown ? ` ` : ""} ${proto.type === "consumable" ? ` ` : ""}
`; } function openInventoryModal() { updateInventoryUI(); document.getElementById("modal-inventory")?.classList.remove("hidden"); } function closeInventoryModal() { document.getElementById("modal-inventory")?.classList.add("hidden"); } // ================= CRAFTING & FORGE WORKSHOP ================= function getCraftingLocationType() { if (hero.house?.owned && hero.house?.tier >= 3 && inTown && Math.abs(hero.x - 5) <= 2 && Math.abs(hero.y - 10) <= 2) { return "home_forge"; } for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const tx = hero.x + dx; const ty = hero.y + dy; const t = mapGrid[ty]?.[tx]?.type; if (t === "forge_damaged") return "dungeon_forge"; if (t === "npc_smith" || t === "anvil") return "town_forge"; } } if (inTown && typeof townNpcs !== "undefined") { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const npc = townNpcs.find(n => n.x === hero.x + dx && n.y === hero.y + dy); if (npc && npc.type === "npc_smith") return "town_forge"; } } } return "field"; } function getAdjustedRecipeCost(recipe, locationType) { const mult = locationType === "dungeon_forge" ? 1.2 : (locationType === "town_forge" ? 0.85 : 1.0); const adj = {}; for (const [mat, c] of Object.entries(recipe.cost)) { adj[mat] = Math.max(1, Math.ceil(c * mult)); } return adj; } function canAffordRecipe(r, adjustedCost) { const cost = adjustedCost || r.cost; for (const [mat, count] of Object.entries(cost)) { if ((hero.materials[mat] || 0) < count) return false; } return true; } function openCraftingModal() { const loc = getCraftingLocationType(); if (loc === "field") { if (currentCraftFilter === "all" || currentCraftFilter === "starter" || currentCraftFilter === "master") { currentCraftFilter = "ammo"; } } ["all", "starter", "ammo", "alchemy", "master"].forEach(c => { const btn = document.getElementById(`craft-cat-${c}`); if (btn) btn.className = c === currentCraftFilter ? "gold-btn px-2.5 py-1 rounded-lg" : "fantasy-btn px-2.5 py-1 rounded-lg"; }); renderCraftingUI(); document.getElementById("modal-crafting")?.classList.remove("hidden"); } function closeCraftingModal() { document.getElementById("modal-crafting")?.classList.add("hidden"); } function setCraftFilter(cat) { currentCraftFilter = cat; ["all", "starter", "ammo", "alchemy", "master"].forEach(c => { const btn = document.getElementById(`craft-cat-${c}`); if (btn) { btn.className = c === cat ? "gold-btn px-2.5 py-1 rounded-lg" : "fantasy-btn px-2.5 py-1 rounded-lg"; } }); renderCraftingUI(); } function executeCraftRecipe(recipeId) { const r = CRAFT_RECIPES.find(x => x.id === recipeId); if (!r) return; const loc = getCraftingLocationType(); const isGear = (r.category === "starter" || r.category === "master"); if (loc === "field" && isGear) { showToast("⚠️ Ausrüstung kann nur an einer Schmiede hergestellt werden!"); return; } const cost = getAdjustedRecipeCost(r, loc); if (!canAffordRecipe(r, cost)) { showToast("Nicht genügend Rohstoffe!"); return; } for (const [mat, count] of Object.entries(cost)) { hero.materials[mat] -= count; } if (r.resultArrows) { hero.arrows += r.resultArrows; showToast(`🏹 +${r.resultArrows} Pfeile hergestellt!`); } else if (r.resultMat) { addMaterial(r.resultMat, r.resultCount || 1); showToast(`🔷 ${r.name} erfolgreich!`); } else if (r.resultItem) { if (!addItemToInventory(r.resultItem, r.resultCount || 1)) { showToast("Beutel voll! Gegenstand konnte nicht verstaut werden."); for (const [mat, count] of Object.entries(cost)) { hero.materials[mat] += count; } return; } showToast(`⚒️ ${r.name} hergestellt (im Beutel)!`); } playSound("craft"); updateHUD(); renderCraftingUI(); saveGameProgress(); } function renderCraftingUI() { const grid = document.getElementById("crafting-recipes-grid"); if (!grid) return; const loc = getCraftingLocationType(); const isField = (loc === "field"); const isDamagedDungeon = (loc === "dungeon_forge"); let bannerHtml = ""; if (isField) { bannerHtml = `
🎒 Unterwegs im Verlies: Es können nur Pfeile & Alchemie-Tränke hergestellt werden. Für Rüstung und Waffen musst du eine versteckte Schmiede im Dungeon oder die Stadt aufsuchen!
`; } else if (isDamagedDungeon) { bannerHtml = `
⚒️ Beschädigte Dungeon-Schmiede: Alle Ausrüstungen können geschmiedet werden (+20% Materialkosten).
`; } else { bannerHtml = `
⚒️ Meisterliche Schmiede: Standard-Materialkosten ohne Aufpreis!
`; } const list = CRAFT_RECIPES.filter(r => currentCraftFilter === "all" || r.category === currentCraftFilter); const recipesHtml = list.map(r => { const isGear = (r.category === "starter" || r.category === "master"); const blockedByField = isField && isGear; const cost = getAdjustedRecipeCost(r, loc); const affordable = canAffordRecipe(r, cost) && !blockedByField; const costBadges = Object.entries(cost).map(([mat, reqQty]) => { const have = hero.materials[mat] || 0; const meta = MATERIAL_META[mat] || { name: mat, icon: "📦" }; const ok = have >= reqQty; return `${meta.icon} ${have}/${reqQty} ${meta.name}`; }).join(""); return `
${r.icon}
${r.name} ${isDamagedDungeon && isGear ? `+20% Erz` : ""}
${r.desc}
${costBadges}
`; }).join(""); grid.innerHTML = bannerHtml + recipesHtml; } // ================= PLAYER HOUSING & INFINITY CHEST SYSTEM ================= function openPlayerHouseModal() { if (!hero.house) hero.house = { owned: false, tier: 0, chest: [] }; renderPlayerHouseUI(); document.getElementById("modal-player-house")?.classList.remove("hidden"); } function closePlayerHouseModal() { document.getElementById("modal-player-house")?.classList.add("hidden"); } function buyPlayerHouse() { const cost = 350; if (hero.gold < cost) { showToast(`Nicht genügend Gold! Du benötigst ${cost}💰.`); return; } hero.gold -= cost; hero.house = { owned: true, tier: 1, chest: [] }; playSound("chest"); showToast("🏡 Glückwunsch! Du besitzt jetzt ein Anwesen in Grimhold!"); logCombat(`🏡 Du hast ein Grundstück mit Haus in Grimhold für ${cost}💰 erworben! Deine unendliche Truhe ist bereit.`); updateHUD(); renderPlayerHouseUI(); saveGameProgress(); } function upgradePlayerHouse(tier) { if (!hero.house || !hero.house.owned) return; if (tier === 2) { const costWood = 40; const costStone = 30; if ((hero.materials.wood_branch || 0) < costWood || (hero.materials.iron_ore || 0) < costStone) { showToast(`Ausbau benötigt ${costWood}🪵 Holz & ${costStone}🪨 Erz!`); return; } hero.materials.wood_branch -= costWood; hero.materials.iron_ore -= costStone; hero.house.tier = 2; playSound("craft"); showToast("🛏️ Gemütliches Bett fertiggestellt! Vollheilung kostenlos."); logCombat(`🛏️ Haus-Ausbau auf Stufe 2 (Gemütliches Bett) abgeschlossen!`); } else if (tier === 3) { const costWood = 80; const costStone = 60; const costMyth = 15; if ((hero.materials.wood_branch || 0) < costWood || (hero.materials.iron_ore || 0) < costStone || (hero.materials.mythril_ingot || 0) < costMyth) { showToast(`Ausbau benötigt ${costWood}🪵, ${costStone}🪨 & ${costMyth}🔷 Mythril!`); return; } hero.materials.wood_branch -= costWood; hero.materials.iron_ore -= costStone; hero.materials.mythril_ingot -= costMyth; hero.house.tier = 3; playSound("craft"); showToast("⚒️ Heim-Schmiede fertiggestellt! Schmiede ab sofort daheim."); logCombat(`⚒️ Haus-Ausbau auf Stufe 3 (Private Heim-Schmiede) abgeschlossen!`); } updateHUD(); renderPlayerHouseUI(); saveGameProgress(); } function restInHouseBed() { if (!hero.house || hero.house.tier < 2) return; hero.hp = hero.maxHp; hero.mp = hero.maxMp; playSound("potion"); spawnFloatingText(hero.x, hero.y, "✨ Voll erholt!", "#34d399"); showToast("🛏️ Du hast dich im eigenen Bett ausgeruht: HP und MP sind voll!"); updateHUD(); renderPlayerHouseUI(); saveGameProgress(); } function depositItemToChest(bagIdx) { if (!hero.house || !hero.house.owned) return; if (!hero.house.chest) hero.house.chest = []; const item = hero.inventory[bagIdx]; if (!item) return; hero.inventory.splice(bagIdx, 1); hero.house.chest.push(item); playSound("chest"); updateHUD(); updateInventoryUI(); renderPlayerHouseUI(); saveGameProgress(); showToast(`📦 ${ITEMS[item.id]?.name || "Gegenstand"} in der Truhe eingelagert.`); } function withdrawItemFromChest(chestIdx) { if (!hero.house || !hero.house.chest) return; const maxSlots = getMaxBagSlots(); if (hero.inventory.length >= maxSlots) { showToast(`Beutel voll (${hero.inventory.length}/${maxSlots})! Mache Platz.`); return; } const item = hero.house.chest[chestIdx]; if (!item) return; hero.house.chest.splice(chestIdx, 1); hero.inventory.push(item); playSound("chest"); updateHUD(); updateInventoryUI(); renderPlayerHouseUI(); saveGameProgress(); showToast(`🎒 ${ITEMS[item.id]?.name || "Gegenstand"} in den Beutel genommen.`); } function renderPlayerHouseUI() { const container = document.getElementById("player-house-view"); if (!container) return; if (!hero.house || !hero.house.owned) { container.innerHTML = `
🏡

Freies Grundstück in Grimhold-Feste

Erwirb dein eigenes Account-gebundenes Anwesen! Beinhaltet sofort eine 📦 Truhe mit unendlichem Platz und lässt sich mit einem 🛏️ Bett und einer ⚒️ privaten Schmiede ausbauen.

Preis: 350 Gold 💰
`; return; } const tier = hero.house.tier || 1; const chestItems = hero.house.chest || []; container.innerHTML = `
🏡
Haus von ${hero.playerName}
${tier === 1 ? "Stufe 1: Landhaus & Unendliche Kiste" : (tier === 2 ? "Stufe 2: Gemütliches Bett & Kiste" : "Stufe 3: Meister-Heim & eigene Schmiede")}
${tier >= 2 ? ` ` : ""} ${tier >= 3 ? ` ` : ""}
${tier === 1 ? `
🛏️ Stufe 2: Gemütliches Bett bauen
Erlaubt kostenlose Vollheilung (HP & MP). Kosten: 40🪵 Holz, 30🪨 Erz.
` : (tier === 2 ? `
⚒️ Stufe 3: Private Heim-Schmiede errichten
Schmiede daheim zu Standardkosten. Kosten: 80🪵, 60🪨, 15🔷 Mythril.
` : `
⭐ Maximale Ausbaustufe erreicht! Dein Anwesen besitzt Bett & eigene Schmiede.
`)}
📦

Kiste des Hauses (Unendlicher Platz)

${chestItems.length} Gegenstände eingelagert
${chestItems.length === 0 ? `
Die Kiste ist noch leer. Lagere Gegenstände aus deinem Beutel unten ein.
` : chestItems.map((entry, idx) => { const proto = ITEMS[entry.id]; if (!proto) return ""; return `
${proto.icon}
${proto.name} ${entry.qty > 1 ? `x${entry.qty}` : ""}
${formatItemStatsBadge(proto, entry.upgrade || 0)}
`; }).join("")}
Beutel-Inhalt schnell einlagern:
${hero.inventory.length === 0 ? `
Dein Beutel ist aktuell leer.
` : hero.inventory.map((entry, idx) => { const proto = ITEMS[entry.id]; if (!proto) return ""; return `
${proto.icon} ${proto.name}
`; }).join("")}
`; } // ================= QUICKSLOTS & SHORTCUTS ================= function renderQuickslotsBar() { const container = document.getElementById("quickslots-bar"); if (!container) return; container.innerHTML = quickslots.map((slot, idx) => { const keyLabel = (slot?.key || `${idx + 1}`).toUpperCase(); if (!slot || !slot.id) { return ` `; } if (slot.type === "skill") { const sk = SKILLS_DB[slot.id]; if (!sk) return ""; const isRanged = (slot.id === "ranged_shot" || slot.id === "snipe"); const arrowBadge = isRanged ? `🏹${hero.arrows || 0}` : ""; return ` `; } const proto = ITEMS[slot.id]; const totalQty = hero.inventory.filter(x => x.id === slot.id).reduce((s, x) => s + x.qty, 0); return ` `; }).join(""); } function openShortcutsModal() { renderShortcutsModal(); document.getElementById("modal-shortcuts")?.classList.remove("hidden"); } function closeShortcutsModal() { awaitingKeybindTarget = null; document.getElementById("modal-shortcuts")?.classList.add("hidden"); } function selectShortcutSlotForEdit(idx) { selectedShortcutSlot = idx; awaitingKeybindTarget = null; renderShortcutsModal(); } function assignSelectedQuickslot(type, id) { const prevKey = quickslots[selectedShortcutSlot]?.key || `${selectedShortcutSlot + 1}`; if (!type || !id) { quickslots[selectedShortcutSlot] = { type: null, id: null, key: prevKey }; } else { quickslots[selectedShortcutSlot] = { type, id, key: prevKey }; } renderQuickslotsBar(); renderShortcutsModal(); saveGameProgress(); } function startRebindingSlotKey() { awaitingKeybindTarget = { kind: "slot", index: selectedShortcutSlot }; renderShortcutsModal(); } function startRebindingKey(actionType) { awaitingKeybindTarget = { kind: "action", action: actionType }; renderShortcutsModal(); } function resetDefaultShortcuts() { quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS[hero.classId] || DEFAULT_QUICKSLOTS.knight)); sysKeybinds = { attack: "f", wait: " ", inv: "i", craft: "c", ranged: "f" }; renderQuickslotsBar(); renderShortcutsModal(); saveGameProgress(); showToast("Standard-Shortcuts wiederhergestellt!"); } function renderShortcutsModal() { // Update Attack and Wait Action Key labels const labelAttack = document.getElementById("label-key-attack"); if (labelAttack) { labelAttack.textContent = (awaitingKeybindTarget?.action === "attack") ? "..." : (sysKeybinds.attack === " " ? "SPACE" : (sysKeybinds.attack || "F").toUpperCase()); } const labelWait = document.getElementById("label-key-wait"); if (labelWait) { labelWait.textContent = (awaitingKeybindTarget?.action === "wait") ? "..." : (sysKeybinds.wait === " " ? "SPACE" : (sysKeybinds.wait || "R").toUpperCase()); } const slotsEl = document.getElementById("shortcut-editor-slots"); if (slotsEl) { slotsEl.innerHTML = quickslots.map((s, idx) => { const active = idx === selectedShortcutSlot; const itemOrSkill = s?.type === "skill" ? SKILLS_DB[s.id] : ITEMS[s?.id]; return ` `; }).join(""); } const titleEl = document.getElementById("sc-selected-slot-title"); if (titleEl) { const k = (quickslots[selectedShortcutSlot]?.key || `${selectedShortcutSlot + 1}`).toUpperCase(); titleEl.textContent = `Slot ${selectedShortcutSlot + 1} (Taste [${k}])`; } const rebindBtn = document.getElementById("btn-rebind-slot-key"); if (rebindBtn) { rebindBtn.textContent = awaitingKeybindTarget?.kind === "slot" ? "Drücke eine Taste..." : "Slot-Taste ändern"; } const skillsList = document.getElementById("sc-assign-skills-list"); if (skillsList) { skillsList.innerHTML = Object.values(SKILLS_DB).map(sk => ` `).join(""); } const itemsList = document.getElementById("sc-assign-items-list"); if (itemsList) { const consumables = Object.values(ITEMS).filter(i => i.type === "consumable"); itemsList.innerHTML = consumables.map(it => ` `).join(""); } } // ================= HERO SELECT ("ZERO-TO-HERO" STARTER LOADOUT) ================= function openHeroSelectModal() { const resumeBox = document.getElementById("resume-run-box"); const resumeInfo = document.getElementById("resume-run-info"); if (resumeBox) { if (hasActiveRun && !isGameOver && hero.hp > 0) { resumeBox.classList.remove("hidden"); if (resumeInfo) resumeInfo.textContent = `${hero.name} (${hero.playerName}) • Ebene ${currentFloor} • Stufe ${hero.level}`; } else { resumeBox.classList.add("hidden"); } } document.getElementById("modal-hero-select")?.classList.remove("hidden"); } function closeHeroSelectModal() { document.getElementById("modal-hero-select")?.classList.add("hidden"); } function selectHeroClass(cls) { selectedClassId = cls; ["knight", "mage", "ranger"].forEach(c => { const card = document.getElementById(`card-class-${c}`); if (card) { card.className = c === cls ? "p-3 rounded-xl border-2 border-amber-500 bg-stone-900/95 cursor-pointer transition flex flex-col justify-between shadow-lg" : "p-3 rounded-xl border border-stone-800 bg-stone-900/60 cursor-pointer transition flex flex-col justify-between opacity-80 hover:opacity-100"; } }); } function startNewDungeonRun() { const nameInput = document.getElementById("input-player-name"); const savedAuthUser = localStorage.getItem("grimhold_username"); const customName = savedAuthUser || (nameInput && nameInput.value.trim() ? nameInput.value.trim() : "Abenteurer"); try { localStorage.setItem("mythic_player_name", customName); } catch (e) {} isGameOver = false; hasActiveRun = true; inTown = false; currentDungeonId = "catacombs"; floorCache = {}; const freshMaterials = { iron_ore: 0, wood_branch: 0, healing_herb: 0, mana_shard: 0, spider_silk: 0, monster_bone: 0, fire_essence: 0, mythril_ingot: 0 }; const emptyEquipped = { helmet: null, armor: null, pants: null, boots: null, weapon: null, shield: null, ring: null, amulet: null }; // ZERO-TO-HERO: 1 Class Weapon, 1 Health Potion, 1 Mana Potion, 5 Bag Slots! if (selectedClassId === "knight") { hero = { playerName: customName, classId: "knight", name: "Klingensänger", icon: "⚔️", x: 5, y: 5, level: 1, xp: 0, xpNeeded: 80, statPoints: 0, spentPoints: { hp: 0, mana: 0, str: 0 }, hp: 110, maxHp: 110, baseMaxHp: 110, mp: 25, maxMp: 25, baseMaxMp: 25, baseAtk: 10, baseDef: 1, baseCrit: 0.08, baseSpellPwr: 1.0, arrows: 0, gold: 15, turns: 0, kills: 0, score: 0, house: { owned: false, tier: 0, chest: [] }, materials: { ...freshMaterials }, equipped: { ...emptyEquipped, weapon: { id: "rusty_sword", qty: 1, upgrade: 0 } }, inventory: [ { id: "health_potion", qty: 1, upgrade: 0 }, { id: "mana_potion", qty: 1, upgrade: 0 } ] }; } else if (selectedClassId === "mage") { hero = { playerName: customName, classId: "mage", name: "Arkanist", icon: "🔮", x: 5, y: 5, level: 1, xp: 0, xpNeeded: 80, statPoints: 0, spentPoints: { hp: 0, mana: 0, str: 0 }, hp: 75, maxHp: 75, baseMaxHp: 75, mp: 70, maxMp: 70, baseMaxMp: 70, baseAtk: 6, baseDef: 0, baseCrit: 0.10, baseSpellPwr: 1.0, arrows: 0, gold: 15, turns: 0, kills: 0, score: 0, house: { owned: false, tier: 0, chest: [] }, materials: { ...freshMaterials }, equipped: { ...emptyEquipped, weapon: { id: "apprentice_wand", qty: 1, upgrade: 0 } }, inventory: [ { id: "health_potion", qty: 1, upgrade: 0 }, { id: "mana_potion", qty: 1, upgrade: 0 } ] }; } else { hero = { playerName: customName, classId: "ranger", name: "Waldläufer", icon: "🏹", x: 5, y: 5, level: 1, xp: 0, xpNeeded: 80, statPoints: 0, spentPoints: { hp: 0, mana: 0, str: 0 }, hp: 90, maxHp: 90, baseMaxHp: 90, mp: 35, maxMp: 35, baseMaxMp: 35, baseAtk: 8, baseDef: 1, baseCrit: 0.14, baseSpellPwr: 1.0, arrows: 15, gold: 15, turns: 0, kills: 0, score: 0, house: { owned: false, tier: 0, chest: [] }, materials: { ...freshMaterials }, equipped: { ...emptyEquipped, weapon: { id: "branch_bow", qty: 1, upgrade: 0 } }, inventory: [ { id: "health_potion", qty: 1, upgrade: 0 }, { id: "mana_potion", qty: 1, upgrade: 0 } ] }; } quickslots = JSON.parse(JSON.stringify(DEFAULT_QUICKSLOTS[selectedClassId] || DEFAULT_QUICKSLOTS.knight)); closeHeroSelectModal(); document.getElementById("modal-gameover")?.classList.add("hidden"); renderQuickslotsBar(); generateDungeonFloor(1, "fresh"); logCombat(`🔥 Neuer Durchlauf gestartet! Sammle Holz, Erz & Wandfackeln (🔥) oder besuche jederzeit die 🏘️ Stadt Grimhold!`); } // ================= HUD, LOGS, GAMEOVER & HIGHSCORES ================= function updateHUD() { recalcHeroStats(); const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const zone = ZONES[Math.min(currentFloor - 1, ZONES.length - 1)]; if (inTown) { setTxt("hud-floor-badge", "🏘️ Stadt"); setTxt("hud-zone-name", "Grimhold-Feste (Sicher)"); setTxt("icon-town-toggle", "⚔️"); setTxt("label-town-toggle", `Dungeon (E${savedDungeonFloorBeforeTown || 1})`); } else { setTxt("hud-floor-badge", `${dCfg.icon} E${currentFloor}/${MAX_FLOORS}`); setTxt("hud-zone-name", `${dCfg.name}: ${zone ? zone.name : ""}`); setTxt("icon-town-toggle", "🏘️"); setTxt("label-town-toggle", "Stadt"); } setTxt("hud-bag-count", hero.inventory.length); const authUser = localStorage.getItem("grimhold_username"); setTxt("hud-account-label", authUser ? authUser : "Konto"); setTxt("hud-hero-icon", hero.icon); setTxt("hud-hero-class", `${hero.name} (${hero.playerName})`); setTxt("hud-xp-text", `St. ${hero.level} (${hero.xp}/${hero.xpNeeded})`); setTxt("hud-hp-text", `${Math.max(0, hero.hp)} / ${hero.maxHp}`); setTxt("hud-mp-text", `${Math.max(0, hero.mp)} / ${hero.maxMp}`); setTxt("hud-gold", hero.gold); setTxt("hud-arrows", hero.arrows); const hpPct = Math.max(0, Math.min(100, (hero.hp / Math.max(1, hero.maxHp)) * 100)); const mpPct = Math.max(0, Math.min(100, (hero.mp / Math.max(1, hero.maxMp)) * 100)); const xpPct = Math.max(0, Math.min(100, (hero.xp / Math.max(1, hero.xpNeeded)) * 100)); const hpBar = document.getElementById("hud-hp-bar"); const mpBar = document.getElementById("hud-mp-bar"); const xpBar = document.getElementById("hud-xp-bar"); if (hpBar) hpBar.style.width = `${hpPct}%`; if (mpBar) mpBar.style.width = `${mpPct}%`; if (xpBar) xpBar.style.width = `${xpPct}%`; // Update Always-Visible Header Resource Bar (High Contrast even at 0) if (hero.materials) { Object.keys(MATERIAL_META).forEach(k => { const el = document.getElementById(`mat-${k}`); if (el) { const val = hero.materials[k] || 0; el.textContent = val; if (el.parentElement) { el.parentElement.style.opacity = val > 0 ? "1" : "0.92"; } } }); } // Highlight craft badge if any starter recipe is craftable const craftBadge = document.getElementById("craft-badge"); if (craftBadge) { const anyCraftable = CRAFT_RECIPES.some(r => canAffordRecipe(r)); if (anyCraftable) craftBadge.classList.remove("hidden"); else craftBadge.classList.add("hidden"); } // Unspent stat points alert in top bar const statAlert = document.getElementById("hud-stat-points-alert"); if (statAlert) { if (hero.statPoints && hero.statPoints > 0) statAlert.classList.remove("hidden"); else statAlert.classList.add("hidden"); } // Action bar arrows & weapon indicator (even when weapon unequipped) setTxt("hud-bar-arrows-val", hero.arrows !== undefined ? hero.arrows : 0); const wepBadgeIcon = document.getElementById("hud-bar-weapon-icon"); if (wepBadgeIcon) { const wep = hero.equipped?.weapon || hero.equipped?.ranged; const wepId = typeof wep === "string" ? wep : wep?.id; const wepProto = wepId ? ITEMS[wepId] : null; wepBadgeIcon.textContent = wepProto ? wepProto.icon : (hero.arrows > 0 ? "🏹" : "👊"); } renderQuickslotsBar(); } let toastTimer = null; function logCombat(html) { const ticker = document.getElementById("combat-log-ticker"); if (!ticker) return; ticker.innerHTML = html; ticker.style.opacity = "1"; clearTimeout(toastTimer); toastTimer = setTimeout(() => { ticker.style.opacity = "0"; }, 3200); } function showToast(msg) { logCombat(`✨ ${msg}`); } function showFloorBanner(floor, title, sub) { const b = document.getElementById("floor-transition-banner"); if (!b) return; const tEl = document.getElementById("trans-floor-title"); const dEl = document.getElementById("trans-floor-desc"); if (tEl) tEl.textContent = floor === 0 ? `🏘️ ${title}` : `Ebene ${floor}: ${title}`; if (dEl) dEl.textContent = sub; b.classList.remove("hidden"); b.style.opacity = "1"; setTimeout(() => { b.style.opacity = "0"; setTimeout(() => b.classList.add("hidden"), 300); }, 1600); } function spawnFloatingText(tx, ty, text, color = "#fff") { floatingNumbers.push({ x: tx * tileSize + tileSize / 2, y: ty * tileSize, text, color, alpha: 1.0, vy: -0.9 }); } async function triggerGameOver(victory, killerName) { isGameOver = true; hasActiveRun = false; clearSavedGameProgress(); playSound(victory ? "levelup" : "gameover"); const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const finalScore = Math.round((currentFloor * 250 + (hero.kills || 0) * 35 + (hero.gold || 0) * 2 + (victory ? 2500 : 0)) * (dCfg.diffMult || 1)); const modal = document.getElementById("modal-gameover"); if (modal) { const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; setTxt("gameover-icon", victory ? "🏆" : "💀"); setTxt("gameover-title", victory ? `SIEG IN ${dCfg.name.toUpperCase()}!` : "Gefallen im Verlies"); setTxt("gameover-cause", victory ? `Du hast alle ${MAX_FLOORS} Ebenen von ${dCfg.name} gemeistert und ${killerName || "den Endboss"} bezwungen!` : `Besiegt von ${killerName || "den Schatten"} auf Ebene ${currentFloor} (${dCfg.name}).`); setTxt("go-stat-floor", `${dCfg.icon} Ebene ${currentFloor}`); setTxt("go-stat-kills", hero.kills || 0); setTxt("go-stat-gold", `${hero.gold || 0} 💰`); setTxt("go-stat-turns", `${hero.turns || 0} ⏳`); setTxt("go-stat-score", `${finalScore} Punkte`); modal.classList.remove("hidden"); } try { const token = localStorage.getItem("grimhold_token"); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; await fetch("/api/dungeon/finish-run", { method: "POST", headers, body: JSON.stringify({ client_id: clientId, player_name: hero.playerName || "Abenteurer", hero_name: hero.playerName || "Abenteurer", hero_class: hero.name, floor_reached: currentFloor, hero_level: hero.level || 1, monsters_slain: hero.kills || 0, kills: hero.kills || 0, gold_collected: hero.gold || 0, gold: hero.gold || 0, turns_count: hero.turns || 0, score: finalScore, cause_of_death: victory ? `Sieg über ${killerName}` : `Besiegt von ${killerName || "Monster"}`, is_victory: victory, victory, dungeon_name: dCfg.name }) }); } catch (e) {} } async function openHighscoresModal() { const modal = document.getElementById("modal-highscores"); const list = document.getElementById("highscores-list"); if (!modal || !list) return; modal.classList.remove("hidden"); list.innerHTML = `
Lade Halle der Helden...
`; try { const res = await fetch("/api/dungeon/highscores"); const data = await res.json(); const rows = data.highscores || []; if (rows.length === 0) { list.innerHTML = `
Noch keine Einträge vorhanden.
`; return; } list.innerHTML = rows.map((r, i) => `
#${i + 1}
${r.hero_name || r.username || "Held"} (${r.hero_class}) ${r.victory ? "🏆" : ""}
Ebene ${r.floor_reached} · ⚔️ ${r.kills} Kills · 💰 ${r.gold}G
${r.score} Pkt
`).join(""); } catch (e) { list.innerHTML = `
Fehler beim Laden.
`; } } function closeHighscoresModal() { document.getElementById("modal-highscores")?.classList.add("hidden"); } // ================= TOWN MERCHANT SHOP (HÄNDLER BALTHASAR) ================= const TOWN_SHOP_GEAR_OFFERS = [ { itemId: "iron_sword", price: 45 }, { itemId: "short_bow", price: 45 }, { itemId: "fire_staff", price: 85 }, { itemId: "iron_helm", price: 40 }, { itemId: "leather_armor", price: 55 }, { itemId: "cloth_trousers", price: 30 }, { itemId: "leather_pants", price: 55 }, { itemId: "iron_greaves", price: 90 }, { itemId: "traveler_boots", price: 32 }, { itemId: "iron_sabatons", price: 65 }, { itemId: "shadow_boots", price: 110 }, { itemId: "buckler", price: 45 }, { itemId: "bone_ring", price: 50 }, { itemId: "ruby_ring", price: 95 }, { itemId: "bone_talisman", price: 55 }, { itemId: "ward_amulet", price: 95 }, { itemId: "ember_amulet", price: 140 } ]; const TOWN_SHOP_SUPPLIES = [ { kind: "item", id: "health_potion", price: 22, qty: 1 }, { kind: "item", id: "mana_potion", price: 22, qty: 1 }, { kind: "arrows", name: "15x Pfeile", icon: "🏹", desc: "Munition für Bögen & Armbrüste", price: 18, qty: 15 }, { kind: "item", id: "fire_bomb", price: 35, qty: 1 }, { kind: "item", id: "scroll_teleport", price: 30, qty: 1 }, { kind: "mat", id: "iron_ore", price: 16, qty: 1 }, { kind: "mat", id: "wood_branch", price: 10, qty: 1 }, { kind: "mat", id: "healing_herb", price: 12, qty: 1 }, { kind: "mat", id: "mana_shard", price: 20, qty: 1 }, { kind: "mat", id: "fire_essence", price: 28, qty: 1 }, { kind: "mat", id: "mythril_ingot", price: 65, qty: 1 } ]; function openTownShopModal() { if (!inTown) { showToast("🪙 Händler Balthasar ist nur in der Stadt Grimhold auf dem Marktplatz anzutreffen!"); return; } renderTownShopUI(); document.getElementById("modal-town-shop")?.classList.remove("hidden"); } function closeTownShopModal() { document.getElementById("modal-town-shop")?.classList.add("hidden"); } function setTownShopTab(tab) { currentTownShopTab = tab; currentTownShopFilter = "all"; ["buy_gear", "buy_supplies", "sell"].forEach(t => { const btn = document.getElementById(`shop-tab-${t}`); if (btn) btn.className = t === tab ? "gold-btn px-3 py-1.5 rounded-lg" : "fantasy-btn px-3 py-1.5 rounded-lg"; }); renderTownShopUI(); } function renderTownShopFilters() { const container = document.getElementById("shop-filter-bar"); if (!container) return; if (currentTownShopTab === "buy_supplies") { container.classList.add("hidden"); return; } container.classList.remove("hidden"); let categories = []; if (currentTownShopTab === "buy_gear") { categories = [ { id: "all", label: "Alle", icon: "✨" }, { id: "weapon", label: "Waffen", icon: "⚔️" }, { id: "helmet", label: "Hüte", icon: "🪖" }, { id: "armor", label: "Rüstung", icon: "👕" }, { id: "pants", label: "Hosen", icon: "👖" }, { id: "boots", label: "Schuhe", icon: "👢" }, { id: "shield", label: "Schilde", icon: "🛡️" }, { id: "relic", label: "Relikte", icon: "💍" } ]; } else if (currentTownShopTab === "sell") { categories = [ { id: "all", label: "Alle", icon: "✨" }, { id: "weapon", label: "Waffen", icon: "⚔️" }, { id: "helmet", label: "Hüte", icon: "🪖" }, { id: "armor", label: "Rüstung", icon: "👕" }, { id: "pants", label: "Hosen", icon: "👖" }, { id: "boots", label: "Schuhe", icon: "👢" }, { id: "shield", label: "Schilde", icon: "🛡️" }, { id: "relic", label: "Relikte", icon: "💍" }, { id: "consumable", label: "Tränke", icon: "🧪" } ]; } const counts = {}; if (currentTownShopTab === "buy_gear") { counts.all = TOWN_SHOP_GEAR_OFFERS.length; categories.forEach(c => { if (c.id !== "all") { counts[c.id] = TOWN_SHOP_GEAR_OFFERS.filter(off => { const it = ITEMS[off.itemId]; return getItemFilterCategory(it) === c.id; }).length; } }); } else if (currentTownShopTab === "sell") { counts.all = hero.inventory.length; categories.forEach(c => { if (c.id !== "all") { counts[c.id] = hero.inventory.filter(entry => { const p = ITEMS[entry.id]; return getItemFilterCategory(p) === c.id; }).length; } }); } container.innerHTML = categories.map(c => { const isAct = currentTownShopFilter === c.id; const cnt = counts[c.id] || 0; return ` `; }).join(""); } function setTownShopFilter(cat) { currentTownShopFilter = cat; renderTownShopUI(); } function renderTownShopUI() { const goldEl = document.getElementById("shop-gold-display"); if (goldEl) goldEl.textContent = hero.gold; const box = document.getElementById("town-shop-content"); if (!box) return; renderTownShopFilters(); if (currentTownShopTab === "buy_gear") { const filteredOffers = TOWN_SHOP_GEAR_OFFERS.filter(off => { if (currentTownShopFilter === "all") return true; const it = ITEMS[off.itemId]; return getItemFilterCategory(it) === currentTownShopFilter; }); if (filteredOffers.length === 0) { box.innerHTML = `
Keine Angebote in dieser Kategorie bei Balthasar vorhanden.
`; return; } box.innerHTML = filteredOffers.map(off => { const it = ITEMS[off.itemId]; if (!it) return ""; const canBuy = hero.gold >= off.price; const slotLabel = getItemSlotLabel(it); return `
${it.icon}
${slotLabel}
${it.name}
${formatItemStatsBadgesHtml(it, 0)}
${it.desc ? `
${it.desc}
` : ""}
Preis: ${off.price}💰
`; }).join(""); return; } if (currentTownShopTab === "buy_supplies") { box.innerHTML = TOWN_SHOP_SUPPLIES.map((s, idx) => { const canBuy = hero.gold >= s.price; let icon = s.icon || "📦"; let name = s.name || ""; let desc = s.desc || ""; let statsHtml = ""; if (s.kind === "item" && ITEMS[s.id]) { const it = ITEMS[s.id]; icon = it.icon; name = it.name; desc = it.desc; statsHtml = formatItemStatsBadgesHtml(it, 0); } else if (s.kind === "mat" && MATERIAL_META[s.id]) { icon = MATERIAL_META[s.id].icon; name = `${s.qty}x ${MATERIAL_META[s.id].name}`; desc = `Handwerks-Rohstoff für Schmiede & Alchemie`; } else if (s.kind === "arrows") { icon = "🏹"; name = s.name; desc = s.desc; statsHtml = `🏹 +15 Pfeile`; } return `
${icon}
${s.kind === "mat" ? "📦 ROHSTOFF" : (s.kind === "arrows" ? "🏹 MUNITION" : "🧪 VORRAT")}
${name}
${statsHtml ? `
${statsHtml}
` : ""} ${desc ? `
${desc}
` : ""}
Preis: ${s.price}💰
`; }).join(""); return; } // Sell tab with category filtering! if (hero.inventory.length === 0) { box.innerHTML = `
Dein Rucksack ist leer. Keine Beute zum Verkaufen.
`; return; } const filteredBag = hero.inventory .map((entry, idx) => ({ entry, idx })) .filter(({ entry }) => { if (currentTownShopFilter === "all") return true; const proto = ITEMS[entry.id]; return getItemFilterCategory(proto) === currentTownShopFilter; }); if (filteredBag.length === 0) { box.innerHTML = `
Keine Gegenstände in dieser Kategorie im Beutel vorhanden.
`; return; } box.innerHTML = filteredBag.map(({ entry, idx }) => { const proto = ITEMS[entry.id]; if (!proto) return ""; const baseSalvage = getItemSalvageReward(entry); const townGold = Math.round(baseSalvage.gold * 1.35); const slotLabel = getItemSlotLabel(proto); const upg = entry.upgrade || 0; return `
${proto.icon} ${entry.qty > 1 ? `x${entry.qty}` : ""}
${slotLabel}
${proto.name} ${upg > 0 ? `+${upg}` : ""}
${formatItemStatsBadgesHtml(proto, upg)}
${proto.desc ? `
${proto.desc}
` : ""}
Verkaufswert: +${townGold}💰
`; }).join(""); } // ================= WORLD PORTAL: MULTIPLE DUNGEONS SELECTOR ================= function openDungeonsModal() { renderDungeonsModal(); document.getElementById("modal-dungeons")?.classList.remove("hidden"); } function closeDungeonsModal() { document.getElementById("modal-dungeons")?.classList.add("hidden"); } function selectAndEnterDungeon(dungeonId, floorNum = 1) { closeDungeonsModal(); leaveTownToDungeon(dungeonId, floorNum); } function renderDungeonsModal() { const container = document.getElementById("dungeons-list-container"); if (!container) return; container.innerHTML = Object.values(DUNGEONS_DB).map(d => { const isCurrent = currentDungeonId === d.id; const resumeFloor = isCurrent ? (savedDungeonFloorBeforeTown || currentFloor || 1) : 1; const bossProto = MONSTER_TYPES[d.bossId] || MONSTER_TYPES.boss_goblin; return `
${d.icon}
${d.name} Empf. ab St. ${d.recLevel}

${d.desc}

👑 Endboss (Ebene 10): ${bossProto.icon} ${bossProto.name}
${isCurrent && resumeFloor > 1 ? ` ` : ""}
`; }).join(""); } // ================= MULTIPLAYER LIVE-CHAT & ONLINE PRESENCE ================= function openChatModal() { syncMultiplayerPresence(); document.getElementById("modal-chat")?.classList.remove("hidden"); setTimeout(() => document.getElementById("chat-input-field")?.focus(), 80); } function closeChatModal() { document.getElementById("modal-chat")?.classList.add("hidden"); } async function syncMultiplayerPresence() { if (!hasActiveRun) return; try { const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const token = localStorage.getItem("grimhold_token"); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch("/api/dungeon/presence", { method: "POST", headers, body: JSON.stringify({ client_id: clientId, player_name: hero.playerName || "Abenteurer", hero_class: hero.name, hero_icon: hero.icon, level: hero.level || 1, dungeon_id: currentDungeonId, dungeon_name: dCfg.name, floor: currentFloor, in_town: inTown, x: hero.x, y: hero.y }) }); const data = await res.json(); if (data.online_players) onlinePlayers = data.online_players; if (data.chat_messages) chatMessages = data.chat_messages; renderOnlinePlayersAndChat(); } catch (e) {} } function renderOnlinePlayersAndChat() { const badge = document.getElementById("online-count-badge"); if (badge) badge.textContent = Math.max(1, onlinePlayers.length); const onlineEl = document.getElementById("online-players-list"); if (onlineEl) { onlineEl.innerHTML = onlinePlayers.map(p => ` ${p.hero_icon || "⚔️"} ${p.player_name} (St.${p.level} • ${p.in_town ? "🏘️ Stadt" : `E${p.floor}`}) `).join(""); } const chatBox = document.getElementById("chat-messages-box"); if (chatBox) { chatBox.innerHTML = chatMessages.map(m => { if (m.is_system) { return `
${m.hero_icon || "🏰"} ${m.sender}: ${m.message}
`; } return `
${m.hero_icon || "⚔️"} ${m.sender}: ${m.message}
`; }).join(""); chatBox.scrollTop = chatBox.scrollHeight; } } async function submitChatMessage(e) { if (e && e.preventDefault) e.preventDefault(); const inp = document.getElementById("chat-input-field"); if (!inp || !inp.value.trim()) return; const msgText = inp.value.trim(); inp.value = ""; try { const token = localStorage.getItem("grimhold_token"); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch("/api/dungeon/chat", { method: "POST", headers, body: JSON.stringify({ client_id: clientId, sender: hero.playerName || "Abenteurer", hero_icon: hero.icon, message: msgText, is_system: false }) }); const data = await res.json(); if (data.chat_messages) { chatMessages = data.chat_messages; renderOnlinePlayersAndChat(); } } catch (err) {} } // ================= FREE2PLAY + OPTIONAL ACCOUNT REGISTRATION / LOGIN ================= function openAccountModal() { const token = localStorage.getItem("grimhold_token"); const uname = localStorage.getItem("grimhold_username"); const loggedBox = document.getElementById("account-logged-in-box"); const formBox = document.getElementById("account-auth-form-box"); const nameDisp = document.getElementById("account-username-display"); const errEl = document.getElementById("auth-error-msg"); if (errEl) errEl.classList.add("hidden"); if (token && uname) { loggedBox?.classList.remove("hidden"); formBox?.classList.add("hidden"); if (nameDisp) nameDisp.textContent = uname; } else { loggedBox?.classList.add("hidden"); formBox?.classList.remove("hidden"); const uInp = document.getElementById("auth-username-input"); if (uInp && !uInp.value && hero.playerName && hero.playerName !== "Abenteurer") { uInp.value = hero.playerName; } } document.getElementById("modal-account")?.classList.remove("hidden"); } function closeAccountModal() { document.getElementById("modal-account")?.classList.add("hidden"); } async function submitAccountAuth(mode) { const uInp = document.getElementById("auth-username-input"); const pInp = document.getElementById("auth-password-input"); const errEl = document.getElementById("auth-error-msg"); const username = (uInp?.value || "").trim(); const password = pInp?.value || ""; if (errEl) errEl.classList.add("hidden"); if (username.length < 3) { if (errEl) { errEl.textContent = "Benutzername muss mindestens 3 Zeichen haben."; errEl.classList.remove("hidden"); } return; } if (password.length < 4) { if (errEl) { errEl.textContent = "Passwort muss mindestens 4 Zeichen haben."; errEl.classList.remove("hidden"); } return; } try { const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login"; const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, fortress_name: `Feste ${username}` }) }); const data = await res.json(); if (!res.ok) { if (errEl) { errEl.textContent = data.detail || "Fehler bei der Anmeldung."; errEl.classList.remove("hidden"); } return; } localStorage.setItem("grimhold_token", data.token); localStorage.setItem("grimhold_username", data.username); localStorage.setItem("mythic_player_name", data.username); hero.playerName = data.username; // If logging in, check if the account already has a cloud save; otherwise upload current guest progress to the account! if (mode === "login") { const loadRes = await fetch(`/api/dungeon/load-active?client_id=${encodeURIComponent(clientId)}`, { headers: { "Authorization": `Bearer ${data.token}` } }); const loadData = await loadRes.json(); if (loadData && loadData.has_saved_run && loadData.run_state) { const parsed = JSON.parse(loadData.run_state); restoreFromStateObject(parsed); hero.playerName = data.username; } } saveGameProgress(); updateHUD(); openAccountModal(); syncMultiplayerPresence(); showToast(`✅ Willkommen, ${data.username}! Dein Spielstand ist jetzt mit deinem Konto verknüpft.`); } catch (e) { if (errEl) { errEl.textContent = "Netzwerkfehler."; errEl.classList.remove("hidden"); } } } function logoutAccount() { localStorage.removeItem("grimhold_token"); localStorage.removeItem("grimhold_username"); updateHUD(); openAccountModal(); showToast("Abgemeldet. Du spielst im Free2Play Gast-Modus weiter."); } // ================= MOBILE TOUCH HANDLERS, DPAD TOGGLE & TOP MENU ================= let showDpad = localStorage.getItem("mythic_show_dpad") !== "false"; // default true function setDpadVisible(visible) { showDpad = Boolean(visible); try { localStorage.setItem("mythic_show_dpad", showDpad ? "true" : "false"); } catch (e) {} const dpad = document.getElementById("mobile-dpad-container"); if (dpad) { if (showDpad) dpad.classList.remove("hidden"); else dpad.classList.add("hidden"); } updateTopMenuUI(); } function toggleDpad() { setDpadVisible(!showDpad); showToast(showDpad ? "🕹️ Laufkreuz aktiviert" : "🕹️ Laufkreuz ausgeblendet (Tippe auf Karte zum Laufen/Angreifen)"); } function openTopMenuModal() { updateTopMenuUI(); document.getElementById("modal-top-menu")?.classList.remove("hidden"); } function closeTopMenuModal() { document.getElementById("modal-top-menu")?.classList.add("hidden"); } function updateTopMenuUI() { const dpadVal = document.getElementById("menu-toggle-dpad-val"); if (dpadVal) { dpadVal.textContent = showDpad ? "AN" : "AUS"; dpadVal.className = showDpad ? "text-emerald-400 font-bold" : "text-rose-400 font-bold"; } const sndVal = document.getElementById("menu-toggle-sound-val"); if (sndVal) { sndVal.textContent = soundEnabled ? "AN" : "AUS"; sndVal.className = soundEnabled ? "text-emerald-400 font-bold" : "text-stone-400 font-bold"; } } // ================= EMPTY QUIVER (NO ARROWS) POP-UP MODAL ================= function openNoArrowsModal() { const modal = document.getElementById("modal-no-arrows"); if (!modal) return; const setTxt = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; setTxt("no-arrows-wood-count", hero.materials?.wood_branch || 0); setTxt("no-arrows-ore-count", hero.materials?.iron_ore || 0); setTxt("no-arrows-gold-count", hero.gold || 0); const craftBtn = document.getElementById("btn-quick-craft-arrows"); const wood = hero.materials?.wood_branch || 0; const ore = hero.materials?.iron_ore || 0; if (craftBtn) { if (wood >= 3 && ore >= 1) { craftBtn.innerHTML = `🪵 15 Pfeile herstellen (3 Holz, 1 Erz)`; craftBtn.className = "w-full py-2.5 rounded-xl emerald-btn flex items-center justify-center space-x-2 font-bold shadow"; } else if (wood >= 1) { craftBtn.innerHTML = `🪵 5 Pfeile aus 1x Holz schnitzen`; craftBtn.className = "w-full py-2.5 rounded-xl emerald-btn flex items-center justify-center space-x-2 font-bold shadow"; } else { craftBtn.innerHTML = `🪵 Kein Holz vorhanden (Holz benötigt)`; craftBtn.className = "w-full py-2.5 rounded-xl bg-stone-800 text-stone-500 flex items-center justify-center space-x-2 font-bold cursor-not-allowed"; } } modal.classList.remove("hidden"); } function closeNoArrowsModal() { document.getElementById("modal-no-arrows")?.classList.add("hidden"); } function craftArrowsQuickFromModal() { const wood = hero.materials?.wood_branch || 0; const ore = hero.materials?.iron_ore || 0; if (wood >= 3 && ore >= 1) { hero.materials.wood_branch -= 3; hero.materials.iron_ore -= 1; hero.arrows = (hero.arrows || 0) + 15; playSound("craft"); showToast("🏹 +15 Pfeile erfolgreich hergestellt!"); closeNoArrowsModal(); updateHUD(); renderQuickslotsBar(); saveGameProgress(); return; } if (wood >= 1) { hero.materials.wood_branch -= 1; hero.arrows = (hero.arrows || 0) + 5; playSound("craft"); showToast("🏹 +5 Pfeile aus 1x Holz geschnitzt!"); closeNoArrowsModal(); updateHUD(); renderQuickslotsBar(); saveGameProgress(); return; } showToast("⚠️ Du benötigst mindestens 1x Holz für Pfeile! Suche Kisten im Dungeon."); } function handleTouchDpad(e, dx, dy) { if (e && e.preventDefault) e.preventDefault(); handleDirectionalMove(dx, dy); } function handleTouchWait(e) { if (e && e.preventDefault) e.preventDefault(); playerWaitTurn(); } function handleTouchRanged(e) { if (e && e.preventDefault) e.preventDefault(); triggerRangedAttackOrAim(); } function resizeCanvas() { if (!canvas) return; const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = Math.max(300, rect.width); canvas.height = Math.max(200, rect.height); } function drawIconBadge(cx, cy, radius, fillColor, strokeColor) { ctx.save(); ctx.fillStyle = fillColor; ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); if (strokeColor) { ctx.strokeStyle = strokeColor; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.restore(); } function renderDungeon() { if (!ctx || !canvas || mapGrid.length === 0) { requestAnimationFrame(renderDungeon); return; } // Smooth camera tracking const targetCamX = hero.x * tileSize + tileSize / 2 - canvas.width / 2; const targetCamY = hero.y * tileSize + tileSize / 2 - canvas.height / 2; camera.x += (targetCamX - camera.x) * 0.22; camera.y += (targetCamY - camera.y) * 0.22; const dCfg = DUNGEONS_DB[currentDungeonId] || DUNGEONS_DB.catacombs; const zone = ZONES[Math.min(currentFloor - 1, ZONES.length - 1)] || ZONES[0]; const wallColor = inTown ? "#3b3254" : (dCfg.wallTint || zone.wall || "#2e2540"); const floorColor = inTown ? "#241f36" : (dCfg.floorTint || zone.floor || "#1b1728"); ctx.fillStyle = "#050408"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(-Math.round(camera.x), -Math.round(camera.y)); const startX = Math.max(0, Math.floor(camera.x / tileSize) - 1); const endX = Math.min(MAP_W - 1, Math.ceil((camera.x + canvas.width) / tileSize) + 1); const startY = Math.max(0, Math.floor(camera.y / tileSize) - 1); const endY = Math.min(MAP_H - 1, Math.ceil((camera.y + canvas.height) / tileSize) + 1); const fontPx = Math.max(16, Math.round(tileSize * 0.58)); ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (let y = startY; y <= endY; y++) { for (let x = startX; x <= endX; x++) { const fov = fovMap[y]?.[x]; if (!fov || (!fov.explored && !fov.visible)) continue; const tile = mapGrid[y][x]; const px = x * tileSize; const py = y * tileSize; const cx = px + tileSize / 2; const cy = py + tileSize / 2; if (tile.type === "wall") { ctx.fillStyle = wallColor; ctx.fillRect(px, py, tileSize, tileSize); ctx.fillStyle = "rgba(0,0,0,0.28)"; ctx.fillRect(px, py + tileSize * 0.68, tileSize, tileSize * 0.32); ctx.strokeStyle = "rgba(0,0,0,0.4)"; ctx.strokeRect(px, py, tileSize, tileSize); if (!fov.visible) { ctx.fillStyle = "rgba(3, 3, 9, 0.45)"; ctx.fillRect(px, py, tileSize, tileSize); } else if (tile.hasTorch) { drawIconBadge(cx, cy, tileSize * 0.32, "rgba(234, 88, 12, 0.45)", "#fb923c"); ctx.font = `${Math.round(tileSize * 0.46)}px sans-serif`; ctx.fillText("🔥", cx, cy); } } else { ctx.fillStyle = floorColor; ctx.fillRect(px, py, tileSize, tileSize); if ((x + y) % 2 === 0) { ctx.fillStyle = "rgba(255,255,255,0.03)"; ctx.fillRect(px, py, tileSize, tileSize); } if (!fov.visible) { ctx.fillStyle = "rgba(3, 3, 9, 0.42)"; ctx.fillRect(px, py, tileSize, tileSize); } if (tile.type !== "floor") { ctx.save(); ctx.globalAlpha = fov.visible ? 1.0 : 0.88; ctx.font = `${fontPx}px sans-serif`; if (tile.type === "stairs_down") { drawIconBadge(cx, cy, tileSize * 0.42, "rgba(14, 116, 144, 0.55)", "#38bdf8"); ctx.fillText("🪜", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.23))}px sans-serif`; ctx.fillStyle = "#7dd3fc"; ctx.fillText(inTown ? `▼ E${savedDungeonFloorBeforeTown || 1}` : `▼ E${currentFloor + 1}`, cx, py + tileSize - 5); } else if (tile.type === "stairs_up") { drawIconBadge(cx, cy, tileSize * 0.42, "rgba(180, 83, 9, 0.55)", "#fbbf24"); ctx.fillText(currentFloor === 1 ? "🏘️" : "🪜", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#fde68a"; ctx.fillText(currentFloor === 1 ? "▲ Stadt" : `▲ E${currentFloor - 1}`, cx, py + tileSize - 5); } else if (tile.type === "town_portal") { drawIconBadge(cx, cy, tileSize * 0.45, "rgba(8, 145, 178, 0.65)", "#22d3ee"); ctx.fillText("🌀", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#a5f3fc"; ctx.fillText("Dungeons", cx, py + tileSize - 4); } else if (tile.type === "player_house") { drawIconBadge(cx, cy, tileSize * 0.46, "rgba(180, 83, 9, 0.65)", "#fbbf24"); ctx.fillText("🏡", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#fde68a"; ctx.fillText(hero.house?.owned ? "Eigenheim" : "Grundstück", cx, py + tileSize - 4); } else if (tile.type === "forge_damaged") { drawIconBadge(cx, cy, tileSize * 0.42, "rgba(194, 65, 12, 0.65)", "#fb923c"); ctx.fillText("⚒️", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.20))}px sans-serif`; ctx.fillStyle = "#fdba74"; ctx.fillText("Schmiede", cx, py + tileSize - 4); } else if (tile.type === "npc_merchant") { drawIconBadge(cx, cy, tileSize * 0.44, "rgba(180, 83, 9, 0.65)", "#fbbf24"); ctx.fillText("🧔", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#fde68a"; ctx.fillText("Händler", cx, py + tileSize - 4); } else if (tile.type === "npc_smith") { drawIconBadge(cx, cy, tileSize * 0.44, "rgba(6, 95, 70, 0.65)", "#34d399"); ctx.fillText("👨‍🏭", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#6ee7b7"; ctx.fillText("Schmied", cx, py + tileSize - 4); } else if (tile.type === "npc_healer") { drawIconBadge(cx, cy, tileSize * 0.44, "rgba(190, 24, 93, 0.60)", "#f472b6"); ctx.fillText("🧝‍♀️", cx, cy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#fbcfe8"; ctx.fillText("Heilerin", cx, py + tileSize - 4); } else if (tile.type === "chest") { drawIconBadge(cx, cy, tileSize * 0.38, "rgba(120, 53, 15, 0.55)", "#f59e0b"); ctx.fillText("📦", cx, cy); } else if (tile.type === "anvil") { drawIconBadge(cx, cy, tileSize * 0.40, "rgba(6, 95, 70, 0.55)", "#34d399"); ctx.fillText("⚒️", cx, cy); } else if (tile.type === "node_ore") { drawIconBadge(cx, cy, tileSize * 0.36, "rgba(30, 41, 59, 0.72)", "#94a3b8"); ctx.fillText("🪨", cx, cy); } else if (tile.type === "node_wood") { drawIconBadge(cx, cy, tileSize * 0.36, "rgba(69, 26, 3, 0.65)", "#d97706"); ctx.fillText("🪵", cx, cy); } else if (tile.type === "node_herb") { drawIconBadge(cx, cy, tileSize * 0.36, "rgba(6, 78, 59, 0.65)", "#10b981"); ctx.fillText("🌿", cx, cy); } else if (tile.type === "node_crystal") { drawIconBadge(cx, cy, tileSize * 0.36, "rgba(49, 46, 129, 0.65)", "#818cf8"); ctx.fillText("💎", cx, cy); } ctx.restore(); } } } } // Traps ctx.font = `${Math.round(tileSize * 0.52)}px sans-serif`; floorTraps.forEach(t => { if (fovMap[t.y]?.[t.x]?.visible) { const cx = t.x * tileSize + tileSize / 2; const cy = t.y * tileSize + tileSize / 2; drawIconBadge(cx, cy, tileSize * 0.34, "rgba(127, 29, 29, 0.55)", "#f87171"); ctx.fillText("🪤", cx, cy); } }); // Floor Loot floorItems.forEach(fi => { if (fovMap[fi.y]?.[fi.x]?.visible) { const cx = fi.x * tileSize + tileSize / 2; const cy = fi.y * tileSize + tileSize / 2; const icon = fi.matKey ? MATERIAL_META[fi.matKey]?.icon : ITEMS[fi.itemId]?.icon; drawIconBadge(cx, cy, tileSize * 0.35, "rgba(23, 37, 84, 0.65)", "#60a5fa"); ctx.fillText(icon || "🎒", cx, cy); } }); // Monsters ctx.font = `${Math.round(tileSize * 0.64)}px sans-serif`; const aimTarget = aimMode.active ? aimMode.validTargets?.[aimMode.targetIndex] : null; monsters.forEach(m => { if (m.hp <= 0 || !fovMap[m.y]?.[m.x]?.visible) return; const mx = m.x * tileSize + tileSize / 2; const my = m.y * tileSize + tileSize / 2; drawIconBadge( mx, my + 1, tileSize * 0.38, m.isBoss ? "rgba(88, 28, 135, 0.65)" : "rgba(69, 10, 10, 0.62)", m.isBoss ? "#c084fc" : "rgba(248, 113, 113, 0.7)" ); if (aimTarget && aimTarget.id === m.id) { ctx.strokeStyle = "#10b981"; ctx.lineWidth = 3; ctx.strokeRect(m.x * tileSize + 2, m.y * tileSize + 2, tileSize - 4, tileSize - 4); } ctx.fillText(m.icon, mx, my + 1); // HP Bar const bw = tileSize - 6; const bx = m.x * tileSize + 3; const by = m.y * tileSize + 2; ctx.fillStyle = "rgba(0,0,0,0.85)"; ctx.fillRect(bx, by, bw, 4.5); ctx.fillStyle = m.isBoss ? "#a855f7" : "#ef4444"; ctx.fillRect(bx, by, Math.max(2, bw * (m.hp / m.maxHp)), 4.5); }); // Render Dynamic Wandering Town NPCs if (inTown && townNpcs.length > 0) { townNpcs.forEach(npc => { const ncx = npc.x * tileSize + tileSize / 2; const ncy = npc.y * tileSize + tileSize / 2; drawIconBadge(ncx, ncy, tileSize * 0.44, "rgba(180, 83, 9, 0.65)", "#fbbf24"); ctx.font = `${Math.round(tileSize * 0.58)}px sans-serif`; ctx.fillText(npc.icon, ncx, ncy - 2); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#fde68a"; ctx.fillText(npc.name.split(" ")[0], ncx, npc.y * tileSize + tileSize - 4); }); } // Render Other Online Players in Town Hub! if (inTown && onlinePlayers.length > 1) { onlinePlayers.forEach(op => { if (!op.in_town || op.player_name === hero.playerName) return; const ox = (op.x || 16) * tileSize + tileSize / 2; const oy = (op.y || 10) * tileSize + tileSize / 2; drawIconBadge(ox, oy, tileSize * 0.38, "rgba(14, 116, 144, 0.45)", "#38bdf8"); ctx.font = `${Math.round(tileSize * 0.56)}px sans-serif`; ctx.fillText(op.hero_icon || "⚔️", ox, oy); ctx.font = `bold ${Math.max(9, Math.round(tileSize * 0.22))}px sans-serif`; ctx.fillStyle = "#7dd3fc"; ctx.fillText(op.player_name, ox, oy - tileSize * 0.45); }); } // Hero const hx = hero.x * tileSize + tileSize / 2; const hy = hero.y * tileSize + tileSize / 2; drawIconBadge(hx, hy, tileSize * 0.42, "rgba(245, 158, 11, 0.34)", "#fbbf24"); ctx.font = `${Math.round(tileSize * 0.66)}px sans-serif`; ctx.fillText(hero.icon, hx, hy); // Projectiles for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i]; p.progress += 0.22; const cx = p.x + (p.tx - p.x) * p.progress; const cy = p.y + (p.ty - p.y) * p.progress; ctx.font = `${Math.round(tileSize * 0.52)}px sans-serif`; ctx.fillText(p.icon, cx, cy); if (p.progress >= 1) projectiles.splice(i, 1); } // Floating Damage Numbers for (let i = floatingNumbers.length - 1; i >= 0; i--) { const fn = floatingNumbers[i]; fn.y += fn.vy; fn.alpha -= 0.025; ctx.save(); ctx.globalAlpha = Math.max(0, fn.alpha); ctx.font = "bold 14px sans-serif"; ctx.fillStyle = fn.color; ctx.fillText(fn.text, fn.x, fn.y); ctx.restore(); if (fn.alpha <= 0) floatingNumbers.splice(i, 1); } ctx.restore(); renderMiniMap(); requestAnimationFrame(renderDungeon); } function renderMiniMap() { if (!miniCtx || !miniCanvas || mapGrid.length === 0) return; miniCtx.fillStyle = "#050408"; miniCtx.fillRect(0, 0, miniCanvas.width, miniCanvas.height); const pw = miniCanvas.width / MAP_W; const ph = miniCanvas.height / MAP_H; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (!fovMap[y]?.[x]?.explored) continue; const t = mapGrid[y][x].type; if (t === "wall") continue; if (t === "stairs_down" || t === "town_portal") miniCtx.fillStyle = "#38bdf8"; else if (t === "stairs_up" || t === "npc_merchant") miniCtx.fillStyle = "#f59e0b"; else if (t === "anvil" || t === "npc_smith") miniCtx.fillStyle = "#10b981"; else if (t === "npc_healer") miniCtx.fillStyle = "#f472b6"; else miniCtx.fillStyle = "#57534e"; miniCtx.fillRect(x * pw, y * ph, Math.ceil(pw), Math.ceil(ph)); } } monsters.forEach(m => { if (m.hp > 0 && fovMap[m.y]?.[m.x]?.visible) { miniCtx.fillStyle = "#ef4444"; miniCtx.fillRect(m.x * pw, m.y * ph, Math.max(2, pw), Math.max(2, ph)); } }); miniCtx.fillStyle = "#fbbf24"; miniCtx.fillRect(hero.x * pw - 1, hero.y * ph - 1, Math.max(3, pw + 1), Math.max(3, ph + 1)); } // ================= INPUTS (KEYBOARD + MOBILE TAP TO MOVE/SHOOT) ================= function setupInputListeners() { window.addEventListener("resize", resizeCanvas); window.addEventListener("keydown", e => { if (document.activeElement && ["INPUT", "SELECT", "TEXTAREA"].includes(document.activeElement.tagName)) return; const key = e.key.toLowerCase(); if (awaitingKeybindTarget) { e.preventDefault(); if (awaitingKeybindTarget.kind === "slot") { if (quickslots[awaitingKeybindTarget.index]) { quickslots[awaitingKeybindTarget.index].key = key; } } else if (awaitingKeybindTarget.kind === "action") { const act = awaitingKeybindTarget.action; sysKeybinds[act] = (e.code === "Space" || key === " ") ? " " : key; try { localStorage.setItem("mythic_sys_keys_v3", JSON.stringify(sysKeybinds)); } catch (err) {} } awaitingKeybindTarget = null; renderQuickslotsBar(); renderShortcutsModal(); saveGameProgress(); return; } if (e.key === "Escape") { cancelAimMode(); closeInventoryModal(); closeCraftingModal(); closeShortcutsModal(); closeHighscoresModal(); closeTownShopModal(); closeDungeonsModal(); closeChatModal(); closeAccountModal(); closePlayerHouseModal(); return; } if (e.key === "Tab" && aimMode.active) { e.preventDefault(); cycleAimTarget(); return; } if (key === (sysKeybinds.inv || "i")) { e.preventDefault(); const inv = document.getElementById("modal-inventory"); inv?.classList.contains("hidden") ? openInventoryModal() : closeInventoryModal(); return; } if (key === (sysKeybinds.craft || "c")) { e.preventDefault(); const cr = document.getElementById("modal-crafting"); cr?.classList.contains("hidden") ? openCraftingModal() : closeCraftingModal(); return; } // Configurable Attack Key (Melee if adjacent, else Ranged/Aim) const isAttackKey = (key === (sysKeybinds.attack || "f").toLowerCase()) || (sysKeybinds.attack === " " && e.key === " ") || (key === (sysKeybinds.ranged || "f").toLowerCase()); if (isAttackKey) { e.preventDefault(); const adjMonster = monsters.find(m => m.hp > 0 && Math.abs(m.x - hero.x) <= 1 && Math.abs(m.y - hero.y) <= 1 && (m.x === hero.x || m.y === hero.y)); if (adjMonster) { executePlayerMelee(adjMonster); endPlayerTurn(); } else { triggerRangedAttackOrAim(); } return; } // Configurable Rest / Wait Key const isWaitKey = (key === (sysKeybinds.wait || "r").toLowerCase()) || (sysKeybinds.wait === " " && e.key === " ") || key === "r" || e.key === "."; if (isWaitKey) { e.preventDefault(); playerWaitTurn(); return; } const slotIdx = quickslots.findIndex(s => s && (s.key || "").toLowerCase() === key); if (slotIdx !== -1) { e.preventDefault(); useQuickSlot(slotIdx); return; } if (e.key === "ArrowUp" || key === "w") { e.preventDefault(); handleDirectionalMove(0, -1); } else if (e.key === "ArrowDown" || key === "s") { e.preventDefault(); handleDirectionalMove(0, 1); } else if (e.key === "ArrowLeft" || key === "a") { e.preventDefault(); handleDirectionalMove(-1, 0); } else if (e.key === "ArrowRight" || key === "d") { e.preventDefault(); handleDirectionalMove(1, 0); } }); // Canvas Tap / Click: Tap monster in range to shoot or step towards tapped tile canvas.addEventListener("pointerdown", e => { if (isGameOver || !hasActiveRun) return; const rect = canvas.getBoundingClientRect(); const worldX = e.clientX - rect.left + camera.x; const worldY = e.clientY - rect.top + camera.y; const tx = Math.floor(worldX / tileSize); const ty = Math.floor(worldY / tileSize); const clickedMonster = getMonsterAt(tx, ty); if (clickedMonster && fovMap[ty]?.[tx]?.visible) { const dist = Math.hypot(tx - hero.x, ty - hero.y); if (dist <= 1.5 && (tx === hero.x || ty === hero.y)) { handleDirectionalMove(tx - hero.x, ty - hero.y); return; } if (dist <= 6.5) { fireRangedAtMonster(clickedMonster); cancelAimMode(); return; } } const dx = tx - hero.x; const dy = ty - hero.y; if (dx === 0 && dy === 0) { playerWaitTurn(); return; } if (Math.abs(dx) >= Math.abs(dy)) { handleDirectionalMove(Math.sign(dx), 0); } else { handleDirectionalMove(0, Math.sign(dy)); } }); } // ================= BOOTSTRAP ================= async function initGame() { canvas = document.getElementById("dungeon-canvas"); ctx = canvas.getContext("2d"); miniCanvas = document.getElementById("minimap-canvas"); miniCtx = miniCanvas ? miniCanvas.getContext("2d") : null; const savedName = localStorage.getItem("grimhold_username") || localStorage.getItem("mythic_player_name"); const nameInp = document.getElementById("input-player-name"); if (savedName && nameInp) nameInp.value = savedName; resizeCanvas(); setupInputListeners(); setDpadVisible(showDpad); selectHeroClass("knight"); renderQuickslotsBar(); const restored = await tryLoadSavedRun(); if (!restored) { openHeroSelectModal(); } syncMultiplayerPresence(); setInterval(syncMultiplayerPresence, 8500); requestAnimationFrame(renderDungeon); } window.addEventListener("DOMContentLoaded", initGame);