SOURCE / PINNED RELEASE
Made of little things.
blockfall
- Release
- a6bb863994d9…
- Author-recorded commit
- e6ab5899ddac…
- License
- LICENSE
- Author’s source reference
- nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-afafae55ef1
Archive hash verified: ba8de5cbee33e11e…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { Game, type GameBanner, type GameStats } from './game.js';
import { onGameSettings } from './napplet-settings.js';
import './styles.css';
const BEST_KEY = 'blockfall.best-score';
const ROCKET_PIPS = 8;
const elements = {
canvas: requireElement<HTMLCanvasElement>('#scene'),
score: requireElement<HTMLElement>('#score'),
level: requireElement<HTMLElement>('#level'),
best: requireElement<HTMLElement>('#best'),
fuel: requireElement<HTMLElement>('#fuelFill'),
ammo: requireElement<HTMLElement>('#ammo'),
ammoCount: requireElement<HTMLElement>('#ammoCount'),
hint: requireElement<HTMLElement>('#hint'),
thrustButton: requireElement<HTMLButtonElement>('#thrustBtn'),
thrustLabel: requireElement<HTMLElement>('#thrustLabel'),
leftButton: requireElement<HTMLButtonElement>('#leftBtn'),
rightButton: requireElement<HTMLButtonElement>('#rightBtn'),
restartButton: requireElement<HTMLButtonElement>('#restartBtn'),
banner: requireElement<HTMLElement>('#banner'),
bannerTitle: requireElement<HTMLElement>('#bannerTitle'),
bannerSub: requireElement<HTMLElement>('#bannerSub'),
};
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
function pad(value: number, digits: number): string {
return String(Math.max(0, Math.floor(value))).padStart(digits, '0');
}
const hints: Record<GameStats['phase'], string> = {
ready: 'Aim the rail · launch when locked',
flying: 'Aim for supports · collapse the towers',
reloading: 'Loading next rocket',
clear: 'Sector cleared · standby',
over: 'Range closed · reopen to retry',
};
const pips: HTMLElement[] = [];
for (let index = 0; index < ROCKET_PIPS; index += 1) {
const pip = document.createElement('span');
pip.className = 'pip';
elements.ammo.append(pip);
pips.push(pip);
}
let best = 0;
let savedBest = 0;
let lastPhase: GameStats['phase'] | null = null;
let keyLeft = false;
let keyRight = false;
let padLeft = false;
let padRight = false;
let keyThrust = false;
let padThrust = false;
let bannerTimer = 0;
const hasStorage = runtimeHasDomain(STORAGE_DOMAIN);
function renderBest(): void {
elements.best.textContent = pad(best, 6);
}
function renderAmmo(rockets: number): void {
for (const [index, pip] of pips.entries()) {
pip.classList.toggle('spent', index >= rockets);
}
elements.ammoCount.textContent = `×${rockets}`;
elements.ammo.setAttribute('aria-label', `${rockets} rockets remaining`);
}
function renderStats(stats: GameStats): void {
elements.score.textContent = pad(stats.score, 6);
elements.level.textContent = pad(stats.level, 2);
elements.fuel.style.width = `${stats.fuel}%`;
elements.fuel.dataset.low = stats.fuel < 25 ? 'true' : 'false';
elements.hint.textContent = hints[stats.phase];
elements.thrustLabel.textContent = stats.phase === 'flying' ? 'Thrust' : 'Launch';
renderAmmo(stats.rockets);
const interactive = stats.phase === 'ready' || stats.phase === 'flying';
elements.leftButton.disabled = !interactive;
elements.rightButton.disabled = !interactive;
elements.thrustButton.disabled = !interactive;
elements.restartButton.hidden = stats.phase !== 'over';
if (stats.score > best) {
best = stats.score;
renderBest();
}
if (stats.phase !== lastPhase) {
if (stats.phase === 'over' || stats.phase === 'clear') persistBest();
lastPhase = stats.phase;
}
}
function showBanner(banner: GameBanner): void {
elements.banner.hidden = false;
elements.banner.dataset.tone = banner.tone;
elements.bannerTitle.textContent = banner.title;
elements.bannerSub.textContent = banner.sub;
elements.banner.classList.remove('is-in');
void elements.banner.offsetWidth;
elements.banner.classList.add('is-in');
window.clearTimeout(bannerTimer);
if (banner.tone !== 'lose') {
bannerTimer = window.setTimeout(() => {
elements.banner.hidden = true;
}, 2500);
}
}
async function loadBest(): Promise<void> {
if (!hasStorage) return;
try {
const raw = await storage.getItem(BEST_KEY);
const value = Number(raw);
if (Number.isFinite(value) && value > 0) {
best = Math.floor(value);
savedBest = best;
renderBest();
}
} catch {
// Storage is optional; an unavailable best score must not block play.
}
}
function persistBest(): void {
if (!hasStorage || best <= savedBest) return;
savedBest = best;
try {
void storage.setItem(BEST_KEY, String(best)).catch(() => undefined);
} catch {
// Optional persistence only.
}
}
const game = new Game(elements.canvas, {
onStats: renderStats,
onBanner: showBanner,
});
onGameSettings((settings) => {
game.setOptions({ effects: settings.effects, guide: settings.trajectoryGuide });
});
function syncSteer(): void {
game.setSteer(keyLeft || padLeft, keyRight || padRight);
}
function syncThrust(): void {
if (keyThrust || padThrust) game.pressPrimary();
else game.releasePrimary();
}
function clearInputs(): void {
keyLeft = false;
keyRight = false;
keyThrust = false;
padLeft = false;
padRight = false;
padThrust = false;
syncSteer();
syncThrust();
}
window.addEventListener('keydown', (event) => {
const target = event.target;
if (
target instanceof HTMLElement &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
) {
return;
}
switch (event.code) {
case 'ArrowLeft':
case 'KeyA':
event.preventDefault();
keyLeft = true;
syncSteer();
break;
case 'ArrowRight':
case 'KeyD':
event.preventDefault();
keyRight = true;
syncSteer();
break;
case 'ArrowUp':
case 'KeyW':
case 'Space':
event.preventDefault();
if (event.repeat) break;
if (lastPhase === 'over') {
game.restart();
} else {
keyThrust = true;
syncThrust();
}
break;
case 'KeyR':
if (lastPhase === 'over') game.restart();
break;
default:
break;
}
});
window.addEventListener('keyup', (event) => {
switch (event.code) {
case 'ArrowLeft':
case 'KeyA':
keyLeft = false;
syncSteer();
break;
case 'ArrowRight':
case 'KeyD':
keyRight = false;
syncSteer();
break;
case 'ArrowUp':
case 'KeyW':
case 'Space':
keyThrust = false;
syncThrust();
break;
default:
break;
}
});
window.addEventListener('blur', clearInputs);
elements.canvas.addEventListener('contextmenu', (event) => event.preventDefault());
function bindHold(
button: HTMLButtonElement,
onPress: () => void,
onRelease: () => void,
): void {
button.addEventListener('pointerdown', (event) => {
event.preventDefault();
button.classList.add('is-held');
try {
button.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is best effort.
}
onPress();
});
const release = (event: PointerEvent): void => {
button.classList.remove('is-held');
if (button.hasPointerCapture(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
onRelease();
};
button.addEventListener('pointerup', release);
button.addEventListener('pointercancel', release);
button.addEventListener('lostpointercapture', () => {
button.classList.remove('is-held');
onRelease();
});
}
bindHold(
elements.leftButton,
() => {
padLeft = true;
syncSteer();
},
() => {
padLeft = false;
syncSteer();
},
);
bindHold(
elements.rightButton,
() => {
padRight = true;
syncSteer();
},
() => {
padRight = false;
syncSteer();
},
);
bindHold(
elements.thrustButton,
() => {
padThrust = true;
syncThrust();
},
() => {
padThrust = false;
syncThrust();
},
);
elements.restartButton.addEventListener('click', () => {
clearInputs();
game.restart();
});
function releasePadInputs(): void {
if (!padLeft && !padRight && !padThrust) return;
padLeft = false;
padRight = false;
padThrust = false;
syncSteer();
syncThrust();
}
window.addEventListener('pointerup', releasePadInputs);
window.addEventListener('pointercancel', releasePadInputs);
renderBest();
renderAmmo(ROCKET_PIPS);
void loadBest();
window.addEventListener(
'pagehide',
() => {
persistBest();
game.dispose();
},
{ once: true },
);
