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
18 changes: 16 additions & 2 deletions src/actors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
44 changes: 29 additions & 15 deletions src/battle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
});
Expand All @@ -50,7 +61,7 @@ export function createBattleScreen(root) {
<span class="boss"></span>
<div class="rule" aria-hidden="true"><i></i><b></b><i></i></div>
<div class="clash">
<div class="roll"><span class="d20"></span><span class="label">your roll</span></div>
<div class="roll"><span class="d20"></span><span class="label">your roll</span><span class="bonus"></span></div>
<div class="vs">vs</div>
<div class="dc"><span class="value"></span><span class="label">DC</span></div>
</div>
Expand Down Expand Up @@ -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 = `<b style="color:${h.colour}">${h.name}</b>`
li.innerHTML = `<b style="color:${h.colour}">${h.name}${party ? ` <small>L${party.heroes[i].level}</small>` : ''}</b>`
+ `<span class="bar"><i style="width:${(100 * Math.max(0, after[i])) / h.hp}%"></i></span>`
+ `<span class="hp">${Math.max(0, after[i])}/${h.hp}</span>`
+ `<span class="lost">${lost > 0 ? `−${lost}` : ''}${after[i] <= 0 && before[i] > 0 ? ' DOWN' : ''}</span>`;
+ `<span class="lost">${lost > 0 ? `−${lost}` : ''}${after[i] <= 0 && before[i] > 0 ? ' DOWN' : ''}${levels[i] ? ' LEVEL UP!' : ''}</span>`;
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;
Expand Down
24 changes: 16 additions & 8 deletions src/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/hud.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function createHud(root, { onStart }) {
li.innerHTML = `<b style="--hero:${h.colour}">${h.mark}</b><span class="name">${h.name}</span>`
+ '<span class="bar"><i></i></span><span class="hp"></span><span class="hit"></span>';
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. */
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 12 additions & 12 deletions src/monsters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
43 changes: 42 additions & 1 deletion src/party.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
17 changes: 16 additions & 1 deletion test/battle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand All @@ -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);
});
6 changes: 3 additions & 3 deletions test/helpers/autopilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' };
}
Expand Down
Loading
Loading