Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
15 kB · 446 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447//! Production adapter fixtures: no compatibility tuple renderer is used here.use crate::{ entry::render_production_scene, interface_scene::descriptors, presentation_settings::PresentationPreferences, session::{GameSession, PlayerSetup, PresentationLandmarkKind},};use hexwork_core::{ action::{CommittedAction, Direction}, actor::Stance, character::{self, Ancestry, Calling}, light::LightSource, runtime::{ActorIdentity, ActorRole}, state::State, voxel::{material, Voxel},};use hexwork_render::{ camera::{self, View}, scene::{EntityDescriptor, RenderScratch, SceneBounds, SemanticRole, TacticalMapContext}, FrameBuffer,};use std::collections::BTreeMap;
fn session() -> GameSession { GameSession::load( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."), 223, PlayerSetup::new( "Semantic fixture", character::new(Ancestry::Mudborn, Calling::Ratcatcher), ), ) .unwrap()}fn bounds(context: &TacticalMapContext) -> SceneBounds { SceneBounds { min: context.min, max: context.max, }}fn render( state: &State, view: View, context: &TacticalMapContext, landmarks: &[crate::session::PresentationLandmark], ascii: bool,) -> FrameBuffer { let mut frame = FrameBuffer::new(80, 24); render_production_scene( state, view, context, landmarks, &PresentationPreferences::default().render_tuning(), ascii, &mut frame, &mut RenderScratch::default(), ); frame}fn at(frame: &FrameBuffer, view: View, cell: [i32; 3]) -> char { let x = cell[0] as i64 - (view.eye[0] as i32 as i64 - 40); let y = cell[1] as i64 - (view.eye[1] as i32 as i64 - 12); assert!((0..80).contains(&x) && (0..24).contains(&y)); frame.cells[y as usize * 80 + x as usize].glyph}fn fixture() -> (State, TacticalMapContext, u64) { let mut state = State::new(223); state.spawn_actor_with_identity( hexwork_core::spatial::FixedVec3::from_cell_feet([0,0,0]).unwrap(), ActorIdentity::new("player", "Player", ActorRole::Player), ); let target = state.spawn_actor_with_identity( hexwork_core::spatial::FixedVec3::from_cell_feet([4,0,0]).unwrap(), ActorIdentity::new("rat", "Rat", ActorRole::Monster), ); state.grid.set_world(4, 0, 1, Voxel::default()); // allocate the light source's air chunk state.lights.push(LightSource { x: 4, y: 0, z: 1, radius: 12, warm: true, }); ( state, TacticalMapContext { min: [-8, -8, -2], max: [12, 8, 5], player_cell: [0, 0, 0], }, target, )}fn eligible( state: &State, context: &TacticalMapContext, lights: &BTreeMap<(i32, i32, i32), u8>,) -> Vec<EntityDescriptor> { descriptors(state, &bounds(context), context.player_cell, &[], lights)}
#[test]fn tactical_half_cell_center_has_one_player_marker() { for coordinate in [-3, 3] { let mut state = State::new(223); state.spawn_actor_with_identity( hexwork_core::spatial::FixedVec3::from_cell_feet([coordinate,coordinate,0]).unwrap(), ActorIdentity::new("player", "Player", ActorRole::Player), ); let context = TacticalMapContext { min: [-8, -8, -2], max: [8, 8, 3], player_cell: [coordinate, coordinate, 0], }; let view = camera::top_tactical( [coordinate as f32 + 0.5, coordinate as f32 + 0.5, 0.0], 0, 1.0, ); let frame = render(&state, view, &context, &[], false); assert_eq!( frame.cells.iter().filter(|cell| cell.glyph == '@').count(), 1 ); assert_eq!(at(&frame, view, context.player_cell), '@'); }}
#[test]fn real_loot_service_exit_projection() { let mut session = session(); let player = session.player_id().unwrap(); let item = *session.state().unwrap().packs[&player] .items .keys() .next() .unwrap(); session .dispatch(CommittedAction::Drop { item_id: item }) .unwrap(); let dropped = session.player_cell().unwrap(); assert!(session.state().unwrap().items.contains_key(&item)); assert_eq!( session.state().unwrap().ground_items[&item], (dropped[0], dropped[1], dropped[2]) ); // A legal action separates the player marker from the pile, not a fabricated // tuple. Real movement advances one 300 mm sample per Move, so leaving the // pile's 1000 mm cell can take several legal Moves: keep walking a legal // direction until the player's cell genuinely differs from the pile's. let mut moved = false; let mut separated = false; for direction in [ Direction::North, Direction::South, Direction::East, Direction::West, ] { for _ in 0..4 { if session .dispatch(CommittedAction::Move(direction.clone())) .is_err() { break; } moved = true; if session.player_cell().unwrap() != dropped { separated = true; break; } } if separated { break; } } assert!(moved, "a legal Move direction must exist"); assert!( separated, "legal 300 mm Moves must leave the pile's cell so the marker is not co-located" ); let context = session.tactical_context().unwrap(); let context = TacticalMapContext { min: context.bounds.min, max: context.bounds.max, player_cell: context.player_cell, }; let landmarks = session.presentation_landmarks().unwrap(); assert!(landmarks .iter() .any(|l| matches!(l.kind, PresentationLandmarkKind::Interaction(_)))); assert!(landmarks .iter() .any(|l| matches!(l.kind, PresentationLandmarkKind::Connector(_)))); let hash = session.canonical_hash().unwrap(); let state = session.state().unwrap(); let lights = hexwork_core::light::sample_light_field(&state.grid, &state.lights); let entities = descriptors( state, &bounds(&context), context.player_cell, &landmarks, &lights, ); let pile = entities .iter() .find(|e| e.id == item && e.role == SemanticRole::Item) .expect("real dropped item remains lit and visible"); assert_eq!(pile.glyph, '!'); assert_eq!(pile.anchor, camera::voxel_center(dropped.map(|v| v as f32))); let view = camera::top_tactical(dropped.map(|v| v as f32), dropped[2], 1.); let frame = render(state, view, &context, &landmarks, true); assert_eq!(at(&frame, view, dropped), '!'); let eye = camera::first_person( camera::voxel_center(context.player_cell.map(|v| v as f32)), 0., 0., ) .eye; let delta = [ pile.anchor[0] - eye[0], pile.anchor[1] - eye[1], pile.anchor[2] + 0.15 - eye[2], ]; let perspective = camera::first_person( camera::voxel_center(context.player_cell.map(|v| v as f32)), delta[1].atan2(delta[0]), delta[2].atan2(delta[0].hypot(delta[1])), ); for ascii in [false, true] { let frame = render(state, perspective, &context, &landmarks, ascii); assert!( frame.cells.iter().any(|c| c.glyph == '!'), "real dropped pile must project in aimed perspective, ascii={ascii}" ); } // Actual catalog IDs/access points in explicitly synthetic clear geometry. // This isolates projection from Town wall placement without fabricating landmarks. for landmark in &landmarks { let [x, y, z] = landmark.access; let mut local = State::new(223); local.spawn_actor_with_identity( hexwork_core::spatial::FixedVec3::from_cell_feet([x-2,y,z]).unwrap(), ActorIdentity::new("player", "Player", ActorRole::Player), ); local.grid.set_world(x, y, z, Voxel::default()); local.lights.push(LightSource { x, y, z, radius: 8, warm: true, }); let ctx = TacticalMapContext { min: [x - 4, y - 4, z - 1], max: [x + 4, y + 4, z + 3], player_cell: [x - 2, y, z], }; let light = hexwork_core::light::sample_light_field(&local.grid, &local.lights); let projected = descriptors( &local, &bounds(&ctx), ctx.player_cell, std::slice::from_ref(landmark), &light, ); let entity = projected .iter() .find(|e| e.id == landmark.stable_id) .expect("catalog landmark is eligible in clear lit geometry"); assert_eq!( entity.anchor, camera::voxel_center(landmark.access.map(|v| v as f32)) ); let glyph = match landmark.kind { PresentationLandmarkKind::Interaction(_) => '?', PresentationLandmarkKind::Connector( hexwork_worldgen::runtime::TransitionDirection::Up, ) => '<', PresentationLandmarkKind::Connector( hexwork_worldgen::runtime::TransitionDirection::Down, ) => '>', }; let view = camera::top_tactical([x as f32, y as f32, z as f32], z, 1.); let frame = render(&local, view, &ctx, std::slice::from_ref(landmark), true); assert_eq!(at(&frame, view, landmark.access), glyph); } assert_eq!(session.canonical_hash().unwrap(), hash);}
#[test]fn semantic_visibility_matrix() { let (mut state, context, target) = fixture(); let has = |state: &State, lights: &BTreeMap<_, _>| { eligible(state, &context, lights) .iter() .any(|e| e.id == target) }; let torso = BTreeMap::from([((4, 0, 0), 24)]); let head = BTreeMap::from([((4, 0, 1), 24)]); assert!(!has(&state, &BTreeMap::new()), "unlit actors must not leak"); assert!(has(&state, &torso)); assert!(has(&state, &head)); // Lower sample ray enters this voxel; upper sample ray stays above it. state.grid.set_world( 3, 0, 0, Voxel { material: material::STONE, occ: 255, ..Default::default() }, ); assert!( !has(&state, &torso), "lit occluded torso + dark clear head must not combine" ); assert!(has(&state, &head), "lit clear head is sufficient"); state .actors .iter_mut() .find(|a| a.id == target) .unwrap() .stance = Stance::Prone; assert!( !has(&state, &head), "prone samples cannot borrow standing head light" ); state .actors .iter_mut() .find(|a| a.id == target) .unwrap() .stance = Stance::Stand; state.grid.set_world( 3, 0, 1, Voxel { material: material::WOOD, occ: 255, ..Default::default() }, ); assert!(!has(&state, &head), "closed wooden doorway blocks head LOS"); state.grid.set_world(3, 0, 0, Voxel::default()); state.grid.set_world(3, 0, 1, Voxel::default()); assert!(has(&state, &head), "open doorway restores LOS"); let mut view = camera::top_tactical([0., 0., 0.], 0, 1.); view.yaw = std::f32::consts::PI; // target is behind the saved FP heading let frame = render(&state, view, &context, &[], false); let glyph = eligible(&state, &context, &head) .into_iter() .find(|e| e.id == target) .unwrap() .glyph; assert_eq!(at(&frame, view, [4, 0, 0]), glyph); view.tactical_slice_z = Some(1); let off = render(&state, view, &context, &[], false); assert_ne!( at(&off, view, [4, 0, 0]), glyph, "other-floor actor suppressed" );}
#[test]fn no_color_preserves_semantic_glyphs() { let (state, context, _) = fixture(); let view = camera::top_tactical([0., 0., 0.], 0, 1.); let frame = render(&state, view, &context, &[], true); assert_eq!(at(&frame, view, [0, 0, 0]), '@'); assert_ne!(at(&frame, view, [4, 0, 0]), ' '); let mut ansi = String::new(); hexwork_render::ansi::encode_full_mode( &frame, &mut ansi, hexwork_render::ansi::ColorMode::NoColor, ); // Decode the full encoder's CSI controls, retaining only printable cell glyphs. let mut cells = Vec::new(); let mut chars = ansi.chars(); while let Some(c) = chars.next() { if c == '\u{1b}' { assert_eq!(chars.next(), Some('[')); for p in chars.by_ref() { if ('@'..='~').contains(&p) { break; } } } else if !c.is_control() { cells.push(c); } } assert_eq!( cells, frame.cells.iter().map(|c| c.glyph).collect::<Vec<_>>() ); assert!(!ansi.contains("38;")); assert!(!ansi.contains("48;"));}
#[test]fn active_location_render_scope() { let (mut state, context, _) = fixture(); let view = camera::first_person(camera::voxel_center([0., 0., 0.]), 0., 0.); let before = render(&state, view, &context, &[], false); state.spawn_actor_with_identity( hexwork_core::spatial::FixedVec3::from_cell_feet([14,0,0]).unwrap(), ActorIdentity::new("rat", "Remote rat", ActorRole::Monster), ); for y in -8..=8 { for z in -2..=5 { state.grid.set_world( 14, y, z, Voxel { material: material::STONE, occ: 255, ..Default::default() }, ); } } let after = render(&state, view, &context, &[], false); let differences = before .cells .iter() .zip(&after.cells) .enumerate() .filter(|(_, (a, b))| a != b) .map(|(i, (a, b))| (i, *a, *b)) .collect::<Vec<_>>(); assert!( differences.is_empty(), "remote geometry changed {} cells; first: {:?}", differences.len(), differences.first() ); let light = BTreeMap::from([((14, 0, 0), 24), ((14, 0, 1), 24)]); assert!(!eligible(&state, &context, &light) .iter() .any(|e| e.anchor[0] > 13.));}