hopkit

Find your next move

Search the guide, API, and recipes.

    Pocket Trail / Chapter 03 of 08

    Beyond the canvas

    Make a world bigger than the screen.

    Download checkpoint
    src/chapters/03.ts
    import { makeGame, drawTrail, 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 = { x: 72, y: 275 }
        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) => {
          player.x = Math.max(
            24,
            Math.min(
              trailWidth - 24,
              player.x + scene.input.axis("left", "right") * options.speed * dt,
            ),
          )
        })
        options.status("Walk right. The world is wider than the canvas. Collision comes next.")
        options.ready(scene)
      })
      return { game, restart: () => game.restart() }
    }
    Live gameOpen game ↗
    Play, change a setting, and play again.How it works ↓

    The trail is now twice as wide as the canvas. Your character can walk all the way across it while the camera follows.

    Try it

    Walk right until the camera starts moving. Keep going until the world ends. Change Camera follow, then restart. A low value produces a slower catch-up.

    Give the camera a target

    scene.sprite("hero", { bind: player, ... }) explicitly follows the player model. scene.camera.follow(player) reads that same position object. The bounds stop it revealing empty space beyond the level. speed controls exponential smoothing in inverse seconds; zero follows immediately.

    drawTrail creates terrain from plain rectangles and artwork. It adds each sprite to this scene. The helper lives in the downloaded src/trail.ts file, shown below. The scene starts fresh on every restart.

    Collision arrives next. For now you can walk past the raised platforms.

    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

    Set camera speed to 2, then 18. Which helps you judge the space ahead?