P6.3: per-gem idle/select/land/clear animations (sx, iOS sim)
New gem_anim.sx adds a purely-visual per-gem pose set driven by a single animation clock: a calm always-on idle breath (scale-pulse + bob, per-gem phase, ramped in from rest), a selection pop, a landing squash-bounce, and a clear pop. BoardView draws every settled gem through gem_pose_at / gem_pose_frame; the move timeline (P6.1) and FX (P6.2) are untouched and the input-lock semantics are unchanged (idle never locks input). Determinism: the idle is always-on, so main reads M3TE_ANIM_TIME=<seconds> to freeze the clock at a chosen phase (t==0 == the resting board, so the pre-P6.3 goldens reproduce) and M3TE_SELECT=<cellIndex> to force a selection for capture. tests/gem_pose.sx locks the t==0-rest invariant and the reaction envelopes headlessly (fails if the idle ramp is dropped). Goldens (deterministic capture): p6_idle_t0 (resting), p6_idle_mid (pinned mid-breath), p6_select (selection pop on cell 3,3). Purely visual: no change to model/score/moves/hit-testing.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
#import "board_layout.sx";
|
||||
#import "board_anim.sx";
|
||||
#import "board_fx.sx";
|
||||
#import "gem_anim.sx";
|
||||
#import "swipe.sx";
|
||||
|
||||
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
|
||||
@@ -120,10 +121,14 @@ load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
|
||||
BoardSelection :: struct {
|
||||
active: bool;
|
||||
cell: Cell;
|
||||
// Animation clock value when this selection last became active, so the
|
||||
// selection-pop reaction (gem_anim) can age from the moment of the tap.
|
||||
since: f32;
|
||||
|
||||
init :: (self: *BoardSelection) {
|
||||
self.active = false;
|
||||
self.cell = Cell.{ col = 0, row = 0 };
|
||||
self.since = 0.0;
|
||||
}
|
||||
|
||||
clear :: (self: *BoardSelection) {
|
||||
@@ -174,6 +179,7 @@ BoardView :: struct {
|
||||
anim: *BoardAnim;
|
||||
fx: *BoardFx;
|
||||
fxassets: *BoardFxAssets;
|
||||
motion: *GemMotion;
|
||||
safe: EdgeInsets;
|
||||
|
||||
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
|
||||
@@ -212,14 +218,48 @@ BoardView :: struct {
|
||||
Frame.make(cx - d * 0.5, cy - d * 0.5, d, d)
|
||||
}
|
||||
|
||||
// Settled-board gems: one sprite per non-empty cell at its cell frame. Used
|
||||
// whenever no move is animating.
|
||||
render_gems :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
|
||||
// Frame for a gem at cell (col,row) drawn with a per-gem animation pose: the
|
||||
// sprite is scaled about its cell centre and nudged by the pose offset (both
|
||||
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
|
||||
// idle pose draws identically to the static sprite.
|
||||
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
|
||||
cs := self.layout.cell_size;
|
||||
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
|
||||
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
|
||||
w := dim * pose.scale_x;
|
||||
h := dim * pose.scale_y;
|
||||
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
|
||||
}
|
||||
|
||||
// The per-gem animation pose for a settled cell: the always-on idle breath,
|
||||
// plus a squash-bounce if the cell landed recently, plus a pop if it is the
|
||||
// selected cell. Purely visual — composed from gem_anim's pure functions.
|
||||
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
|
||||
pose := idle_pose(self.motion.clock, col, row);
|
||||
|
||||
sq := land_squash(self.motion.land_local(Board.idx(col, row)));
|
||||
pose.scale_x += sq;
|
||||
pose.scale_y -= sq;
|
||||
|
||||
if self.sel != null and self.sel.active
|
||||
and self.sel.cell.col == col and self.sel.cell.row == row {
|
||||
ts := if self.motion.pinned then self.motion.clock else self.motion.clock - self.sel.since;
|
||||
sp := select_pop_scale(ts);
|
||||
pose.scale_x *= sp;
|
||||
pose.scale_y *= sp;
|
||||
}
|
||||
pose
|
||||
}
|
||||
|
||||
// Settled-board gems: one sprite per non-empty cell, drawn with its live
|
||||
// per-gem animation pose. Used whenever no move is animating.
|
||||
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
g := self.board.at(col, row);
|
||||
if g != .empty {
|
||||
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
|
||||
pose := self.gem_pose_at(col, row);
|
||||
gf := self.gem_pose_frame(col, row, dim, pose);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
}
|
||||
}
|
||||
@@ -303,17 +343,17 @@ BoardView :: struct {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear segment: matched gems shrink toward nothing; the rest hold position.
|
||||
// Clear segment: matched gems pop outward then collapse to nothing (a
|
||||
// satisfying pop, composing with the particle burst); the rest hold position.
|
||||
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
|
||||
shrink := 1.0 - ease_in_quad(t);
|
||||
if shrink < 0.0 { shrink = 0.0; }
|
||||
pop := clear_pop_scale(t);
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
g := rd.before[i];
|
||||
if g == .empty { continue; }
|
||||
col := i % BOARD_COLS;
|
||||
row := i / BOARD_COLS;
|
||||
if rd.matched.cells[i] {
|
||||
gf := self.gem_frame_scaled(col, row, dim, shrink);
|
||||
gf := self.gem_frame_scaled(col, row, dim, pop);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
} else {
|
||||
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
|
||||
@@ -427,7 +467,7 @@ impl View for BoardView {
|
||||
if self.anim != null and self.anim.active {
|
||||
self.render_anim(ctx, gem_inset, gem_dim);
|
||||
} else {
|
||||
self.render_gems(ctx, gem_inset, gem_dim);
|
||||
self.render_gems(ctx, gem_dim);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +523,8 @@ impl View for BoardView {
|
||||
} else {
|
||||
if hit := self.layout.point_to_cell(start) {
|
||||
self.sel.toggle(hit);
|
||||
// Re-arm the selection-pop reaction from this tap's moment.
|
||||
if self.sel.active { self.sel.since = self.motion.clock; }
|
||||
} else {
|
||||
self.sel.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user