SOURCE / PINNED RELEASE
Made of little things.
Chase'n Those Narly Waves
- Release
- 0c7dde9d4921…
- Author-recorded commit
- 88e58516be17…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9
Archive hash verified: e1acfdbe67c54dd4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// The flat layer on top of the 3D world: score, hearts, meters,
// shark warnings and the pop-up words like "TOTALLY RAD, BRO!".
import { CATCH_ZONE, PERFECT_ZONE, RIDE_TIME, START_HEARTS, TIMING_RANGE, waveBehind, type Game, type Popup } from '../game.js';
import { BASIC_TRICKS, comboText } from '../tricks.js';
import type { ScreenPoint } from './scene.js';
const FONT = "'Trebuchet MS', 'Arial Rounded MT Bold', 'Verdana', sans-serif";
export const HUD_COLORS = {
ink: '#0b1d2a',
white: '#f4fbff',
yellow: '#ffd23f',
teal: '#2de1c2',
pink: '#ff4f9a',
danger: '#ff4f5e',
panel: 'rgba(8, 24, 38, 0.6)',
};
export interface HudOptions {
width: number;
height: number;
/** Keeps the meters above on-screen touch buttons. */
bottomSpace: number;
sharkWarnings: ScreenPoint[];
}
export function drawHud(ctx: CanvasRenderingContext2D, g: Game, options: HudOptions): void {
const { width, height } = options;
ctx.clearRect(0, 0, width, height);
if (g.phase === 'title') return;
const s = baseSize(width, height);
drawSharkWarnings(ctx, g, options.sharkWarnings, s);
drawStats(ctx, g, options, s);
for (const p of g.popups) drawPopup(ctx, width, height, p, s);
}
function baseSize(width: number, height: number): number {
return Math.max(10, Math.min(26, Math.min(width, height) * 0.045));
}
function outlinedText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, fill: string, size: number): void {
ctx.font = `bold ${size}px ${FONT}`;
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(2, size * 0.18);
ctx.strokeStyle = HUD_COLORS.ink;
ctx.strokeText(text, x, y);
ctx.fillStyle = fill;
ctx.fillText(text, x, y);
}
function drawSharkWarnings(ctx: CanvasRenderingContext2D, g: Game, points: ScreenPoint[], s: number): void {
if (Math.floor(g.time * 6) % 2 !== 0) return;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
for (const point of points) outlinedText(ctx, '!', point.x, point.y, HUD_COLORS.danger, s * 1.4);
}
function drawStats(ctx: CanvasRenderingContext2D, g: Game, options: HudOptions, s: number): void {
const { width, height, bottomSpace } = options;
const pad = s * 0.7;
const bottom = height - bottomSpace;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
outlinedText(ctx, `${Math.floor(g.score)}`, pad, pad, HUD_COLORS.yellow, s * 1.5);
outlinedText(ctx, `BEST ${Math.max(g.best, Math.floor(g.score))}`, pad, pad + s * 1.7, HUD_COLORS.white, s * 0.7);
for (let i = 0; i < START_HEARTS; i++) {
const size = s * 1.1;
const x = width - pad - size * 0.6 - i * size * 1.25;
drawHeart(ctx, x, pad + size * 0.55, size, i < g.hearts);
}
// Bottom-left: the timing bar (or how much ride is left while riding).
const barW = Math.min(width * 0.5, s * 13);
const barH = s * 0.7;
const barX = pad;
const barY = bottom - pad - barH;
if (g.riding > 0) drawRideBar(ctx, g, barX, barY, barW, barH, s);
else drawTimingBar(ctx, g, barX, barY, barW, barH, s);
const coming = g.wave?.mode === 'coming' ? g.wave : null;
if (coming && !coming.tried && waveBehind(coming) > TIMING_RANGE && Math.floor(g.time * 4) % 2 === 0) {
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
outlinedText(ctx, 'WAVE COMING FROM BEHIND! GET READY TO TAP W!', width / 2, height * 0.33, HUD_COLORS.teal, s * 0.85);
}
const riding = g.riding > 0;
// Basic trick list while riding, so you can remember the combos.
if (riding && height > 260 && bottomSpace === 0) {
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
const size = s * 0.62;
BASIC_TRICKS.forEach((trick, i) => {
const y = height - pad - (BASIC_TRICKS.length - 1 - i) * size * 1.35;
outlinedText(ctx, `${trick.name} ${comboText(trick.combo)}`, width - pad, y, HUD_COLORS.white, size);
});
}
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
}
function drawRideBar(ctx: CanvasRenderingContext2D, g: Game, x: number, y: number, w: number, h: number, s: number): void {
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
outlinedText(ctx, 'RIDING THE WAVE!', x, y - s * 0.2, HUD_COLORS.pink, s * 0.7);
ctx.fillStyle = HUD_COLORS.panel;
roundRect(ctx, x, y, w, h, h / 2);
ctx.fill();
const amount = Math.min(1, g.riding / RIDE_TIME);
if (amount > 0.02) {
ctx.fillStyle = HUD_COLORS.pink;
roundRect(ctx, x, y, w * amount, h, h / 2);
ctx.fill();
}
}
/**
* The wave slides along the bar toward you (the right end).
* Tap W when it's in the green zone. Gold in the middle is PERFECT.
*/
function drawTimingBar(ctx: CanvasRenderingContext2D, g: Game, x: number, y: number, w: number, h: number, s: number): void {
const at = (distance: number): number => x + w * (1 - distance / TIMING_RANGE);
const wave = g.wave?.mode === 'coming' ? g.wave : null;
const close = wave !== null && waveBehind(wave) < TIMING_RANGE;
let label = 'WAITING FOR A WAVE...';
let labelColor = HUD_COLORS.white;
if (close && wave.tried) {
label = 'TOO EARLY! WAIT FOR THE NEXT ONE';
labelColor = '#ffb347';
} else if (close) {
label = 'TAP W IN THE GREEN ZONE!';
labelColor = HUD_COLORS.teal;
}
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
outlinedText(ctx, label, x, y - s * 0.2, labelColor, s * 0.7);
ctx.fillStyle = HUD_COLORS.panel;
roundRect(ctx, x, y, w, h, h / 2);
ctx.fill();
ctx.save();
roundRect(ctx, x, y, w, h, h / 2);
ctx.clip();
ctx.fillStyle = close && !wave.tried ? 'rgba(45, 225, 120, 0.85)' : 'rgba(45, 225, 120, 0.35)';
ctx.fillRect(at(CATCH_ZONE.far), y, at(CATCH_ZONE.near) - at(CATCH_ZONE.far), h);
ctx.fillStyle = close && !wave.tried ? HUD_COLORS.yellow : 'rgba(255, 210, 63, 0.4)';
ctx.fillRect(at(PERFECT_ZONE.far), y, at(PERFECT_ZONE.near) - at(PERFECT_ZONE.far), h);
ctx.restore();
// The wave marker.
if (close) {
const markerX = Math.min(x + w, Math.max(x, at(waveBehind(wave))));
ctx.beginPath();
ctx.arc(markerX, y + h / 2, h * 0.75, 0, Math.PI * 2);
ctx.fillStyle = wave.tried ? '#9aa7b0' : HUD_COLORS.white;
ctx.fill();
ctx.lineWidth = Math.max(2, s * 0.12);
ctx.strokeStyle = HUD_COLORS.ink;
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#1f6fa8';
ctx.font = `bold ${h}px ${FONT}`;
ctx.fillText('~', markerX, y + h / 2);
}
}
function drawHeart(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, full: boolean): void {
const r = size / 2;
ctx.beginPath();
ctx.moveTo(x, y + r * 0.9);
ctx.bezierCurveTo(x - r * 1.4, y - r * 0.1, x - r * 0.7, y - r * 1.1, x, y - r * 0.35);
ctx.bezierCurveTo(x + r * 0.7, y - r * 1.1, x + r * 1.4, y - r * 0.1, x, y + r * 0.9);
ctx.fillStyle = full ? HUD_COLORS.danger : HUD_COLORS.panel;
ctx.fill();
ctx.lineWidth = Math.max(1.5, size * 0.1);
ctx.strokeStyle = HUD_COLORS.ink;
ctx.stroke();
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawPopup(ctx: CanvasRenderingContext2D, width: number, height: number, p: Popup, s: number): void {
const grow = p.age < 0.15 ? 0.4 + (p.age / 0.15) * 0.8 : p.age < 0.3 ? 1.2 - ((p.age - 0.15) / 0.15) * 0.2 : 1;
const fade = Math.min(1, (p.life - p.age) / 0.4);
let size = (p.big ? s * 2 : s * 1.2) * grow;
ctx.font = `bold ${size}px ${FONT}`;
const textWidth = ctx.measureText(p.text).width;
if (textWidth > width * 0.9) size *= (width * 0.9) / textWidth;
const y = height * 0.2 + p.slot * s * 3.6 - p.age * s * 1.2;
ctx.save();
ctx.globalAlpha = Math.max(0, fade);
ctx.translate(width / 2, y);
ctx.rotate(p.slot % 2 === 0 ? -0.06 : 0.05);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
outlinedText(ctx, p.text, 0, 0, p.color, size);
if (p.sub) outlinedText(ctx, p.sub, 0, size * 0.85, HUD_COLORS.white, Math.max(9, s * 0.7));
ctx.restore();
}
