use crate::app::{Screen, Sim, layout};
use bevy::asset::uuid_handle;
use bevy::prelude::*;
use bevy::text::FontCx;
use parlance::Script;
pub(super) fn setup_camera(mut commands: Commands) {
commands.spawn((
Camera2d,
bevy::camera::Hdr,
bevy::core_pipeline::tonemapping::Tonemapping::Linear,
grade(),
Msaa::Off,
));
}
pub(super) fn apply_antialiasing(
settings: Res<crate::app::settings::GameSettings>,
mut cameras: Query<&mut Msaa, crate::app::match_setup::GameCamera>,
) {
let want = if crate::app::dev::msaa_override().unwrap_or(settings.antialiasing) {
Msaa::Sample4
} else {
Msaa::Off
};
for mut msaa in &mut cameras {
msaa.set_if_neq(want);
}
}
pub(crate) fn grade() -> bevy::render::view::ColorGrading {
use bevy::render::view::{ColorGrading, ColorGradingGlobal, ColorGradingSection};
ColorGrading {
global: ColorGradingGlobal {
temperature: 0.01,
post_saturation: 1.06,
..default()
},
midtones: ColorGradingSection {
contrast: 1.04,
..default()
},
..default()
}
}
pub const ITALIC_FONT: Handle<Font> = uuid_handle!("9b451db2-8b49-4ad5-9794-ac1ce2ac943e");
pub const JP_FONT: Handle<Font> = uuid_handle!("6c2f0e33-3d4b-4d2e-9a58-6f2b2c9d4d71");
pub const DISPLAY_FONT: Handle<Font> = uuid_handle!("6cf5591f-337a-4b2e-bb6a-697aece4ab2b");
const JP_FAMILY: &str = "Noto Sans Mono CJK JP";
const JP_SCRIPTS: [Script; 3] = [
Script::from_bytes(*b"Hira"),
Script::from_bytes(*b"Kana"),
Script::from_bytes(*b"Hani"),
];
pub(super) fn install_ui_font(mut fonts: ResMut<Assets<Font>>) {
let upright = include_bytes!("../../assets/fonts/DejaVuSansMono.ttf");
if fonts
.insert(
&Handle::<Font>::default(),
Font::from_bytes(upright.to_vec()),
)
.is_err()
{
warn!("UI font failed to install; accented glyphs will be missing");
}
let slanted = include_bytes!("../../assets/fonts/DejaVuSansMono-Oblique.ttf");
if fonts
.insert(&ITALIC_FONT, Font::from_bytes(slanted.to_vec()))
.is_err()
{
warn!("italic UI font failed to install; notices will read upright");
}
let display = include_bytes!("../../assets/fonts/Nunito-ExtraBold.ttf");
if fonts
.insert(&DISPLAY_FONT, Font::from_bytes(display.to_vec()))
.is_err()
{
warn!("display font failed to install; headings will read in the UI face");
}
let kanji = include_bytes!("../../assets/fonts/NotoSansMonoCJKjp-Subset.otf");
if fonts
.insert(&JP_FONT, Font::from_bytes(kanji.to_vec()))
.is_err()
{
warn!("Japanese font failed to install; Japanese will draw blank");
}
}
pub(super) fn teach_the_kanji_fallback(
mut fonts: ResMut<FontCx>,
mut lines: Query<&mut TextFont>,
mut taught: Local<bool>,
) {
if *taught {
return;
}
let Some(family) = fonts.collection.family_id(JP_FAMILY) else {
return;
};
for script in JP_SCRIPTS {
fonts
.collection
.append_fallbacks(script, std::iter::once(family));
}
for mut line in &mut lines {
line.set_changed();
}
*taught = true;
}
struct View {
scale: f32,
offset: Vec2,
}
fn resting_view(window: &Window, ui: f32, screen: Screen, board: &crate::sim::Board) -> View {
let menu = crate::app::postcard_screen(screen);
let chrome = screen.chrome();
let (chrome_w, chrome_top, chrome_bottom) =
(chrome.width * ui, chrome.top * ui, chrome.bottom * ui);
let chrome_h = chrome_top + chrome_bottom;
let Vec2 {
x: board_w,
y: board_h,
} = layout::board_extent(board);
let fit_w = (window.width() - chrome_w).max(layout::TILE);
let fit_h = (window.height() - chrome_h).max(layout::TILE);
let scale = if menu {
1.0 / ui.max(f32::EPSILON)
} else {
board_scale(
Vec2::new(board_w, board_h),
Vec2::new(fit_w, fit_h),
window.scale_factor(),
)
};
let offset = Vec2::new(
0.0,
if menu {
0.0
} else {
(chrome_top - chrome_bottom) / 2.0
},
);
View { scale, offset }
}
pub(super) fn board_foot(
window: &Window,
ui: f32,
screen: Screen,
board: &crate::sim::Board,
) -> f32 {
let View { scale, offset } = resting_view(window, ui, screen, board);
let below_centre =
layout::board_extent(board).y / 2.0 + crate::app::board_render::FENCE_DEPTH / 2.0;
(window.height() / 2.0 + offset.y + below_centre / scale.max(f32::EPSILON))
/ ui.max(f32::EPSILON)
}
pub(super) fn fit_camera(
sim: Res<Sim>,
screen: Res<State<Screen>>,
ui_scale: Res<UiScale>,
windows: Query<&Window>,
mut cameras: Query<(&mut Projection, &mut Transform), crate::app::match_setup::GameCamera>,
) {
let Ok(window) = windows.single() else {
return;
};
let View { scale, offset } = resting_view(window, ui_scale.0, *screen.get(), &sim.0);
for (mut projection, mut transform) in &mut cameras {
if let Projection::Orthographic(ortho) = &mut *projection
&& (ortho.scale - scale).abs() > 0.001
{
ortho.scale = scale;
}
let target = Vec2::new(-offset.x, offset.y) * scale;
if transform.translation.truncate() != target {
transform.translation.x = target.x;
transform.translation.y = target.y;
}
}
}
fn board_scale(board: Vec2, fit: Vec2, scale_factor: f32) -> f32 {
let fitted = |board: Vec2| {
let rim = Vec2::splat(2.0 * crate::app::board_render::RIM);
let grid = board / fit;
let framed = (board + rim) / (fit + rim);
grid.max_element()
.max(framed.max_element())
.max(scale_factor / layout::MAX_ZOOM)
};
let small = fitted(layout::SMALLEST_FIT).min(1.0 / layout::SMALL_BOARD_ZOOM);
fitted(board).max(small)
}
pub(super) fn shake_camera(
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut trauma: ResMut<crate::app::effects::Trauma>,
mut cameras: Query<(&Projection, &mut Transform), crate::app::match_setup::GameCamera>,
) {
let offset = trauma.offset(time.delta_secs(), time.elapsed_secs());
if offset == Vec2::ZERO || settings.reduced_motion || crate::app::dev::screenshotting() {
return;
}
for (projection, mut transform) in &mut cameras {
let scale = match projection {
Projection::Orthographic(ortho) => ortho.scale,
Projection::Perspective(_) | Projection::Custom(_) => 1.0,
};
transform.translation.x += offset.x * scale;
transform.translation.y += offset.y * scale;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::board_render::RIM;
#[test]
fn antialiasing_leaves_the_preview_cameras_alone() {
use bevy::ecs::system::RunSystemOnce;
let mut world = World::new();
world.insert_resource(crate::app::settings::GameSettings {
antialiasing: true,
..crate::app::settings::GameSettings::default()
});
let game = world.spawn((Camera2d, Msaa::Off)).id();
let preview = world
.spawn((Camera2d, crate::app::match_setup::PreviewCamera, Msaa::Off))
.id();
world.run_system_once(apply_antialiasing).expect("runs");
assert_eq!(world.get::<Msaa>(game), Some(&Msaa::Sample4));
assert_eq!(world.get::<Msaa>(preview), Some(&Msaa::Off));
}
fn old_scale(board: Vec2, fit: Vec2) -> f32 {
(board / fit).max_element().max(0.8)
}
#[test]
fn a_720p_window_fits_as_it_did() {
let fit = Vec2::new(1280.0 - 490.0, 720.0 - 137.0);
let xl = Vec2::new(20.0, 13.0) * layout::TILE;
assert!(old_scale(xl, fit) > 1.0, "the XL beach is zoomed out");
assert_eq!(board_scale(xl, fit, 1.0), old_scale(xl, fit));
let classic = Vec2::new(12.0, 9.0) * layout::TILE;
let (new, old) = (board_scale(classic, fit, 1.0), old_scale(classic, fit));
assert!((new / old - 1.0).abs() < 0.01, "{new} against {old}");
}
#[test]
fn the_camera_comes_to_rest_where_it_started_after_a_shake() {
use crate::app::effects::Trauma;
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.insert_state(Screen::Versus);
app.insert_resource(Sim(crate::sim::classic_arena(false, 2)));
app.insert_resource(UiScale(1.0));
app.insert_resource(crate::app::settings::GameSettings::default());
app.init_resource::<Trauma>();
app.init_resource::<Time>();
app.world_mut().spawn(Window::default());
let camera = app.world_mut().spawn(Camera2d).id();
app.add_systems(Update, (fit_camera, shake_camera).chain());
let rest = |app: &App| {
app.world()
.get::<Transform>(camera)
.expect("the camera has a transform")
.translation
};
let frame = |app: &mut App| {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(16));
app.update();
};
frame(&mut app);
let home = rest(&app);
for _ in 0..40 {
app.world_mut().resource_mut::<Trauma>().add(0.08);
frame(&mut app);
}
for _ in 0..120 {
frame(&mut app);
}
assert_eq!(rest(&app), home, "back exactly where it rested");
}
#[test]
fn a_small_board_keeps_its_old_zoom_in_a_small_window() {
let fit = Vec2::new(1280.0 - 490.0, 720.0 - 137.0);
let first = Vec2::new(5.0, 3.0) * layout::TILE;
assert_eq!(board_scale(first, fit, 1.0), old_scale(first, fit));
}
#[test]
fn a_small_board_grows_no_further_than_the_classic_one() {
let fit = Vec2::new(1920.0 - 490.0, 1152.0 - 137.0);
let classic = board_scale(Vec2::new(12.0, 9.0) * layout::TILE, fit, 1.0);
for tiles in [
Vec2::new(5.0, 3.0),
Vec2::new(8.0, 6.0),
Vec2::new(12.0, 5.0),
] {
let scale = board_scale(tiles * layout::TILE, fit, 1.0);
assert_eq!(scale, classic, "{tiles}");
}
let long = board_scale(Vec2::new(20.0, 5.0) * layout::TILE, fit, 1.0);
assert!(long > classic, "a 20-wide board zooms out further: {long}");
}
#[test]
fn a_tile_stops_at_its_sprite() {
let board = Vec2::new(12.0, 9.0) * layout::TILE;
let fit = Vec2::new(6000.0, 4000.0);
for scale_factor in [1.0, 1.5, 2.0] {
let scale = board_scale(board, fit, scale_factor);
let physical = layout::TILE / scale * scale_factor;
assert!(
(physical - layout::SPRITE_PX).abs() < 0.01,
"{physical} px at {scale_factor}x"
);
}
}
#[test]
fn a_zoomed_board_keeps_its_rim_in_the_room_it_had() {
let board = Vec2::new(12.0, 9.0) * layout::TILE;
let fit = Vec2::new(1920.0 - 490.0, 1152.0 - 137.0);
let scale = board_scale(board, fit, 1.0);
assert!(scale < 0.8, "a 1080p-class window zooms past the old cap");
let drawn = (board + Vec2::splat(2.0 * RIM)) / scale;
let room = fit + Vec2::splat(2.0 * RIM);
assert!(
drawn.x <= room.x + 0.01 && drawn.y <= room.y + 0.01,
"{drawn} in {room}"
);
}
}