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