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
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?