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