Merge branch 'flow/m3te/P17.3' into m3te-plan
This commit is contained in:
40
README.md
40
README.md
@@ -344,6 +344,46 @@ env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||||
The change is render-only — no `board.sx` model change, and normal play is
|
||||
byte-identical apart from the fall's per-column timing.
|
||||
|
||||
### Organic fall — landing squash-&-settle (P17.3)
|
||||
|
||||
Each landing gem now flattens **wide-and-short on impact then wobbles back to rest**
|
||||
(P15.1's `squash_envelope`), applied WITHIN the fall so EVERY cascade round bounces,
|
||||
staggered per column — not only the final whole-move settle as before. `render_fall`
|
||||
ages a per-column bounce from each column's touch-down instant (`fall_landing_frac` ·
|
||||
`FALL_ANIM_DUR`), so a gem still in the air is drawn unsquashed and only a gem that
|
||||
has reached its cell flattens; the squash carries across the fall→clear seam
|
||||
(`render_clear` continues the previous round's bounce) and across the final
|
||||
render_anim → render_gems seam (the settle stamp is **back-dated** per column so
|
||||
`land_squash` resumes exactly where the fall left it — ONE bounce, no double-pop).
|
||||
`land_squash` is now `LAND_SQUASH_A · squash_envelope(tl/LAND_DUR)`, so the per-round
|
||||
fall bounce and the settle bounce are the same single envelope; amplitude is the
|
||||
tasteful ~13 % peak (`LAND_SQUASH_A = 0.18`). Durations are unchanged, so the
|
||||
cascade-cue snapshots don't churn; `M3TE_ANIM_TIME=0` still reproduces
|
||||
`goldens/p6_idle_t0.png` exactly (a resting board carries no landing stamp).
|
||||
|
||||
The visual tell: pin a round's fall just before it ends and the leading columns sit
|
||||
landed-and-squashed (wide-short) while the trailing columns are still airborne — a
|
||||
staggered squash wave. On seed 1337, `M3TE_FX=11` **round 4** (the refill round) at
|
||||
`1.94` shows columns 2–4 landed and flattened with columns 5–7 still pouring in
|
||||
(every round behaves identically — round 2 `[1.02,1.24)` at `1.21` and round 3
|
||||
`[1.38,1.60)` near `1.58` bounce the same way, so the bounce is NOT limited to the
|
||||
last settle):
|
||||
|
||||
```bash
|
||||
# Staggered landing squash mid-pour (leading cols flattened, trailing airborne):
|
||||
# goldens/p17_land.png
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.94 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Same cascade past the timeline — fully settled, bounce decayed to rest (no golden):
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
`goldens/p17_fall.png` stays the pre-stagger lockstep reference (P17.1) and
|
||||
`goldens/p17_stagger.png` the pure per-column stagger (P17.2); both pin moments where
|
||||
no column has landed yet, so neither carries squash. The change is render-only — no
|
||||
`board.sx` model change, and a resting board is untouched.
|
||||
|
||||
### FPS counter — dev overlay (P20.1)
|
||||
|
||||
A small FPS readout for gauging frame cost while tuning the animations. It is a
|
||||
|
||||
@@ -117,6 +117,25 @@ fall_stagger_t :: (t: f32, col: s64) -> f32 {
|
||||
lt
|
||||
}
|
||||
|
||||
// The LOCAL fall-progress fraction at which column `col` finishes its drop — the
|
||||
// instant `fall_stagger_t(.,col)` reaches 1 (delay + window). Column 0 lands first
|
||||
// at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing
|
||||
// squash-bounce (P17.3) ages from this instant per column, so the squash begins
|
||||
// the moment a gem touches its cell rather than at a flat whole-row settle.
|
||||
fall_landing_frac :: (col: s64) -> f32 {
|
||||
(1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1))
|
||||
}
|
||||
|
||||
// Absolute time (s) on the swap→(clear,fall)* timeline at which round `k` finishes
|
||||
// dropping column `col`'s gem onto its destination cell — the landing instant the
|
||||
// per-round bounce ages from. Round k's fall starts after the swap, k clear+fall
|
||||
// pairs, and that round's own clear; column `col` then lands `fall_landing_frac`
|
||||
// of the fall window into it. Pure + headless, mirrors `phase`'s segment walk.
|
||||
round_land_time :: (k: s64, col: s64) -> f32 {
|
||||
SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR
|
||||
+ fall_landing_frac(col) * FALL_ANIM_DUR
|
||||
}
|
||||
|
||||
// One recorded cascade round. `before` is the board at the round's start (the
|
||||
// swapped board for round 0, the previous round's `after` otherwise — never has
|
||||
// holes). `matched` flags the cells cleared this round (they scale out). `src`
|
||||
@@ -147,6 +166,24 @@ AnimMove :: struct {
|
||||
awarded: s64;
|
||||
}
|
||||
|
||||
// The most recent round at or before `kmax` that dropped a MOVED gem onto
|
||||
// destination cell `i` (a slide-down survivor or a top refill — `src != row`), or
|
||||
// -1 if the gem now resting at `i` never moved over those rounds. The gem at `i`
|
||||
// landed in that round, so its squash-bounce ages from `round_land_time(round,
|
||||
// col)`; scanning newest-first means a cell cleared and refilled across rounds
|
||||
// ages from its LATEST arrival, never a stale earlier one. Pure + headless: the
|
||||
// per-round bounce (render_fall/clear) and the final-settle stamp share this so
|
||||
// one envelope plays continuously across every seam.
|
||||
delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
|
||||
row := i / BOARD_COLS;
|
||||
k := kmax;
|
||||
while k >= 0 {
|
||||
if mv.rounds.items[k].src[i] != row { return k; }
|
||||
k -= 1;
|
||||
}
|
||||
-1
|
||||
}
|
||||
|
||||
// Commit the player's swap authoritatively AND record its visual timeline. The
|
||||
// real board is mutated by `commit_swap` exactly as the non-animated path did;
|
||||
// the recording runs on a separate value-copy taken BEFORE the commit, so it
|
||||
|
||||
@@ -306,6 +306,20 @@ BoardView :: struct {
|
||||
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
|
||||
}
|
||||
|
||||
// Frame for a gem at a (possibly fractional) row in column `col`, squashed by
|
||||
// `sq` about its cell centre: scale_x = 1+sq (wider), scale_y = 1-sq (shorter)
|
||||
// — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred
|
||||
// placement EXACTLY, so a gem still mid-fall (or one that never moved) draws
|
||||
// byte-identically to the plain fall; only a landed gem flattens.
|
||||
gem_squash_frame :: (self: *BoardView, col: s64, frow: f32, dim: f32, sq: f32) -> Frame {
|
||||
cs := self.layout.cell_size;
|
||||
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs;
|
||||
cy := self.layout.origin.y + (frow + 0.5) * cs;
|
||||
w := dim * (1.0 + sq);
|
||||
h := dim * (1.0 - sq);
|
||||
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.
|
||||
@@ -326,6 +340,20 @@ BoardView :: struct {
|
||||
pose
|
||||
}
|
||||
|
||||
// Per-round landing squash for the gem resting at cell `i` at move-timeline
|
||||
// time `elapsed`, considering rounds up to `kmax`. The gem landed in its
|
||||
// `delivering_round`; the bounce ages from that round's landing instant through
|
||||
// the shared `land_squash` envelope. A gem still mid-fall reads a NEGATIVE age
|
||||
// (land_squash → 0, so it draws unsquashed) and one that never moved reads 0.
|
||||
// render_fall passes the current round; render_clear the previous (its board is
|
||||
// that round's `after`), so the one bounce plays on across the fall→clear seam.
|
||||
rest_squash :: (self: *BoardView, i: s64, kmax: s64, elapsed: f32) -> f32 {
|
||||
m := delivering_round(@self.anim.move, i, kmax);
|
||||
if m < 0 { return 0.0; }
|
||||
col := i % BOARD_COLS;
|
||||
land_squash(elapsed - round_land_time(m, col))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -387,22 +415,27 @@ BoardView :: struct {
|
||||
ctx.push_clip(grid);
|
||||
|
||||
mv := @self.anim.move;
|
||||
e := self.anim.elapsed;
|
||||
if ph.kind == .swap {
|
||||
self.render_swap(ctx, mv, inset, dim, ph.t);
|
||||
} else if ph.kind == .clear {
|
||||
rd := @mv.rounds.items[ph.round];
|
||||
self.render_clear(ctx, rd, inset, dim, ph.t);
|
||||
self.render_clear(ctx, rd, ph.round, e, dim, ph.t);
|
||||
} else if ph.kind == .fall {
|
||||
rd := @mv.rounds.items[ph.round];
|
||||
self.render_fall(ctx, rd, inset, dim, ph.t);
|
||||
self.render_fall(ctx, rd, ph.round, e, dim, ph.t);
|
||||
} else {
|
||||
// Settled tail of the timeline — draw the final (model) board. tick()
|
||||
// normally clears `active` before this is reached, so it is the seam
|
||||
// safety net rather than a frame the player typically sees.
|
||||
// Settled tail of the timeline — draw the final (model) board, still
|
||||
// carrying the final round's landing bounce so this rare safety-net
|
||||
// frame matches both the fall it follows and the render_gems hand-off
|
||||
// (which resumes the same back-dated stamp). tick() normally clears
|
||||
// `active` before this is reached.
|
||||
last := mv.rounds.len - 1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
g := mv.final[i];
|
||||
if g != .empty {
|
||||
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
|
||||
sq := self.rest_squash(i, last, e);
|
||||
gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
}
|
||||
}
|
||||
@@ -460,7 +493,7 @@ BoardView :: struct {
|
||||
|
||||
// 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) {
|
||||
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
|
||||
pop := clear_pop_scale(t);
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
g := rd.before[i];
|
||||
@@ -471,7 +504,12 @@ BoardView :: struct {
|
||||
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);
|
||||
// before[k] is round k-1's settled board, so a survivor here still
|
||||
// carries the bounce from the round that dropped it in — continue it
|
||||
// across the fall→clear seam (kmax = k-1). sq==0 for round 0's clear
|
||||
// (nothing has fallen yet), keeping that frame byte-identical.
|
||||
sq := self.rest_squash(i, k - 1, e);
|
||||
gf := self.gem_squash_frame(col, cast(f32) row, dim, sq);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
}
|
||||
}
|
||||
@@ -550,7 +588,7 @@ BoardView :: struct {
|
||||
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
|
||||
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its
|
||||
// cell and the seam to the next round / settled board stays invisible.
|
||||
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
|
||||
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
g := rd.after[i];
|
||||
if g == .empty { continue; }
|
||||
@@ -559,7 +597,12 @@ BoardView :: struct {
|
||||
src := rd.src[i];
|
||||
te := ease_in_cubic(fall_stagger_t(t, col));
|
||||
cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
|
||||
gf := self.gem_frame(cast(f32) col, cur_row, inset, dim);
|
||||
// Squash on landing: rest_squash ages the bounce from this column's
|
||||
// touch-down (kmax = k). A gem still falling reads a negative age → 0, so
|
||||
// in-flight gems stay byte-identical to the plain fall; only a gem that
|
||||
// has reached its cell flattens wide-and-short, then wobbles out.
|
||||
sq := self.rest_squash(i, k, e);
|
||||
gf := self.gem_squash_frame(col, cur_row, dim, sq);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
}
|
||||
}
|
||||
|
||||
20
gem_anim.sx
20
gem_anim.sx
@@ -12,6 +12,7 @@
|
||||
#import "modules/std.sx";
|
||||
#import "modules/math";
|
||||
#import "board.sx";
|
||||
#import "board_anim.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
|
||||
@@ -63,14 +64,15 @@ select_pop_scale :: (ts: f32) -> f32 {
|
||||
// --- 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).
|
||||
// The shape IS P15.1's `squash_envelope` (board_anim) over the normalized window
|
||||
// tl/LAND_DUR, scaled to a tasteful amplitude — so the per-round fall bounce and
|
||||
// this settle bounce are the EXACT same envelope (P17.3 drives both through here).
|
||||
LAND_DUR :f32: 0.42;
|
||||
LAND_SQUASH_A :f32: 0.13;
|
||||
LAND_OSC :f32: 1.5; // oscillations across the window
|
||||
LAND_SQUASH_A :f32: 0.18; // peak ~13% wide-and-short — reads on landing, still tasteful
|
||||
|
||||
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
|
||||
LAND_SQUASH_A * squash_envelope(tl / LAND_DUR)
|
||||
}
|
||||
|
||||
// --- Clear pop ---------------------------------------------------------------
|
||||
@@ -117,7 +119,15 @@ GemMotion :: struct {
|
||||
}
|
||||
|
||||
stamp_land :: (self: *GemMotion, i: s64) {
|
||||
self.land_at[i] = self.clock;
|
||||
self.stamp_land_at(i, self.clock);
|
||||
}
|
||||
|
||||
// Record cell `i`'s landing at an explicit clock value, so the settle hand-off
|
||||
// can BACK-DATE the stamp to when the gem actually touched down mid-fall (each
|
||||
// column lands at a staggered instant): land_squash then resumes the per-round
|
||||
// bounce exactly where render_fall left it, with no double-pop at the seam.
|
||||
stamp_land_at :: (self: *GemMotion, i: s64, at: f32) {
|
||||
self.land_at[i] = at;
|
||||
}
|
||||
|
||||
land_local :: (self: *GemMotion, i: s64) -> f32 {
|
||||
|
||||
BIN
goldens/p17_land.png
Normal file
BIN
goldens/p17_land.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
17
main.sx
17
main.sx
@@ -265,9 +265,24 @@ frame :: () {
|
||||
if !g_motion.pinned { g_motion.clock += g_delta_time; }
|
||||
if g_anim != null {
|
||||
if g_anim_prev_active and !g_anim.active {
|
||||
// On the frame the timeline settles, hand the final round's
|
||||
// per-column landing bounce to land_squash so render_gems resumes it
|
||||
// seamlessly. Each gem the LAST round delivered to cell i is
|
||||
// back-dated to when its column actually touched down — (1 -
|
||||
// fall_landing_frac)·FALL_ANIM_DUR ago — so the bounce picks up
|
||||
// exactly where render_fall left it: one bounce, no double-pop at the
|
||||
// render_anim → render_gems seam. A gem that settled in an earlier
|
||||
// round already bounced then (its back-dated age exceeds LAND_DUR, so
|
||||
// land_squash reads rest); a gem that never moved is skipped.
|
||||
mv := @g_anim.move;
|
||||
total := g_anim.total();
|
||||
last := mv.rounds.len - 1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if mv.pre[i] != mv.final[i] { g_motion.stamp_land(i); }
|
||||
m := delivering_round(mv, i, last);
|
||||
if m >= 0 {
|
||||
col := i % BOARD_COLS;
|
||||
g_motion.stamp_land_at(i, g_motion.clock - (total - round_land_time(m, col)));
|
||||
}
|
||||
}
|
||||
}
|
||||
g_anim_prev_active = g_anim.active;
|
||||
|
||||
@@ -176,6 +176,40 @@ main :: () -> s32 {
|
||||
if !stg_cascade { fails += 1; }
|
||||
if !stg_mono { fails += 1; }
|
||||
|
||||
// 7. Per-column landing instant (P17.3): `fall_landing_frac` is the LOCAL fall
|
||||
// progress at which each column finishes its drop — exactly the seam where
|
||||
// `fall_stagger_t` reaches 1, the moment the landing squash-bounce begins.
|
||||
// Lock: column 0 lands first at `1 - FALL_STAGGER_MAX`, the last column at
|
||||
// 1.0; it rises monotonically across columns; at that instant the column's
|
||||
// stagger progress IS 1 (landed) while a hair earlier it is still < 1 (in
|
||||
// air). `round_land_time` then maps it onto the move timeline — later for
|
||||
// each later column, and round k+1's first landing strictly after round k's
|
||||
// last — so the per-round bounces never run before their gems touch down.
|
||||
print("== landing instant ==\n");
|
||||
lf_first := approx(fall_landing_frac(0), 1.0 - FALL_STAGGER_MAX);
|
||||
lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0);
|
||||
lf_mono := true;
|
||||
lf_seam := true;
|
||||
for 0..BOARD_COLS: (c) {
|
||||
if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; }
|
||||
lf := fall_landing_frac(c);
|
||||
if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf
|
||||
if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before
|
||||
}
|
||||
rlt_col_mono := true;
|
||||
for 1..BOARD_COLS: (c) {
|
||||
if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; }
|
||||
}
|
||||
rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1);
|
||||
print("landing_first {} landing_last {} landing_mono {} landing_seam {} landtime_col_mono {} landtime_round_after {}\n",
|
||||
lf_first, lf_last, lf_mono, lf_seam, rlt_col_mono, rlt_round_after);
|
||||
if !lf_first { fails += 1; }
|
||||
if !lf_last { fails += 1; }
|
||||
if !lf_mono { fails += 1; }
|
||||
if !lf_seam { fails += 1; }
|
||||
if !rlt_col_mono { fails += 1; }
|
||||
if !rlt_round_after { fails += 1; }
|
||||
|
||||
if fails == 0 {
|
||||
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
|
||||
return 0;
|
||||
|
||||
@@ -10,4 +10,6 @@ squash_moves true squash_two_sided true squash_bounded true
|
||||
bounce_ends true peak_amp true peak_loc true overshoots true overshoot_bounded true settles true
|
||||
== fall stagger bounded ==
|
||||
stagger_t0 true stagger_t1 true stagger_cascade true stagger_mono true
|
||||
== landing instant ==
|
||||
landing_first true landing_last true landing_mono true landing_seam true landtime_col_mono true landtime_round_after true
|
||||
ok: easing toolkit endpoints locked + amplitudes bounded
|
||||
|
||||
Reference in New Issue
Block a user