fuzzyworld

explodorama / commit

The view stays where the player dragged it through turns (0.2.0 work)

d94649bad7ef4c9eae98af03d62d159c2c4f066e · Pavel Safronov · 2026-11-01 10:35:18 -0800 · parents d64916dfc83b

Claude Code usage: this commit 21.8K out / 2.4M cached · cost unknown (no per-model trailer) · session 9 tally 228 in / 60.4K out / 6.4M cached · usage by commit and session

The view stays where the player dragged it through turns (0.2.0 work)

A dragged view is now held: a turn moves the player but not the view, and it
follows the player again only when a turn would take them out of it, or on a
new level, new run or the demo. Reported by Pavel (Q40).

FW-Tokens-In: 228
FW-Tokens-Out: 60421
FW-Tokens-Cached: 6376059
FW-Session: 20903b28-0dc8-40de-aad7-258052bc2947
FW-Rtk-Saved: 2841132
FW-Rtk-Cmds: 8292

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md            |  4 ++--
 decisions.md         |  8 ++++++++
 src/changelog.ts     |  7 +++++++
 src/main.ts          |  9 ++++++---
 src/render/camera.ts | 21 +++++++++++++++++++--
 5 files changed, 42 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index a288933..9778047 100644
--- a/README.md
+++ b/README.md
@@ -474,8 +474,8 @@ The demo and the clips in the dialogs are silent. On an iPhone, the silent switc
 long as an early one, not two or three times as long). **You always see 9 × 13 tiles**, on a phone
 and on a desktop alike (bars cover the rest of a wide or tall screen), so no screen sees more of the
 board than another. A bigger board **scrolls with you**, smoothly, and stops at its edges. **Drag
-the board** (with nothing picked) to look around it; letting go does nothing, and your next turn
-brings the view back to you. What is out of sight shows at the edge of the view as a small picture
+the board** (with nothing picked) to look around it; letting go does nothing, and the view **stays where you put it**
+through your turns; it follows you again when you would walk out of it, and on a new level. What is out of sight shows at the edge of the view as a small picture
 with an arrow pointing to it: every enemy, every bomb, the exit and the gem once uncovered, and you,
 when the view is dragged away. (Every board was 9 × 11, seen whole,
 until 2026-10-25.) A stone outer wall and metal pillars
diff --git a/decisions.md b/decisions.md
index 393cd2c..2949886 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2387,3 +2387,11 @@ clip: it would cover the clip.) Only test scenes have such choices.
 Timing, same day: the choice comes after the turn that led to it has come to rest (a ~1.1 s pause in a
 clip, 1.2 s in the `?dev` demo), shows ~2 s, goes, and the second follows after a pause (the clip's
 own gap after the first goes, then 0.2 s), rather than at once over the slide.
