Files
grimhold/app/static/app.js
T

4903 lines
204 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========================================================
// 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 <b>Stadt Grimhold-Feste</b>! Sprich mit <b>🧔 Händler Balthasar</b>, <b>🧝‍♀️ Heilerin Seraphina</b>, besuche dein <b>🏡 Eigenheim</b> oder wähle am <b>🌀 Weltportal</b> 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(`🧝‍♀️ <b>Heilerin Seraphina</b> 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: <b class="text-emerald-300">${qty}x ${meta.icon} ${meta.name}</b>!`);
}
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(`🏹 <b>Bogen-Nahkampf</b>: Du stößt <b>${monster.name}</b> mit dem Bogen zurück (<span class="text-amber-300 font-bold">1 Schaden</span>${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 <b>${monster.name}</b> für <span class="text-rose-400 font-bold">${dmg}</span> 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: <b class="text-amber-300">+${goldAmt}G</b>, <b class="text-emerald-300">${ITEMS[dropId]?.name || dropId}</b> & +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: <b class="text-emerald-300">${fi.qty || 1}x ${m.icon} ${m.name}</b>.`);
floorItems.splice(i, 1);
} else if (addItemToInventory(fi.itemId, fi.qty || 1)) {
logCombat(`🎒 Aufgehoben: <b class="text-amber-300">${ITEMS[fi.itemId]?.name || fi.itemId}</b>.`);
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 <b>Ebene ${nextFloor}</b> (+12% LP regeneriert).`);
} else {
logCombat(`🔽 Du steigst wieder hinab auf <b>Ebene ${nextFloor}</b>.`);
}
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 <b>Ebene ${prevFloor}</b>.`);
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 <b>${monster.name}</b>: <span class="text-emerald-300 font-bold">${dmg}</span> 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(`⚔️ <b>Wirbelwind-Hieb</b> 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(`🛡️ <b>Schildstoß</b> 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 <b>Dornenfalle</b> 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(`⚡ <b>Arkanpfeil</b> trifft ${t.name} für <b>${dmg}</b> 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(`🔥 <b>Feuerball-Nova</b> 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(`❄️ <b>Frostnova</b> 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(`🧙‍♂️ <b>${m.name}</b> heilt <b>${woundedAlly.name}</b> 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(`🩸 <b>${m.name}</b> trifft dich für <span class="text-rose-400 font-bold">${dmg}</span> 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 || "🏹"} <b>${m.name}</b> trifft dich aus der Distanz (<span class="text-orange-400 font-bold">-${dmg} LP</span>)!`);
}
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(`💀 <b>${m.name}</b> 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(`<span class="px-1.5 py-0.5 rounded bg-amber-950/70 border border-amber-500/40 text-amber-300 font-bold">⚔️ +${proto.atk + upg * 2} ATK</span>`);
if (proto.rangedAtk) badges.push(`<span class="px-1.5 py-0.5 rounded bg-emerald-950/70 border border-emerald-500/40 text-emerald-300 font-bold">🏹 +${proto.rangedAtk + upg * 3} Fern</span>`);
if (proto.def) badges.push(`<span class="px-1.5 py-0.5 rounded bg-sky-950/70 border border-sky-500/40 text-sky-300 font-bold">🛡️ +${proto.def + upg * 2} Rüst</span>`);
if (proto.maxHp) badges.push(`<span class="px-1.5 py-0.5 rounded bg-rose-950/70 border border-rose-500/40 text-rose-300 font-bold">❤️ +${proto.maxHp + upg * 8} HP</span>`);
if (proto.maxMp) badges.push(`<span class="px-1.5 py-0.5 rounded bg-cyan-950/70 border border-cyan-500/40 text-cyan-300 font-bold">🔮 +${proto.maxMp + upg * 5} MP</span>`);
if (proto.crit) badges.push(`<span class="px-1.5 py-0.5 rounded bg-amber-950/70 border border-amber-400/40 text-amber-200 font-bold">🎯 +${Math.round((proto.crit + upg * 0.02) * 100)}% Crit</span>`);
if (proto.spellPwr) badges.push(`<span class="px-1.5 py-0.5 rounded bg-purple-950/70 border border-purple-500/40 text-purple-300 font-bold">⚡ +${Math.round((proto.spellPwr + upg * 0.05) * 100)}% Magie</span>`);
if (proto.lifesteal) badges.push(`<span class="px-1.5 py-0.5 rounded bg-red-950/70 border border-red-500/40 text-red-300 font-bold">🩸 +${Math.round(proto.lifesteal * 100)}% Raub</span>`);
if (proto.lightRadius) badges.push(`<span class="px-1.5 py-0.5 rounded bg-yellow-950/70 border border-yellow-500/40 text-yellow-300 font-bold">☀️ +${proto.lightRadius} Sicht</span>`);
if (proto.heal) badges.push(`<span class="px-1.5 py-0.5 rounded bg-emerald-950/70 border border-emerald-500/40 text-emerald-300 font-bold">💚 Heilt ${proto.heal} HP</span>`);
if (proto.mana) badges.push(`<span class="px-1.5 py-0.5 rounded bg-cyan-950/70 border border-cyan-500/40 text-cyan-300 font-bold">💙 +${proto.mana} MP</span>`);
if (proto.permHp) badges.push(`<span class="px-1.5 py-0.5 rounded bg-rose-950/70 border border-rose-500/40 text-rose-300 font-bold">❤️ Perm +${proto.permHp} HP</span>`);
if (proto.permAtk) badges.push(`<span class="px-1.5 py-0.5 rounded bg-amber-950/70 border border-amber-500/40 text-amber-300 font-bold">⚔️ Perm +${proto.permAtk} ATK</span>`);
if (proto.throwDmg) badges.push(`<span class="px-1.5 py-0.5 rounded bg-amber-950/70 border border-amber-500/40 text-amber-300 font-bold">🗡️ ${proto.throwDmg} Schaden</span>`);
if (proto.bombDmg) badges.push(`<span class="px-1.5 py-0.5 rounded bg-orange-950/70 border border-orange-500/40 text-orange-300 font-bold">💣 ${proto.bombDmg} Explosion</span>`);
if (badges.length === 0) {
if (proto.desc) return `<span class="text-stone-300 italic">${proto.desc}</span>`;
return `<span class="text-stone-400 italic">Basis-Gegenstand</span>`;
}
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 `
<div class="p-2.5 rounded-xl border border-dashed border-stone-700 bg-stone-900/80 flex items-center gap-2.5 opacity-90">
<div class="w-10 h-10 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center text-xl shrink-0 shadow-inner">${sm.emptyIcon}</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-stone-400 font-bold">${sm.label}</div>
<div class="text-xs text-stone-500 italic">Nicht angelegt</div>
</div>
</div>
`;
}
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 `
<div class="p-2.5 rounded-xl border border-amber-500/45 bg-stone-900/95 flex flex-col justify-between gap-2 shadow">
<div class="flex items-start gap-2.5">
<div class="w-10 h-10 rounded-lg bg-amber-950/60 border border-amber-500/40 flex items-center justify-center text-2xl shrink-0 shadow-inner">${proto.icon}</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-amber-400/90 font-bold">${sm.label}</div>
<div class="text-xs sm:text-sm font-bold text-stone-100 flex items-center gap-1.5 flex-wrap">
<span>${proto.name}</span>
${upg > 0 ? `<span class="text-amber-300 font-bold">+${upg}</span>` : ""}
</div>
<div class="text-[11px] text-emerald-300 font-semibold flex flex-wrap gap-x-1.5 gap-y-0.5 mt-0.5">${formatItemStatsBadgesHtml(proto, upg)}</div>
</div>
</div>
<div class="flex items-center gap-1.5 pt-1.5 border-t border-stone-800">
<button onclick="upgradeGearItem('${sm.key}')" class="flex-1 py-1 px-2 rounded-lg text-[10px] font-bold transition ${canUpg ? "bg-amber-600/30 hover:bg-amber-600/50 text-amber-200 border border-amber-500/40" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
⚒️ +${nextUpg} (${35 * nextUpg}G, ${nextUpg}🪨)
</button>
<button onclick="unequipSlot('${sm.key}')" class="py-1 px-3 rounded-lg bg-stone-800 hover:bg-stone-700 text-stone-200 text-[10px] font-bold">
Ablegen
</button>
</div>
</div>
`;
}
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 = `
<div class="p-3 rounded-2xl bg-gradient-to-b from-stone-900 via-stone-950 to-stone-900 border border-amber-500/30 flex flex-col sm:flex-row items-center justify-between gap-3 shadow-inner">
<div class="flex items-center gap-3">
<div class="py-1 px-2.5 rounded-xl bg-stone-900/90 border border-stone-800 flex flex-col items-center gap-1 shrink-0 shadow">
<div class="w-8 h-8 rounded-full bg-stone-950 border border-amber-500/40 flex items-center justify-center text-base shadow" title="Kopf / Hut">
${getSlotEquippedIcon("helmet", "🙂")}
</div>
<div class="flex items-center justify-center gap-1.5">
<div class="w-7 h-7 rounded-lg bg-stone-950 border border-stone-700 flex items-center justify-center text-sm" title="Waffe">
${getSlotEquippedIcon("weapon", "👊")}
</div>
<div class="w-9 h-9 rounded-xl bg-stone-950 border border-amber-500/40 flex items-center justify-center text-lg shadow" title="Brust">
${getSlotEquippedIcon("armor", hero.icon)}
</div>
<div class="w-7 h-7 rounded-lg bg-stone-950 border border-stone-700 flex items-center justify-center text-sm" title="Schild">
${getSlotEquippedIcon("shield", "🛡️")}
</div>
</div>
<div class="flex items-center justify-center gap-1">
<div class="w-5 h-5 rounded-full bg-stone-950 border border-purple-500/40 flex items-center justify-center text-[10px]" title="Ring">
${getSlotEquippedIcon("ring", "💍")}
</div>
<div class="w-7 h-7 rounded-lg bg-stone-950 border border-stone-700 flex items-center justify-center text-sm" title="Hose">
${getSlotEquippedIcon("pants", "👖")}
</div>
<div class="w-5 h-5 rounded-full bg-stone-950 border border-cyan-500/40 flex items-center justify-center text-[10px]" title="Amulett">
${getSlotEquippedIcon("amulet", "📿")}
</div>
</div>
<div class="w-7 h-6 rounded-lg bg-stone-950 border border-stone-700 flex items-center justify-center text-sm" title="Schuhe">
${getSlotEquippedIcon("boots", "👢")}
</div>
</div>
<div>
<div class="text-[10px] font-mono uppercase tracking-widest text-amber-400/90">${dCfg.icon} ${inTown ? "Stadt Grimhold-Feste" : `${dCfg.name} (E${currentFloor})`}</div>
<div class="font-cinzel font-bold text-base text-stone-100">${hero.playerName}</div>
<div class="text-xs text-amber-300 font-semibold">${hero.icon} ${hero.name} • <span class="text-emerald-400 font-bold">Stufe ${hero.level}</span></div>
<div class="text-[11px] text-stone-300 mt-0.5">Erfahrung: <span class="font-mono text-stone-300">${hero.xp}/${hero.xpNeeded} XP</span></div>
</div>
</div>
<div class="p-2 sm:p-2.5 rounded-xl bg-stone-900 border border-stone-800 text-center shrink-0">
<div class="text-[9px] uppercase tracking-wider text-stone-400 font-mono">Beutel-Plätze</div>
<div class="text-sm font-mono font-bold text-amber-300 mt-0.5">${hero.inventory.length} / ${maxSlots}</div>
<div class="text-[9px] text-stone-300 font-mono mt-0.5">+2 Plätze pro Stufe</div>
</div>
</div>
`;
}
// 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
? `<span class="animate-bounce">⭐</span> <span class="text-amber-200 font-bold font-cinzel">${pts} Attributspunkt${pts > 1 ? "e" : ""} verfügbar!</span>`
: `<span class="text-stone-300 font-medium">Attributs-Verteilung <span class="text-[10px] text-stone-300">(0 freie Punkte • +3 bei Level-Up)</span></span>`;
}
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 = `
<div class="p-2.5 rounded-xl bg-stone-900 border ${pts > 0 ? "border-rose-500/50 shadow-sm" : "border-stone-800"} flex items-center justify-between">
<div class="min-w-0 pr-1">
<div class="font-bold text-rose-300 truncate">❤️ Lebenskraft (HP)</div>
<div class="text-[10px] text-stone-300 truncate">${hero.maxHp} HP <span class="text-stone-300">(+${hpSpent} vergeben, +${growth.hp.val}/Pkt)</span></div>
</div>
<button onclick="spendAttributePoint('hp')" ${pts > 0 ? "" : "disabled"} class="px-2.5 py-1 rounded-lg text-xs font-bold shrink-0 transition ${pts > 0 ? "gold-btn cursor-pointer" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
+1
</button>
</div>
<div class="p-2.5 rounded-xl bg-stone-900 border ${pts > 0 ? "border-cyan-500/50 shadow-sm" : "border-stone-800"} flex items-center justify-between">
<div class="min-w-0 pr-1">
<div class="font-bold text-cyan-300 truncate">✨ Willenskraft (MP)</div>
<div class="text-[10px] text-stone-300 truncate">${hero.maxMp} MP <span class="text-stone-300">(+${manaSpent} vergeben, +${growth.mana.val}/Pkt)</span></div>
</div>
<button onclick="spendAttributePoint('mana')" ${pts > 0 ? "" : "disabled"} class="px-2.5 py-1 rounded-lg text-xs font-bold shrink-0 transition ${pts > 0 ? "gold-btn cursor-pointer" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
+1
</button>
</div>
<div class="p-2.5 rounded-xl bg-stone-900 border ${pts > 0 ? "border-amber-500/50 shadow-sm" : "border-stone-800"} flex items-center justify-between">
<div class="min-w-0 pr-1">
<div class="font-bold text-amber-300 truncate">💪 Stärke / Macht</div>
<div class="text-[10px] text-stone-300 truncate">${hero.baseAtk} ATK <span class="text-stone-300">(+${strSpent} vergeben, +${growth.str.val}/Pkt)</span></div>
</div>
<button onclick="spendAttributePoint('str')" ${pts > 0 ? "" : "disabled"} class="px-2.5 py-1 rounded-lg text-xs font-bold shrink-0 transition ${pts > 0 ? "gold-btn cursor-pointer" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
+1
</button>
</div>
`;
}
}
// 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 = `
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
${EQUIP_SLOT_META.map(renderSingleEquipSlotCard).join("")}
</div>
`;
}
// 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 = `
<div class="text-center py-6 px-4 rounded-xl border border-dashed border-stone-700 text-stone-300 text-xs">
Dein Beutel ist leer. Sammle Kräuter, Erz & Beute von Monstern oder besuche <b>🪙 Händler Balthasar</b>!
</div>
`;
} else if (filteredInventory.length === 0) {
const activeCat = ITEM_FILTER_CATEGORIES.find(c => c.id === currentInventoryFilter);
listHtml = `
<div class="text-center py-8 px-4 rounded-xl border border-dashed border-stone-800 text-stone-400 text-xs">
Keine Gegenstände in der Kategorie <b>${activeCat ? activeCat.label : currentInventoryFilter}</b> im Beutel vorhanden.
<div class="mt-2.5">
<button onclick="setInventoryFilter('all')" class="gold-btn px-3 py-1.5 rounded-lg text-xs font-bold">✨ Alle anzeigen</button>
</div>
</div>
`;
} 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) => `
<div class="p-2.5 rounded-xl border border-dashed border-stone-800 bg-stone-950/40 flex items-center justify-between text-stone-400 text-[11px] px-3">
<span>📦 Freier Beutel-Platz ${hero.inventory.length + i + 1}</span>
<span class="text-[10px] font-mono">Leer</span>
</div>
`).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 `
<button onclick="setInventoryFilter('${c.id}')" class="px-2.5 py-1 rounded-lg text-xs font-bold shrink-0 transition flex items-center gap-1 ${isAct ? "gold-btn border-amber-400" : "fantasy-btn text-stone-300 opacity-90 hover:opacity-100"}">
<span>${c.icon}</span>
<span>${c.label}</span>
<span class="text-[10px] font-mono px-1 rounded ${isAct ? "bg-black/30 text-amber-200" : "bg-stone-800 text-stone-400"}">${cnt}</span>
</button>
`;
}).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 `
<div class="p-2.5 sm:p-3 rounded-xl border border-amber-500/35 bg-stone-900/95 hover:border-amber-400/60 flex flex-col gap-2 shadow transition">
<!-- Top Section: Icon + Category Badge + Full Item Name & Full Stats (Never Cut Off!) -->
<div class="flex items-start gap-2.5">
<div class="w-10 h-10 rounded-xl bg-amber-950/60 border border-amber-500/40 flex items-center justify-center text-2xl shrink-0 relative shadow-inner">
${proto.icon}
${entry.qty > 1 ? `<span class="absolute -bottom-1 -right-1 px-1 rounded bg-stone-900 border border-amber-500/50 text-[10px] font-bold text-amber-300">x${entry.qty}</span>` : ""}
</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-amber-400/90 font-bold">${slotLabel}</div>
<div class="text-xs sm:text-sm font-bold text-stone-100 flex items-center gap-1.5 flex-wrap">
<span>${proto.name}</span>
${upg > 0 ? `<span class="text-amber-300 font-bold">+${upg}</span>` : ""}
</div>
<!-- Detailed Stats Badges Row -->
<div class="text-[11px] text-emerald-300 font-semibold flex flex-wrap gap-x-1.5 gap-y-0.5 mt-0.5">
${formatItemStatsBadgesHtml(proto, upg)}
</div>
${proto.desc ? `<div class="text-[10px] text-stone-300 italic mt-0.5">${proto.desc}</div>` : ""}
</div>
</div>
<!-- Bottom Section: Clean Action Buttons -->
<div class="flex items-center gap-1.5 pt-1.5 border-t border-stone-800">
<button onclick="useInventoryItem(${idx})" class="flex-1 py-1.5 px-3 rounded-lg ${isEquip ? "gold-btn" : "emerald-btn"} font-bold text-xs transition shadow flex items-center justify-center gap-1">
<span>${isEquip ? "🛡️" : "🧪"}</span>
<span>${isEquip ? "Anlegen" : "Benutzen"}</span>
</button>
${houseOwned && inTown ? `
<button onclick="depositItemToChest(${idx})" title="In unendliche Haus-Truhe einlagern" class="fantasy-btn px-2.5 py-1.5 rounded-lg text-amber-300 text-xs font-bold shrink-0">
📦 Truhe
</button>
` : ""}
${proto.type === "consumable" ? `
<button onclick="pinItemToShortcut('${entry.id}')" title="Auf Schnellzugriff-Leiste legen" class="fantasy-btn px-2.5 py-1.5 rounded-lg text-amber-300 text-xs font-bold shrink-0">
📌
</button>
` : ""}
<button onclick="salvageBagItem(${idx})" title="Zerlegen & Verkaufen (Gibt Gold + Rohstoffe zurück)" class="fantasy-btn px-2.5 py-1.5 rounded-lg text-amber-200 hover:text-emerald-300 border border-amber-500/30 text-[11px] font-bold shrink-0">
${salvageLabel}
</button>
</div>
</div>
`;
}
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 = `
<div class="p-2.5 rounded-xl bg-amber-950/70 border border-amber-500/50 text-xs text-amber-200 mb-2">
🎒 <b>Unterwegs im Verlies:</b> Es können nur <b>Pfeile & Alchemie-Tränke</b> hergestellt werden. Für Rüstung und Waffen musst du eine <b>versteckte Schmiede</b> im Dungeon oder die <b>Stadt</b> aufsuchen!
</div>
`;
} else if (isDamagedDungeon) {
bannerHtml = `
<div class="p-2.5 rounded-xl bg-orange-950/70 border border-orange-500/60 text-xs text-orange-200 mb-2">
⚒️ <b>Beschädigte Dungeon-Schmiede:</b> Alle Ausrüstungen können geschmiedet werden (+20% Materialkosten).
</div>
`;
} else {
bannerHtml = `
<div class="p-2.5 rounded-xl bg-emerald-950/70 border border-emerald-500/50 text-xs text-emerald-200 mb-2">
⚒️ <b>Meisterliche Schmiede:</b> Standard-Materialkosten ohne Aufpreis!
</div>
`;
}
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 `<span class="px-2 py-0.5 rounded text-[11px] font-bold border ${ok ? "bg-emerald-950/60 border-emerald-500/40 text-emerald-300" : "bg-rose-950/50 border-rose-500/40 text-rose-300"}">${meta.icon} ${have}/${reqQty} ${meta.name}</span>`;
}).join("");
return `
<div class="p-3 rounded-xl border ${affordable ? "border-emerald-500/40 bg-stone-900" : "border-stone-700 bg-stone-900/80 opacity-95"} flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
<div class="flex items-start gap-3 min-w-0 flex-1">
<div class="w-11 h-11 rounded-xl bg-stone-950 border border-stone-700 flex items-center justify-center text-2xl shrink-0">${r.icon}</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-bold text-xs sm:text-sm text-stone-100">${r.name}</span>
${isDamagedDungeon && isGear ? `<span class="text-[9px] font-mono px-1 rounded bg-orange-950 text-orange-300 border border-orange-700/50">+20% Erz</span>` : ""}
</div>
<div class="text-[11px] text-stone-300 mb-1.5">${r.desc}</div>
<div class="flex flex-wrap items-center gap-1.5">${costBadges}</div>
</div>
</div>
<button onclick="executeCraftRecipe('${r.id}')" ${affordable ? "" : "disabled"} class="px-4 py-2 rounded-xl font-bold text-xs shrink-0 transition ${affordable ? "emerald-btn cursor-pointer" : "bg-stone-800 text-stone-400 cursor-not-allowed"}">
${blockedByField ? "⚠️ Schmiede nötig" : (affordable ? "⚒️ Herstellen" : "Rohstoffe fehlen")}
</button>
</div>
`;
}).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 <b>Grundstück mit Haus</b> in Grimhold für <b>${cost}💰</b> 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 <b>Stufe 2 (Gemütliches Bett)</b> 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 <b>Stufe 3 (Private Heim-Schmiede)</b> 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 = `
<div class="p-4 rounded-2xl bg-gradient-to-b from-amber-950/60 to-stone-900 border border-amber-500/50 text-center space-y-3">
<span class="text-4xl animate-bounce inline-block">🏡</span>
<h3 class="font-cinzel text-lg font-bold text-amber-300">Freies Grundstück in Grimhold-Feste</h3>
<p class="text-xs text-stone-300 max-w-md mx-auto">
Erwirb dein eigenes Account-gebundenes Anwesen! Beinhaltet sofort eine <b>📦 Truhe mit unendlichem Platz</b> und lässt sich mit einem <b>🛏️ Bett</b> und einer <b>⚒️ privaten Schmiede</b> ausbauen.
</p>
<div class="flex items-center justify-center gap-3 pt-2">
<div class="text-sm font-bold font-mono text-amber-400">Preis: 350 Gold 💰</div>
<button onclick="buyPlayerHouse()" class="gold-btn px-6 py-2.5 rounded-xl text-xs font-cinzel font-bold shadow-lg">
🏡 Anwesen kaufen
</button>
</div>
</div>
`;
return;
}
const tier = hero.house.tier || 1;
const chestItems = hero.house.chest || [];
container.innerHTML = `
<!-- House Status & Upgrades Header -->
<div class="p-3.5 rounded-xl bg-stone-950 border border-amber-500/40 space-y-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-2xl">🏡</span>
<div>
<div class="font-cinzel font-bold text-sm text-amber-300">Haus von ${hero.playerName}</div>
<div class="text-[11px] text-stone-300">
${tier === 1 ? "Stufe 1: Landhaus & Unendliche Kiste" : (tier === 2 ? "Stufe 2: Gemütliches Bett & Kiste" : "Stufe 3: Meister-Heim & eigene Schmiede")}
</div>
</div>
</div>
<div class="flex items-center gap-1.5">
${tier >= 2 ? `
<button onclick="restInHouseBed()" class="emerald-btn px-3 py-1.5 rounded-lg text-xs font-bold" title="Kostenlos voll regenerieren">
🛏️ Im Bett ausruhen
</button>
` : ""}
${tier >= 3 ? `
<button onclick="openCraftingModal()" class="gold-btn px-3 py-1.5 rounded-lg text-xs font-bold" title="Heim-Schmiede öffnen">
⚒️ Heim-Schmiede
</button>
` : ""}
</div>
</div>
<!-- Upgrade Options -->
${tier === 1 ? `
<div class="p-2.5 rounded-lg bg-stone-900 border border-stone-800 flex items-center justify-between gap-2 text-xs">
<div>
<div class="font-bold text-stone-200">🛏️ Stufe 2: Gemütliches Bett bauen</div>
<div class="text-[10px] text-stone-400">Erlaubt kostenlose Vollheilung (HP & MP). Kosten: 40🪵 Holz, 30🪨 Erz.</div>
</div>
<button onclick="upgradePlayerHouse(2)" class="gold-btn px-3 py-1.5 rounded-lg text-xs shrink-0">
Ausbauen
</button>
</div>
` : (tier === 2 ? `
<div class="p-2.5 rounded-lg bg-stone-900 border border-stone-800 flex items-center justify-between gap-2 text-xs">
<div>
<div class="font-bold text-stone-200">⚒️ Stufe 3: Private Heim-Schmiede errichten</div>
<div class="text-[10px] text-stone-400">Schmiede daheim zu Standardkosten. Kosten: 80🪵, 60🪨, 15🔷 Mythril.</div>
</div>
<button onclick="upgradePlayerHouse(3)" class="gold-btn px-3 py-1.5 rounded-lg text-xs shrink-0">
Ausbauen
</button>
</div>
` : `
<div class="text-[10px] text-emerald-400 font-semibold text-center">⭐ Maximale Ausbaustufe erreicht! Dein Anwesen besitzt Bett & eigene Schmiede.</div>
`)}
</div>
<!-- Infinite Chest Section -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-lg">📦</span>
<h3 class="font-cinzel font-bold text-xs text-amber-400 uppercase tracking-wider">Kiste des Hauses (Unendlicher Platz)</h3>
</div>
<span class="text-xs font-mono text-stone-400">${chestItems.length} Gegenstände eingelagert</span>
</div>
<div class="p-2.5 rounded-xl bg-stone-950 border border-stone-800 max-h-[36vh] overflow-y-auto custom-scroll space-y-1.5">
${chestItems.length === 0 ? `
<div class="text-center py-6 text-stone-300 text-xs">Die Kiste ist noch leer. Lagere Gegenstände aus deinem Beutel unten ein.</div>
` : chestItems.map((entry, idx) => {
const proto = ITEMS[entry.id];
if (!proto) return "";
return `
<div class="p-2 rounded-lg bg-stone-900 border border-stone-800 flex items-center justify-between gap-2 text-xs">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="text-xl shrink-0">${proto.icon}</span>
<div class="min-w-0 flex-1">
<div class="font-bold text-stone-200 truncate">${proto.name} ${entry.qty > 1 ? `x${entry.qty}` : ""}</div>
<div class="text-[10px] text-emerald-300 truncate">${formatItemStatsBadge(proto, entry.upgrade || 0)}</div>
</div>
</div>
<button onclick="withdrawItemFromChest(${idx})" class="gold-btn px-3 py-1 rounded-lg text-xs shrink-0 font-bold">
🎒 Entnehmen
</button>
</div>
`;
}).join("")}
</div>
</div>
<!-- Quick Deposit from Inventory -->
<div class="space-y-2 pt-1 border-t border-stone-800">
<div class="text-xs font-cinzel font-bold text-stone-300 uppercase">Beutel-Inhalt schnell einlagern:</div>
<div class="max-h-[22vh] overflow-y-auto custom-scroll space-y-1.5">
${hero.inventory.length === 0 ? `
<div class="text-stone-300 text-xs italic">Dein Beutel ist aktuell leer.</div>
` : hero.inventory.map((entry, idx) => {
const proto = ITEMS[entry.id];
if (!proto) return "";
return `
<div class="p-2 rounded-lg bg-stone-900/70 border border-stone-800 flex items-center justify-between gap-2 text-xs">
<div class="flex items-center gap-2 truncate">
<span>${proto.icon}</span>
<span class="font-medium text-stone-200 truncate">${proto.name}</span>
</div>
<button onclick="depositItemToChest(${idx})" class="fantasy-btn px-2.5 py-1 rounded-lg text-xs font-bold text-amber-300 border-amber-500/40">
📦 In Kiste
</button>
</div>
`;
}).join("")}
</div>
</div>
`;
}
// ================= 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 `
<button onclick="openShortcutsModal()" class="quickslot-btn h-11 sm:h-12 rounded-xl flex flex-col items-center justify-center relative">
<span class="absolute top-0.5 left-1.5 text-[9px] font-mono text-stone-300">[${keyLabel}]</span>
<span class="text-xs text-stone-300">+</span>
</button>
`;
}
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 ? `<span class="absolute bottom-0.5 right-1 text-[8px] font-mono font-bold ${hero.arrows > 0 ? "text-emerald-300" : "text-rose-400"}">🏹${hero.arrows || 0}</span>` : "";
return `
<button onclick="useQuickSlot(${idx})" title="${sk.name}: ${sk.desc}" class="quickslot-btn h-11 sm:h-12 rounded-xl flex flex-col items-center justify-center relative">
<span class="absolute top-0.5 left-1 text-[8px] sm:text-[9px] font-mono font-bold text-amber-300">[${keyLabel}]</span>
<span class="text-base sm:text-lg leading-none mt-1">${sk.icon}</span>
<span class="text-[8px] font-bold text-cyan-300 leading-none mt-0.5">${sk.mpCost > 0 ? `${sk.mpCost}MP` : (isRanged ? `🏹${hero.arrows || 0}` : "Fern")}</span>
${arrowBadge}
</button>
`;
}
const proto = ITEMS[slot.id];
const totalQty = hero.inventory.filter(x => x.id === slot.id).reduce((s, x) => s + x.qty, 0);
return `
<button onclick="useQuickSlot(${idx})" title="${proto ? proto.name : ""}" class="quickslot-btn h-11 sm:h-12 rounded-xl flex flex-col items-center justify-center relative ${totalQty === 0 ? "opacity-80" : ""}">
<span class="absolute top-0.5 left-1 text-[8px] sm:text-[9px] font-mono font-bold text-amber-300">[${keyLabel}]</span>
<span class="text-base sm:text-lg leading-none mt-1">${proto ? proto.icon : "❓"}</span>
<span class="text-[8px] font-bold ${totalQty === 0 ? "text-rose-300" : "text-emerald-300"} leading-none mt-0.5">x${totalQty}</span>
</button>
`;
}).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 `
<button onclick="selectShortcutSlotForEdit(${idx})" class="p-2 rounded-xl border ${active ? "border-amber-400 bg-amber-950/40" : "border-stone-800 bg-stone-900"} flex flex-col items-center">
<span class="text-[10px] font-mono text-amber-300">[${(s?.key || idx + 1).toUpperCase()}]</span>
<span class="text-xl my-0.5">${itemOrSkill ? itemOrSkill.icon : "➕"}</span>
<span class="text-[10px] text-stone-300 truncate max-w-full">${itemOrSkill ? itemOrSkill.name : "Leer"}</span>
</button>
`;
}).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 => `
<button onclick="assignSelectedQuickslot('skill', '${sk.id}')" class="w-full p-2 rounded-lg bg-stone-900 hover:bg-stone-800 border border-stone-800 flex items-center gap-2 text-left text-xs">
<span class="text-lg">${sk.icon}</span>
<div class="min-w-0 flex-1">
<div class="font-bold text-stone-200">${sk.name}</div>
<div class="text-[10px] text-stone-400 truncate">${sk.desc}</div>
</div>
</button>
`).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 => `
<button onclick="assignSelectedQuickslot('item', '${it.id}')" class="w-full p-2 rounded-lg bg-stone-900 hover:bg-stone-800 border border-stone-800 flex items-center gap-2 text-left text-xs">
<span class="text-lg">${it.icon}</span>
<div class="min-w-0 flex-1">
<div class="font-bold text-stone-200">${it.name}</div>
<div class="text-[10px] text-stone-400 truncate">${it.desc}</div>
</div>
</button>
`).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(`🔥 <b>Neuer Durchlauf gestartet!</b> Sammle Holz, Erz & Wandfackeln (🔥) oder besuche jederzeit die <b>🏘️ Stadt Grimhold</b>!`);
}
// ================= 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 = `<div class="text-xs text-stone-400 text-center py-4">Lade Halle der Helden...</div>`;
try {
const res = await fetch("/api/dungeon/highscores");
const data = await res.json();
const rows = data.highscores || [];
if (rows.length === 0) {
list.innerHTML = `<div class="text-xs text-stone-400 text-center py-6">Noch keine Einträge vorhanden.</div>`;
return;
}
list.innerHTML = rows.map((r, i) => `
<div class="p-2.5 rounded-xl bg-stone-950 border border-stone-800 flex items-center justify-between text-xs">
<div class="flex items-center gap-2.5">
<span class="font-bold text-amber-400 w-5">#${i + 1}</span>
<div>
<div class="font-bold text-stone-100">${r.hero_name || r.username || "Held"} <span class="text-stone-400 font-normal">(${r.hero_class})</span> ${r.victory ? "🏆" : ""}</div>
<div class="text-[10px] text-stone-400">Ebene ${r.floor_reached} · ⚔️ ${r.kills} Kills · 💰 ${r.gold}G</div>
</div>
</div>
<span class="font-mono font-bold text-amber-300">${r.score} Pkt</span>
</div>
`).join("");
} catch (e) {
list.innerHTML = `<div class="text-xs text-rose-400 text-center py-4">Fehler beim Laden.</div>`;
}
}
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 `
<button onclick="setTownShopFilter('${c.id}')" class="px-2.5 py-1 rounded-lg text-xs font-bold shrink-0 transition flex items-center gap-1 ${isAct ? "gold-btn border-amber-400" : "fantasy-btn text-stone-300 opacity-90 hover:opacity-100"}">
<span>${c.icon}</span>
<span>${c.label}</span>
<span class="text-[10px] font-mono px-1 rounded ${isAct ? "bg-black/30 text-amber-200" : "bg-stone-800 text-stone-400"}">${cnt}</span>
</button>
`;
}).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 = `<div class="text-center py-8 text-xs text-stone-400">Keine Angebote in dieser Kategorie bei Balthasar vorhanden.</div>`;
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 `
<div class="p-2.5 sm:p-3 rounded-xl border border-amber-500/35 bg-stone-900/95 hover:border-amber-400/60 flex flex-col gap-2 shadow transition">
<div class="flex items-start gap-2.5">
<div class="w-10 h-10 rounded-xl bg-amber-950/60 border border-amber-500/40 flex items-center justify-center text-2xl shrink-0 shadow-inner">
${it.icon}
</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-amber-400/90 font-bold">${slotLabel}</div>
<div class="text-xs sm:text-sm font-bold text-stone-100">${it.name}</div>
<div class="text-[11px] text-emerald-300 font-semibold flex flex-wrap gap-x-1.5 gap-y-0.5 mt-0.5">
${formatItemStatsBadgesHtml(it, 0)}
</div>
${it.desc ? `<div class="text-[10px] text-stone-300 italic mt-0.5">${it.desc}</div>` : ""}
</div>
</div>
<div class="flex items-center justify-between pt-1.5 border-t border-stone-800">
<span class="text-xs font-mono font-bold text-amber-300">Preis: ${off.price}💰</span>
<button onclick="buyTownShopGear('${off.itemId}', ${off.price})" class="px-3.5 py-1.5 rounded-lg text-xs font-bold ${canBuy ? "gold-btn" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
💰 ${off.price}G kaufen
</button>
</div>
</div>
`;
}).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 = `<span class="px-1.5 py-0.5 rounded bg-emerald-950/70 border border-emerald-500/40 text-emerald-300 font-bold">🏹 +15 Pfeile</span>`;
}
return `
<div class="p-2.5 sm:p-3 rounded-xl border border-stone-800 bg-stone-900/95 hover:border-emerald-500/40 flex flex-col gap-2 shadow transition">
<div class="flex items-start gap-2.5">
<div class="w-10 h-10 rounded-xl bg-stone-950 border border-stone-700 flex items-center justify-center text-2xl shrink-0 shadow-inner">
${icon}
</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-emerald-400 font-bold">${s.kind === "mat" ? "📦 ROHSTOFF" : (s.kind === "arrows" ? "🏹 MUNITION" : "🧪 VORRAT")}</div>
<div class="text-xs sm:text-sm font-bold text-stone-100">${name}</div>
${statsHtml ? `<div class="text-[11px] text-emerald-300 font-semibold flex flex-wrap gap-x-1.5 gap-y-0.5 mt-0.5">${statsHtml}</div>` : ""}
${desc ? `<div class="text-[10px] text-stone-300 italic mt-0.5">${desc}</div>` : ""}
</div>
</div>
<div class="flex items-center justify-between pt-1.5 border-t border-stone-800">
<span class="text-xs font-mono font-bold text-amber-300">Preis: ${s.price}💰</span>
<button onclick="buyTownShopSupply(${idx})" class="px-3.5 py-1.5 rounded-lg text-xs font-bold ${canBuy ? "emerald-btn" : "bg-stone-800 text-stone-500 cursor-not-allowed"}">
💰 ${s.price}G kaufen
</button>
</div>
</div>
`;
}).join("");
return;
}
// Sell tab with category filtering!
if (hero.inventory.length === 0) {
box.innerHTML = `<div class="text-center py-8 text-xs text-stone-400">Dein Rucksack ist leer. Keine Beute zum Verkaufen.</div>`;
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 = `<div class="text-center py-8 text-xs text-stone-400">Keine Gegenstände in dieser Kategorie im Beutel vorhanden.</div>`;
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 `
<div class="p-2.5 sm:p-3 rounded-xl border border-amber-500/35 bg-stone-900/95 hover:border-amber-400/60 flex flex-col gap-2 shadow transition">
<div class="flex items-start gap-2.5">
<div class="w-10 h-10 rounded-xl bg-amber-950/60 border border-amber-500/40 flex items-center justify-center text-2xl shrink-0 shadow-inner">
${proto.icon}
${entry.qty > 1 ? `<span class="absolute -bottom-1 -right-1 px-1 rounded bg-stone-900 border border-amber-500/50 text-[10px] font-bold text-amber-300">x${entry.qty}</span>` : ""}
</div>
<div class="min-w-0 flex-1">
<div class="text-[9px] uppercase tracking-wider text-amber-400/90 font-bold">${slotLabel}</div>
<div class="text-xs sm:text-sm font-bold text-stone-100 flex items-center gap-1.5 flex-wrap">
<span>${proto.name}</span>
${upg > 0 ? `<span class="text-amber-300 font-bold">+${upg}</span>` : ""}
</div>
<div class="text-[11px] text-emerald-300 font-semibold flex flex-wrap gap-x-1.5 gap-y-0.5 mt-0.5">
${formatItemStatsBadgesHtml(proto, upg)}
</div>
${proto.desc ? `<div class="text-[10px] text-stone-300 italic mt-0.5">${proto.desc}</div>` : ""}
</div>
</div>
<div class="flex items-center justify-between pt-1.5 border-t border-stone-800">
<span class="text-xs font-mono font-bold text-emerald-400">Verkaufswert: +${townGold}💰</span>
<button onclick="sellBagItemInTown(${idx})" class="gold-btn px-3.5 py-1.5 rounded-lg text-xs font-bold">
💰 Für +${townGold}G verkaufen
</button>
</div>
</div>
`;
}).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 `
<div class="p-3.5 rounded-2xl border ${isCurrent ? "border-amber-400 bg-stone-900/95" : "border-stone-700 bg-stone-900/80"} flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-lg">
<div class="flex items-start gap-3">
<div class="w-12 h-12 rounded-xl bg-stone-950 border border-stone-700 flex items-center justify-center text-3xl shrink-0">${d.icon}</div>
<div>
<div class="flex items-center gap-2">
<span class="font-cinzel font-bold text-sm sm:text-base text-amber-300">${d.name}</span>
<span class="px-2 py-0.5 rounded bg-stone-950 border border-stone-700 text-[10px] font-mono text-cyan-300">Empf. ab St. ${d.recLevel}</span>
</div>
<p class="text-xs text-stone-300 mt-0.5">${d.desc}</p>
<div class="text-[11px] text-rose-300 font-semibold mt-1">👑 Endboss (Ebene 10): ${bossProto.icon} ${bossProto.name}</div>
</div>
</div>
<div class="flex flex-col gap-1.5 shrink-0">
${isCurrent && resumeFloor > 1 ? `
<button onclick="selectAndEnterDungeon('${d.id}', ${resumeFloor})" class="emerald-btn px-4 py-2 rounded-xl text-xs font-bold">
▶ Ebene ${resumeFloor} fortsetzen
</button>
` : ""}
<button onclick="selectAndEnterDungeon('${d.id}', 1)" class="${isCurrent && resumeFloor > 1 ? "fantasy-btn" : "gold-btn"} px-4 py-2 rounded-xl text-xs font-bold">
🌀 Ebene 1 betreten
</button>
</div>
</div>
`;
}).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 => `
<span class="px-2 py-0.5 rounded-lg bg-stone-900 border border-stone-700 text-stone-200 flex items-center gap-1">
<span>${p.hero_icon || "⚔️"}</span>
<b class="text-amber-300">${p.player_name}</b>
<span class="text-stone-400 text-[10px]">(St.${p.level} • ${p.in_town ? "🏘️ Stadt" : `E${p.floor}`})</span>
</span>
`).join("");
}
const chatBox = document.getElementById("chat-messages-box");
if (chatBox) {
chatBox.innerHTML = chatMessages.map(m => {
if (m.is_system) {
return `<div class="p-1.5 rounded-lg bg-amber-950/40 border border-amber-500/30 text-amber-200 text-[11px]">${m.hero_icon || "🏰"} <b>${m.sender}:</b> ${m.message}</div>`;
}
return `
<div class="p-1.5 rounded-lg bg-stone-900/90 border border-stone-800/90 text-xs">
<span class="mr-1">${m.hero_icon || "⚔️"}</span>
<b class="text-cyan-300">${m.sender}:</b>
<span class="text-stone-100 ml-1">${m.message}</span>
</div>
`;
}).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 = `<span>🪵</span> <span>15 Pfeile herstellen (3 Holz, 1 Erz)</span>`;
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 = `<span>🪵</span> <span>5 Pfeile aus 1x Holz schnitzen</span>`;
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 = `<span>🪵</span> <span>Kein Holz vorhanden (Holz benötigt)</span>`;
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);