Compare commits
19 Commits
c232444ef0
...
v0.40.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c66baceb10 | |||
| 329f224ffd | |||
| 2fc190ee42 | |||
| 060efaee7b | |||
| ef599ffa17 | |||
| 22334e0dd5 | |||
| 942b9c2161 | |||
| fde863a4e4 | |||
| 0cf5fc4293 | |||
| 79ddfbc034 | |||
| 7919365775 | |||
| f88b6f61d0 | |||
| 189e0afd24 | |||
| a32d666751 | |||
| a5902ac0af | |||
| c3b83f30d1 | |||
| a46505fe45 | |||
| 1602f1952d | |||
| 81893788c1 |
@@ -47,6 +47,11 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste
|
||||
clicks, delayed first-run onboarding until splash teardown, and kept dragged
|
||||
stacks above all piles.
|
||||
- **Draw-Three waste fan hit testing on Android.** The renderer and the click
|
||||
hit-test now share a single `waste_fan_step` / `tableau_col_step` source. They
|
||||
previously diverged under Android's tighter column spacing, shifting the top
|
||||
fanned waste card's hit target onto the card beneath it, so dragging the visible
|
||||
card played the wrong one.
|
||||
- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface
|
||||
sizing, WebGL2 shader compatibility, and Firefox boot/render behavior.
|
||||
- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched
|
||||
|
||||
+7
-2
@@ -35,7 +35,7 @@ rm /tmp/cmdline-tools.zip
|
||||
echo ''
|
||||
echo '# Android dev'
|
||||
echo 'export ANDROID_HOME="$HOME/Android/Sdk"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.3.11579264"'
|
||||
echo 'export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/30.0.14904198"'
|
||||
echo 'export JAVA_HOME="$(dirname $(dirname $(readlink -f $(which java))))"'
|
||||
echo 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"'
|
||||
} >> ~/.bashrc
|
||||
@@ -49,10 +49,15 @@ sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"ndk;26.3.11579264" \
|
||||
"ndk;30.0.14904198" \
|
||||
"emulator" \
|
||||
"system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# The exact NDK/build-tools versions above are not load-bearing — newer ones
|
||||
# work (verified on NDK 30.0.14904198 / build-tools 37.0.0). `scripts/build_android_apk.sh`
|
||||
# auto-discovers the newest installed NDK and build-tools, so set ANDROID_NDK_HOME
|
||||
# (step 3) to whatever version you actually install here.
|
||||
|
||||
# 6. AVD for testing (one-time).
|
||||
echo no | avdmanager create avd \
|
||||
-n bevy_test \
|
||||
|
||||
@@ -63,6 +63,36 @@ pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||
|
||||
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
|
||||
///
|
||||
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
|
||||
/// rather than a fixed fraction of card width, so the fan scales with the
|
||||
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
|
||||
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
|
||||
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
|
||||
/// land on the card beneath it.
|
||||
pub fn waste_fan_step(layout: &Layout) -> f32 {
|
||||
tableau_col_step(layout) * 0.224
|
||||
}
|
||||
|
||||
/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
|
||||
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
|
||||
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
|
||||
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
|
||||
pub fn tableau_col_step(layout: &Layout) -> f32 {
|
||||
let t1 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let t2 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
(t2.x - t1.x).abs()
|
||||
}
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
|
||||
@@ -908,34 +938,18 @@ fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2,
|
||||
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||
];
|
||||
|
||||
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
||||
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
||||
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
||||
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||
// the top fanned card's centre within the waste column's own horizontal
|
||||
// footprint instead of spilling into the adjacent gap.
|
||||
let tableau_col_step = {
|
||||
let t1 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let t2 = layout
|
||||
.pile_positions
|
||||
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
(t2.x - t1.x).abs()
|
||||
};
|
||||
let waste_fan_step = tableau_col_step * 0.224;
|
||||
// Draw-Three waste fan step, proportional to the column spacing so it scales
|
||||
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
|
||||
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
|
||||
// click target on the card beneath it).
|
||||
let waste_fan_step = waste_fan_step(layout);
|
||||
|
||||
for (pile_type, is_stock_area) in piles {
|
||||
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||
continue;
|
||||
};
|
||||
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||
base.x -= tableau_col_step;
|
||||
base.x -= tableau_col_step(layout);
|
||||
}
|
||||
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! - `Esc` → handled by `PausePlugin` (overlay toggle + paused flag)
|
||||
//!
|
||||
//! Mouse:
|
||||
//! - Left-click on the stock pile (face-down deck) or waste slot → `DrawRequestEvent`
|
||||
//! - Left-click on the stock pile (face-down deck) → `DrawRequestEvent`
|
||||
//! (the waste card is left free to play: double-click to auto-move, or drag)
|
||||
//! - Left-press-drag-release on a face-up card → `MoveRequestEvent` between
|
||||
//! the origin pile and whatever pile the cursor is over at release.
|
||||
//! On rejection, the drag cards snap back to their origin via a
|
||||
@@ -34,7 +35,7 @@ use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::{
|
||||
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC,
|
||||
CardEntity, CardEntityIndex, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC, waste_fan_step,
|
||||
};
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::events::{
|
||||
@@ -536,8 +537,9 @@ fn handle_stock_click(
|
||||
|
||||
// `pile_positions[Stock]` is the waste column (col_x(1)). card_plugin renders the
|
||||
// face-down deck one column to the left via `base.x -= tableau_col_step`, placing it
|
||||
// at Tableau1's x (col_x(0)). Hit-test both the deck AND the waste slot: in standard
|
||||
// Klondike UX clicking either card draws from the deck.
|
||||
// at Tableau1's x (col_x(0)). Only the deck draws — clicking the waste card must
|
||||
// leave it free to be played (double-click to auto-move, or drag); hit-testing the
|
||||
// waste slot here would intercept that click and draw the next card instead.
|
||||
let Some(&waste_pos) = layout.0.pile_positions.get(&KlondikePile::Stock) else {
|
||||
return;
|
||||
};
|
||||
@@ -549,9 +551,7 @@ fn handle_stock_click(
|
||||
return;
|
||||
};
|
||||
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
||||
if point_in_rect(world, deck_pos, layout.0.card_size)
|
||||
|| point_in_rect(world, waste_pos, layout.0.card_size)
|
||||
{
|
||||
if point_in_rect(world, deck_pos, layout.0.card_size) {
|
||||
draw.write(DrawRequestEvent);
|
||||
}
|
||||
}
|
||||
@@ -596,9 +596,9 @@ fn handle_touch_stock_tap(
|
||||
continue;
|
||||
};
|
||||
let deck_pos = Vec2::new(t1_pos.x, waste_pos.y);
|
||||
if point_in_rect(world, deck_pos, layout.0.card_size)
|
||||
|| point_in_rect(world, waste_pos, layout.0.card_size)
|
||||
{
|
||||
// Only the face-down deck draws; tapping the waste card leaves it free to
|
||||
// play (double-tap to auto-move, or drag).
|
||||
if point_in_rect(world, deck_pos, layout.0.card_size) {
|
||||
draw.write(DrawRequestEvent);
|
||||
game_consumed.0 = true;
|
||||
break; // one draw per tap frame
|
||||
@@ -1175,12 +1175,15 @@ fn card_position(
|
||||
Vec2::new(base.x, base.y + y_offset)
|
||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
||||
// In Draw-Three mode the top 3 waste cards are fanned in X to match
|
||||
// card_plugin::card_positions(). Hit-testing must use the same offsets
|
||||
// so clicking the visually rightmost (top) card actually registers.
|
||||
// card_plugin::card_positions(). Hit-testing uses the same `waste_fan_step`
|
||||
// so clicking the visually rightmost (top) card actually registers — a
|
||||
// fixed `card_size.x * 0.28` matched the renderer on desktop but drifted
|
||||
// on Android (tighter column spacing), shifting the top card's hit target
|
||||
// onto the card beneath it.
|
||||
let pile_len = game.waste_cards().len();
|
||||
let visible_start = pile_len.saturating_sub(3);
|
||||
let slot = stack_index.saturating_sub(visible_start) as f32;
|
||||
Vec2::new(base.x + slot * layout.card_size.x * 0.28, base.y)
|
||||
Vec2::new(base.x + slot * waste_fan_step(layout), base.y)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
@@ -1829,7 +1832,7 @@ const _VEC3_REFERENCED: Option<Vec3> = None;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::compute_layout;
|
||||
use solitaire_core::{Foundation, Tableau};
|
||||
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||
|
||||
fn clear_test_piles(game: &mut GameState) {
|
||||
@@ -1910,6 +1913,90 @@ mod tests {
|
||||
assert_eq!(result.2.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||
// cards in the waste, clicking the visible top must pick the actual top
|
||||
// (last index), not the buffer card underneath it.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
let top_index = waste.len() - 1; // 2 = the visible top
|
||||
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||
assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_picks_lone_waste_card() {
|
||||
// "can't play the first card in the stock" — a waste of one card must
|
||||
// still be draggable.
|
||||
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
clear_test_piles(&mut game);
|
||||
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||
game.set_test_waste_cards(vec![card.clone()]);
|
||||
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||
assert_eq!(result.0, KlondikePile::Stock);
|
||||
assert_eq!(result.1, 0);
|
||||
assert_eq!(result.2, vec![card]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||
// fanned card's click target lands on the card beneath it — so dragging
|
||||
// the visible top card plays the wrong one.
|
||||
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
|
||||
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||
let cw = layout.card_size.x;
|
||||
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||
layout.pile_positions.insert(
|
||||
KlondikePile::Tableau(Tableau::Tableau2),
|
||||
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||
);
|
||||
|
||||
clear_test_piles(&mut game);
|
||||
let waste = vec![
|
||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||
];
|
||||
game.set_test_waste_cards(waste.clone());
|
||||
|
||||
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||
let top_index = waste.len() - 1;
|
||||
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||
|
||||
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||
assert!(
|
||||
(pos.x - expected).abs() < 1e-3,
|
||||
"hit-test must use the shared waste fan step"
|
||||
);
|
||||
// The old fixed constant would have drifted from the renderer here.
|
||||
let old = base.x + 2.0 * cw * 0.28;
|
||||
assert!(
|
||||
(pos.x - old).abs() > 1.0,
|
||||
"shared step must differ from the old fixed step under tight spacing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_draggable_skips_face_down_cards() {
|
||||
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||
|
||||
@@ -142,10 +142,32 @@ async function main() {
|
||||
const page = await context.newPage();
|
||||
const results = [];
|
||||
|
||||
// Load the page once, then reset each game in place via the bridge's
|
||||
// newGame(). A fresh page.goto() per game (hundreds of navigations in a
|
||||
// single browser context) accumulates resources and eventually makes
|
||||
// waitForFunction time out around game ~100. One load stays fast.
|
||||
await page.goto(`${baseUrl}/${route}`, { waitUntil: "domcontentloaded" });
|
||||
if (route === "play-classic") {
|
||||
const resumeVisible = await page
|
||||
.locator("#resume-overlay:not(.hidden)")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
typeof window.__FERROUS_DEBUG__.newGame === "function",
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
|
||||
for (let i = 0; i < games; i++) {
|
||||
const seed = i;
|
||||
const draw3 = i % 2 === 1;
|
||||
const suffix = draw3 ? "&draw3=" : "";
|
||||
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
@@ -158,27 +180,10 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrl}/${route}?seed=${seed}${suffix}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
if (route === "play-classic") {
|
||||
const resumeVisible = await page
|
||||
.locator("#resume-overlay:not(.hidden)")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (resumeVisible) {
|
||||
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__FERROUS_DEBUG__ === "object" &&
|
||||
window.__FERROUS_DEBUG__.seed() !== null,
|
||||
null,
|
||||
{ timeout: 30_000 }
|
||||
// Reset to a fresh seeded game without navigating.
|
||||
await page.evaluate(
|
||||
({ seed, draw3 }) => window.__FERROUS_DEBUG__.newGame(seed, draw3),
|
||||
{ seed, draw3 }
|
||||
);
|
||||
|
||||
const run = await page.evaluate(({ stepCap, policyName, maxVisits }) => {
|
||||
|
||||
@@ -203,7 +203,12 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
// and the wasm-bindgen-generated `web/pkg/`). The HTML page is the
|
||||
// same regardless of `:id` — it reads the path from `location` in JS
|
||||
// and fetches the replay JSON from `/api/replays/:id`.
|
||||
let web = Router::new()
|
||||
// HTML pages are `include_str!`'d into the binary and change on every
|
||||
// deploy, so they get `Cache-Control: no-cache` (always revalidate). The
|
||||
// `/web` + `/assets` static files keep ServeDir's default Last-Modified
|
||||
// caching — applying no-cache to *those* too made the e2e cycle gate's 240
|
||||
// page reloads recompile the wasm each time and time out.
|
||||
let html_pages = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|| async { Html(include_str!("../web/home.html")) }),
|
||||
@@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
"/replays",
|
||||
get(|| async { Html(include_str!("../web/replays.html")) }),
|
||||
)
|
||||
.layer(axum_middleware::from_fn(no_cache_headers));
|
||||
|
||||
let web = Router::new()
|
||||
.merge(html_pages)
|
||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||
.nest_service("/assets", ServeDir::new("assets"))
|
||||
.layer(axum_middleware::from_fn(security_headers));
|
||||
@@ -267,14 +276,18 @@ async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware:
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
|
||||
// Force revalidation of the web assets. The HTML pages are compiled into
|
||||
// the binary via `include_str!` and the wasm-bindgen output (canvas.js,
|
||||
// canvas_bg.wasm, solitaire_wasm.*) keeps fixed filenames that change in
|
||||
// place on every deploy. Without this, browsers heuristically cache them
|
||||
// and keep serving stale builds even after a hard reload. `no-cache` lets
|
||||
// the browser keep a copy but revalidate first; ServeDir supplies
|
||||
// Last-Modified/ETag so unchanged assets still return a cheap 304.
|
||||
headers.insert("Cache-Control", HeaderValue::from_static("no-cache"));
|
||||
res
|
||||
}
|
||||
|
||||
/// Adds `Cache-Control: no-cache` so the browser always revalidates before
|
||||
/// using a cached copy. Scoped to the `include_str!` HTML pages (which change
|
||||
/// on every deploy and have no validators) — not the ServeDir static assets,
|
||||
/// which keep normal Last-Modified caching so repeated page loads can reuse the
|
||||
/// already-downloaded/compiled wasm.
|
||||
async fn no_cache_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
|
||||
let mut res = next.run(req).await;
|
||||
res.headers_mut()
|
||||
.insert("Cache-Control", HeaderValue::from_static("no-cache"));
|
||||
res
|
||||
}
|
||||
|
||||
|
||||
@@ -994,6 +994,14 @@ window.__FERROUS_DEBUG__ = {
|
||||
serialize() {
|
||||
return game ? game.serialize() : null;
|
||||
},
|
||||
// Reset to a fresh seeded game in place (no page reload). Lets the cycle
|
||||
// regression harness reuse one page across hundreds of games instead of
|
||||
// navigating per game.
|
||||
newGame(seed, drawThreeMode) {
|
||||
drawThree = !!drawThreeMode;
|
||||
startGame(seed ?? randomSeed());
|
||||
return game ? game.state() : null;
|
||||
},
|
||||
applyLegalMove(index) {
|
||||
if (!game) return { ok: false, error: "game_not_ready" };
|
||||
const result = game.debug_apply_legal_move(index);
|
||||
|
||||
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114856, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114855, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9848, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9847, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9842, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9844, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9840, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9839, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
|
||||
return ret;
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -7,23 +7,10 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #000; overflow: hidden; }
|
||||
/* Cap the canvas at WebGL2's 2048 max texture dimension (this mirrors
|
||||
the Window `resize_constraints` in solitaire_web/src/lib.rs — winit
|
||||
applies that constraint as the canvas's own max-width/max-height).
|
||||
On wasm Bevy creates the device with downlevel_webgl2_defaults()
|
||||
whose max_texture_dimension_2d is a fixed 2048, so a larger surface
|
||||
(e.g. a 4K display at 150% scale → a 2560x1440 logical viewport)
|
||||
makes Surface::configure panic. `max-*` directly on the canvas keeps
|
||||
the surface ≤ 2048 regardless of how fit_canvas_to_parent resolves
|
||||
its 100% width; `margin: 0 auto` centres the letterbox horizontally. */
|
||||
#bevy-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 2048px;
|
||||
max-height: 2048px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* No size cap: the wgpu device now takes its max_texture_dimension from
|
||||
the adapter (see solitaire_web/src/lib.rs), so the surface can match
|
||||
the full viewport. fit_canvas_to_parent sizes the canvas to 100%. */
|
||||
#bevy-canvas { display: block; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+19
-26
@@ -13,7 +13,7 @@ use bevy::asset::AssetMetaCheck;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::RenderPlugin;
|
||||
use bevy::render::settings::{RenderCreation, WgpuSettings, WgpuSettingsPriority};
|
||||
use bevy::window::{Window, WindowPlugin, WindowResizeConstraints, WindowResolution};
|
||||
use bevy::window::{Window, WindowPlugin, WindowResolution};
|
||||
use solitaire_data::LocalOnlyProvider;
|
||||
use solitaire_engine::CoreGamePlugin;
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -34,27 +34,15 @@ pub fn start() {
|
||||
fit_canvas_to_parent: true,
|
||||
// Prevent the browser stealing keyboard events and scroll.
|
||||
prevent_default_event_handling: true,
|
||||
// Force scale_factor = 1.0 so the wgpu surface is sized in
|
||||
// CSS/logical pixels rather than physical pixels. Without this,
|
||||
// HiDPI displays (devicePixelRatio ≥ 2) produce a framebuffer
|
||||
// whose physical width can exceed WebGL2's 2048-pixel per-
|
||||
// dimension limit, causing a wgpu validation panic on the first
|
||||
// resize event and killing the WASM thread.
|
||||
// Render at CSS/logical pixels (scale_factor 1.0) rather
|
||||
// than physical (CSS × devicePixelRatio). This keeps the
|
||||
// surface smaller on HiDPI displays — lighter GPU load and
|
||||
// stable sizing — at the cost of some crispness. The wgpu
|
||||
// texture-dimension limit is now taken from the adapter (see
|
||||
// the RenderPlugin below), so this is purely a quality/perf
|
||||
// choice, no longer a crash-avoidance hack.
|
||||
resolution: WindowResolution::default()
|
||||
.with_scale_factor_override(1.0),
|
||||
// Cap the surface at WebGL2's max texture dimension (2048).
|
||||
// On wasm Bevy creates the device with
|
||||
// `Limits::downlevel_webgl2_defaults()` (max_texture_dimension_2d
|
||||
// = 2048), so a larger surface — e.g. a 4K display at 150% scale
|
||||
// gives a 2560x1440 logical viewport — makes Surface::configure
|
||||
// panic on the first frame. winit maps this constraint to the
|
||||
// canvas's `max-width`/`max-height` style, so the surface can
|
||||
// never exceed it. Viewports wider/taller than 2048 letterbox.
|
||||
resize_constraints: WindowResizeConstraints {
|
||||
max_width: 2048.0,
|
||||
max_height: 2048.0,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
@@ -66,14 +54,19 @@ pub fn start() {
|
||||
meta_check: AssetMetaCheck::Never,
|
||||
..default()
|
||||
})
|
||||
// WebGL2 priority constrains naga (the shader translator) to emit
|
||||
// GLES 300es-compatible GLSL. Without this, Chromium's ANGLE driver
|
||||
// rejects certain shader constructs (storage buffers, tight component
|
||||
// limits) causing a fatal wgpu "Shader translation error". Firefox is
|
||||
// more lenient; this setting makes both browsers work identically.
|
||||
// `Functionality` makes wgpu adopt the *adapter's* real limits
|
||||
// instead of the conservative `downlevel_webgl2_defaults()` that
|
||||
// `WebGL2` priority forces. On the WebGL2 (Gl) backend the adapter
|
||||
// already reports WebGL2-constrained features/limits — no storage
|
||||
// buffers, etc., so shaders stay GLES-compatible on both Firefox and
|
||||
// Chromium — but it reports the GPU's *true* `max_texture_dimension`
|
||||
// (e.g. 16384) rather than 2048. The device is requested with exactly
|
||||
// what the adapter offers, so creation can't fail, and the surface is
|
||||
// no longer capped at 2048: large viewports (4K, etc.) render natively
|
||||
// with no letterbox and no hardcoded cap.
|
||||
.set(RenderPlugin {
|
||||
render_creation: RenderCreation::Automatic(WgpuSettings {
|
||||
priority: WgpuSettingsPriority::WebGL2,
|
||||
priority: WgpuSettingsPriority::Functionality,
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
|
||||
Reference in New Issue
Block a user