Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec94cb34aa | |||
| 40768f3b0a | |||
| 2186f55913 | |||
| e0f369d322 | |||
| ea98774ccb | |||
| ea9dd848fd | |||
| a328059933 |
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v0.36.2)'
|
||||
required: true
|
||||
default: 'v0.36.2'
|
||||
|
||||
env:
|
||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||
@@ -42,7 +48,12 @@ jobs:
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
echo "name=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Decode release keystore
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||
|
||||
@@ -355,7 +355,7 @@ Must always be handled explicitly:
|
||||
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||
avoid placing interactive elements in that zone
|
||||
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
||||
* `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
|
||||
layout constants are `#[cfg(target_os = "android")]` gated
|
||||
* JNI calls must use `attach_current_thread_permanently` — not
|
||||
`attach_current_thread` — to avoid detach-on-drop panics
|
||||
|
||||
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: 7840ef9e
|
||||
newTag: ea9dd848
|
||||
|
||||
@@ -9,9 +9,11 @@ use crate::pile::PileType;
|
||||
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||
match to {
|
||||
PileType::Foundation(_) => 10,
|
||||
PileType::Tableau(_) => {
|
||||
if matches!(from, PileType::Waste) { 5 } else { 0 }
|
||||
}
|
||||
PileType::Tableau(_) => match from {
|
||||
PileType::Waste => 5,
|
||||
PileType::Foundation(_) => -15,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -71,13 +73,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// Foundation → Tableau is impossible in practice but must score 0.
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||
// Tableau → Tableau (restack) scores 0.
|
||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
||||
fn foundation_to_tableau_penalises_fifteen() {
|
||||
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn move_to_stock_or_waste_scores_zero() {
|
||||
// These destinations are illegal moves in practice, but the function
|
||||
|
||||
@@ -298,9 +298,16 @@ impl SolverState {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every foundation slot has 13 cards.
|
||||
/// True when every foundation slot holds a complete Ace-through-King sequence.
|
||||
fn is_won(&self) -> bool {
|
||||
self.foundation.iter().all(|f| f.len() == 13)
|
||||
self.foundation.iter().all(|pile| {
|
||||
pile.len() == 13
|
||||
&& pile[0].rank == crate::card::Rank::Ace
|
||||
&& pile.windows(2).all(|w| {
|
||||
w[0].suit == w[1].suit
|
||||
&& w[1].rank.value() == w[0].rank.value() + 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the foundation slot that already claims `suit`, or the
|
||||
|
||||
@@ -258,6 +258,11 @@ fn advance_card_anims(
|
||||
anim.delay = (anim.delay - dt).max(0.0);
|
||||
continue;
|
||||
}
|
||||
if anim.duration <= 0.0 {
|
||||
transform.translation = anim.target;
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
continue;
|
||||
}
|
||||
anim.elapsed += dt;
|
||||
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Volume amplitude used for the auto-complete activation chime.
|
||||
@@ -111,11 +112,15 @@ fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
game: Res<GameStateResource>,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.cooldown -= time.delta_secs();
|
||||
if state.cooldown > 0.0 {
|
||||
|
||||
@@ -41,7 +41,9 @@ use crate::ui_theme::{
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
|
||||
/// dynamic update from `update_tableau_fan_frac` produce identical spacing.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
|
||||
|
||||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
|
||||
@@ -51,18 +53,22 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// renderer creates a visible offset between the card face and where
|
||||
/// clicks land.
|
||||
///
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||
/// used at runtime, while this one is the minimum floor used by
|
||||
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||
/// `Transform.translation.z` for a card at a given stack index without
|
||||
/// drifting from the value used by [`card_positions`].
|
||||
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||||
// Android corner label) so every card's sprite covers all children of the
|
||||
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||||
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
@@ -1141,11 +1147,13 @@ fn add_android_corner_label(
|
||||
let bg_w = font_size * 2.0;
|
||||
let bg_h = font_size * 1.25;
|
||||
|
||||
// Solid background that hides the card art's small corner label.
|
||||
// Background covers the PNG's baked-in small corner text.
|
||||
// Classic PNG cards have a white face, so the background must be white too.
|
||||
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: CARD_FACE_COLOUR,
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
@@ -1159,6 +1167,22 @@ fn add_android_corner_label(
|
||||
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||
//
|
||||
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||
// Terminal theme background.
|
||||
let text_col = if card.suit.is_red() {
|
||||
if color_blind {
|
||||
RED_SUIT_COLOUR_CBM
|
||||
} else if high_contrast {
|
||||
RED_SUIT_COLOUR_HC
|
||||
} else {
|
||||
RED_SUIT_COLOUR
|
||||
}
|
||||
} else {
|
||||
CARD_FACE_COLOUR
|
||||
};
|
||||
let label_text = mobile_label_for(card);
|
||||
parent.spawn((
|
||||
AndroidCornerLabel(label_text.clone()),
|
||||
@@ -1169,7 +1193,7 @@ fn add_android_corner_label(
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
TextColor(text_col),
|
||||
Anchor::TOP_LEFT,
|
||||
Transform::from_xyz(
|
||||
-card_size.x / 2.0 + inset,
|
||||
|
||||
@@ -140,6 +140,12 @@ pub struct HudColumn;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudActionBar;
|
||||
|
||||
/// Marker on the text node inside each action-bar button (Android only).
|
||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Component, Debug)]
|
||||
struct ActionButtonLabel;
|
||||
|
||||
/// Marker on the circular profile-picture button anchored to the
|
||||
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
||||
/// Shows the server avatar image when loaded; falls back to the player's
|
||||
@@ -489,6 +495,11 @@ impl Plugin for HudPlugin {
|
||||
.after(TouchDragSet::AfterStartDrag)
|
||||
.in_set(TouchDragSet::BeforeEndDrag),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
resize_action_bar_labels
|
||||
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,11 +854,25 @@ fn handle_avatar_button(
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
windows: Query<&Window>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
// On Android the glyph labels must scale with the viewport so they remain
|
||||
// legible on any screen density. Use the window width at startup; the
|
||||
// resize_action_bar_labels system keeps this current on window changes.
|
||||
#[cfg(target_os = "android")]
|
||||
let action_font_size = {
|
||||
let w = windows.iter().next().map_or(900.0, |win| win.width());
|
||||
action_bar_font_size(w)
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let action_font_size = TYPE_BODY;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _windows = windows;
|
||||
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
font_size: action_font_size,
|
||||
..default()
|
||||
};
|
||||
|
||||
@@ -992,6 +1017,9 @@ fn spawn_action_button<M: Component>(
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
#[cfg(target_os = "android")]
|
||||
b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||
if let Some(key) = hotkey {
|
||||
// Hotkey hint rendered as a dim caption next to the label —
|
||||
@@ -2483,6 +2511,32 @@ fn restore_hud_on_modal(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the action-bar glyph font size for a given logical window width.
|
||||
/// Scales linearly so glyphs remain legible at any phone density.
|
||||
#[cfg(target_os = "android")]
|
||||
fn action_bar_font_size(window_width: f32) -> f32 {
|
||||
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
||||
// Clamped so it never goes too tiny on narrow viewports or too large
|
||||
// on landscape tablets.
|
||||
(window_width / 40.0).clamp(16.0, 30.0)
|
||||
}
|
||||
|
||||
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||
/// change or window resize).
|
||||
#[cfg(target_os = "android")]
|
||||
fn resize_action_bar_labels(
|
||||
layout: Res<crate::layout::LayoutResource>,
|
||||
windows: Query<&Window>,
|
||||
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||
) {
|
||||
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||
let new_size = action_bar_font_size(w);
|
||||
for mut font in &mut labels {
|
||||
font.font_size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn toggle_hud_on_tap(
|
||||
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
||||
|
||||
@@ -96,13 +96,32 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||
/// Android: 80 px gives the same content rows comfortable clearance.
|
||||
/// (Previously 128 px when action buttons lived in the top band; those are
|
||||
/// now in the bottom bar so the larger reserve is no longer needed.)
|
||||
/// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
|
||||
/// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
|
||||
/// still contributing gap height in Bevy's flex layout, the actual HUD
|
||||
/// height can reach ~80 px before the grid starts; 112 px gives ~28 px
|
||||
/// of clearance between the HUD bottom and the top card edge, preventing
|
||||
/// the overlap seen with the previous 80 px value.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
#[cfg(target_os = "android")]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 80.0;
|
||||
pub const HUD_BAND_HEIGHT: f32 = 112.0;
|
||||
|
||||
/// Height of the bottom action-bar (the row of ≡ ← || ? ! M + buttons).
|
||||
///
|
||||
/// The action bar sits *above* the OS gesture/navigation zone, so it is NOT
|
||||
/// covered by `safe_area_bottom`. `compute_layout` adds this constant to
|
||||
/// `safe_area_bottom` before computing the height-based card-size candidate
|
||||
/// and the available tableau height, ensuring the deepest fanned column
|
||||
/// never scrolls behind the button row.
|
||||
///
|
||||
/// Derivation (Android): `min_height 44 px` buttons
|
||||
/// + `padding.top 8 px` + `padding.bottom 8 px` outer bar padding = **60 px**.
|
||||
/// Desktop: no persistent bottom bar, so 0.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const BOTTOM_BAR_HEIGHT: f32 = 0.0;
|
||||
#[cfg(target_os = "android")]
|
||||
const BOTTOM_BAR_HEIGHT: f32 = 60.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
@@ -123,7 +142,7 @@ pub struct Layout {
|
||||
pub pile_positions: HashMap<PileType, Vec2>,
|
||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
||||
/// windows it expands to fill the available vertical space so the tableau
|
||||
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
||||
/// and hit testing (`input_plugin`) both read from this field so they
|
||||
@@ -187,9 +206,13 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
||||
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
||||
// largest w that fits gives:
|
||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||
// Reserve space for both the OS gesture/nav bar and the app's own action
|
||||
// bar, which sits above it and is invisible to safe_area_bottom.
|
||||
let effective_safe_bottom = safe_area_bottom + BOTTOM_BAR_HEIGHT;
|
||||
|
||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom;
|
||||
let card_width_height_based = (window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -238,7 +261,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
||||
//
|
||||
// avail = distance from the top of the first tableau card to the bottom
|
||||
// margin — i.e. the space available for 12 fan steps.
|
||||
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let ideal_fan_frac = if card_height > 0.0 {
|
||||
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||
} else {
|
||||
|
||||
@@ -139,6 +139,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<DisplayNameBuffer>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||
|
||||
@@ -138,12 +138,13 @@ fn handle_open_dialog(
|
||||
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
existing: Query<(), With<PlayBySeedScreen>>,
|
||||
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||
) {
|
||||
if requests.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
// Guard against double-spawn (e.g. two events in one frame).
|
||||
if !existing.is_empty() {
|
||||
// Guard against double-spawn (e.g. two events in one frame) or stacking over another modal.
|
||||
if !existing.is_empty() || !other_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
let font = font_res.as_deref();
|
||||
|
||||
@@ -512,6 +512,7 @@ pub struct ReplayPlaybackPlugin;
|
||||
impl Plugin for ReplayPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ReplayPlaybackState>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -305,7 +305,7 @@ fn update_field_borders(
|
||||
fn handle_auth_button(
|
||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
@@ -392,6 +392,14 @@ fn handle_auth_button(
|
||||
pending.task = Some(task);
|
||||
pending.url = url;
|
||||
pending.username = username;
|
||||
|
||||
// Zero the password buffer immediately — it must not linger in ECS
|
||||
// components after the credential has been handed off to the async task.
|
||||
for (kind, mut buf) in &mut fields {
|
||||
if *kind == SyncFieldKind::Password {
|
||||
buf.0.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the in-flight auth task. On success updates settings + provider.
|
||||
|
||||
@@ -508,7 +508,7 @@ fn collect_session_achievements(
|
||||
) {
|
||||
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
||||
// achievements from the previous session are not carried into the next one.
|
||||
if new_games.read().last().is_some() {
|
||||
if new_games.read().next().is_some() {
|
||||
session.names.clear();
|
||||
}
|
||||
for ev in unlocks.read() {
|
||||
@@ -539,6 +539,7 @@ fn spawn_win_summary_after_delay(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
time: Res<Time>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
|
||||
mut delay: Local<Option<f32>>,
|
||||
) {
|
||||
// Process new win events.
|
||||
@@ -569,8 +570,8 @@ fn spawn_win_summary_after_delay(
|
||||
*remaining -= time.delta_secs();
|
||||
if *remaining <= 0.0 {
|
||||
*delay = None;
|
||||
// Only spawn if there is no overlay already.
|
||||
if overlays.is_empty() {
|
||||
// Only spawn if no overlay of any kind is already visible.
|
||||
if overlays.is_empty() && other_scrims.is_empty() {
|
||||
// Drain any XpAwardedEvents that arrived this frame but were
|
||||
// not yet consumed by `cache_win_data` (which may run later in
|
||||
// the same schedule). Accumulating here ensures the modal
|
||||
|
||||
@@ -341,8 +341,6 @@ pub async fn get_me(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Allowed MIME types for uploaded avatars.
|
||||
const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
/// Maximum avatar upload size in bytes (1 MB).
|
||||
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
@@ -361,23 +359,15 @@ pub async fn upload_avatar(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
|
||||
"jpg"
|
||||
} else if mime.contains("png") {
|
||||
"png"
|
||||
} else if mime.contains("webp") {
|
||||
"webp"
|
||||
} else if mime.contains("gif") {
|
||||
"gif"
|
||||
} else {
|
||||
return Err(AppError::BadRequest(
|
||||
let ext = match mime.as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => return Err(AppError::BadRequest(
|
||||
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||||
));
|
||||
)),
|
||||
};
|
||||
|
||||
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
|
||||
return Err(AppError::BadRequest("unsupported image type".into()));
|
||||
}
|
||||
if body.len() > AVATAR_MAX_BYTES {
|
||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user