diff --git a/src/actors.js b/src/actors.js index aff39dc..61e4285 100644 --- a/src/actors.js +++ b/src/actors.js @@ -296,6 +296,8 @@ export function createActors(GFX, dungeon, world, { killY }) { let touched = null; for (const m of mobs) { if (m.dead || m.gone) continue; + if (m.guard > 0) m.guard -= dt; + const open = !(m.guard > 0); const kind = m.stats; if (m.ball) { const b = m.ball; @@ -328,7 +330,7 @@ export function createActors(GFX, dungeon, world, { killY }) { m.moving = Math.hypot(b.vx, b.vz) > 0.3; m.x = b.x; m.y = b.y - kind.radius; m.z = b.z; if (b.y < killY) m.gone = true; - if (die && !touched) { + if (die && !touched && open) { const reach = die.r + kind.radius + 0.02; const ex = die.x - b.x, ey = die.y - b.y, ez = die.z - b.z; if (ex * ex + ey * ey + ez * ez < reach * reach) touched = m; @@ -342,7 +344,7 @@ export function createActors(GFX, dungeon, world, { killY }) { m.moving = true; if (kind.move === 'fly') m.y = m.flight; else m.y = dungeon.heightAt(x, z) ?? m.y; - if (die && !touched) { + if (die && !touched && open) { const centre = kind.move === 'fly' ? m.y : m.y + 0.2; const flat = Math.hypot(die.x - x, die.z - z); if (flat < die.r + kind.radius * (kind.move === 'fly' ? 1 : 0.8) && Math.abs(die.y - centre) < 0.65) touched = m; @@ -360,6 +362,18 @@ export function createActors(GFX, dungeon, world, { killY }) { return touched; }, + /** + * The monster takes `hits`. A boss has hit points and shrugs off a + * blow it can stand, with a moment to itself before it can be struck + * again; anything else is gone with the first. Returns the hits left. + */ + wound(mob, hits = 1) { + mob.hp = (mob.hp ?? mob.stats.hp ?? 1) - hits; + if (mob.hp > 0) { mob.guard = 1.5; return mob.hp; } + this.defeat(mob); + return 0; + }, + /** The monster is beaten: it goes down (see `sync`), and is gone from the dungeon. */ defeat(mob) { mob.dead = true; diff --git a/src/battle.js b/src/battle.js index 9e04115..dce590e 100644 --- a/src/battle.js +++ b/src/battle.js @@ -12,22 +12,33 @@ import { HEROES } from './party.js'; * monster's power, and more the further under * 1 fumble: the monster gets in twice as hard * + * A boss never goes down for free: whatever the roll, short of a fumble, it + * lands at least a quarter of its power, and a roll under its DC costs a + * third more than it would from anything else. + * + * A boss has several hit points (`hp`): a critical lands two hits, a fumble + * none, anything else one, and the fight goes on until they are spent. + * * The party always wins the fight itself: losing is only ever running out * of hit points. The game only needs `damage` and `gold` back. (A full * turn-based system is kept aside in docs/battle-system.md.) */ -export function resolveBattle({ roll, monster }) { - const { dc, power, gold } = monster; - if (roll >= 20) return { roll, dc, grade: 'critical', damage: 0, gold: gold * 2 }; - if (roll <= 1) return { roll, dc, grade: 'fumble', damage: power * 2, gold }; - if (roll >= dc) return { roll, dc, grade: 'success', damage: Math.max(0, Math.ceil(power / 2) - (roll - dc)), gold }; - return { roll, dc, grade: 'struggle', damage: Math.ceil(power / 2) + Math.ceil((dc - roll) / 3), gold }; +export function resolveBattle({ roll: natural, monster, bonus = 0 }) { + const { dc, power, gold, boss } = monster; + // A natural 20 and a natural 1 stand; the party's levels nudge everything between. + const roll = natural > 1 && natural < 20 ? Math.min(19, natural + bonus) : natural; + const floor = boss ? Math.ceil(power / 4) : 0; + if (roll >= 20) return { roll: natural, dc, grade: 'critical', damage: floor, hits: 2, gold: gold * 2 }; + if (roll <= 1) return { roll: natural, dc, grade: 'fumble', damage: power * 2, hits: 0, gold }; + if (roll >= dc) return { roll: natural, dc, grade: 'success', damage: Math.max(floor, Math.ceil(power / 2) - (roll - dc)), hits: 1, gold }; + const under = Math.ceil(power / 2) + Math.ceil((dc - roll) / (boss ? 2 : 3)); + return { roll: natural, dc, grade: 'struggle', damage: boss ? Math.ceil(under * 4 / 3) : under, hits: 1, gold }; } export const GRADES = Object.freeze({ - critical: { title: 'CRITICAL!', line: 'A natural 20. The party cuts it down without a scratch.' }, - success: { title: 'VICTORY', line: 'Over the DC: a clean fight.' }, + critical: { title: 'CRITICAL!', line: 'A natural 20. The party cuts it down without a scratch.', boss: 'A natural 20. The boss falls, but not before it lands a blow.' }, + success: { title: 'VICTORY', line: 'Over the DC: a clean fight.', boss: 'Over the DC, and still it hits back hard.' }, struggle: { title: 'HARD-WON', line: 'Under the DC: the party wins, but it hurts.' }, fumble: { title: 'FUMBLE!', line: 'A natural 1. It gets in twice before it goes down.' }, }); @@ -50,7 +61,7 @@ export function createBattleScreen(root) {
-
your roll
+
your roll
vs
DC
@@ -78,27 +89,30 @@ export function createBattleScreen(root) { get open() { return done !== null; }, /** `before` and `after` are each hero's hit points either side of the fight. */ - show({ monster, result, before, after }) { + show({ monster, result, before, after, left = 0, party = null, levels = [], xp = 0, bonus = 0 }) { const grade = GRADES[result.grade]; + const line = (monster.boss && grade.boss) || grade.line; $('.foe').textContent = monster.name; $('.boss').textContent = monster.boss ? 'the boss' : ''; $('.d20').textContent = String(result.roll); + $('.bonus').textContent = bonus > 0 && result.roll > 1 && result.roll < 20 ? `+${bonus} levels` : ''; $('.d20').dataset.grade = result.grade; $('.dc .value').textContent = String(result.dc); $('.grade').textContent = grade.title; $('.grade').dataset.grade = result.grade; - $('.line').textContent = grade.line; - $('.heroes').replaceChildren(...HEROES.map((h, i) => { + $('.line').textContent = line; + $('.heroes').replaceChildren(...HEROES.map((base, i) => { + const h = party ? { ...base, hp: party.heroes[i].max } : base; const li = document.createElement('li'); const lost = before[i] - after[i]; - li.innerHTML = `${h.name}` + li.innerHTML = `${h.name}${party ? ` L${party.heroes[i].level}` : ''}` + `` + `${Math.max(0, after[i])}/${h.hp}` - + `${lost > 0 ? `โˆ’${lost}` : ''}${after[i] <= 0 && before[i] > 0 ? ' DOWN' : ''}`; + + `${lost > 0 ? `โˆ’${lost}` : ''}${after[i] <= 0 && before[i] > 0 ? ' DOWN' : ''}${levels[i] ? ' LEVEL UP!' : ''}`; if (after[i] <= 0) li.classList.add('down'); return li; })); - $('.loot').textContent = `+${result.gold} gold`; + $('.loot').textContent = left > 0 ? `${left} ${left === 1 ? 'hit' : 'hits'} to go` : `+${result.gold} gold ยท +${xp} xp`; el.hidden = false; el.classList.remove('pop'); void el.offsetWidth; diff --git a/src/game.js b/src/game.js index 2a33da8..5773c91 100644 --- a/src/game.js +++ b/src/game.js @@ -5,7 +5,7 @@ import { createEffects } from './effects.js'; import { LEVELS } from './levels.js'; import { BOARD_DETAIL, MONSTERS } from './monsters.js'; import { readyAll } from './models/library.js'; -import { createParty, heal, hurt, rest, revive, seeded, wiped } from './party.js'; +import { createParty, grant, heal, hurt, rest, revive, rollBonus, seeded, wiped } from './party.js'; import { turn } from './physics.js'; import { createDungeonMeshes, createExit, createTorches } from './scenery.js'; @@ -217,19 +217,27 @@ export function createGame({ stage, hud, input, audio, storage }) { enter('battle'); playZoom = view.aim.zoom; view.aim.zoom = FIGHT_ZOOM; - const result = resolveBattle({ roll, monster: mob.stats }); + const result = resolveBattle({ roll, monster: mob.stats, bonus: rollBonus(party) }); const before = party.heroes.map((h) => h.hp); const took = hurt(party, result.damage, random); + const left = delve.actors.wound(mob, result.hits); + const xp = left ? 0 : Math.ceil(result.gold / 2); + if (!left) gold += result.gold; + const levels = left ? party.heroes.map(() => 0) : grant(party, xp); const after = party.heroes.map((h) => h.hp); - gold += result.gold; audio.stopMusic(); audio.battle(result.grade); hud.banner(null); - battle.show({ monster: mob.stats, result, before, after }).then(() => { - delve.actors.defeat(mob); - effects.poof(mob.x, mob.y, mob.z, mob.stats.boss ? 2.5 : 1); - effects.coins(mob.x, mob.y, mob.z, Math.min(30, 6 + result.gold / 4)); - audio.coin(); + battle.show({ monster: mob.stats, result, before, after, left, party, levels, xp, bonus: rollBonus(party) }).then(() => { + if (left) { + // Still standing: the blow throws the die clear. + const dx = b.x - mob.x, dz = b.z - mob.z, d = Math.hypot(dx, dz) || 1; + b.vx = (dx / d) * 6; b.vz = (dz / d) * 6; + } else { + effects.poof(mob.x, mob.y, mob.z, mob.stats.boss ? 2.5 : 1); + effects.coins(mob.x, mob.y, mob.z, Math.min(30, 6 + result.gold / 4)); + audio.coin(); + } hud.party(party, took); hud.gold(gold); view.aim.zoom = playZoom; diff --git a/src/hud.js b/src/hud.js index 39474d7..3790c26 100644 --- a/src/hud.js +++ b/src/hud.js @@ -44,7 +44,7 @@ export function createHud(root, { onStart }) { li.innerHTML = `${h.mark}${h.name}` + ''; partyEl.appendChild(li); - return { li, bar: li.querySelector('i'), hp: li.querySelector('.hp'), hit: li.querySelector('.hit') }; + return { li, name: li.querySelector('.name'), bar: li.querySelector('i'), hp: li.querySelector('.hp'), hit: li.querySelector('.hit') }; }); /** The map: every tile seen so far, the die, the monsters near it and the stairs once found. */ @@ -138,6 +138,7 @@ export function createHud(root, { onStart }) { r.bar.style.width = `${(100 * Math.max(0, h.hp)) / h.max}%`; r.bar.classList.toggle('low', h.hp > 0 && h.hp <= h.max * 0.3); r.hp.textContent = `${Math.max(0, h.hp)}/${h.max}`; + r.name.textContent = `${h.name} L${h.level}`; r.li.classList.toggle('down', h.hp <= 0); const t = took?.[i] ?? 0; if (t !== 0) { diff --git a/src/monsters.js b/src/monsters.js index 38bcfb1..980ce5e 100644 --- a/src/monsters.js +++ b/src/monsters.js @@ -7,24 +7,24 @@ import { BUILT, standUp } from './models/looks.js'; * over the floor like the die is, and after it when it comes near), * `crawl` (round a set loop on the floor) or `fly` (round a set loop in the * air, over gaps and all) โ€” and what it is like to fight: `dc`, the roll the - * party needs to beat it cleanly, `power`, how hard it hits back, and the - * gold it was carrying. + * party needs to beat it cleanly, `power`, how hard it hits back, the + * gold it was carrying, and (a boss) `hp`, how many hits it takes to put it down. * * How each one looks is its model (models/): sculpted, or ported from one * of our other projects. */ export const MONSTERS = Object.freeze({ - rat: { name: 'Giant Rat', move: 'walk', dc: 6, power: 3, gold: 4, radius: 0.26, speed: 4.2 }, - skeleton: { name: 'Skeleton', move: 'walk', dc: 10, power: 5, gold: 12, radius: 0.32, speed: 3 }, - goblin: { name: 'Goblin', move: 'walk', dc: 9, power: 4, gold: 10, radius: 0.3, speed: 3.8 }, - orc: { name: 'Orc Brute', move: 'walk', dc: 13, power: 6, gold: 25, radius: 0.4, speed: 2.6 }, - slime: { name: 'Slime', move: 'crawl', dc: 8, power: 4, gold: 8, radius: 0.42, speed: 1.4 }, - egg: { name: 'Eggdreessen', move: 'walk', dc: 16, power: 10, gold: 200, radius: 0.8, speed: 1.2, boss: true }, - rock: { name: 'Boulder', move: 'walk', dc: 12, power: 5, gold: 16, radius: 0.4, speed: 2.2 }, - bat: { name: 'Cave Bat', move: 'fly', dc: 7, power: 2, gold: 5, radius: 0.28, speed: 2.4 }, - wraith: { name: 'Wraith', move: 'fly', dc: 14, power: 6, gold: 30, radius: 0.34, speed: 1.8 }, - dragon: { name: 'Red Dragon', move: 'walk', dc: 17, power: 12, gold: 250, radius: 0.85, speed: 1.4, boss: true }, + rat: { name: 'Giant Rat', move: 'walk', dc: 7, power: 4, gold: 4, radius: 0.26, speed: 4.2 }, + skeleton: { name: 'Skeleton', move: 'walk', dc: 11, power: 7, gold: 12, radius: 0.32, speed: 3 }, + goblin: { name: 'Goblin', move: 'walk', dc: 10, power: 6, gold: 10, radius: 0.3, speed: 3.8 }, + orc: { name: 'Orc Brute', move: 'walk', dc: 14, power: 9, gold: 25, radius: 0.4, speed: 2.6 }, + slime: { name: 'Slime', move: 'crawl', dc: 9, power: 5, gold: 8, radius: 0.42, speed: 1.4 }, + egg: { name: 'Eggdreessen', move: 'walk', dc: 17, power: 10, gold: 200, radius: 0.8, speed: 1.2, boss: true, hp: 3 }, + rock: { name: 'Boulder', move: 'walk', dc: 13, power: 7, gold: 16, radius: 0.4, speed: 2.2 }, + bat: { name: 'Cave Bat', move: 'fly', dc: 8, power: 3, gold: 5, radius: 0.28, speed: 2.4 }, + wraith: { name: 'Wraith', move: 'fly', dc: 15, power: 9, gold: 30, radius: 0.34, speed: 1.8 }, + dragon: { name: 'Red Dragon', move: 'walk', dc: 18, power: 14, gold: 250, radius: 0.85, speed: 1.4, boss: true, hp: 4 }, }); /** How high a flyer goes over the floor where its loop starts. */ diff --git a/src/party.js b/src/party.js index 53aa79e..ea9fe93 100644 --- a/src/party.js +++ b/src/party.js @@ -6,6 +6,10 @@ * * Revive potions are carried, and one is drunk the moment anyone falls. * + * Heroes level up: every hero standing when a monster falls earns its + * experience, and each level brings more hit points (healed on the spot) and, + * as the party's average level climbs, a bonus on the battle roll. + * * Harm is dealt a point at a time to a living hero picked by weight, from * whatever random source is passed in, so the tests can deal it the same way * every time. @@ -31,7 +35,7 @@ export function seeded(seed = 1) { } export function createParty() { - return { heroes: HEROES.map((h) => ({ id: h.id, name: h.name, hp: h.hp, max: h.hp, weight: h.weight })), revives: 0 }; + return { heroes: HEROES.map((h) => ({ id: h.id, name: h.name, hp: h.hp, max: h.hp, weight: h.weight, level: 1, xp: 0 })), revives: 0 }; } export const standing = (party) => party.heroes.filter((h) => h.hp > 0); @@ -88,3 +92,40 @@ export function rest(party) { for (const h of party.heroes) h.hp = Math.min(h.max, Math.max(h.hp, 0) + Math.ceil(h.max / 2)); return party; } + +export const MAX_LEVEL = 10; + +/** Experience to go from `level` to the next. */ +export const xpToNext = (level) => 30 * level; + +/** Hit points a hero gains with each level. */ +const hpPerLevel = (id) => 1 + Math.round(HEROES.find((h) => h.id === id).hp * 0.12); + +/** + * `xp` for every hero still standing. Returns, per hero, how many levels + * they gained; each gain raises their best hit points and heals them by as + * much. + */ +export function grant(party, xp) { + return party.heroes.map((h) => { + if (h.hp <= 0 || h.level >= MAX_LEVEL) return 0; + h.xp += xp; + let gained = 0; + while (h.level < MAX_LEVEL && h.xp >= xpToNext(h.level)) { + h.xp -= xpToNext(h.level); + h.level += 1; + gained += 1; + const hp = hpPerLevel(h.id); + h.max += hp; + h.hp += hp; + } + if (h.level >= MAX_LEVEL) h.xp = 0; + return gained; + }); +} + +/** The bonus on the battle roll: a point for every three levels the party averages over the first. */ +export function rollBonus(party) { + const avg = party.heroes.reduce((s, h) => s + h.level, 0) / party.heroes.length; + return Math.floor((avg - 1) / 3); +} diff --git a/src/styles.css b/src/styles.css index ef814a6..7d0c831 100644 --- a/src/styles.css +++ b/src/styles.css @@ -639,3 +639,6 @@ kbd { #title h1 .rule i { width: 2px; height: auto; flex: 1; background: linear-gradient(180deg, rgba(217, 164, 65, 0), var(--old-gold) 40%, var(--old-gold) 60%, rgba(217, 164, 65, 0)); } } .help { font-family: var(--display); color: #a8987e; } + +#battle .heroes small { opacity: 0.7; font-weight: normal; } +#battle .bonus { display: block; font-size: 0.7em; opacity: 0.75; } diff --git a/test/battle.test.js b/test/battle.test.js index 225eea2..34fa371 100644 --- a/test/battle.test.js +++ b/test/battle.test.js @@ -21,7 +21,7 @@ test('a better roll never costs the party more', () => { assert.ok(Number.isInteger(damage) && damage >= 0); last = damage; } - assert.equal(resolveBattle({ roll: 20, monster }).damage, 0); + assert.equal(resolveBattle({ roll: 20, monster }).damage, monster.boss ? Math.ceil(monster.power / 4) : 0); assert.ok(resolveBattle({ roll: 1, monster }).damage > resolveBattle({ roll: 2, monster }).damage); } }); @@ -31,3 +31,18 @@ test('the gold is the monster\'s, doubled on a critical', () => { assert.equal(resolveBattle({ roll: 14, monster }).gold, monster.gold); assert.equal(resolveBattle({ roll: 20, monster }).gold, monster.gold * 2); }); + +test('a boss always lands a blow, and hits harder than the same roll would cost elsewhere', () => { + const boss = MONSTERS.dragon; + for (let roll = 2; roll <= 20; roll++) assert.ok(resolveBattle({ roll, monster: boss }).damage >= Math.ceil(boss.power / 4)); + const plain = { ...boss, boss: false }; + assert.ok(resolveBattle({ roll: 8, monster: boss }).damage > resolveBattle({ roll: 8, monster: plain }).damage); +}); + +test('a boss takes several hits: a critical lands two, a fumble none', () => { + const boss = MONSTERS.dragon; + assert.ok(boss.hp >= 3); + assert.equal(resolveBattle({ roll: 20, monster: boss }).hits, 2); + assert.equal(resolveBattle({ roll: 1, monster: boss }).hits, 0); + assert.equal(resolveBattle({ roll: 12, monster: boss }).hits, 1); +}); diff --git a/test/helpers/autopilot.js b/test/helpers/autopilot.js index 49d9df3..4aef160 100644 --- a/test/helpers/autopilot.js +++ b/test/helpers/autopilot.js @@ -14,7 +14,7 @@ import { hammerAt, liftAt, spikesAt } from '../../src/actors.js'; import { resolveBattle } from '../../src/battle.js'; import { createDelve, HARM, putBack, STEP, stepDelve } from '../../src/delve.js'; import { FALL_LIMIT } from '../../src/physics.js'; -import { createParty, heal, hurt, revive, seeded, wiped } from '../../src/party.js'; +import { createParty, grant, heal, hurt, revive, rollBonus, seeded, wiped } from '../../src/party.js'; /** The furthest it will drop off a ledge: well short of a drop that hurts. */ export const DROP = FALL_LIMIT * 0.6; @@ -287,11 +287,11 @@ export function drive(index, { limit = 900, seed = 7, sample = null } = {}) { if (out.outcome === 'exit') return { finished: true, time: t, losses, harms, fights, party, delve }; if (out.fight) { - const result = resolveBattle({ roll: out.roll, monster: out.fight.stats }); + const result = resolveBattle({ roll: out.roll, monster: out.fight.stats, bonus: rollBonus(party) }); hurt(party, result.damage, random); revive(party); + if (!delve.actors.wound(out.fight, result.hits)) grant(party, Math.ceil(result.gold / 2)); fights.push({ kind: out.fight.kind, roll: out.roll, grade: result.grade, damage: result.damage }); - delve.actors.defeat(out.fight); b.vx = b.vz = 0; if (wiped(party)) return { finished: false, time: t, losses, harms, fights, party, stuck: 'party wiped' }; } diff --git a/test/party.test.js b/test/party.test.js index f266690..d9da571 100644 --- a/test/party.test.js +++ b/test/party.test.js @@ -58,3 +58,32 @@ test('a revive potion is carried until someone falls, then gets every fallen her assert.deepEqual(got.map((g) => g > 0), [true, false, false, true]); assert.equal(revive(party), null, 'none left'); }); + +test('heroes level up on experience: more hit points, healed on the spot, and the fallen earn nothing', async () => { + const { grant, rollBonus, xpToNext, MAX_LEVEL } = await import('../src/party.js'); + const party = createParty(); + hurt(party, 10, seeded(2)); + party.heroes[3].hp = 0; + const hp = party.heroes.map((h) => h.hp); + const gained = grant(party, xpToNext(1)); + assert.deepEqual(gained, [1, 1, 1, 0]); + assert.equal(party.heroes[3].level, 1); + for (const [i, h] of party.heroes.slice(0, 3).entries()) { + assert.equal(h.level, 2); + assert.ok(h.max > HEROES[i].hp); + assert.equal(h.hp, hp[i] + (h.max - HEROES[i].hp)); + } + grant(party, 100000); + assert.equal(party.heroes[0].level, MAX_LEVEL); + assert.equal(rollBonus(createParty()), 0); + assert.ok(rollBonus(party) === 0 || rollBonus(party) >= 1); +}); + +test('a level bonus nudges the roll, but never past a natural 20 or 1', async () => { + const { resolveBattle } = await import('../src/battle.js'); + const { MONSTERS } = await import('../src/monsters.js'); + const monster = MONSTERS.skeleton; + assert.equal(resolveBattle({ roll: monster.dc - 1, monster, bonus: 1 }).grade, 'success'); + assert.equal(resolveBattle({ roll: 1, monster, bonus: 3 }).grade, 'fumble'); + assert.equal(resolveBattle({ roll: 19, monster, bonus: 3 }).grade, 'success'); +});