fuzzyworld

explodorama / files / src / render / camera.ts

camera.ts

Blame: the commit that last changed each line · file · history

5551eb37 2026-10-20 Pavel Safronov1import { mainCanvasSize, setCameraPos, setCameraScale, vec2 } from 'littlejsengine'
2import type { GameState } from '../sim/types'
ebcd0a8f 2026-11-02 Pavel Safronov3import { revealShift } from './reveal'
5551eb37 2026-10-20 Pavel Safronov4
5/** CSS pixels covered at the top (HUD) and bottom (action bar) of the canvas. */
6export interface Insets {
7 top: number
8 bottom: number
9}
10
e6bb7c42 2026-10-25 Pavel Safronov11/**
12 * The view (PLAN.md §19.2; user's choice, 2026-10-24): the same number of tiles on every screen,
13 * 9 across and 13 down, so a phone and a desktop see as much of the board, and no screen gives
14 * an advantage. A board bigger than that scrolls: the view follows the player, eased, and stays
15 * inside the board. Outside the view, bars cover the rest (style.css `#view-mask`).
16 */
17export const VIEW_COLUMNS = 9
18export const VIEW_ROWS = 13
19
20/** The part of the board in view, in world units (x right, y up): what the edge arrows point out of. */
21export interface ViewRect {
22 left: number
23 bottom: number
24 right: number
25 top: number
26}
27
28let center: { x: number; y: number } | undefined
29let rect: ViewRect = { left: 0, bottom: 0, right: 0, top: 0 }
30/**
31 * How far a drag has moved the view off the player, in world units (user request, 2026-10-25: a
d94649ba 2026-11-01 Pavel Safronov32 * swipe to look around teleported instead). The view then stays where it was put, also through
33 * turns (user request, 2026-11-01), until the player would leave it or a new level starts.
e6bb7c42 2026-10-25 Pavel Safronov34 */
35let pan = { x: 0, y: 0 }
36let panning = false
d94649ba 2026-11-01 Pavel Safronov37/** The view was put somewhere by a drag, and stays there; otherwise it follows the player. */
38let held = false
ebcd0a8f 2026-11-02 Pavel Safronov39/** Where the view is heading (its centre, world units), for `revealTile`: `center` lags behind it while easing. */
40let aim = { x: 0, y: 0 }
41
42/**
43 * The controller's target is on tile (x, y) of a board `height` rows tall: if it has come within a
44 * tile of the view's edge, move the view just far enough to keep it on screen (eased, like a
45 * finished drag).
46 */
47export function revealTile(x: number, y: number, width: number, height: number): void {
48 const shift = revealShift(aim, { x: x + 0.5, y: height - y - 0.5 }, VIEW_COLUMNS, VIEW_ROWS, 1, { w: width, h: height })
49 // A view the board won't let move (already at its edge) is left alone, and not marked as held.
50 if (shift.x === 0 && shift.y === 0) return
51 panView(shift.x, shift.y)
52 endPan()
53 aim = { x: aim.x + shift.x, y: aim.y + shift.y }
54}
e6bb7c42 2026-10-25 Pavel Safronov55
56/** A drag on the board moves the view by this much (world units; x right, y up). */
57export function panView(dx: number, dy: number): void {
58 pan = { x: pan.x + dx, y: pan.y + dy }
59 panning = true
d94649ba 2026-11-01 Pavel Safronov60 held = true
61}
62
63/**
64 * The player moved from one tile to another in a turn: a held view stays on the same part of the
65 * board, so the offset from the player changes by the opposite of the move.
66 */
67export function keepView(from: { x: number; y: number }, to: { x: number; y: number }): void {
68 if (!held) return
69 pan = { x: pan.x - (to.x - from.x), y: pan.y + (to.y - from.y) }
70 // A view that no longer has the player in it goes back to following them.
71 if (Math.abs(pan.x) > VIEW_COLUMNS / 2 - 0.5 || Math.abs(pan.y) > VIEW_ROWS / 2 - 0.5) resetPan()
e6bb7c42 2026-10-25 Pavel Safronov72}
73
74/** The drag is over: the view stays where it was dragged, eased again from here on. */
75export function endPan(): void {
76 panning = false
77}
78
d94649ba 2026-11-01 Pavel Safronov79/** Back to following the player (a new level). */
e6bb7c42 2026-10-25 Pavel Safronov80export function resetPan(): void {
81 pan = { x: 0, y: 0 }
d94649ba 2026-11-01 Pavel Safronov82 held = false
e6bb7c42 2026-10-25 Pavel Safronov83}
84
85/** The part of the board in view this frame. */
86export function viewRect(): ViewRect {
87 return rect
88}
5551eb37 2026-10-20 Pavel Safronov89
90/**
e6bb7c42 2026-10-25 Pavel Safronov91 * The view between the HUD and the action bar. `whole`: fit the whole board instead (the demo's
92 * small rooms, which are made to be seen at once). `snap` jumps straight there, for the first
93 * frame of a level. Returns the view's place on the screen, in CSS pixels, for the bars around it.
92cc4ee3 2026-11-02 Pavel Safronov94 * `zoom` (above 1, with `whole`): the demo's controller scenes that look around, showing 1/zoom of
95 * the room's width and height, which the stick (or a drag) then moves like it does in play.
5551eb37 2026-10-20 Pavel Safronov96 */
92cc4ee3 2026-11-02 Pavel Safronov97export function updateCamera(s: GameState, insets: Insets, snap = false, whole = false, zoom = 1): { x: number; y: number; width: number; height: number } {
5551eb37 2026-10-20 Pavel Safronov98 const width = mainCanvasSize.x
99 const height = mainCanvasSize.y
100 const available = Math.max(1, height - insets.top - insets.bottom)
92cc4ee3 2026-11-02 Pavel Safronov101 const columns = whole ? s.width / zoom : VIEW_COLUMNS
102 const rows = whole ? s.height / zoom : VIEW_ROWS
103 // A whole room has nothing to scroll, unless it is zoomed.
104 const scrolls = !whole || zoom > 1
e6bb7c42 2026-10-25 Pavel Safronov105 const scale = Math.min(available / rows, width / columns)
5551eb37 2026-10-20 Pavel Safronov106 setCameraScale(scale)
107
e6bb7c42 2026-10-25 Pavel Safronov108 // Centre on the player, kept inside the board; a board narrower (or shorter) than the view is
109 // centred that way instead.
110 // A drag moves it off the player, still inside the board (the pan kept to what the board allows).
111 const follow = (player: number, size: number, view: number) => (size <= view ? size / 2 : clamp(player, view / 2, size - view / 2))
112 const base = { x: s.player.x + 0.5, y: s.height - s.player.y - 0.5 }
d94649ba 2026-11-01 Pavel Safronov113 if (!held) pan = { x: 0, y: 0 }
e6bb7c42 2026-10-25 Pavel Safronov114 const target = {
92cc4ee3 2026-11-02 Pavel Safronov115 x: follow(base.x + (scrolls ? pan.x : 0), s.width, columns),
116 y: follow(base.y + (scrolls ? pan.y : 0), s.height, rows),
e6bb7c42 2026-10-25 Pavel Safronov117 }
92cc4ee3 2026-11-02 Pavel Safronov118 if (scrolls) pan = { x: target.x - base.x, y: target.y - base.y }
ebcd0a8f 2026-11-02 Pavel Safronov119 aim = target
e6bb7c42 2026-10-25 Pavel Safronov120 center = !center || snap || panning ? target : { x: center.x + (target.x - center.x) * 0.2, y: center.y + (target.y - center.y) * 0.2 }
121 rect = { left: center.x - columns / 2, right: center.x + columns / 2, bottom: center.y - rows / 2, top: center.y + rows / 2 }
5551eb37 2026-10-20 Pavel Safronov122
e6bb7c42 2026-10-25 Pavel Safronov123 // The camera centres the canvas; move it so the view centres in the gap between the bars.
5551eb37 2026-10-20 Pavel Safronov124 const offset = insets.top + available / 2 - height / 2
e6bb7c42 2026-10-25 Pavel Safronov125 setCameraPos(vec2(center.x, center.y + offset / scale))
126 return { x: (width - columns * scale) / 2, y: insets.top + (available - rows * scale) / 2, width: columns * scale, height: rows * scale }
5551eb37 2026-10-20 Pavel Safronov127}
128
129function clamp(v: number, lo: number, hi: number): number {
130 return Math.min(hi, Math.max(lo, v))
131}