diff --git a/js/data/items.ts b/js/data/items.ts index 31e4d9f..8daebe4 100644 --- a/js/data/items.ts +++ b/js/data/items.ts @@ -229,3 +229,61 @@ export function addToInventory(player, itemId, qty = 1) { player.inventory.push({ id: itemId, qty }); } } + +// Apply equipment effect to player stats when an item is equipped. +export function applyItemEffect(player, item) { + if (!item?.effect) return; + switch (item.effect) { + case 'hp10': player.maxHp += 10; player.hp = Math.min(player.hp + 10, player.maxHp); break; + case 'hp15': player.maxHp += 15; player.hp = Math.min(player.hp + 15, player.maxHp); break; + case 'mp8': player.maxMp += 8; player.mp = Math.min((player.mp || 0) + 8, player.maxMp); break; + case 'mp15': player.maxMp += 15; player.mp = Math.min((player.mp || 0) + 15, player.maxMp); break; + case 'str2': player.stats.str += 2; break; + case 'str4': player.stats.str += 4; break; + case 'dex2': player.stats.dex += 2; break; + case 'def2': player._bonusDef = (player._bonusDef || 0) + 2; break; + case 'fire_dmg': player._fireDmgBonus = (player._fireDmgBonus || 0) + 1; break; + case 'luck5': player._luckBonus = (player._luckBonus || 0) + 5; break; + case 'curse_str': player.stats.str -= 2; break; + case 'curse_dex': player.stats.dex -= 2; break; + } +} + +// Remove equipment effect from player stats when an item is unequipped. +export function removeItemEffect(player, item) { + if (!item?.effect) return; + switch (item.effect) { + case 'hp10': player.maxHp -= 10; player.hp = Math.min(player.hp, player.maxHp); break; + case 'hp15': player.maxHp -= 15; player.hp = Math.min(player.hp, player.maxHp); break; + case 'mp8': player.maxMp -= 8; player.mp = Math.min(player.mp || 0, player.maxMp); break; + case 'mp15': player.maxMp -= 15; player.mp = Math.min(player.mp || 0, player.maxMp); break; + case 'str2': player.stats.str -= 2; break; + case 'str4': player.stats.str -= 4; break; + case 'dex2': player.stats.dex -= 2; break; + case 'def2': player._bonusDef = Math.max(0, (player._bonusDef || 0) - 2); break; + case 'fire_dmg': player._fireDmgBonus = Math.max(0, (player._fireDmgBonus || 0) - 1); break; + case 'luck5': player._luckBonus = Math.max(0, (player._luckBonus || 0) - 5); break; + case 'curse_str': player.stats.str += 2; break; + case 'curse_dex': player.stats.dex += 2; break; + } +} + +// Human-readable description of an item effect for the inventory UI. +export function getEffectDescription(effect) { + const map = { + hp10: '+10 Max HP', + hp15: '+15 Max HP', + mp8: '+8 Max MP', + mp15: '+15 Max MP', + str2: '+2 Strength', + str4: '+4 Strength', + dex2: '+2 Dexterity', + def2: '+2 Defense', + fire_dmg: '+1 Fire Damage', + luck5: '+5% Dodge Chance', + curse_str: '-2 Strength (CURSED)', + curse_dex: '-2 Dexterity (CURSED)', + str_boost: '+2 STR for 5 turns', + }; + return map[effect] || null; +} diff --git a/js/systems/combat.ts b/js/systems/combat.ts index 667a522..07bcc78 100644 --- a/js/systems/combat.ts +++ b/js/systems/combat.ts @@ -136,8 +136,9 @@ export function playerAttack(combat, abilityId = null) { baseDmg = rng.int(1, 4); } - // Stat bonus - const strBonus = Math.floor((player.stats.str - 10) / 2); + // Stat bonus (includes temporary STR boost from potions) + const strActive = player.stats.str + ((combat._strBoostTurns > 0) ? (combat._strBoostAmt || 0) : 0); + const strBonus = Math.floor((strActive - 10) / 2); baseDmg = Math.max(1, baseDmg + strBonus); // Skill modifiers @@ -170,6 +171,11 @@ export function playerAttack(combat, abilityId = null) { // Defense reduction dmg = Math.max(1, baseDmg - monster.def); + // Fire damage bonus from Ring of Fire or similar accessories + if (player._fireDmgBonus > 0) { + dmg += player._fireDmgBonus; + } + // Dwarf trait: enemy damage reduced if (player.race === 'dwarf') dmg = Math.max(1, dmg - 1); @@ -386,6 +392,13 @@ export function playerUseItem(combat, itemId) { player.mp = player.maxMp; logMsg(combat, `You drink the ${item.name}. Full health and magic restored!`); } + if (item.effect === 'str_boost') { + const boostAmt = 2; + const boostTurns = 5; + combat._strBoostTurns = (combat._strBoostTurns || 0) + boostTurns; + combat._strBoostAmt = (combat._strBoostAmt || 0) + boostAmt; + logMsg(combat, `You drink the ${item.name}. Strength surges through you! (+${boostAmt} STR for ${boostTurns} turns)`); + } if (item.flee) { combat.fleeing = true; logMsg(combat, 'You throw a smoke bomb and run!'); @@ -450,6 +463,12 @@ function advanceToEnemyTurn(combat) { processMonsterStatus(combat, monster); if (combat.state !== COMBAT_STATE.ENEMY_TURN) return; + // Boss phase 2: trigger when HP drops below 50% + if (monster.isBoss && monster.currentPhase === 0 && monster.hp < monster.maxHp * 0.5) { + monster.currentPhase = 1; + logMsg(combat, `${monster.name} enters a RAGE! Its attacks grow far more powerful!`, 'system'); + } + // Frozen monsters skip turn const frozen = monster.statusEffects?.find(s => s.type === 'frozen'); if (frozen) { @@ -526,6 +545,10 @@ function monsterAttack(combat, monster) { monster._atkBonusTurns--; if (monster._atkBonusTurns <= 0) monster._atkBonus = 0; } + // Phase 2 rage: boss deals 30% more damage + if (monster.isBoss && monster.currentPhase >= 1) { + baseDmg = Math.floor(baseDmg * 1.3); + } const playerDef = getPlayerDefense(player); let dmg = Math.max(1, baseDmg - playerDef); @@ -543,11 +566,12 @@ function monsterAttack(combat, monster) { if (absorbed > 0) logMsg(combat, `Your magic shield absorbs ${absorbed} damage.`); } - // Halfling dodge bonus + // Dodge chance (race traits, skills, luck bonus from accessories) let dodgeChance = 0; if (player.race === 'halfling') dodgeChance += 15; if (hasSkill(player, 'stealth')) dodgeChance += 10; if (player.race === 'elf') dodgeChance += 10; + dodgeChance += player._luckBonus || 0; if (rng.chance(dodgeChance)) { logMsg(combat, `${monster.name} attacks but you dodge!`); return; @@ -628,25 +652,29 @@ function executeMonsterAbility(combat, monster, ability) { break; } case 'fire_breath': { - const dmg = rng.roll(3, 8); + const phase2 = monster.isBoss && monster.currentPhase >= 1; + const dmg = phase2 ? rng.roll(4, 12) : rng.roll(3, 8); player.hp -= dmg; logMsg(combat, `${monster.name} breathes fire! You take ${dmg} fire damage!`); break; } case 'inferno': { - const dmg = rng.roll(5, 10); + const phase2 = monster.isBoss && monster.currentPhase >= 1; + const dmg = phase2 ? rng.roll(7, 15) : rng.roll(5, 10); player.hp -= dmg; logMsg(combat, `${monster.name} unleashes INFERNO! ${dmg} fire damage!`); break; } case 'death_bolt': { - const dmg = rng.roll(4, 10); + const phase2 = monster.isBoss && monster.currentPhase >= 1; + const dmg = phase2 ? rng.roll(6, 15) : rng.roll(4, 10); player.hp -= dmg; logMsg(combat, `The Lich fires a death bolt! You take ${dmg} necrotic damage!`); break; } case 'drain_life': { - const drain = 20; + const phase2 = monster.isBoss && monster.currentPhase >= 1; + const drain = phase2 ? 30 : 20; player.hp -= drain; monster.hp = Math.min(monster.maxHp, monster.hp + drain); logMsg(combat, `The Lich drains ${drain} HP from your life force!`); @@ -750,6 +778,12 @@ function getPlayerDefense(player) { const offhand = getItem(player.equipment.offhand); if (offhand) def += offhand.def || 0; } + if (player.equipment.helmet) { + const helmet = getItem(player.equipment.helmet); + if (helmet) def += helmet.def || 0; + } + // Accessory def2 bonus (Ring of Protection, etc.) + def += player._bonusDef || 0; return Math.max(0, def); } @@ -801,10 +835,19 @@ export function xpToLevel(level) { return level * (level + 1) * 30; } -// Process beginning-of-player-turn effects (poison, etc.) +// Process beginning-of-player-turn effects (poison, str boost, etc.) export function startPlayerTurn(combat) { processPlayerStatus(combat.player, combat.log); + // Decrement strength boost counter + if (combat._strBoostTurns > 0) { + combat._strBoostTurns--; + if (combat._strBoostTurns === 0) { + combat._strBoostAmt = 0; + combat.log.push({ msg: 'The strength boost wears off.', type: 'system' }); + } + } + if (combat.playerSkipTurn) { combat.playerSkipTurn = false; combat.turn++; diff --git a/js/ui/screens/inventory.ts b/js/ui/screens/inventory.ts index 16302a2..05dda6a 100644 --- a/js/ui/screens/inventory.ts +++ b/js/ui/screens/inventory.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { C, COLS, ROWS, STATE, SLOT } from '../../data/constants'; -import { getItem, ITEMS } from '../../data/items'; +import { getItem, ITEMS, applyItemEffect, removeItemEffect, getEffectDescription } from '../../data/items'; import { ScrollList } from '../menu'; export class InventoryScreen { @@ -84,9 +84,20 @@ export class InventoryScreen { const slot = item.slot; if (player.equipment[slot] === this.selected.id) { + // Unequipping + removeItemEffect(player, item); player.equipment[slot] = null; this.game.addMessage(`Unequipped ${item.name}.`, 'normal'); } else { + // Remove old item in that slot first + if (player.equipment[slot]) { + const oldItem = getItem(player.equipment[slot]); + removeItemEffect(player, oldItem); + } + if (item.cursed) { + this.game.addMessage(`WARNING: ${item.name} feels malevolent as you put it on!`, 'system'); + } + applyItemEffect(player, item); player.equipment[slot] = this.selected.id; this.game.addMessage(`Equipped ${item.name}.`, 'normal'); } @@ -191,22 +202,32 @@ export class InventoryScreen { renderer.write(42, 10, item.name, C.YELLOW, C.BLACK); renderer.write(42, 11, `Type: ${item.type}`, C.LIGHT_GRAY, C.BLACK); - if (item.dmg) renderer.write(42, 12, `Damage: ${item.dmg[0]}-${item.dmg[1]}`, C.RED, C.BLACK); - if (item.def) renderer.write(42, 13, `Defense: ${item.def}`, C.BLUE, C.BLACK); - if (item.heal) renderer.write(42, 14, `Heals: ${item.heal} HP`, C.GREEN, C.BLACK); - if (item.mp) renderer.write(42, 15, `Restores: ${item.mp} MP`, C.CYAN, C.BLACK); + let detailRow = 12; + if (item.dmg) { renderer.write(42, detailRow, `Damage: ${item.dmg[0]}-${item.dmg[1]}`, C.RED, C.BLACK); detailRow++; } + if (item.def) { renderer.write(42, detailRow, `Defense: +${item.def}`, C.BLUE, C.BLACK); detailRow++; } + if (item.heal) { renderer.write(42, detailRow, `Heals: ${item.heal} HP`, C.GREEN, C.BLACK); detailRow++; } + if (item.mp) { renderer.write(42, detailRow, `Restores: ${item.mp} MP`, C.CYAN, C.BLACK); detailRow++; } + if (item.effect) { + const effDesc = getEffectDescription(item.effect); + if (effDesc) { + const effColor = item.cursed ? C.MAGENTA : C.CYAN; + renderer.write(42, detailRow, `Effect: ${effDesc}`, effColor, C.BLACK); + detailRow++; + } + } - renderer.write(42, 16, `Value: ${item.value}g`, C.YELLOW, C.BLACK); + renderer.write(42, detailRow, `Value: ${item.value}g`, C.YELLOW, C.BLACK); // Description wrapped const descLines = wrapText(item.desc || '', COLS - 44); - for (let i = 0; i < descLines.length && i < 4; i++) { - renderer.write(42, 18 + i, descLines[i], C.DARK_GRAY, C.BLACK); + for (let i = 0; i < descLines.length && i < 3; i++) { + renderer.write(42, detailRow + 2 + i, descLines[i], C.DARK_GRAY, C.BLACK); } // Equipped indicator const isEquipped = Object.values(player.equipment).includes(this.selected.id); if (isEquipped) renderer.write(42, 23, '[ EQUIPPED ]', C.GREEN, C.BLACK); + if (item.cursed && isEquipped) renderer.write(55, 23, '[CURSED]', C.MAGENTA, C.BLACK); } } diff --git a/js/ui/screens/location.ts b/js/ui/screens/location.ts index 127cc0d..965a51f 100644 --- a/js/ui/screens/location.ts +++ b/js/ui/screens/location.ts @@ -350,6 +350,47 @@ export class LocationScreen { const chest = activeData.chests?.find(c => c.x === x && c.y === y && !c.opened); if (!chest) { this.game.addMessage('The chest is empty.', 'normal'); return; } + // Handle locked chests + if (chest.locked) { + const player = this.game.player; + const hasSkill = player?.skills?.includes('lockpicking'); + const lockpickIdx = player?.inventory.findIndex(i => i.id === 'lockpick'); + const hasLockpick = lockpickIdx !== undefined && lockpickIdx >= 0; + + if (!hasSkill && !hasLockpick) { + this.game.addMessage('The chest is locked. You need a lockpick or lockpicking skill.', 'system'); + return; + } + + // Lockpicking skill: 80% success; lockpick item alone: 50% + const successChance = hasSkill ? 80 : 50; + if (!this.game.rng.chance(successChance)) { + if (hasLockpick && !hasSkill) { + // Lockpick breaks on failure + if (player.inventory[lockpickIdx].qty > 1) { + player.inventory[lockpickIdx].qty--; + } else { + player.inventory.splice(lockpickIdx, 1); + } + this.game.addMessage('You try to pick the lock, but the pick snaps!', 'system'); + } else { + this.game.addMessage('You fail to pick the lock.', 'system'); + } + return; + } + + // Success - consume lockpick if used without skill + if (hasLockpick && !hasSkill) { + if (player.inventory[lockpickIdx].qty > 1) { + player.inventory[lockpickIdx].qty--; + } else { + player.inventory.splice(lockpickIdx, 1); + } + } + this.game.addMessage('You pick the lock!', 'normal'); + chest.locked = false; + } + chest.opened = true; activeData.tiles[y * activeData.width + x] = LOC_TILE.CHEST_OPEN; diff --git a/js/world/dungeogen.ts b/js/world/dungeogen.ts index 3d13a9d..356d72c 100644 --- a/js/world/dungeogen.ts +++ b/js/world/dungeogen.ts @@ -356,14 +356,15 @@ export function generateDungeon(rng, loc) { g.exits.push({ x: lastPos.x, y: lastPos.y, dest: 'world' }); } - // Place chests + // Place chests (30% chance each chest is locked) const chestRooms = rng.shuffle([...rooms]).slice(0, rng.int(2, 5)); for (const room of chestRooms) { const cx = room.x + rng.int(1, Math.max(1, room.w - 2)); const cy = room.y + rng.int(1, Math.max(1, room.h - 2)); if (getTile(g, cx, cy) === LOC_TILE.FLOOR) { + const locked = rng.chance(30); setTile(g, cx, cy, LOC_TILE.CHEST); - g.chests.push({ x: cx, y: cy, tier: danger, opened: false }); + g.chests.push({ x: cx, y: cy, tier: danger, opened: false, locked }); } }