+
+### 2026-11-01 — A dragged view stays put through turns
+
+User report: the view snapped back to the player after every turn, a sudden jump, even when they
+had put it there on purpose. Now a drag "holds" the view (`held` in camera.ts): a turn moves the
+player but not the view (`keepView` shifts the offset by the move). It follows the player again
+when a turn would take the player out of the view (checked after the turn, not while dragging, so a
+far look stays put) and on a new level, a new run or the demo (`resetView`). Supersedes "the next turn brings the view back" in the 2026-10-25 entry above.
diff --git a/src/changelog.ts b/src/changelog.ts
index fc5ffff..417469c 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,13 @@ export const RELEASES: Release[] = [
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
 export const UNRELEASED: Change[] = [
+  {
+    kind: 'changed',
+    icon: 'star',
+    title: 'The view stays where you put it',
+    text: ['If you drag the board to look around and then play a turn, the view no longer jumps back to your character. It stays where you left it, and goes back to following you only when you would walk out of it, or on a new level. Reported by Pavel.'],
+    date: '2026-11-01',
+  },
   {
     kind: 'added',
     icon: 'star',
diff --git a/src/main.ts b/src/main.ts
index ae15066..b732dc3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -21,7 +21,7 @@ import {
 } from 'littlejsengine'
 import { BACKGROUND, drawBoard, drawIndicators, playerPos, toTile, toWorld } from './render/board'
 import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate'
-import { endPan, panView, resetPan, updateCamera, viewRect } from './render/camera'
+import { endPan, keepView, panView, resetPan, updateCamera, viewRect } from './render/camera'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { approachTile, canPick, membersOf, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from './sim/picks'
@@ -121,6 +121,7 @@ function resetView(): void {
   inspecting = false
   destination = null
   snapCamera = true
+  resetPan()
 }
 
 function startRun(seed: number, options: RunOptions): void {
@@ -306,9 +307,11 @@ function rememberTurn(prev: GameState, action: Action, events: GameEvent[]): voi
 /** A turn played: the new state, and everything that animates it (also for the demo). */
 function animateTurn(result: ReturnType<typeof step>, action: Action): void {
   const newLevel = animateBoard(anim, state, result, action, time)
+  const from = state.player
   state = result.state
-  // A turn brings a view dragged away back to the player.
-  resetPan()
+  // A view the player dragged somewhere stays there (Q40); a new level starts following again.
+  if (newLevel) resetPan()
+  else if (!demo) keepView(from, state.player)
   if (newLevel) snapCamera = true
   if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null
 }
diff --git a/src/render/camera.ts b/src/render/camera.ts
index 9091389..a473166 100644
--- a/src/render/camera.ts
+++ b/src/render/camera.ts
@@ -28,15 +28,30 @@ let center: { x: number; y: number } | undefined
 let rect: ViewRect = { left: 0, bottom: 0, right: 0, top: 0 }
 /**
  * How far a drag has moved the view off the player, in world units (user request, 2026-10-25: a
- * swipe to look around teleported instead). Until the next turn, which brings it back.
+ * swipe to look around teleported instead). The view then stays where it was put, also through
+ * turns (user request, 2026-11-01), until the player would leave it or a new level starts.
  */
 let pan = { x: 0, y: 0 }
 let panning = false
+/** The view was put somewhere by a drag, and stays there; otherwise it follows the player. */
+let held = false
 
 /** A drag on the board moves the view by this much (world units; x right, y up). */
 export function panView(dx: number, dy: number): void {
   pan = { x: pan.x + dx, y: pan.y + dy }
   panning = true
+  held = true
+}
+
+/**
+ * The player moved from one tile to another in a turn: a held view stays on the same part of the
+ * board, so the offset from the player changes by the opposite of the move.
+ */
+export function keepView(from: { x: number; y: number }, to: { x: number; y: number }): void {
+  if (!held) return
+  pan = { x: pan.x - (to.x - from.x), y: pan.y + (to.y - from.y) }
+  // A view that no longer has the player in it goes back to following them.
+  if (Math.abs(pan.x) > VIEW_COLUMNS / 2 - 0.5 || Math.abs(pan.y) > VIEW_ROWS / 2 - 0.5) resetPan()
 }
 
 /** The drag is over: the view stays where it was dragged, eased again from here on. */
@@ -44,9 +59,10 @@ export function endPan(): void {
   panning = false
 }
 
-/** Back to following the player (a turn was played). */
+/** Back to following the player (a new level). */
 export function resetPan(): void {
   pan = { x: 0, y: 0 }
+  held = false
 }
 
 /** The part of the board in view this frame. */
@@ -73,6 +89,7 @@ export function updateCamera(s: GameState, insets: Insets, snap = false, whole =
   // A drag moves it off the player, still inside the board (the pan kept to what the board allows).
   const follow = (player: number, size: number, view: number) => (size <= view ? size / 2 : clamp(player, view / 2, size - view / 2))
   const base = { x: s.player.x + 0.5, y: s.height - s.player.y - 0.5 }
+  if (!held) pan = { x: 0, y: 0 }
   const target = {
     x: follow(base.x + (whole ? 0 : pan.x), s.width, columns),
     y: follow(base.y + (whole ? 0 : pan.y), s.height, rows),