Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions js/data/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
59 changes: 51 additions & 8 deletions js/systems/combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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!');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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!`);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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++;
Expand Down
37 changes: 29 additions & 8 deletions js/ui/screens/inventory.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
41 changes: 41 additions & 0 deletions js/ui/screens/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions js/world/dungeogen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}

Expand Down
Loading