Прыгающие спрайты
Scene2D, setLoop, двигающий каждый шарик на speed * dt, и тапы, порождающие шарики в мировых координатах. Движение зависит от dt — значит одинаково при любом FPS; ось Y мира направлена вверх, поэтому отскок меняет знак той компоненты скорости, что пересекла край. Размер экрана приходит через событие resize, поэтому демо инициализируется, как только появляется вьюпорт.
// A Scene2D, a per-frame loop, and taps that spawn into world space. Each ball carries its own
// velocity and moves by `speed * dt`, so motion is identical at any frame rate. World Y is up, so
// a bounce flips the sign of whichever velocity component crossed an edge.
const scene = new Scene2D({ background: "#0b0d12", filter: "linear" })
const COLORS = ["#FF4032", "#FFB020", "#22c55e", "#38bdf8", "#a855f7"]
const R = 26
// One white Canvas disc, reused as the texture for every ball — a per-sprite tint recolors it.
const disc = new Canvas(R * 2, R * 2, { pixelRatio: 2 })
disc.fillStyle = "#ffffff"
disc.beginPath().arc(R, R, R, 0, Mathf.TAU).fill()
type Ball = { node: Sprite, vx: number, vy: number }
const balls: Ball[] = []
let W = 0, H = 0
function spawn(x: number, y: number) {
const node = new Sprite({ texture: disc, size: [R * 2, R * 2], position: [x, y] })
node.color = COLORS[Mathf.randomInt(0, COLORS.length - 1)]!
scene.add(node)
balls.push({ node, vx: Mathf.random(-260, 260), vy: Mathf.random(-260, 260) })
}
// device.width/height are 0 until the host reports a size, and change on rotate — so keep the
// camera centred on every resize and seed the first few balls once we actually have a viewport.
function fit(width: number, height: number) {
W = width; H = height
scene.camera.position = [W / 2, H / 2]
if (balls.length === 0 && W > 0 && H > 0) {
for (let i = 0; i < 5; i++) spawn(Mathf.random(R, W - R), Mathf.random(R, H - R))
}
}
fit(device.width, device.height)
device.addEventListener("resize", fit)
scene.addEventListener("click", ev => { if (W > 0) spawn(ev.worldX, ev.worldY) })
setLoop(dt => {
for (const ball of balls) {
const p = ball.node.position
p.x += ball.vx * dt
p.y += ball.vy * dt
if (p.x < R) { p.x = R; ball.vx = Math.abs(ball.vx) }
else if (p.x > W - R) { p.x = W - R; ball.vx = -Math.abs(ball.vx) }
if (p.y < R) { p.y = R; ball.vy = Math.abs(ball.vy) }
else if (p.y > H - R) { p.y = H - R; ball.vy = -Math.abs(ball.vy) }
ball.node.position = p
}
})
// A HUD over a scene is a UIWidget, never a UIScreen — a screen and a scene can't be open at the
// same time. attachTo(scene) binds the widget to the scene's layer so it shows and hides with it.
const hud = UIWidget([
UIText("Tap to spawn").style({ fontSize: 14, color: "#c7ccd6", fontWeight: 600 }),
]).style({ top: "max(safe-top, 16px)", left: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
Нажмите «Запустить» — откроется плавающий симулятор. Он остаётся на месте, пока вы читаете: другой пример просто заменит приложение внутри.