diff --git a/VERSION b/VERSION index 096f277..8fe9a83 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00007 +v202611-00008 diff --git a/lang/de.lang b/lang/de.lang index 1712c50..11c4cb4 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -225,6 +225,7 @@ set.volume = Effekt-Lautstärke set.music = Musik-Lautstärke set.mute = Stumm set.rotate_speed = Drehgeschwindigkeit +set.pan_speed = Kamerageschwindigkeit set.zoom_speed = Zoomgeschwindigkeit set.edge_scroll = Bildschirmrand-Scrollen set.invert = Mausdrehung umkehren diff --git a/lang/en.lang b/lang/en.lang index b83f072..60cfb0a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -226,6 +226,7 @@ set.volume = Sound volume set.music = Music volume set.mute = Mute set.rotate_speed = Rotation speed +set.pan_speed = Pan speed set.zoom_speed = Zoom speed set.edge_scroll = Screen edge scrolling set.invert = Invert mouse rotation diff --git a/src/game.rs b/src/game.rs index 3418694..cc183cd 100644 --- a/src/game.rs +++ b/src/game.rs @@ -288,12 +288,20 @@ pub struct Ent { pub target: Option, // attackers: side whose core (or gate) they march on #[serde(default)] pub chained: bool, // a lone creature chained in a sealed cave: joins when the cave is claimed + #[serde(skip, default = "zero_f32")] + pub disp_x: f32, // smoothed screen position (not part of the saved or synced world state) + #[serde(skip, default = "zero_f32")] + pub disp_y: f32, } fn yes() -> bool { true } +fn zero_f32() -> f32 { + 0.0 +} + impl Ent { fn dummy() -> Ent { Ent { @@ -350,6 +358,8 @@ impl Ent { side: 0, target: None, chained: false, + disp_x: 0.0, + disp_y: 0.0, } } pub fn tile(&self) -> usize { @@ -1129,6 +1139,8 @@ impl Game { e.hero = hero; e.x = x; e.y = y; + e.disp_x = x; + e.disp_y = y; e.level = level; e.max_hp = s.hp * (1.0 + 0.15 * (level - 1) as f32); e.hp = e.max_hp; @@ -1178,6 +1190,30 @@ impl Game { // ---------------------------------------------------------------- update + // smoothed positions for the screen only: the simulation stays fixed-step and deterministic + pub fn smooth_display(&mut self, dt: f32) { + if dt <= 0.0 { + return; + } + let k = (1.0 - (-22.0 * dt).exp()).clamp(0.0, 1.0); + for e in self.ents.iter_mut() { + if e.disp_x == 0.0 && e.disp_y == 0.0 { + e.disp_x = e.x; + e.disp_y = e.y; + continue; + } + let dx = e.x - e.disp_x; + let dy = e.y - e.disp_y; + if dx * dx + dy * dy > 4.0 { + e.disp_x = e.x; + e.disp_y = e.y; + } else { + e.disp_x += dx * k; + e.disp_y += dy * k; + } + } + } + pub fn update(&mut self, dt: f32) { let mut steps = 0usize; let cmds = if self.netplay.is_some() { diff --git a/src/main.rs b/src/main.rs index b739055..f0f5dfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -394,6 +394,7 @@ impl App { self.game = l.game; if let Some(c) = l.camera { self.cam = c; + self.cam.snap(); } if let Some(s) = l.settings { self.settings = s; @@ -778,7 +779,7 @@ async fn main() { if playing { let cam = &mut app.cam; let (right, fwd) = cam.ground_axes(); - let pan = cam.zoom * 0.9 * dt; + let pan = cam.zoom * keys.pan_speed.max(0.25) * dt; let move_cam = |dx: f32, dz: f32, cam: &mut Cam| { let d = right * dx + fwd * dz; cam.tx += d.x; @@ -1134,6 +1135,9 @@ async fn main() { } app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32); app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32); + app.cam.rx = app.cam.rx.clamp(0.0, mw() as f32); + app.cam.rz = app.cam.rz.clamp(0.0, mh() as f32); + app.cam.update_render(dt); if settings_changed { apply_settings(&app.settings, &mut r3d); @@ -1167,10 +1171,11 @@ async fn main() { } } } + app.game.smooth_display(dt); let hear = app.cam.zoom * 1.2; let (right, _) = app.cam.ground_axes(); for ev in app.game.events.drain(..) { - sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now); + sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } // a won mission goes into the campaign progress (with the carry-over) if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { @@ -1195,7 +1200,7 @@ async fn main() { | Some(Page::Account) | Some(Page::Bestiary { from_title: true, .. }) ); - music.update(&app.game, on_title, (app.cam.tx, app.cam.tz)); + music.update(&app.game, on_title, (app.cam.rx, app.cam.rz)); for m in app.game.msgs.drain(..) { if app.settings.voice && voice::speaks(m.key) { voice.say(&i18n::trf(m.key, &m.args), now, 12); diff --git a/src/menu.rs b/src/menu.rs index 54c1c8f..25c9db9 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -41,6 +41,7 @@ pub enum Item { MusicVolume, Mute, RotateSpeed, + PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse, @@ -59,7 +60,7 @@ fn tab_items(tab: usize) -> &'static [Item] { use Item::*; match tab { 0 => &[Language, Volume, MusicVolume, Mute, Voice], - 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], + 1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 3 => &[GameSpeed, Hints, TouchUi], _ => &[], @@ -73,6 +74,7 @@ fn item_label(it: Item) -> String { Item::MusicVolume => "set.music", Item::Mute => "set.mute", Item::RotateSpeed => "set.rotate_speed", + Item::PanSpeed => "set.pan_speed", Item::ZoomSpeed => "set.zoom_speed", Item::EdgeScroll => "set.edge_scroll", Item::InvertMouse => "set.invert", @@ -104,6 +106,7 @@ fn item_value(it: Item, s: &Settings) -> String { Item::Mute => onoff(s.muted), Item::Voice => onoff(s.voice), Item::RotateSpeed => pct(s.rotate_speed), + Item::PanSpeed => pct(s.pan_speed), Item::ZoomSpeed => pct(s.zoom_speed), Item::EdgeScroll => onoff(s.edge_scroll), Item::InvertMouse => onoff(s.invert_mouse), @@ -162,6 +165,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::Mute => s.muted = !s.muted, Item::Voice => s.voice = !s.voice, Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25), + Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25), Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25), Item::EdgeScroll => s.edge_scroll = !s.edge_scroll, Item::InvertMouse => s.invert_mouse = !s.invert_mouse, diff --git a/src/props.rs b/src/props.rs index 602cc9f..0aef74d 100644 --- a/src/props.rs +++ b/src/props.rs @@ -165,15 +165,15 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) { } // creatures in chains: an iron post with chains to the creature for e in g.ents.iter().filter(|e| e.alive && e.chained) { - if (e.x as i32) < x0 || (e.x as i32) > x1 || (e.y as i32) < z0 || (e.y as i32) > z1 { + if (e.disp_x as i32) < x0 || (e.disp_x as i32) > x1 || (e.disp_y as i32) < z0 || (e.disp_y as i32) > z1 { continue; } - let post = vec3(e.x.floor() + 0.5, 0.0, e.y.floor() + 0.15); + let post = vec3(e.disp_x.floor() + 0.5, 0.0, e.disp_y.floor() + 0.15); cube(post + vec3(0.0, 0.35, 0.0), vec3(0.08, 0.7, 0.08), rgb(70, 70, 78, 0.6)); for n in 0..6 { let f = n as f32 / 6.0; let side = if n % 2 == 0 { 0.08 } else { -0.08 }; - let p = post.lerp(vec3(e.x + side, 0.25, e.y), f) + vec3(0.0, 0.3 * (1.0 - f) + 0.05 * (f * 3.14).sin(), 0.0); + let p = post.lerp(vec3(e.disp_x + side, 0.25, e.disp_y), f) + vec3(0.0, 0.3 * (1.0 - f) + 0.05 * (f * 3.14).sin(), 0.0); cube(p, vec3(0.05, 0.05, 0.05), rgb(120, 120, 130, 0.6)); } } diff --git a/src/render3d.rs b/src/render3d.rs index 35eea79..c5a4b11 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -26,11 +26,46 @@ pub struct Cam { pub yaw: f32, pub pitch: f32, pub zoom: f32, // visible world height in tiles (orthographic) + #[serde(skip, default = "zero_f32")] + pub rx: f32, // smoothed look-at target used by the screen (world x) + #[serde(skip, default = "zero_f32")] + pub rz: f32, // smoothed look-at target used by the screen (world z = map y) + #[serde(skip, default = "cam_uninit")] + pub render_init: bool, +} + +fn zero_f32() -> f32 { + 0.0 +} + +fn cam_uninit() -> bool { + false } impl Cam { pub fn new(tx: f32, tz: f32) -> Cam { - Cam { tx, tz, yaw: 0.785, pitch: 0.6155, zoom: 18.0 } + Cam { tx, tz, yaw: 0.785, pitch: 0.6155, zoom: 18.0, rx: tx, rz: tz, render_init: true } + } + + pub fn snap(&mut self) { + self.rx = self.tx; + self.rz = self.tz; + self.render_init = true; + } + + pub fn update_render(&mut self, dt: f32) { + if !self.render_init { + self.snap(); + return; + } + let dist = (self.tx - self.rx).abs() + (self.tz - self.rz).abs(); + if dist > self.zoom.max(1.0) { + self.snap(); + return; + } + let k = (1.0 - (-28.0 * dt.max(0.0)).exp()).clamp(0.0, 1.0); + self.rx += (self.tx - self.rx) * k; + self.rz += (self.tz - self.rz) * k; } pub fn eye_dir(&self) -> Vec3 { @@ -38,7 +73,7 @@ impl Cam { } fn target(&self) -> Vec3 { - vec3(self.tx, 0.0, self.tz) + vec3(self.rx, 0.0, self.rz) } pub fn camera(&self) -> Camera3D { @@ -990,7 +1025,7 @@ impl R3d { if !e.alive || e.held || (player_only && e.hero) { continue; } - let s = Cam::project(&m, vec3(e.x, 0.3, e.y)); + let s = Cam::project(&m, vec3(e.disp_x, 0.3, e.disp_y)); let d = s.distance(vec2(sx, sy)); if d < bd { bd = d; @@ -1231,7 +1266,7 @@ impl R3d { if mirror { std::mem::swap(&mut u0, &mut u1); } - let a = amb(e.x, e.y) * if sleeping || e.captive == 1 { 0.6 } else { 1.0 }; + let a = amb(e.disp_x, e.disp_y) * if sleeping || e.captive == 1 { 0.6 } else { 1.0 }; // spell looks: stone grey, plague green, see-through when invisible let alpha = if e.invis > 0.0 { 0.35 } else { 1.0 }; let c = if white { @@ -1247,7 +1282,7 @@ impl R3d { }; // pulled towards the camera: unchanged on screen (orthographic), but walls behind // the unit no longer cut into the sprite - let base = vec3(e.x, bob, e.y) + toward * 0.45 + r * (lunge * fwd); + let base = vec3(e.disp_x, bob, e.disp_y) + toward * 0.45 + r * (lunge * fwd); let (sr, cr) = rot.sin_cos(); let corner = |dx: f32, dy: f32| { let (dx, dy) = (dx * sx, dy * sy); @@ -1288,7 +1323,7 @@ impl R3d { } let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Wyrm | EK::Gargoyle | EK::Giant); let lift = if flies(e.kind) && !(matches!(e.job, Job::Sleep(_)) && !e.moving) { 0.45 } else { 0.0 }; - let head = proj(vec3(e.x, lift + if big { 0.9 } else { 0.72 }, e.y)); + let head = proj(vec3(e.disp_x, lift + if big { 0.9 } else { 0.72 }, e.disp_y)); // bar width in block characters, depending on the zoom let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8); if (e.hp < e.max_hp || e.hero) && e.captive == 0 { @@ -1320,7 +1355,7 @@ impl R3d { gfx.glyph(b'z', head.x + cw * 2.0, head.y - GH * S() - rise, S(), Color::new(0.8, 0.85, 1.0, 1.0 - k)); } if e.carry > 0 { - let p = proj(vec3(e.x, 0.35, e.y)); + let p = proj(vec3(e.disp_x, 0.35, e.disp_y)); gfx.glyph(0x0F, p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255)); } } @@ -1385,7 +1420,7 @@ fn draw_gear_glow(g: &Game, cam: &Cam, t: f32) { for e in g.ents.iter().filter(|e| gear_shown(e) && e.level >= 9) { let w = sprite_size(e.kind); let lift = if flies(e.kind) { 0.45 } else { 0.0 }; - let base = vec3(e.x, lift, e.y) + toward * 0.5; + let base = vec3(e.disp_x, lift, e.disp_y) + toward * 0.5; let c = if e.hero { Color::new(0.8, 0.9, 1.0, 0.8) } else { Color::new(1.0, 0.8, 0.3, 0.8) }; let y = w * 1.12 + (t * 2.0 + e.anim).sin() * 0.02; for k in 0..8 { @@ -1394,7 +1429,7 @@ fn draw_gear_glow(g: &Game, cam: &Cam, t: f32) { line3(base + vec3(a0.cos() * 0.12, y, a0.sin() * 0.12), base + vec3(a1.cos() * 0.12, y, a1.sin() * 0.12), 0.025, c); } if e.level >= 10 { - disc(vec3(e.x, floor_y(g, e.x, e.y) + 0.015, e.y), 0.42 + 0.03 * (t * 3.0).sin(), Color::new(c.r, c.g, c.b, 0.18), 16); + disc(vec3(e.disp_x, floor_y(g, e.disp_x, e.disp_y) + 0.015, e.disp_y), 0.42 + 0.03 * (t * 3.0).sin(), Color::new(c.r, c.g, c.b, 0.18), 16); } } } @@ -2090,8 +2125,8 @@ fn draw_shadows(g: &Game) { continue; } let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Giant) { 0.32 } else { 0.24 }; - let fy = floor_y(g, e.x, e.y); - disc(vec3(e.x, fy + 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12); + let fy = floor_y(g, e.disp_x, e.disp_y); + disc(vec3(e.disp_x, fy + 0.012, e.disp_y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12); let rc = if e.captive != 0 { Color::new(0.6, 0.6, 0.6, 0.6) } else if e.dwarf() { @@ -2109,7 +2144,7 @@ fn draw_shadows(g: &Game) { } else { Color::new(0.95, 0.3, 0.35, 0.7) }; - ring(vec3(e.x, fy + 0.02, e.y), r + 0.03, rc); + ring(vec3(e.disp_x, fy + 0.02, e.disp_y), r + 0.03, rc); } } @@ -2190,7 +2225,7 @@ fn draw_traps_solid(g: &Game, t: f32) { } CARRIED => { if let Some(e) = g.ents.iter().find(|e| e.id + 1 == tr.carrier) { - trap_crate(vec3(e.x, 0.42, e.y)); + trap_crate(vec3(e.disp_x, 0.42, e.disp_y)); } } ARMED => { diff --git a/src/settings.rs b/src/settings.rs index 3aaa554..a91dec7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -139,6 +139,7 @@ pub struct Settings { pub voice: bool, // Crookfinger's babbling voice // camera pub rotate_speed: f32, + pub pan_speed: f32, pub zoom_speed: f32, pub edge_scroll: bool, pub invert_mouse: bool, @@ -168,6 +169,7 @@ impl Default for Settings { muted: false, voice: true, rotate_speed: 1.0, + pan_speed: 1.25, zoom_speed: 1.0, edge_scroll: true, invert_mouse: false, diff --git a/src/ui.rs b/src/ui.rs index 185fc25..8d47cd0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -479,7 +479,7 @@ impl Ui { UiAction::Consumed } TBtn::OvRow(id) => match g.ents.iter().find(|e| e.id == id) { - Some(e) => UiAction::MinimapJump(e.x, e.y), + Some(e) => UiAction::MinimapJump(e.disp_x, e.disp_y), None => UiAction::Consumed, }, TBtn::Tool(i) if !g.tool_allowed(i) => { @@ -779,7 +779,7 @@ impl Ui { Color::new(0.4, 0.6, 1.0, 1.0) } } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) }; - draw_rectangle((mrx + e.x.floor() * ms).floor(), (mry + e.y.floor() * ms).floor(), dot, dot, c); + draw_rectangle((mrx + e.disp_x.floor() * ms).floor(), (mry + e.disp_y.floor() * ms).floor(), dot, dot, c); } } if let Some(f) = g.flag {