4903 lines
204 KiB
JavaScript
4903 lines
204 KiB
JavaScript
// ========================================================
|
||
// 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);
|