implements PLN-0013

This commit is contained in:
bQUARKz 2026-04-13 20:29:23 +01:00
parent d9e680082d
commit 2d2c320638
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
3 changed files with 538 additions and 1 deletions

View File

@ -5,7 +5,7 @@ pub const GLYPH_BANK_PALETTE_COUNT_V1: usize = 64;
pub const GLYPH_BANK_COLORS_PER_PALETTE: usize = 16;
/// Standard sizes for square tiles.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum TileSize {
/// 8x8 pixels.
Size8 = 8,

View File

@ -21,6 +21,7 @@ pub mod sample;
pub mod scene_bank;
pub mod scene_layer;
pub mod scene_viewport_cache;
pub mod scene_viewport_resolver;
pub mod sound_bank;
pub mod sprite;
pub mod syscalls;

View File

@ -0,0 +1,536 @@
use crate::glyph_bank::TileSize;
use crate::scene_bank::SceneBank;
use crate::scene_viewport_cache::ViewportRegion;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TileAnchor {
pub x: i32,
pub y: i32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheRefreshRequest {
InvalidateLayer { layer_index: usize },
RefreshLine { layer_index: usize, cache_y: usize },
RefreshColumn { layer_index: usize, cache_x: usize },
RefreshRegion { layer_index: usize, region: ViewportRegion },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LayerCopyRequest {
pub layer_index: usize,
pub tile_size: TileSize,
pub viewport_width_px: i32,
pub viewport_height_px: i32,
pub source_offset_x_px: i32,
pub source_offset_y_px: i32,
pub cache_origin_tile_x: i32,
pub cache_origin_tile_y: i32,
pub camera_x_px: i32,
pub camera_y_px: i32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolverUpdate {
pub master_anchor: TileAnchor,
pub layer_anchors: [TileAnchor; 4],
pub refresh_requests: Vec<CacheRefreshRequest>,
pub copy_requests: [LayerCopyRequest; 4],
}
#[derive(Clone, Debug)]
pub struct SceneViewportResolver {
viewport_width_px: i32,
viewport_height_px: i32,
cache_width_tiles: usize,
cache_height_tiles: usize,
hysteresis_safe_px: i32,
hysteresis_trigger_px: i32,
initialized: bool,
master_anchor: TileAnchor,
layer_anchors: [TileAnchor; 4],
}
impl SceneViewportResolver {
pub fn new(
viewport_width_px: i32,
viewport_height_px: i32,
cache_width_tiles: usize,
cache_height_tiles: usize,
hysteresis_safe_px: i32,
hysteresis_trigger_px: i32,
) -> Self {
Self {
viewport_width_px,
viewport_height_px,
cache_width_tiles,
cache_height_tiles,
hysteresis_safe_px,
hysteresis_trigger_px,
initialized: false,
master_anchor: TileAnchor::default(),
layer_anchors: [TileAnchor::default(); 4],
}
}
pub fn reset_scene(&mut self) -> Vec<CacheRefreshRequest> {
self.initialized = false;
vec![
CacheRefreshRequest::InvalidateLayer { layer_index: 0 },
CacheRefreshRequest::InvalidateLayer { layer_index: 1 },
CacheRefreshRequest::InvalidateLayer { layer_index: 2 },
CacheRefreshRequest::InvalidateLayer { layer_index: 3 },
]
}
pub fn update(
&mut self,
scene: &SceneBank,
camera_x_px: i32,
camera_y_px: i32,
) -> ResolverUpdate {
let mut refresh_requests = Vec::new();
let camera_center_x = camera_x_px + self.viewport_width_px / 2;
let camera_center_y = camera_y_px + self.viewport_height_px / 2;
let layer_inputs: [(i32, i32, i32, i32, i32); 4] = std::array::from_fn(|i| {
let layer = &scene.layers[i];
let tile_size_px = layer.tile_size as i32;
let layer_camera_x_px = ((camera_x_px as f32) * layer.motion_factor.x).floor() as i32;
let layer_camera_y_px = ((camera_y_px as f32) * layer.motion_factor.y).floor() as i32;
let layer_center_x_px = layer_camera_x_px + self.viewport_width_px / 2;
let layer_center_y_px = layer_camera_y_px + self.viewport_height_px / 2;
(
tile_size_px,
layer_camera_x_px,
layer_camera_y_px,
layer_center_x_px,
layer_center_y_px,
)
});
if !self.initialized {
self.master_anchor = self.initial_anchor(
scene.layers[0].tilemap.width,
scene.layers[0].tilemap.height,
scene.layers[0].tile_size as i32,
camera_center_x,
camera_center_y,
);
for (layer_index, layer) in scene.layers.iter().enumerate() {
let (_, _, _, layer_center_x_px, layer_center_y_px) = layer_inputs[layer_index];
self.layer_anchors[layer_index] = self.initial_anchor(
layer.tilemap.width,
layer.tilemap.height,
layer.tile_size as i32,
layer_center_x_px,
layer_center_y_px,
);
refresh_requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(
0,
0,
self.cache_width_tiles,
self.cache_height_tiles,
),
});
}
self.initialized = true;
} else {
let layer0 = &scene.layers[0];
self.master_anchor = self.advance_anchor(
self.master_anchor,
camera_center_x,
camera_center_y,
layer0.tile_size as i32,
layer0.tilemap.width,
layer0.tilemap.height,
);
for (layer_index, layer) in scene.layers.iter().enumerate() {
let previous = self.layer_anchors[layer_index];
let (tile_size_px, _, _, layer_center_x_px, layer_center_y_px) =
layer_inputs[layer_index];
let next = self.advance_anchor(
previous,
layer_center_x_px,
layer_center_y_px,
tile_size_px,
layer.tilemap.width,
layer.tilemap.height,
);
self.layer_anchors[layer_index] = next;
self.emit_refresh_requests(layer_index, previous, next, &mut refresh_requests);
}
}
let copy_requests = std::array::from_fn(|layer_index| {
let layer = &scene.layers[layer_index];
let (tile_size_px, layer_camera_x_px, layer_camera_y_px, _, _) =
layer_inputs[layer_index];
let anchor = self.layer_anchors[layer_index];
let cache_origin_tile_x = anchor.x - (self.cache_width_tiles as i32 / 2);
let cache_origin_tile_y = anchor.y - (self.cache_height_tiles as i32 / 2);
let cache_origin_x_px = cache_origin_tile_x * tile_size_px;
let cache_origin_y_px = cache_origin_tile_y * tile_size_px;
LayerCopyRequest {
layer_index,
tile_size: layer.tile_size,
viewport_width_px: self.viewport_width_px,
viewport_height_px: self.viewport_height_px,
source_offset_x_px: layer_camera_x_px - cache_origin_x_px,
source_offset_y_px: layer_camera_y_px - cache_origin_y_px,
cache_origin_tile_x,
cache_origin_tile_y,
camera_x_px: layer_camera_x_px,
camera_y_px: layer_camera_y_px,
}
});
ResolverUpdate {
master_anchor: self.master_anchor,
layer_anchors: self.layer_anchors,
refresh_requests,
copy_requests,
}
}
fn initial_anchor(
&self,
scene_width_tiles: usize,
scene_height_tiles: usize,
tile_size_px: i32,
camera_center_x_px: i32,
camera_center_y_px: i32,
) -> TileAnchor {
let proposed = TileAnchor {
x: camera_center_x_px.div_euclid(tile_size_px),
y: camera_center_y_px.div_euclid(tile_size_px),
};
self.clamp_anchor(proposed, scene_width_tiles, scene_height_tiles)
}
fn advance_anchor(
&self,
current: TileAnchor,
camera_center_x_px: i32,
camera_center_y_px: i32,
tile_size_px: i32,
scene_width_tiles: usize,
scene_height_tiles: usize,
) -> TileAnchor {
let mut next = current;
loop {
let center_x_px = next.x * tile_size_px + tile_size_px / 2;
let drift_x = camera_center_x_px - center_x_px;
if drift_x.abs() <= self.hysteresis_safe_px {
break;
}
if drift_x > self.hysteresis_trigger_px {
next.x += 1;
continue;
}
if drift_x < -self.hysteresis_trigger_px {
next.x -= 1;
continue;
}
break;
}
loop {
let center_y_px = next.y * tile_size_px + tile_size_px / 2;
let drift_y = camera_center_y_px - center_y_px;
if drift_y.abs() <= self.hysteresis_safe_px {
break;
}
if drift_y > self.hysteresis_trigger_px {
next.y += 1;
continue;
}
if drift_y < -self.hysteresis_trigger_px {
next.y -= 1;
continue;
}
break;
}
self.clamp_anchor(next, scene_width_tiles, scene_height_tiles)
}
fn clamp_anchor(
&self,
proposed: TileAnchor,
scene_width_tiles: usize,
scene_height_tiles: usize,
) -> TileAnchor {
TileAnchor {
x: self.clamp_axis(proposed.x, scene_width_tiles, self.cache_width_tiles),
y: self.clamp_axis(proposed.y, scene_height_tiles, self.cache_height_tiles),
}
}
fn clamp_axis(&self, proposed: i32, scene_tiles: usize, cache_tiles: usize) -> i32 {
let half = (cache_tiles / 2) as i32;
if scene_tiles <= cache_tiles {
return half;
}
let min = half;
let max = (scene_tiles - cache_tiles) as i32 + half;
proposed.clamp(min, max)
}
fn emit_refresh_requests(
&self,
layer_index: usize,
previous: TileAnchor,
next: TileAnchor,
requests: &mut Vec<CacheRefreshRequest>,
) {
let delta_x = next.x - previous.x;
let delta_y = next.y - previous.y;
if delta_x == 0 && delta_y == 0 {
return;
}
if delta_x == 0 {
self.emit_line_requests(layer_index, delta_y, requests);
return;
}
if delta_y == 0 {
self.emit_column_requests(layer_index, delta_x, requests);
return;
}
self.emit_corner_region_requests(layer_index, delta_x, delta_y, requests);
}
fn emit_line_requests(
&self,
layer_index: usize,
delta_y: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let count = delta_y.unsigned_abs() as usize;
if delta_y > 0 {
for offset in 0..count {
requests.push(CacheRefreshRequest::RefreshLine {
layer_index,
cache_y: self.cache_height_tiles - count + offset,
});
}
} else {
for cache_y in 0..count {
requests.push(CacheRefreshRequest::RefreshLine { layer_index, cache_y });
}
}
}
fn emit_column_requests(
&self,
layer_index: usize,
delta_x: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let count = delta_x.unsigned_abs() as usize;
if delta_x > 0 {
for offset in 0..count {
requests.push(CacheRefreshRequest::RefreshColumn {
layer_index,
cache_x: self.cache_width_tiles - count + offset,
});
}
} else {
for cache_x in 0..count {
requests.push(CacheRefreshRequest::RefreshColumn { layer_index, cache_x });
}
}
}
fn emit_corner_region_requests(
&self,
layer_index: usize,
delta_x: i32,
delta_y: i32,
requests: &mut Vec<CacheRefreshRequest>,
) {
let width = delta_x.unsigned_abs() as usize;
let height = delta_y.unsigned_abs() as usize;
let primary_x = if delta_x > 0 { self.cache_width_tiles - width } else { 0 };
requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(primary_x, 0, width, self.cache_height_tiles),
});
let secondary_y = if delta_y > 0 { self.cache_height_tiles - height } else { 0 };
let secondary_x = if delta_x > 0 { 0 } else { width };
let secondary_width = self.cache_width_tiles.saturating_sub(width);
if secondary_width > 0 {
requests.push(CacheRefreshRequest::RefreshRegion {
layer_index,
region: ViewportRegion::new(secondary_x, secondary_y, secondary_width, height),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glyph::Glyph;
use crate::glyph_bank::TileSize;
use crate::scene_layer::{MotionFactor, SceneLayer};
use crate::tile::Tile;
use crate::tilemap::TileMap;
fn make_layer(
tile_size: TileSize,
motion_x: f32,
motion_y: f32,
width: usize,
height: usize,
) -> SceneLayer {
let mut tiles = Vec::new();
for i in 0..(width * height) {
tiles.push(Tile {
active: true,
glyph: Glyph { glyph_id: i as u16, palette_id: 0 },
flip_x: false,
flip_y: false,
});
}
SceneLayer {
active: true,
glyph_bank_id: 1,
tile_size,
motion_factor: MotionFactor { x: motion_x, y: motion_y },
tilemap: TileMap { width, height, tiles },
}
}
fn make_scene() -> SceneBank {
SceneBank {
layers: [
make_layer(TileSize::Size16, 1.0, 1.0, 64, 64),
make_layer(TileSize::Size16, 0.5, 0.5, 64, 64),
make_layer(TileSize::Size16, 1.0, 0.75, 64, 64),
make_layer(TileSize::Size16, 0.25, 1.0, 64, 64),
],
}
}
#[test]
fn first_update_initializes_master_and_layer_anchors() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let update = resolver.update(&scene, 0, 0);
assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 12, y: 8 });
assert_eq!(update.layer_anchors[1], TileAnchor { x: 12, y: 8 });
assert_eq!(update.refresh_requests.len(), 4);
}
#[test]
fn per_layer_copy_requests_follow_motion_factor() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let update = resolver.update(&scene, 32, 48);
assert_eq!(update.copy_requests[0].camera_x_px, 32);
assert_eq!(update.copy_requests[0].camera_y_px, 48);
assert_eq!(update.copy_requests[1].camera_x_px, 16);
assert_eq!(update.copy_requests[1].camera_y_px, 24);
assert_eq!(update.copy_requests[3].camera_x_px, 8);
assert_eq!(update.copy_requests[3].camera_y_px, 48);
}
#[test]
fn hysteresis_prevents_small_back_and_forth_refresh_churn() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 8, 0);
assert!(update.refresh_requests.is_empty());
assert_eq!(update.master_anchor, TileAnchor { x: 12, y: 8 });
}
#[test]
fn repeated_high_speed_movement_advances_in_tile_steps() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 0);
assert!(update.master_anchor.x > 12);
assert!(update.refresh_requests.iter().any(|request| matches!(
request,
CacheRefreshRequest::RefreshColumn { layer_index: 0, .. }
| CacheRefreshRequest::RefreshRegion { layer_index: 0, .. }
)));
}
#[test]
fn anchors_clamp_near_scene_edges() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let update = resolver.update(&scene, 10_000, 10_000);
assert_eq!(update.master_anchor, TileAnchor { x: 51, y: 56 });
assert_eq!(update.layer_anchors[0], TileAnchor { x: 51, y: 56 });
}
#[test]
fn corner_trigger_converts_to_non_overlapping_region_requests() {
let scene = make_scene();
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let _ = resolver.update(&scene, 0, 0);
let update = resolver.update(&scene, 64, 80);
let regions: Vec<_> = update
.refresh_requests
.iter()
.filter_map(|request| match request {
CacheRefreshRequest::RefreshRegion { layer_index: 0, region } => Some(*region),
_ => None,
})
.collect();
assert_eq!(regions.len(), 2);
assert_eq!(regions[0].x + regions[0].width, 25);
assert_eq!(regions[0].height, 16);
assert_eq!(regions[1].width, 24);
assert_eq!(regions[1].height, 1);
}
#[test]
fn reset_scene_requests_full_invalidation_for_all_layers() {
let mut resolver = SceneViewportResolver::new(320, 180, 25, 16, 12, 20);
let requests = resolver.reset_scene();
assert_eq!(requests.len(), 4);
assert!(
requests
.iter()
.all(|request| matches!(request, CacheRefreshRequest::InvalidateLayer { .. }))
);
}
}