explodorama / files / src / render / camera.ts
camera.ts
5551eb37 2026-10-20 Pavel Safronov | 1 | import { mainCanvasSize, setCameraPos, setCameraScale, vec2 } from 'littlejsengine' |
| 2 | import type { GameState } from '../sim/types' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 3 | import { revealShift } from './reveal' |
5551eb37 2026-10-20 Pavel Safronov | 4 | |
| 5 | /** CSS pixels covered at the top (HUD) and bottom (action bar) of the canvas. */ | |
| 6 | export interface Insets { | |
| 7 | top: number | |
| 8 | bottom: number | |
| 9 | } | |
| 10 | ||
e6bb7c42 2026-10-25 Pavel Safronov | 11 | /** |
| 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 | */ | |
| 17 | export const VIEW_COLUMNS = 9 | |
| 18 | export 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. */ | |
| 21 | export interface ViewRect { | |
| 22 | left: number | |
| 23 | bottom: number | |
| 24 | right: number | |
| 25 | top: number | |
| 26 | } | |
| 27 | ||
| 28 | let center: { x: number; y: number } | undefined | |
| 29 | let 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 Safronov | 32 | * 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 Safronov | 34 | */ |
| 35 | let pan = { x: 0, y: 0 } | |
| 36 | let panning = false | |
d94649ba 2026-11-01 Pavel Safronov | 37 | /** The view was put somewhere by a drag, and stays there; otherwise it follows the player. */ |
| 38 | let held = false | |
ebcd0a8f 2026-11-02 Pavel Safronov | 39 | /** Where the view is heading (its centre, world units), for `revealTile`: `center` lags behind it while easing. */ |
| 40 | let 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 | */ | |
| 47 | export 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 Safronov | 55 | |
| 56 | /** A drag on the board moves the view by this much (world units; x right, y up). */ | |
| 57 | export 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 Safronov | 60 | 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 | */ | |
| 67 | export 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 Safronov | 72 | } |
| 73 | ||
| 74 | /** The drag is over: the view stays where it was dragged, eased again from here on. */ | |
| 75 | export function endPan(): void { | |
| 76 | panning = false | |
| 77 | } | |
| 78 | ||
d94649ba 2026-11-01 Pavel Safronov | 79 | /** Back to following the player (a new level). */ |
e6bb7c42 2026-10-25 Pavel Safronov | 80 | export function resetPan(): void { |
| 81 | pan = { x: 0, y: 0 } | |
d94649ba 2026-11-01 Pavel Safronov | 82 | held = false |
e6bb7c42 2026-10-25 Pavel Safronov | 83 | } |
| 84 | ||
| 85 | /** The part of the board in view this frame. */ | |
| 86 | export function viewRect(): ViewRect { | |
| 87 | return rect | |
| 88 | } | |
5551eb37 2026-10-20 Pavel Safronov | 89 | |
| 90 | /** | |
e6bb7c42 2026-10-25 Pavel Safronov | 91 | * 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 Safronov | 94 | * `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 Safronov | 96 | */ |
92cc4ee3 2026-11-02 Pavel Safronov | 97 | export 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 Safronov | 98 | 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 Safronov | 101 | 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 Safronov | 105 | const scale = Math.min(available / rows, width / columns) |
5551eb37 2026-10-20 Pavel Safronov | 106 | setCameraScale(scale) |
| 107 | ||
e6bb7c42 2026-10-25 Pavel Safronov | 108 | // 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 Safronov | 113 | if (!held) pan = { x: 0, y: 0 } |
e6bb7c42 2026-10-25 Pavel Safronov | 114 | const target = { |
92cc4ee3 2026-11-02 Pavel Safronov | 115 | 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 Safronov | 117 | } |
92cc4ee3 2026-11-02 Pavel Safronov | 118 | if (scrolls) pan = { x: target.x - base.x, y: target.y - base.y } |
ebcd0a8f 2026-11-02 Pavel Safronov | 119 | aim = target |
e6bb7c42 2026-10-25 Pavel Safronov | 120 | 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 Safronov | 122 | |
e6bb7c42 2026-10-25 Pavel Safronov | 123 | // The camera centres the canvas; move it so the view centres in the gap between the bars. |
5551eb37 2026-10-20 Pavel Safronov | 124 | const offset = insets.top + available / 2 - height / 2 |
e6bb7c42 2026-10-25 Pavel Safronov | 125 | 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 Safronov | 127 | } |
| 128 | ||
| 129 | function clamp(v: number, lo: number, hi: number): number { | |
| 130 | return Math.min(hi, Math.max(lo, v)) | |
| 131 | } |