mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
SceneEngine: real pause + launch paused
The pause button was just a no-op for animation: localTime was always
(now − sceneStart) × speed, so anything inside the scene kept moving
even when isPlaying=false. The auto-advance task was the only thing
the toggle actually controlled. Worse: every fresh launch from the
Dock immediately started ticking, so the user opened the app and saw
the title scene already several seconds in.
Replace the "wall-clock anchor" with a two-state timer:
- accumulatedLocal: time already watched in this scene
- playSessionStart: wall-clock anchor for the live delta (nil when
paused)
localTime = accumulatedLocal + (now − sessionStart) × speed if playing
= accumulatedLocal if paused
Pause now folds the live delta into accumulatedLocal and clears the
session anchor. Resume re-anchors. Scene navigation (next/previous/
goTo) zeros the accumulator and only opens a fresh session if the
engine was already in the playing state. Speed changes capture the
current localTime at the OLD speed so they don't retroactively rescale
already-elapsed time. Auto-advance fires after the REMAINING duration
(not the full duration) so a pause halfway through Ch1.3 doesn't
restart its 24s clock when the user resumes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
da3cd1d338
commit
463d77f072
1 changed files with 66 additions and 22 deletions
|
|
@ -2,21 +2,33 @@ import SwiftUI
|
|||
|
||||
/// Observable engine driving the immersive linear scene flow.
|
||||
/// Single source of truth for scene-local time. Chapters consume `localTime(at:)`.
|
||||
///
|
||||
/// Pause is FIRST-CLASS: when `isPlaying` is false, `localTime(at:)` returns
|
||||
/// the value it had when the user paused, frozen. Pressing play resumes from
|
||||
/// where time stopped. The app launches paused so the very first scene
|
||||
/// doesn't auto-scrub past its opening beats before the user gets oriented.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SceneEngine {
|
||||
private(set) var currentGlobal: Int = 0
|
||||
private(set) var isPlaying: Bool = false
|
||||
|
||||
/// Wall-clock reference (Date.timeIntervalSinceReferenceDate) when the current scene started.
|
||||
private(set) var sceneStartReference: Double = 0
|
||||
/// Local time accumulated in the current scene from prior play sessions
|
||||
/// (i.e. the part of the scene the user has already watched). When the
|
||||
/// user is paused, this is the full localTime. When playing, the live
|
||||
/// delta from `playSessionStart` is added on top.
|
||||
private var accumulatedLocal: Double = 0
|
||||
/// Wall-clock reference when the current play session started; nil when
|
||||
/// paused. We anchor to `timeIntervalSinceReferenceDate` so the value
|
||||
/// survives system clock drift over the lifetime of one session.
|
||||
private var playSessionStart: Double? = nil
|
||||
|
||||
var speed: Double = 1.0
|
||||
let totalScenes: Int
|
||||
|
||||
/// Monotonic counter; incremented on every stopAutoAdvance() to invalidate in-flight asyncAfter blocks.
|
||||
private var advanceGeneration: Int = 0
|
||||
let sceneDuration: Double = 8.0 // default at 1x
|
||||
let sceneDuration: Double = 8.0
|
||||
|
||||
/// Per-(chapter,scene) duration overrides. Some scenes — notably the
|
||||
/// Ch01 scene-3 slow-motion gossip dramatization — can't compress into
|
||||
|
|
@ -38,12 +50,15 @@ final class SceneEngine {
|
|||
AllChapters.list[address.chapter]
|
||||
}
|
||||
|
||||
/// Compute scene-local time at a given wall-clock date, scaled by speed.
|
||||
/// Returns 0 if the scene hasn't actually started yet (e.g. fresh launch).
|
||||
/// Compute scene-local time at a given wall-clock date, honoring pause
|
||||
/// state. Speed scales the LIVE delta only — already-elapsed time is
|
||||
/// preserved at whatever speed it was clocked at.
|
||||
func localTime(at date: Date) -> Double {
|
||||
guard sceneStartReference > 0 else { return 0 }
|
||||
let delta = date.timeIntervalSinceReferenceDate - sceneStartReference
|
||||
return max(0, delta * speed)
|
||||
guard let start = playSessionStart else {
|
||||
return accumulatedLocal
|
||||
}
|
||||
let live = (date.timeIntervalSinceReferenceDate - start) * speed
|
||||
return max(0, accumulatedLocal + live)
|
||||
}
|
||||
|
||||
/// Progress within current scene (0..1), capped at 1. Honors per-scene
|
||||
|
|
@ -55,7 +70,8 @@ final class SceneEngine {
|
|||
|
||||
init() {
|
||||
self.totalScenes = AllChapters.totalScenes
|
||||
self.sceneStartReference = Date().timeIntervalSinceReferenceDate
|
||||
// Launch paused at t=0. The user sees a still title frame and
|
||||
// explicitly presses play (or the right arrow) to begin.
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
|
@ -65,7 +81,7 @@ final class SceneEngine {
|
|||
stopAutoAdvance()
|
||||
if currentGlobal < totalScenes - 1 {
|
||||
currentGlobal += 1
|
||||
resetSceneTime()
|
||||
resetSceneTime(playing: wasPlaying)
|
||||
if wasPlaying { startAutoAdvance() }
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +91,7 @@ final class SceneEngine {
|
|||
stopAutoAdvance()
|
||||
if currentGlobal > 0 {
|
||||
currentGlobal -= 1
|
||||
resetSceneTime()
|
||||
resetSceneTime(playing: wasPlaying)
|
||||
if wasPlaying { startAutoAdvance() }
|
||||
}
|
||||
}
|
||||
|
|
@ -84,21 +100,37 @@ final class SceneEngine {
|
|||
let wasPlaying = isPlaying
|
||||
stopAutoAdvance()
|
||||
currentGlobal = max(0, min(global, totalScenes - 1))
|
||||
resetSceneTime()
|
||||
resetSceneTime(playing: wasPlaying)
|
||||
if wasPlaying { startAutoAdvance() }
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying.toggle()
|
||||
if isPlaying {
|
||||
resetSceneTime()
|
||||
startAutoAdvance()
|
||||
} else {
|
||||
// Pausing: capture the live delta into the accumulator so the
|
||||
// next localTime() call returns exactly the frozen value.
|
||||
if let start = playSessionStart {
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
accumulatedLocal += (now - start) * speed
|
||||
}
|
||||
playSessionStart = nil
|
||||
isPlaying = false
|
||||
stopAutoAdvance()
|
||||
} else {
|
||||
// Resuming: anchor a new live delta from the current accumulator.
|
||||
playSessionStart = Date().timeIntervalSinceReferenceDate
|
||||
isPlaying = true
|
||||
startAutoAdvance()
|
||||
}
|
||||
}
|
||||
|
||||
func adjustSpeed(delta: Double) {
|
||||
// Capture current localTime at the OLD speed before changing speed,
|
||||
// so the speed change doesn't retroactively scale already-elapsed time.
|
||||
if let start = playSessionStart {
|
||||
let now = Date().timeIntervalSinceReferenceDate
|
||||
accumulatedLocal += (now - start) * speed
|
||||
playSessionStart = now
|
||||
}
|
||||
speed = max(0.25, min(4.0, speed + delta))
|
||||
if isPlaying {
|
||||
stopAutoAdvance()
|
||||
|
|
@ -108,25 +140,37 @@ final class SceneEngine {
|
|||
|
||||
// MARK: - Internals
|
||||
|
||||
private func resetSceneTime() {
|
||||
sceneStartReference = Date().timeIntervalSinceReferenceDate
|
||||
/// Clear scene-local time. If the engine is currently in the playing
|
||||
/// state, anchor a fresh play session from now; otherwise leave it nil
|
||||
/// so the next localTime() call returns 0 cleanly.
|
||||
private func resetSceneTime(playing: Bool) {
|
||||
accumulatedLocal = 0
|
||||
playSessionStart = playing ? Date().timeIntervalSinceReferenceDate : nil
|
||||
}
|
||||
|
||||
private func currentLocalTimeNow() -> Double {
|
||||
guard let start = playSessionStart else { return accumulatedLocal }
|
||||
let live = (Date().timeIntervalSinceReferenceDate - start) * speed
|
||||
return max(0, accumulatedLocal + live)
|
||||
}
|
||||
|
||||
private func startAutoAdvance() {
|
||||
advanceGeneration += 1
|
||||
let myGen = advanceGeneration
|
||||
// Auto-advance honors the current scene's effective duration (longer
|
||||
// scenes get more time). Speed scaling still applies.
|
||||
let interval = sceneDurationFor(address) / max(0.1, speed)
|
||||
// Auto-advance fires after the REMAINING time in this scene (so a
|
||||
// pause→resume midway through Ch1.3 doesn't reset the 24s clock).
|
||||
let remaining = sceneDurationFor(address) - currentLocalTimeNow()
|
||||
let interval = max(0.05, remaining / max(0.1, speed))
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(interval))
|
||||
guard let self, self.advanceGeneration == myGen, self.isPlaying else { return }
|
||||
if self.currentGlobal < self.totalScenes - 1 {
|
||||
self.currentGlobal += 1
|
||||
self.resetSceneTime()
|
||||
self.resetSceneTime(playing: true)
|
||||
self.startAutoAdvance()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.playSessionStart = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue