hopkit

Find your next move

Search the guide, API, and recipes.

    Pocket Trail / Chapter 06 of 08

    Make it feel alive

    Add sound, animation, and a little celebration.

    Download checkpoint
    src/chapters/06.ts
    import { makeGame, drawTrail, movePlayer, playerAtStart, addCoins, trailWidth } from "../trail.js"
    import { animateHero, playSound, coinFeedback, mushroomBump } from "../feedback.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 hero = scene.sprite("hero", { bind: player, anchor: "center", width: 64, height: 64 })
        const coins = addCoins(scene)
        let elapsed = 0
        let hurtUntil = 0
        let won = false
        scene.camera.follow(player, {
          bounds: { x: 0, y: 0, width: trailWidth, height: 360 },
          speed: options.cameraSpeed,
        })
        scene.update((dt) => {
          if (won) return
          elapsed += dt
          const direction = scene.input.axis("left", "right")
          if (movePlayer(player, direction, scene.input.pressed("jump"), dt, options))
            playSound(scene, options, "jump")
          animateHero(scene, hero, player, direction, elapsed, options)
          hurtUntil = mushroomBump(scene, hero, player, options, elapsed, hurtUntil)
          if (coins.collect(player)) {
            playSound(scene, options, "gem")
            coinFeedback(scene, player, options)
          }
          if (coins.finished(player)) {
            won = true
            options.status("Trail complete! Try Reduced motion and compare the coin feedback.")
          }
        })
        options.status("Try the sound button. Walking and jumping now choose their own frames.")
        options.ready(scene)
      })
      return { game, restart: () => game.restart() }
    }
    Live gameOpen game ↗
    Play, change a setting, and play again.How it works ↓

    The rules already work. Now the character walks, changes pose in the air, and celebrates a collection with a short rising coin.

    Try it

    Turn on Sound and jump. Collect a coin. Switch Reduced motion on and collect another. The collection rule stays the same; the extra rising sprite disappears. Your operating system’s reduced-motion preference also disables that effect.

    Drop down beside the red mushroom between the second and third platforms. It bumps you upward and briefly tints the character. The cooldown prevents repeated hits in one instant. Reduced motion removes the tint while preserving the bump.

    Animation reads the game

    animateHero chooses a texture from grounded state, direction and elapsed time. It does not decide when the character moves. The game declares its jump and gem sounds up front. scene.sound(name) plays the decoded sound and ties its voice to the scene. Sound starts only after you enable it using the canvas button.

    coinFeedback uses two short scene-owned tweens. One moves the extra sprite; one fades it out. Scene replacement cancels the work. These helpers live in src/feedback.ts, shown below.

    Read the shared feedback source
    src/feedback.ts
    import type { Sprite } from "@hopkit/game"
    import type { Player, TrailScene } from "./trail.js"
    import type { LessonOptions } from "./types.js"
    
    interface HeroMotion {
      width: number
      height: number
      grounded: boolean
      landed: number
      facing: number
    }
    const heroMotion = new WeakMap<Sprite, HeroMotion>()
    
    export function animateHero(
      scene: TrailScene,
      sprite: Sprite,
      player: Player,
      direction: number,
      elapsed: number,
      options: LessonOptions,
    ): void {
      const frame = !player.grounded
        ? "jump"
        : direction === 0
          ? "hero"
          : Math.floor(elapsed * 9) % 2 === 0
            ? "walkA"
            : "walkB"
      sprite.texture = scene.assets.texture(frame)
      let motion = heroMotion.get(sprite)
      if (!motion) {
        motion = {
          width: Math.abs(sprite.scale.x),
          height: sprite.scale.y,
          grounded: player.grounded,
          landed: -1,
          facing: 1,
        }
        heroMotion.set(sprite, motion)
      }
      if (direction !== 0) motion.facing = Math.sign(direction)
      if (player.grounded && !motion.grounded) motion.landed = elapsed
      motion.grounded = player.grounded
      let stretch = 1
      if (!options.reducedMotion && !scene.reducedMotion) {
        const landing = Math.max(0, 1 - (elapsed - motion.landed) / 0.16)
        stretch = player.grounded
          ? 1 - landing * 0.22 + (direction ? Math.sin(elapsed * 18) * 0.035 : 0)
          : 1 + Math.min(Math.abs(player.vy) / 2200, 0.16)
      }
      // Visual deformation leaves the player's collision box unchanged.
      sprite.scale.set((motion.width / stretch) * motion.facing, motion.height * stretch)
    }
    export function playSound(scene: TrailScene, options: LessonOptions, name: "jump" | "gem"): void {
      if (!options.sound) return
      scene.sound(name, { volume: 0.25 })
    }
    export function coinFeedback(scene: TrailScene, player: Player, options: LessonOptions): void {
      if (options.reducedMotion || scene.reducedMotion) return
      const position = { x: player.x, y: player.y - 28 }
      const sparkle = scene.sprite("coin", { bind: position, anchor: "center", width: 16, height: 16 })
      scene.tween(position, { y: position.y - 35 }, 0.3)
      scene.tween(sparkle, { alpha: 0 }, 0.3, {
        onComplete: () => {
          sparkle.visible = false
        },
      })
    }
    
    /** A small hazard response. The caller owns the cooldown just as it owns the player. */
    export function mushroomBump(
      scene: TrailScene,
      hero: Sprite,
      player: Player,
      options: LessonOptions,
      elapsed: number,
      hurtUntil: number,
    ): number {
      if (elapsed < hurtUntil || player.x < 640 || player.x > 686 || player.y < 266) return hurtUntil
      player.vy = -260
      player.grounded = false
      options.status("Oop! The red mushroom bumps you up. Jump over it next time.")
      playSound(scene, options, "jump")
      if (!options.reducedMotion && !scene.reducedMotion) {
        hero.tint = 0xe06b61
        scene.wait(0.16, () => {
          hero.tint = 0xffffff
        })
      }
      return elapsed + 0.8
    }

    Make one change

    Change the coin rise from 35 to 15 logical units and its duration from 0.3 to 0.15 seconds. Which feels snappier?