diff --git a/crates/console/prometeu-hal/src/glyph_bank.rs b/crates/console/prometeu-hal/src/glyph_bank.rs index f076d568..aa38e700 100644 --- a/crates/console/prometeu-hal/src/glyph_bank.rs +++ b/crates/console/prometeu-hal/src/glyph_bank.rs @@ -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, diff --git a/crates/console/prometeu-hal/src/lib.rs b/crates/console/prometeu-hal/src/lib.rs index 7c994c67..abfc7d7c 100644 --- a/crates/console/prometeu-hal/src/lib.rs +++ b/crates/console/prometeu-hal/src/lib.rs @@ -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; diff --git a/crates/console/prometeu-hal/src/scene_viewport_resolver.rs b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs new file mode 100644 index 00000000..41fad0f2 --- /dev/null +++ b/crates/console/prometeu-hal/src/scene_viewport_resolver.rs @@ -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, + 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 { + 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, + ) { + 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, + ) { + 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, + ) { + 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, + ) { + 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 { .. })) + ); + } +}