SOURCE / PINNED RELEASE
Made of little things.
Mini Samurai
- Release
- a7beb798adbc…
- Author-recorded commit
- 4f97be1515b8…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b
Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import * as THREE from 'three';
import type { Game } from './game/game.js';
import { TEAM_CSS } from './game/game.js';
import type { Fighter } from './game/fighter.js';
interface RowEls {
root: HTMLDivElement;
name: HTMLElement;
hp: HTMLDivElement;
lag: HTMLDivElement;
weapon: HTMLSpanElement;
}
/** One health row: name chip, held item, and a skewed ink health bar with a trailing damage bar. */
function makeRow(className: string): RowEls {
const root = document.createElement('div');
root.className = className;
root.innerHTML = `
<div class="hud-name"><b></b><span class="hud-weapon"></span></div>
<div class="hud-bar"><div class="hud-lag"></div><div class="hud-hp"></div></div>`;
return {
root,
name: root.querySelector('b')!,
hp: root.querySelector('.hud-hp')!,
lag: root.querySelector('.hud-lag')!,
weapon: root.querySelector('.hud-weapon')!,
};
}
/** DOM overlay drawn over the 3D view: health, round pips, wave count, banners, popups and player tags. */
export class Hud {
readonly el: HTMLDivElement;
private readonly teams: HTMLDivElement[];
private readonly waveLabel: HTMLDivElement;
private readonly pips: HTMLDivElement;
private readonly banner: HTMLDivElement;
private readonly layer: HTMLDivElement;
/** Split-screen dividers: one vertical, one horizontal (quarters use both). */
private readonly splitV: HTMLDivElement;
private readonly splitH: HTMLDivElement;
private readonly rows = new Map<Fighter, RowEls>();
private readonly tags = new Map<Fighter, HTMLDivElement>();
/** Split screen: a panel in each player's own view with their health, and their nearest foe's. */
private readonly viewPanels: { root: HTMLDivElement; own: RowEls; foe: RowEls; ownOf: Fighter | null; foeOf: Fighter | null }[] = [];
/** Trailing "damage just taken" bar, per fighter, shared by every bar that shows them. */
private readonly lag = new Map<Fighter, number>();
private popupEls: HTMLDivElement[] = [];
private bannerKey = '';
constructor(parent: HTMLElement) {
this.el = document.createElement('div');
this.el.className = 'hud';
this.el.innerHTML = `
<div class="hud-top">
<div class="hud-team" data-team="0"></div>
<div class="hud-mid"><div class="hud-wave" hidden></div><div class="hud-pips"></div></div>
<div class="hud-team" data-team="1"></div>
</div>
<div class="hud-split wide" hidden></div>
<div class="hud-split" hidden></div>
<div class="hud-views"></div>
<div class="hud-layer"></div>
<div class="hud-banner" aria-live="polite"></div>`;
parent.append(this.el);
this.teams = [...this.el.querySelectorAll<HTMLDivElement>('.hud-team')];
this.waveLabel = this.el.querySelector('.hud-wave')!;
this.pips = this.el.querySelector('.hud-pips')!;
this.banner = this.el.querySelector('.hud-banner')!;
this.layer = this.el.querySelector('.hud-layer')!;
this.splitV = this.el.querySelector('.hud-split.wide')!;
this.splitH = this.el.querySelector('.hud-split:not(.wide)')!;
const views = this.el.querySelector<HTMLDivElement>('.hud-views')!;
for (let i = 0; i < 4; i++) {
const root = document.createElement('div');
root.className = 'hud-view';
root.hidden = true;
const own = makeRow('hud-row hud-own');
const foe = makeRow('hud-row hud-foe');
root.append(own.root, foe.root);
views.append(root);
this.viewPanels.push({ root, own, foe, ownOf: null, foeOf: null });
}
}
/** Rebuild the health rows for a new match. */
private mode: 'battle' | 'waves' | 'explore' = 'battle';
setup(fighters: Fighter[], mode: 'battle' | 'waves' | 'explore' = 'battle'): void {
this.mode = mode;
this.el.classList.toggle('explore', mode === 'explore');
this.el.classList.toggle('waves', mode === 'waves');
for (const t of this.teams) t.replaceChildren();
for (const tag of this.tags.values()) tag.remove();
this.rows.clear();
this.tags.clear();
this.lag.clear();
for (const panel of this.viewPanels) {
panel.ownOf = panel.foeOf = null;
panel.root.hidden = true;
}
for (const f of fighters) {
const row = makeRow('hud-row');
this.styleRow(row, f);
// Free for all has up to four sides: split them between the two corners.
this.teams[f.team % 2]!.append(row.root);
this.rows.set(f, row);
const tag = document.createElement('div');
tag.className = f.human ? 'hud-tag human' : 'hud-tag';
tag.style.setProperty('--team', TEAM_CSS[f.team]!);
tag.textContent = f.human ? f.name : '';
this.layer.append(tag);
this.tags.set(f, tag);
}
}
update(game: Game, dt: number): void {
const s = game.hudState();
for (const f of game.fighters) this.lag.set(f, Math.max(f.health, (this.lag.get(f) ?? 100) - dt * 30));
for (const [f, row] of this.rows) this.fillRow(row, f);
// Split screen: health lives in each player's own view instead of across the top.
const views = game.views();
const split = views.length > 1;
this.el.classList.toggle('split', split);
this.viewPanels.forEach((panel, i) => {
const view = split ? views[i] : undefined;
const me = view?.fighter ?? undefined;
panel.root.hidden = !view || !me;
if (!view || !me) return;
panel.root.style.left = `${view.x}px`;
panel.root.style.top = `${view.y}px`;
panel.root.style.width = `${view.w}px`;
panel.root.classList.toggle('narrow', view.w < 420);
if (panel.ownOf !== me) this.styleRow(panel.own, (panel.ownOf = me));
this.fillRow(panel.own, me);
const foe = game.nearestFoe(me);
panel.foe.root.hidden = !foe;
if (foe) {
if (panel.foeOf !== foe) this.styleRow(panel.foe, (panel.foeOf = foe));
this.fillRow(panel.foe, foe);
}
});
// Rounds have no time limit. Waves mode shows the wave number and a running tally instead of round pips.
const waves = this.mode === 'waves';
this.waveLabel.hidden = !waves;
const waveText = `Wave ${Math.max(1, s.wave)}`;
if (waves && this.waveLabel.textContent !== waveText) this.waveLabel.textContent = waveText;
const pipHtml = waves
? `<span class="hud-tally">${s.enemiesLeft} left · ${s.kills} KO${s.kills === 1 ? '' : 's'}</span>`
: s.wins
.map((w, team) => `<span style="--team:${TEAM_CSS[team]}">${'●'.repeat(w)}${'○'.repeat(Math.max(0, 2 - w))}</span>`)
.join('');
if (this.pips.innerHTML !== pipHtml) this.pips.innerHTML = pipHtml;
// Banner text with a stamp-in animation whenever it changes.
const key = s.banner ? `${s.banner.text}|${s.banner.sub ?? ''}` : '';
if (key !== this.bannerKey) {
this.bannerKey = key;
this.banner.innerHTML = s.banner ? `<div class="hud-banner-text">${s.banner.text}</div>${s.banner.sub ? `<div class="hud-banner-sub">${s.banner.sub}</div>` : ''}` : '';
this.banner.classList.remove('stamp');
void this.banner.offsetWidth;
if (s.banner) this.banner.classList.add('stamp');
}
const fades = s.banner?.text === 'Fight!' ? 1.4 : s.banner?.text === 'Explore' ? 2.6 : Infinity;
if (s.banner && s.banner.age > fades) this.banner.classList.add('fade');
else this.banner.classList.remove('fade');
// Dividers between the players' views.
const right = views.find((v) => v.x > 0);
const lower = views.find((v) => v.y > 0);
this.splitV.hidden = !right;
this.splitH.hidden = !lower;
if (right) this.splitV.style.left = `${right.x}px`;
if (lower) this.splitH.style.top = `${lower.y}px`;
// Player tags over heads.
const head = new THREE.Vector3();
for (const [f, tag] of this.tags) {
// In split screen a player's own marker shows in their own view.
const owner = f.human ? Number(f.name.slice(1)) - 1 : undefined;
const p = game.project(f.headPos(head).setY(head.y + 0.25), owner);
tag.hidden = !p || !f.alive;
if (p) tag.style.transform = `translate(${p.x}px, ${p.y}px)`;
}
// Popups.
while (this.popupEls.length < s.popups.length) {
const el = document.createElement('div');
el.className = 'hud-popup';
this.layer.append(el);
this.popupEls.push(el);
}
this.popupEls.forEach((el, i) => {
const p = s.popups[i];
const at = p ? game.project(p.at) : null;
el.hidden = !p || !at;
if (!p || !at) return;
if (el.textContent !== p.text) el.textContent = p.text;
el.style.color = p.color;
const pop = Math.min(1, p.age * 8);
el.style.opacity = String(Math.min(1, (1.1 - p.age) * 3));
el.style.transform = `translate(${at.x}px, ${at.y}px) translate(-50%, -100%) scale(${0.6 + pop * 0.4})`;
});
}
/** Name chip colour and armour tint for whoever a row shows. */
private styleRow(row: RowEls, f: Fighter): void {
row.root.style.setProperty('--team', TEAM_CSS[f.team]!);
row.root.style.setProperty('--armor', `#${f.livery.armor.toString(16).padStart(6, '0')}`);
row.name.textContent = f.name;
}
private fillRow(row: RowEls, f: Fighter): void {
row.hp.style.width = `${f.health}%`;
row.lag.style.width = `${this.lag.get(f) ?? f.health}%`;
row.root.classList.toggle('hurt', f.hurtAgo < 0.25);
row.root.classList.toggle('out', !f.alive);
row.root.classList.toggle('low', f.health > 0 && f.health < 30);
const held = f.carrying ? `${f.carrying.name} (overhead)` : f.weapon ? f.weapon.def.name : 'Bare hands';
const power = f.powerLabel;
const name = power ? `${held} · ${power}` : held;
if (row.weapon.textContent !== name) row.weapon.textContent = name;
}
set visible(v: boolean) {
this.el.hidden = !v;
}
}
