Back to Chase'n Those Narly Waves
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.

src/render/hud.ts
// 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();
}