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');
+});