// Per-gem animation set (P6.3) — a PURELY VISUAL pose each gem sprite is drawn // with: a calm always-on idle breath, a pop on selection, and a squash-bounce on // landing. Everything here is a pure function of an animation clock and the cell; // it never reads or writes the model, so a gem's idle bob/scale cannot change its // logical cell or break hit-testing (which stays on the grid in board_layout.sx). // // Determinism: the idle is always-on, so a live screenshot would be time- // dependent. `GemMotion.clock` is the single animation time; capture mode // (M3TE_ANIM_TIME, read in main) freezes it at a chosen phase so the board can be // screenshotted reproducibly. Every effect is built so that at clock t==0 the pose // is exactly the resting sprite — so the pre-P6.3 goldens reproduce at t==0. #import "modules/std.sx"; #import "modules/math"; #import "board.sx"; // A gem's draw transform about its cell centre. scale_x/scale_y scale the sprite // (1.0 == the normal cell-fill size) and dx/dy nudge it in CELL units. The resting // pose is all-ones / all-zeros, which draws identically to the static sprite. GemPose :: struct { scale_x: f32; scale_y: f32; dx: f32; dy: f32; } gem_pose_rest :: () -> GemPose { GemPose.{ scale_x = 1.0, scale_y = 1.0, dx = 0.0, dy = 0.0 } } // --- Idle breath ------------------------------------------------------------- // A gentle ~1s pulse + vertical bob, ramped in from rest so a freshly-shown board // (and the t==0 capture) starts on the resting pose. A per-gem phase offset keeps // the board from pulsing in lockstep without ever desyncing the t==0 rest. IDLE_PERIOD :f32: 1.05; // seconds per breath IDLE_SCALE_A :f32: 0.035; // +/- uniform scale amplitude IDLE_BOB_A :f32: 0.024; // vertical bob amplitude (cell units) IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest // Smooth per-cell phase: a diagonal gradient wrapped into one breath period. gem_idle_phase :: (col: s64, row: s64) -> f32 { cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU } idle_pose :: (t: f32, col: s64, row: s64) -> GemPose { ramp := clamp(t / IDLE_RAMP, 0.0, 1.0); w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row); s := IDLE_SCALE_A * sin(w) * ramp; bob := IDLE_BOB_A * cos(w) * ramp; GemPose.{ scale_x = 1.0 + s, scale_y = 1.0 + s, dx = 0.0, dy = bob } } // --- Selection pop ----------------------------------------------------------- // A quick scale-up that settles back: a single hump over the window so the tapped // gem "pops" then relaxes (the highlight overlay still draws on top of this). SELECT_DUR :f32: 0.34; SELECT_POP_A :f32: 0.15; select_pop_scale :: (ts: f32) -> f32 { if ts <= 0.0 or ts >= SELECT_DUR { return 1.0; } 1.0 + SELECT_POP_A * sin(PI * ts / SELECT_DUR) } // --- Landing squash-bounce --------------------------------------------------- // A damped wobble on settle: the gem flattens wide-and-short on impact, then a // couple of decaying overshoots. 0 at tl==0 and again past the window (rest). LAND_DUR :f32: 0.42; LAND_SQUASH_A :f32: 0.13; LAND_OSC :f32: 1.5; // oscillations across the window land_squash :: (tl: f32) -> f32 { if tl <= 0.0 or tl >= LAND_DUR { return 0.0; } decay := 1.0 - tl / LAND_DUR; LAND_SQUASH_A * sin(TAU * LAND_OSC * tl / LAND_DUR) * decay * decay } // --- Clear pop --------------------------------------------------------------- // The matched-gem clear: a snappy outward pop then a collapse to nothing over its // local 0..1, so the clear reads as a satisfying candy pop rather than a plain // shrink. A fast rise to a bigger overshoot makes the snap read; the soft // particle burst / score popup (board_fx.sx) compose on top. CLEAR_POP_A :f32: 0.34; // overshoot height above resting scale CLEAR_POP_RISE :f32: 0.18; // fraction of the window spent rising to the peak clear_pop_scale :: (t: f32) -> f32 { if t <= 0.0 { return 1.0; } if t >= 1.0 { return 0.0; } if t < CLEAR_POP_RISE { return 1.0 + CLEAR_POP_A * (t / CLEAR_POP_RISE); } peak := 1.0 + CLEAR_POP_A; u := (t - CLEAR_POP_RISE) / (1.0 - CLEAR_POP_RISE); peak * (1.0 - u * u) } // Live per-gem animation state, heap-allocated (like BoardAnim/BoardFx) so it // survives BoardView's per-frame rebuild. `clock` is the single animation time: // the frame loop advances it by delta_time, or capture mode pins it. `land_at` // records, per cell, the clock value when that cell last received a gem so only // the cells that actually moved bounce. GemMotion :: struct { clock: f32; pinned: bool; land_at: [BOARD_CELLS]f32; init :: (self: *GemMotion) { self.clock = 0.0; self.pinned = false; self.reset_landings(); } // Drop every landing stamp back to the never-landed sentinel so no cell // carries a squash-bounce. `restart` calls this so a reseeded board starts at // its resting pose instead of replaying the prior move's landing wobble; the // idle clock keeps running, so the always-on idle simply resumes from rest. reset_landings :: (self: *GemMotion) { for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; } } stamp_land :: (self: *GemMotion, i: s64) { self.land_at[i] = self.clock; } land_local :: (self: *GemMotion, i: s64) -> f32 { self.clock - self.land_at[i] } }