hopkit

Find your next move

Search the guide, API, and recipes.

    Pocket Trail / Chapter 05 of 08

    One more coin

    Give the player something to chase.

    Download checkpoint
    src/chapters/05.ts
    import { makeGame, drawTrail, movePlayer, playerAtStart, addCoins, trailWidth } from "../trail.js"
    import type { LessonOptions } from "../types.js"
    
    export async function mount(canvas: HTMLCanvasElement, options: LessonOptions) {
      const game = await makeGame(canvas, options)
      game.scene((scene) => {
        drawTrail(scene)
        const player = playerAtStart()
        const coins = addCoins(scene)
        let won = false
        scene.sprite("hero", { bind: player, anchor: "center", width: 64, height: 64 })
        scene.camera.follow(player, {
          bounds: { x: 0, y: 0, width: trailWidth, height: 360 },
          speed: options.cameraSpeed,
        })
        scene.update((dt) => {
          if (won) return
          movePlayer(
            player,
            scene.input.axis("left", "right"),
            scene.input.pressed("jump"),
            dt,
            options,
          )
          coins.collect(player)
          if (coins.finished(player)) {
            won = true
            options.status("Trail complete! Every coin is home. Restart for another run.")
          }
        })
        options.status("Collect four coins, then reach the red flag. Space jumps.")
        options.ready(scene)
      })
      return { game, restart: () => game.restart() }
    }
    Live gameOpen game ↗
    Play, change a setting, and play again.How it works ↓

    Four coins turn a walk into a challenge. Collect all of them, then reach the red flag at the far end of the trail.

    Try it

    A coin disappears when the player gets within 30 logical units of its center. The counter updates from the same coin state. Reaching the flag early does not finish the game.

    Give the game an ending

    addCoins creates the coin state and its sprites. Its collect function changes that state and hides the matching sprite. finished checks both the collection objective and the player’s position.

    The won flag stops movement after completion. Restart creates a new scene with fresh player and coin state. Nothing carries over accidentally from the old run.

    Read the shared trail source
    src/trail.ts
    import { createGame, Graphics } from "@hopkit/game"
    import type { LessonOptions } from "./types.js"
    
    export function makeGame(canvas: HTMLCanvasElement, options: LessonOptions) {
      return createGame({
        canvas,
        signal: options.signal,
        background: "#c9e5d8",
        assets: {
          hero: { image: `${options.art}character_green_idle.png` },
          jump: { image: `${options.art}character_green_jump.png` },
          walkA: { image: `${options.art}character_green_walk_a.png` },
          walkB: { image: `${options.art}character_green_walk_b.png` },
          coin: { image: `${options.art}coin_gold.png` },
          ground: { image: `${options.art}terrain_grass_horizontal_middle.png` },
          bush: { image: `${options.art}bush.png` },
          mushroom: { image: `${options.art}mushroom_red.png` },
        },
        sounds: { jump: `${options.art}sfx_jump.ogg`, gem: `${options.art}sfx_gem.ogg` },
        actions: {
          left: ["ArrowLeft", "KeyA"],
          right: ["ArrowRight", "KeyD"],
          jump: ["Space", "ArrowUp", "KeyW"],
          pause: ["Escape", "KeyP"],
        },
      })
    }
    export type TrailGame = Awaited<ReturnType<typeof makeGame>>
    export type TrailScene = Parameters<Parameters<TrailGame["scene"]>[0]>[0]
    export interface Platform {
      x: number
      y: number
      width: number
      height: number
    }
    export interface Player {
      x: number
      y: number
      vy: number
      grounded: boolean
      buffer: number
      coyote: number
    }
    export const trailWidth = 1280
    export const platforms: Platform[] = [
      { x: 0, y: 304, width: 1280, height: 56 },
      { x: 240, y: 240, width: 160, height: 32 },
      { x: 480, y: 190, width: 128, height: 32 },
      { x: 720, y: 240, width: 160, height: 32 },
      { x: 992, y: 200, width: 128, height: 32 },
    ]
    export function playerAtStart(): Player {
      return { x: 72, y: 288, vy: 0, grounded: false, buffer: 0, coyote: 0 }
    }
    
    export function drawTrail(scene: TrailScene, terrain = platforms): void {
      const hills = new Graphics()
        .ellipse(180, 340, 250, 190)
        .fill(0x9acbb2)
        .ellipse(600, 370, 270, 190)
        .fill(0xb2d9b8)
        .ellipse(1050, 360, 280, 190)
        .fill(0x9acbb2)
      scene.add(hills)
      for (const platform of terrain) {
        const soil = new Graphics()
          .rect(platform.x, platform.y + 18, platform.width, platform.height)
          .fill(0x79503a)
        scene.add(soil)
        for (let x = platform.x; x < platform.x + platform.width; x += 32) {
          scene.sprite("ground", { position: { x, y: platform.y }, width: 32, height: 32 })
        }
      }
      for (const x of [140, 416, 670, 922, 1160]) {
        scene.sprite("bush", { position: { x, y: 274 }, width: 48, height: 32 })
      }
      scene.sprite("mushroom", { position: { x: 650, y: 276 }, width: 28, height: 28 })
    }
    
    /** The player owns position and velocity. Collision changes this same object. */
    export function movePlayer(
      player: Player,
      direction: number,
      jumpPressed: boolean,
      dt: number,
      options: LessonOptions,
      terrain = platforms,
    ): boolean {
      player.buffer = jumpPressed ? 0.14 : Math.max(0, player.buffer - dt)
      player.coyote = player.grounded ? 0.1 : Math.max(0, player.coyote - dt)
      let jumped = false
      if (player.buffer > 0 && player.coyote > 0) {
        player.vy = -options.jump
        player.buffer = 0
        player.coyote = 0
        player.grounded = false
        jumped = true
      }
      const halfWidth = 12
      const halfHeight = 16
      player.x = Math.max(
        halfWidth,
        Math.min(trailWidth - halfWidth, player.x + direction * options.speed * dt),
      )
      for (const box of terrain) {
        if (player.y + halfHeight <= box.y || player.y - halfHeight >= box.y + box.height) continue
        if (player.x + halfWidth <= box.x || player.x - halfWidth >= box.x + box.width) continue
        player.x =
          direction > 0 ? box.x - halfWidth : direction < 0 ? box.x + box.width + halfWidth : player.x
      }
      const previousY = player.y
      player.vy += 1050 * dt
      player.y += player.vy * dt
      player.grounded = false
      for (const box of terrain) {
        if (player.x + halfWidth <= box.x || player.x - halfWidth >= box.x + box.width) continue
        if (player.vy >= 0 && previousY + halfHeight <= box.y && player.y + halfHeight >= box.y) {
          player.y = box.y - halfHeight
          player.vy = 0
          player.grounded = true
        } else if (
          player.vy < 0 &&
          previousY - halfHeight >= box.y + box.height &&
          player.y - halfHeight <= box.y + box.height
        ) {
          player.y = box.y + box.height + halfHeight
          player.vy = 0
        }
      }
      return jumped
    }
    
    export function addCoins(scene: TrailScene, terrain = platforms) {
      const coins = terrain
        .slice(1)
        .map((box) => ({ x: box.x + box.width / 2, y: box.y - 28, collected: false }))
      const sprites = coins.map((coin) =>
        scene.sprite("coin", { bind: coin, anchor: "center", width: 24, height: 24 }),
      )
      const flag = new Graphics()
        .rect(1215, 230, 4, 74)
        .fill(0x334f3d)
        .poly([1219, 230, 1255, 242, 1219, 257])
        .fill(0xda6556)
      scene.add(flag)
      const count = scene.text("0 / 4 coins", {
        x: 18,
        y: 16,
        fontSize: 18,
        fill: 0x203a30,
        fontWeight: "bold",
      })
      let labelWidth = 0
      let labelResolution = 0
      count.zIndex = 1
      scene.render(() => {
        const width = scene.pixi.canvas.clientWidth
        const resolution = scene.pixi.renderer.resolution
        if (!width || (width === labelWidth && resolution === labelResolution)) return
        labelWidth = width
        labelResolution = resolution
        const unit = 640 / width
        count.scale.set(unit)
        count.resolution = scene.pixi.renderer.resolution * unit
        count.position.set(16 * unit, (width < 520 ? 118 : 22) * unit)
        count.style.fontSize = 16
      })
      return {
        collect(player: Player): number {
          let added = 0
          coins.forEach((coin, index) => {
            if (coin.collected || Math.hypot(player.x - coin.x, player.y - coin.y) > 30) return
            coin.collected = true
            sprites[index]!.visible = false
            added++
          })
          count.text = `${coins.filter((coin) => coin.collected).length} / ${coins.length} coins`
          return added
        },
        finished(player: Player): boolean {
          return coins.every((coin) => coin.collected) && player.x >= 1200
        },
      }
    }

    Make one change

    Move one coin by editing the platform it belongs to in src/trail.ts. Can you still finish with the default jump?