883 lines
31 KiB
Rust
883 lines
31 KiB
Rust
//! Palette-indexed rendering. Filters copy colors, never blend palette entries.
|
||
use crate::assets::{Sprite, TRANSPARENT};
|
||
use anyhow::{Result, ensure};
|
||
use sha2::{Digest, Sha256};
|
||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||
|
||
/// Original tiny gun rounds and sparse final ground-explosion embers lose
|
||
/// important pixels under edge filtering; preserve their exact pixel area.
|
||
pub fn prefers_nearest(asset: u32) -> bool {
|
||
matches!(asset, 0x101c3..=0x101c6 | 0x10178..=0x1018d)
|
||
}
|
||
|
||
pub fn upscale(sprite: &Sprite, factor: usize) -> Result<Sprite> {
|
||
ensure!(
|
||
matches!(factor, 1 | 2 | 3 | 4),
|
||
"scale must be 1, 2, 3 or 4"
|
||
);
|
||
ensure!(
|
||
sprite.width > 0
|
||
&& sprite.height > 0
|
||
&& sprite.pixels.len() == sprite.width * sprite.height,
|
||
"invalid indexed image"
|
||
);
|
||
ensure!(
|
||
sprite.width as u64 * sprite.height as u64 * (factor * factor) as u64 <= 16_777_216,
|
||
"derived image exceeds pixel limit"
|
||
);
|
||
if factor == 1 {
|
||
return Ok(sprite.clone());
|
||
}
|
||
if factor == 4 {
|
||
return upscale(&upscale(sprite, 2)?, 2);
|
||
}
|
||
let mut out = Sprite::blank(sprite.width * factor, sprite.height * factor);
|
||
for y in 0..sprite.height {
|
||
for x in 0..sprite.width {
|
||
let sample = |dx: isize, dy: isize| {
|
||
sprite.pixels[(y as isize + dy).clamp(0, sprite.height as isize - 1) as usize
|
||
* sprite.width
|
||
+ (x as isize + dx).clamp(0, sprite.width as isize - 1) as usize]
|
||
};
|
||
let [a, b, c, d, e, f, g, h, i] = [
|
||
sample(-1, -1),
|
||
sample(0, -1),
|
||
sample(1, -1),
|
||
sample(-1, 0),
|
||
sample(0, 0),
|
||
sample(1, 0),
|
||
sample(-1, 1),
|
||
sample(0, 1),
|
||
sample(1, 1),
|
||
];
|
||
let p = if factor == 2 {
|
||
if b != h && d != f {
|
||
[
|
||
if d == b { d } else { e },
|
||
if b == f { f } else { e },
|
||
if d == h { d } else { e },
|
||
if h == f { f } else { e },
|
||
e,
|
||
e,
|
||
e,
|
||
e,
|
||
e,
|
||
]
|
||
} else {
|
||
[e; 9]
|
||
}
|
||
} else if b != h && d != f {
|
||
[
|
||
if d == b { d } else { e },
|
||
if (d == b && e != c) || (b == f && e != a) {
|
||
b
|
||
} else {
|
||
e
|
||
},
|
||
if b == f { f } else { e },
|
||
if (d == b && e != g) || (d == h && e != a) {
|
||
d
|
||
} else {
|
||
e
|
||
},
|
||
e,
|
||
if (b == f && e != i) || (h == f && e != c) {
|
||
f
|
||
} else {
|
||
e
|
||
},
|
||
if d == h { d } else { e },
|
||
if (d == h && e != i) || (h == f && e != g) {
|
||
h
|
||
} else {
|
||
e
|
||
},
|
||
if h == f { f } else { e },
|
||
]
|
||
} else {
|
||
[e; 9]
|
||
};
|
||
for dy in 0..factor {
|
||
for dx in 0..factor {
|
||
out.pixels[(y * factor + dy) * out.width + x * factor + dx] =
|
||
p[dy * factor + dx];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
pub fn nearest(sprite: &Sprite, factor: usize) -> Sprite {
|
||
let mut out = Sprite::blank(sprite.width * factor, sprite.height * factor);
|
||
for y in 0..out.height {
|
||
for x in 0..out.width {
|
||
out.pixels[y * out.width + x] = sprite.pixels[(y / factor) * sprite.width + x / factor];
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Original VGA light/dark lookup. Inputs are six-bit RGB components, not SDL
|
||
/// eight-bit colors. GFX_Remap uses Manhattan distance and the last equal match.
|
||
pub fn legacy_light_table(palette: &[[u8; 3]; 256], level: i32) -> [u8; 256] {
|
||
std::array::from_fn(|index| {
|
||
let target = palette[index].map(|component| (component as i32 + level).clamp(0, 63));
|
||
let mut best = 0;
|
||
let mut distance = i32::MAX;
|
||
for (candidate, color) in palette.iter().enumerate() {
|
||
let difference: i32 = (0..3)
|
||
.map(|channel| (color[channel] as i32 - target[channel]).abs())
|
||
.sum();
|
||
if difference <= distance {
|
||
best = candidate as u8;
|
||
distance = difference;
|
||
}
|
||
}
|
||
best
|
||
})
|
||
}
|
||
|
||
/// SHADOW_GAdd / GFX_ShadeShape: preserve native mask spans at offset (-3,+4).
|
||
pub fn shade_ground_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||
let scaled = nearest(mask, canvas.scale);
|
||
canvas.shade(
|
||
&scaled,
|
||
((x - 3.0) * canvas.scale as f32).round() as i32,
|
||
((y + 4.0) * canvas.scale as f32).round() as i32,
|
||
table,
|
||
);
|
||
}
|
||
|
||
fn shadow_project(coordinate: i32, center: i32) -> i32 {
|
||
// GFX_3DPoint: view=(160,100,1000), objectZ=1280, distance=200.
|
||
// Keep division before arithmetic shift; negative coordinates round down
|
||
// after the original signed integer division.
|
||
((((coordinate as i64 - center as i64) * (200 << 11)) / 280) >> 11) as i32 + center
|
||
}
|
||
|
||
/// SHADOW_Draw's projected horizontal spans, with duplicate projected rows
|
||
/// suppressed. Fractional positions translate the indexed mask smoothly on the
|
||
/// high-resolution canvas; integer positions retain the original projection.
|
||
pub fn shade_air_shadow(canvas: &mut Canvas, mask: &Sprite, x: f32, y: f32, table: &[u8; 256]) {
|
||
let base_x = x.floor() as i32 - 10;
|
||
let base_y = y.floor() as i32 + 20;
|
||
let fraction_x = (x - x.floor()) * (5.0 / 7.0);
|
||
let fraction_y = (y - y.floor()) * (5.0 / 7.0);
|
||
let scale = canvas.scale as f32;
|
||
let mut previous_row = None;
|
||
for row in 0..mask.height {
|
||
let source = &mask.pixels[row * mask.width..(row + 1) * mask.width];
|
||
if source.iter().all(|pixel| *pixel == TRANSPARENT) {
|
||
continue;
|
||
}
|
||
let projected_y = shadow_project(base_y + row as i32, 100);
|
||
if previous_row == Some(projected_y) {
|
||
continue;
|
||
}
|
||
previous_row = Some(projected_y);
|
||
let top = ((projected_y as f32 + fraction_y) * scale).round() as i32;
|
||
let bottom = ((projected_y as f32 + 1.0 + fraction_y) * scale).round() as i32;
|
||
let mut column = 0;
|
||
while column < mask.width {
|
||
if source[column] == TRANSPARENT {
|
||
column += 1;
|
||
continue;
|
||
}
|
||
let start = column;
|
||
while column < mask.width && source[column] != TRANSPARENT {
|
||
column += 1;
|
||
}
|
||
let left = ((shadow_project(base_x + start as i32, 160) as f32 + fraction_x) * scale)
|
||
.round() as i32;
|
||
let right =
|
||
((shadow_project(base_x + column as i32 - 1, 160) as f32 + 1.0 + fraction_x)
|
||
* scale)
|
||
.round() as i32;
|
||
for yy in top.max(0)..bottom.min(canvas.image.height as i32) {
|
||
for xx in left.max(0)..right.min(canvas.image.width as i32) {
|
||
let pixel =
|
||
&mut canvas.image.pixels[yy as usize * canvas.image.width + xx as usize];
|
||
if *pixel < 256 {
|
||
*pixel = table[*pixel as usize] as u16;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pub struct SpriteCache {
|
||
memory: BTreeMap<String, Sprite>,
|
||
root: Option<PathBuf>,
|
||
}
|
||
impl SpriteCache {
|
||
pub fn new(root: Option<PathBuf>) -> Self {
|
||
Self {
|
||
memory: BTreeMap::new(),
|
||
root,
|
||
}
|
||
}
|
||
pub fn get(&mut self, sprite: &Sprite, factor: usize, pixelated: bool) -> Result<&Sprite> {
|
||
let mut hash = Sha256::new();
|
||
hash.update(b"raptor-scale-v1");
|
||
hash.update(sprite.width.to_le_bytes());
|
||
hash.update(sprite.height.to_le_bytes());
|
||
hash.update([factor as u8, pixelated as u8]);
|
||
for p in &sprite.pixels {
|
||
hash.update(p.to_le_bytes());
|
||
}
|
||
let key = format!("{:x}", hash.finalize());
|
||
if !self.memory.contains_key(&key) {
|
||
let saved = self
|
||
.root
|
||
.as_ref()
|
||
.and_then(|r| fs::read(r.join(format!("{key}.json"))).ok())
|
||
.and_then(|b| serde_json::from_slice::<Sprite>(&b).ok())
|
||
.filter(|s| {
|
||
s.width == sprite.width * factor
|
||
&& s.height == sprite.height * factor
|
||
&& s.pixels.len() == s.width * s.height
|
||
&& s.pixels.iter().all(|p| *p <= TRANSPARENT)
|
||
});
|
||
let scaled = if let Some(s) = saved {
|
||
s
|
||
} else if pixelated {
|
||
nearest(sprite, factor)
|
||
} else {
|
||
upscale(sprite, factor)?
|
||
};
|
||
if let Some(root) = &self.root {
|
||
fs::create_dir_all(root)?;
|
||
if !root.join(format!("{key}.json")).exists() {
|
||
crate::persistence::atomic_write(
|
||
&root.join(format!("{key}.json")),
|
||
&serde_json::to_vec(&scaled)?,
|
||
)?;
|
||
}
|
||
}
|
||
if self
|
||
.memory
|
||
.values()
|
||
.map(|s| s.pixels.len() * 2)
|
||
.sum::<usize>()
|
||
+ scaled.pixels.len() * 2
|
||
> 128 * 1024 * 1024
|
||
{
|
||
self.memory.clear();
|
||
}
|
||
self.memory.insert(key.clone(), scaled);
|
||
}
|
||
Ok(&self.memory[&key])
|
||
}
|
||
}
|
||
pub struct Canvas {
|
||
pub image: Sprite,
|
||
pub scale: usize,
|
||
}
|
||
impl Canvas {
|
||
pub fn new(scale: usize) -> Self {
|
||
Self {
|
||
image: Sprite {
|
||
width: 320 * scale,
|
||
height: 200 * scale,
|
||
pixels: vec![0; 320 * 200 * scale * scale],
|
||
},
|
||
scale,
|
||
}
|
||
}
|
||
pub fn clear(&mut self, color: u16) {
|
||
self.image.pixels.fill(color)
|
||
}
|
||
pub fn blit(&mut self, sprite: &Sprite, x: i32, y: i32) {
|
||
for sy in 0..sprite.height {
|
||
let yy = y + sy as i32;
|
||
if yy < 0 || yy >= self.image.height as i32 {
|
||
continue;
|
||
}
|
||
for sx in 0..sprite.width {
|
||
let xx = x + sx as i32;
|
||
if xx < 0 || xx >= self.image.width as i32 {
|
||
continue;
|
||
}
|
||
let c = sprite.pixels[sy * sprite.width + sx];
|
||
if c != TRANSPARENT {
|
||
self.image.pixels[yy as usize * self.image.width + xx as usize] = c;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pub fn rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: u16) {
|
||
let s = self.scale as i32;
|
||
for yy in (y * s).max(0)..((y + h) * s).min(self.image.height as i32) {
|
||
for xx in (x * s).max(0)..((x + w) * s).min(self.image.width as i32) {
|
||
self.image.pixels[yy as usize * self.image.width + xx as usize] = color;
|
||
}
|
||
}
|
||
}
|
||
pub fn shade(&mut self, mask: &Sprite, x: i32, y: i32, table: &[u8; 256]) {
|
||
for sy in 0..mask.height {
|
||
let yy = y + sy as i32;
|
||
if yy < 0 || yy >= self.image.height as i32 {
|
||
continue;
|
||
}
|
||
for sx in 0..mask.width {
|
||
let xx = x + sx as i32;
|
||
if xx < 0
|
||
|| xx >= self.image.width as i32
|
||
|| mask.pixels[sy * mask.width + sx] == TRANSPARENT
|
||
{
|
||
continue;
|
||
}
|
||
let p = &mut self.image.pixels[yy as usize * self.image.width + xx as usize];
|
||
if *p < 256 {
|
||
*p = table[*p as usize] as u16;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pub fn rgba(&self, palette: &[[u8; 3]; 256], scanlines: bool) -> Vec<u8> {
|
||
let mut out = vec![0; self.image.pixels.len() * 4];
|
||
for (i, p) in self.image.pixels.iter().enumerate() {
|
||
let color = palette[(*p).min(255) as usize];
|
||
let dark = scanlines && (i / self.image.width) % (self.scale * 3) == self.scale * 2;
|
||
for j in 0..3 {
|
||
out[i * 4 + j] = if dark { color[j] / 2 } else { color[j] };
|
||
}
|
||
out[i * 4 + 3] = 255;
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Render cosmetic arcs on either the SD or HD indexed canvas. Geometry depends
|
||
/// only on the arc's coordinates, seed and age, never gameplay RNG or the
|
||
/// number of frames drawn. Ordinary palette entries avoid animated terrain hues.
|
||
pub fn draw_lightning_arcs(
|
||
canvas: &mut Canvas,
|
||
arcs: &[crate::simulation::LightningArc],
|
||
palette: &[[u8; 3]; 256],
|
||
) {
|
||
draw_lightning_arcs_at(canvas, arcs, palette, |arc| {
|
||
[
|
||
[arc.x as f32, arc.y as f32],
|
||
[arc.to_x as f32, arc.to_y as f32],
|
||
]
|
||
});
|
||
}
|
||
|
||
/// Follow attached ships at the same interpolated positions as their sprites.
|
||
/// Detached endpoints keep their last simulation position. Rendering never
|
||
/// changes arc lifetime, anchors, saved state or either gameplay RNG.
|
||
pub fn draw_world_lightning_arcs(
|
||
canvas: &mut Canvas,
|
||
world: &crate::simulation::World,
|
||
palette: &[[u8; 3]; 256],
|
||
alpha: f32,
|
||
) {
|
||
let endpoint = |anchor: Option<crate::simulation::LightningAnchor>, x: i32, y: i32| {
|
||
let Some(anchor) = anchor else {
|
||
return [x as f32, y as f32];
|
||
};
|
||
let position = if anchor.entity == 0 {
|
||
Some((
|
||
world.player.px,
|
||
world.player.py,
|
||
world.player.x,
|
||
world.player.y,
|
||
))
|
||
} else {
|
||
world
|
||
.enemies
|
||
.iter()
|
||
.find(|enemy| enemy.entity == anchor.entity)
|
||
.map(|enemy| (enemy.px, enemy.py, enemy.x, enemy.y))
|
||
};
|
||
position.map_or([x as f32, y as f32], |(px, py, current_x, current_y)| {
|
||
[
|
||
(px as f32 + (current_x as f32 - px as f32) * alpha + anchor.offset[0] as f32)
|
||
.clamp(-32768.0, 32768.0),
|
||
(py as f32 + (current_y as f32 - py as f32) * alpha + anchor.offset[1] as f32)
|
||
.clamp(-32768.0, 32768.0),
|
||
]
|
||
})
|
||
};
|
||
draw_lightning_arcs_at(canvas, &world.lightning_arcs, palette, |arc| {
|
||
[
|
||
endpoint(arc.anchor, arc.x, arc.y),
|
||
endpoint(arc.to_anchor, arc.to_x, arc.to_y),
|
||
]
|
||
});
|
||
}
|
||
|
||
fn draw_lightning_arcs_at(
|
||
canvas: &mut Canvas,
|
||
arcs: &[crate::simulation::LightningArc],
|
||
palette: &[[u8; 3]; 256],
|
||
endpoints: impl Fn(&crate::simulation::LightningArc) -> [[f32; 2]; 2],
|
||
) {
|
||
if !arcs.iter().any(|arc| arc.age < arc.ticks) || canvas.scale == 0 {
|
||
return;
|
||
}
|
||
// Keep a light field until all channels have been drawn. Taking the maximum
|
||
// avoids bright beads at every segment join, and quantizes the glow only
|
||
// once rather than losing its falloff to repeated palette rounding.
|
||
let mut light = vec![0u8; canvas.image.pixels.len()];
|
||
let falloff: [u8; 513] = std::array::from_fn(|i| {
|
||
let distance_squared = i as f32 / 32.0;
|
||
(255.0 * (0.86 * (-distance_squared / 0.55).exp() + 0.14 * (-distance_squared / 4.5).exp()))
|
||
.round() as u8
|
||
});
|
||
for arc in arcs.iter().filter(|arc| arc.age < arc.ticks) {
|
||
let [from, to] = endpoints(arc);
|
||
let direction = [to[0] - from[0], to[1] - from[1]];
|
||
let length = direction[0].hypot(direction[1]);
|
||
let mut noise = (arc.x as u32).wrapping_mul(0x9e37_79b9)
|
||
^ (arc.y as u32).wrapping_mul(0x85eb_ca6b)
|
||
^ (arc.to_x as u32).wrapping_mul(0xc2b2_ae35)
|
||
^ (arc.to_y as u32).rotate_left(11)
|
||
^ arc.age.wrapping_mul(0x27d4_eb2d)
|
||
^ arc.seed;
|
||
let depth = ((length / 2.5).max(1.0).log2().ceil() as u32).clamp(2, 7);
|
||
let mut points = vec![from];
|
||
lightning_path(
|
||
from,
|
||
to,
|
||
(length * 0.17).clamp(1.5, 18.0),
|
||
depth,
|
||
&mut noise,
|
||
&mut points,
|
||
);
|
||
let pulse = match arc.age % 3 {
|
||
0 => 1.0,
|
||
1 => 0.74,
|
||
_ => 0.92,
|
||
} * (1.0 - 0.22 * arc.age as f32 / arc.ticks as f32);
|
||
let mut last_energy = pulse;
|
||
for (i, segment) in points.windows(2).enumerate() {
|
||
let energy = if i + 2 == points.len() {
|
||
pulse
|
||
} else {
|
||
pulse * (0.86 + lightning_noise(&mut noise) * 0.14)
|
||
};
|
||
lightning_channel(
|
||
canvas,
|
||
&mut light,
|
||
&falloff,
|
||
segment,
|
||
[last_energy, energy],
|
||
1.0,
|
||
);
|
||
last_energy = energy;
|
||
}
|
||
// Fine, dying forks follow the discharge direction. They are cosmetic;
|
||
// the mod's explicit damage commands still decide which ships are hit.
|
||
if length > 4.0 {
|
||
let forward = [direction[0] / length, direction[1] / length];
|
||
for _ in 0..((length / 30.0) as usize).clamp(1, 5) {
|
||
let index = 1 + (lightning_noise(&mut noise) * (points.len() - 2) as f32) as usize;
|
||
let root = points[index];
|
||
let side = if lightning_noise(&mut noise) < 0.5 {
|
||
-1.0
|
||
} else {
|
||
1.0
|
||
};
|
||
let spread = side * (0.45 + lightning_noise(&mut noise) * 0.8);
|
||
let reach = (length * (0.2 + lightning_noise(&mut noise) * 0.3)).clamp(4.0, 28.0);
|
||
let tip = [
|
||
root[0] + (forward[0] - forward[1] * spread) * reach,
|
||
root[1] + (forward[1] + forward[0] * spread) * reach,
|
||
];
|
||
let mut fork = vec![root];
|
||
lightning_path(root, tip, reach * 0.2, 3, &mut noise, &mut fork);
|
||
for (i, segment) in fork.windows(2).enumerate() {
|
||
let taper =
|
||
|n: usize| pulse * 0.9 * (1.0 - n as f32 / (fork.len() - 1) as f32).sqrt();
|
||
lightning_channel(
|
||
canvas,
|
||
&mut light,
|
||
&falloff,
|
||
segment,
|
||
[taper(i), taper(i + 1)],
|
||
0.45,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add blue light to the existing scene, with a near-white hot core. Cache
|
||
// only the palette pairs actually used; avoid animated terrain entries.
|
||
LIGHTNING_COLORS.with(|colors| {
|
||
let mut colors = colors.borrow_mut();
|
||
let colors = colors.get_or_insert_with(|| LightningColors::new(palette));
|
||
if colors.palette != *palette {
|
||
*colors = LightningColors::new(palette);
|
||
}
|
||
for (pixel, energy) in canvas.image.pixels.iter_mut().zip(light) {
|
||
if energy >= 8 {
|
||
*pixel = colors.add_light(*pixel, usize::from(energy) / 8);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// A palette-keyed per-thread cache avoids repeating nearest-color searches
|
||
// during every flicker frame. Cached values are pure, so draw order/history
|
||
// cannot change the image; tests and previews may use different palettes.
|
||
std::thread_local! {
|
||
static LIGHTNING_COLORS: std::cell::RefCell<Option<LightningColors>> = const {
|
||
std::cell::RefCell::new(None)
|
||
};
|
||
}
|
||
struct LightningColors {
|
||
palette: [[u8; 3]; 256],
|
||
brightness: [u32; 256],
|
||
emission: [[f32; 3]; 32],
|
||
blend: [[u16; 257]; 32],
|
||
}
|
||
impl LightningColors {
|
||
fn new(palette: &[[u8; 3]; 256]) -> Self {
|
||
Self {
|
||
palette: *palette,
|
||
brightness: palette
|
||
.map(|rgb| 3 * u32::from(rgb[0]) + 6 * u32::from(rgb[1]) + u32::from(rgb[2])),
|
||
emission: std::array::from_fn(|level| {
|
||
let n = level as f32 / 31.0;
|
||
[230.0 * n.powf(2.5), 255.0 * n.powf(0.8), 255.0 * n.sqrt()]
|
||
}),
|
||
blend: [[u16::MAX; 257]; 32],
|
||
}
|
||
}
|
||
fn add_light(&mut self, pixel: u16, level: usize) -> u16 {
|
||
let background = usize::from(pixel.min(TRANSPARENT));
|
||
let result = &mut self.blend[level][background];
|
||
if *result == u16::MAX {
|
||
let (rgb, brightness) = if background < 256 {
|
||
(self.palette[background], self.brightness[background])
|
||
} else {
|
||
([0; 3], 0)
|
||
};
|
||
let target: [i32; 3] = std::array::from_fn(|i| {
|
||
(f32::from(rgb[i]) + self.emission[level][i])
|
||
.min(255.0)
|
||
.round() as i32
|
||
});
|
||
*result = self.palette[..240]
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(index, _)| self.brightness[*index] >= brightness)
|
||
.min_by_key(|(_, candidate)| {
|
||
(0..3)
|
||
.map(|i| (i32::from(candidate[i]) - target[i]).pow(2))
|
||
.sum::<i32>()
|
||
})
|
||
.map_or(pixel, |(index, _)| index as u16);
|
||
}
|
||
*result
|
||
}
|
||
}
|
||
|
||
// A local cosmetic hash stream: never touch the simulation or script RNG.
|
||
fn lightning_noise(state: &mut u32) -> f32 {
|
||
*state = state.wrapping_add(0x9e37_79b9);
|
||
let mut hash = *state;
|
||
hash = (hash ^ (hash >> 16)).wrapping_mul(0x85eb_ca6b);
|
||
hash = (hash ^ (hash >> 13)).wrapping_mul(0xc2b2_ae35);
|
||
hash ^= hash >> 16;
|
||
(hash >> 8) as f32 / 16_777_216.0
|
||
}
|
||
|
||
fn lightning_path(
|
||
from: [f32; 2],
|
||
to: [f32; 2],
|
||
displacement: f32,
|
||
depth: u32,
|
||
noise: &mut u32,
|
||
points: &mut Vec<[f32; 2]>,
|
||
) {
|
||
if depth == 0 {
|
||
points.push(to);
|
||
return;
|
||
}
|
||
let dx = to[0] - from[0];
|
||
let dy = to[1] - from[1];
|
||
let length = dx.hypot(dy).max(0.001);
|
||
let along = 0.35 + lightning_noise(noise) * 0.3;
|
||
let bend = (lightning_noise(noise) * 2.0 - 1.0) * displacement;
|
||
let mid = [
|
||
from[0] + dx * along - dy / length * bend,
|
||
from[1] + dy * along + dx / length * bend,
|
||
];
|
||
lightning_path(from, mid, displacement * 0.6, depth - 1, noise, points);
|
||
lightning_path(mid, to, displacement * 0.6, depth - 1, noise, points);
|
||
}
|
||
|
||
fn lightning_channel(
|
||
canvas: &Canvas,
|
||
light: &mut [u8],
|
||
falloff: &[u8; 513],
|
||
segment: &[[f32; 2]],
|
||
energy: [f32; 2],
|
||
width: f32,
|
||
) {
|
||
let scale = canvas.scale as f32;
|
||
let [x, y] = segment[0].map(|n| n * scale);
|
||
let [to_x, to_y] = segment[1].map(|n| n * scale);
|
||
let dx = to_x - x;
|
||
let dy = to_y - y;
|
||
// Clip before rasterizing: valid API coordinates can be well offscreen.
|
||
// Beyond 2.7 logical pixels the falloff is below the first visible level.
|
||
let pad = 2.7 * scale * width;
|
||
let mut start: f32 = 0.0;
|
||
let mut end: f32 = 1.0;
|
||
for (direction, distance) in [
|
||
(-dx, x + pad),
|
||
(dx, canvas.image.width as f32 - 1.0 + pad - x),
|
||
(-dy, y + pad),
|
||
(dy, canvas.image.height as f32 - 1.0 + pad - y),
|
||
] {
|
||
if direction == 0.0 {
|
||
if distance < 0.0 {
|
||
return;
|
||
}
|
||
} else if direction < 0.0 {
|
||
start = start.max(distance / direction);
|
||
} else {
|
||
end = end.min(distance / direction);
|
||
}
|
||
if start > end {
|
||
return;
|
||
}
|
||
}
|
||
let bounds = [
|
||
[
|
||
(x + dx * start).min(x + dx * end),
|
||
(x + dx * start).max(x + dx * end),
|
||
],
|
||
[
|
||
(y + dy * start).min(y + dy * end),
|
||
(y + dy * start).max(y + dy * end),
|
||
],
|
||
];
|
||
let major_x = dx.abs() >= dy.abs();
|
||
let (major, minor, major_origin, minor_origin, major_delta, minor_delta) = if major_x {
|
||
(0, 1, x, y, dx, dy)
|
||
} else {
|
||
(1, 0, y, x, dy, dx)
|
||
};
|
||
let size = [canvas.image.width as i32, canvas.image.height as i32];
|
||
let first = ((bounds[major][0] - pad).floor() as i32).max(0);
|
||
let last = ((bounds[major][1] + pad).ceil() as i32).min(size[major] - 1);
|
||
let length_squared = (dx * dx + dy * dy).max(0.000_001);
|
||
// Walk a narrow strip along the major axis instead of visiting the entire
|
||
// diagonal bounding rectangle. Work is bounded by the visible canvas.
|
||
for a in first..=last {
|
||
let t = if major_delta.abs() > 0.001 {
|
||
((a as f32 - major_origin) / major_delta).clamp(start, end)
|
||
} else {
|
||
start
|
||
};
|
||
let center = minor_origin + minor_delta * t;
|
||
let minor_pad = pad * std::f32::consts::SQRT_2;
|
||
let low = ((center - minor_pad).floor() as i32).max(0);
|
||
let high = ((center + minor_pad).ceil() as i32).min(size[minor] - 1);
|
||
for b in low..=high {
|
||
let (column, row) = if major_x { (a, b) } else { (b, a) };
|
||
let px = column as f32 - x;
|
||
let py = row as f32 - y;
|
||
let along = ((px * dx + py * dy) / length_squared).clamp(0.0, 1.0);
|
||
let distance_squared = ((px - dx * along).powi(2) + (py - dy * along).powi(2))
|
||
/ (scale * scale * width * width);
|
||
let lookup = (distance_squared * 32.0).round() as usize;
|
||
if let Some(brightness) = falloff.get(lookup) {
|
||
let strength = energy[0] + (energy[1] - energy[0]) * along;
|
||
let brightness = (f32::from(*brightness) * strength).round() as u8;
|
||
let pixel = &mut light[row as usize * canvas.image.width + column as usize];
|
||
*pixel = (*pixel).max(brightness);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
fn uniform_image_unchanged() {
|
||
let s = Sprite {
|
||
width: 2,
|
||
height: 2,
|
||
pixels: vec![9; 4],
|
||
};
|
||
for n in [2, 3, 4] {
|
||
assert!(upscale(&s, n).unwrap().pixels.iter().all(|p| *p == 9));
|
||
}
|
||
}
|
||
#[test]
|
||
fn scale_four_is_two_passes() {
|
||
let s = Sprite {
|
||
width: 3,
|
||
height: 3,
|
||
pixels: vec![0, 1, 0, 1, 1, 0, 0, 0, TRANSPARENT],
|
||
};
|
||
assert_eq!(
|
||
upscale(&s, 4).unwrap(),
|
||
upscale(&upscale(&s, 2).unwrap(), 2).unwrap()
|
||
);
|
||
}
|
||
#[test]
|
||
fn scale_three_guard_preserves_cross() {
|
||
let s = Sprite {
|
||
width: 3,
|
||
height: 3,
|
||
pixels: vec![1, 2, 3, 4, 5, 4, 6, 2, 7],
|
||
};
|
||
let out = upscale(&s, 3).unwrap();
|
||
for y in 3..6 {
|
||
for x in 3..6 {
|
||
assert_eq!(out.pixels[y * 9 + x], 5)
|
||
}
|
||
}
|
||
}
|
||
#[test]
|
||
fn clipped_transparency() {
|
||
let mut c = Canvas::new(1);
|
||
c.clear(3);
|
||
c.blit(
|
||
&Sprite {
|
||
width: 2,
|
||
height: 1,
|
||
pixels: vec![8, TRANSPARENT],
|
||
},
|
||
-1,
|
||
0,
|
||
);
|
||
assert_eq!(c.image.pixels[0], 3);
|
||
}
|
||
}
|
||
|
||
pub struct BitmapFont {
|
||
height: usize,
|
||
glyphs: Vec<Option<Sprite>>,
|
||
widths: Vec<usize>,
|
||
}
|
||
// Mod labels are UTF-8; catalog bitmap fonts have only 256 glyphs. Keep width
|
||
// measurement and drawing on the same normalized character stream.
|
||
fn bitmap_chars(text: &str) -> impl Iterator<Item = u8> + '_ {
|
||
text.chars().flat_map(|character| {
|
||
match character {
|
||
'\u{2010}'..='\u{2015}' | '\u{2212}' => [Some(b'-'), None, None],
|
||
'\u{2018}' | '\u{2019}' => [Some(b'\''), None, None],
|
||
'\u{201c}' | '\u{201d}' => [Some(b'"'), None, None],
|
||
'\u{2026}' => [Some(b'.'), Some(b'.'), Some(b'.')],
|
||
'\u{00a0}' => [Some(b' '), None, None],
|
||
character if character.is_control() => [Some(b' '), None, None],
|
||
character if (character as u32) <= 255 => [Some(character as u8), None, None],
|
||
_ => [Some(b'?'), None, None],
|
||
}
|
||
.into_iter()
|
||
.flatten()
|
||
})
|
||
}
|
||
impl BitmapFont {
|
||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||
let height = crate::assets::i32_at(bytes, 0)?;
|
||
ensure!(
|
||
(1..=64).contains(&height) && bytes.len() >= 772,
|
||
"invalid bitmap font"
|
||
);
|
||
let mut glyphs = Vec::new();
|
||
let mut widths = Vec::new();
|
||
for ch in 0..256 {
|
||
let offset = crate::assets::i16_at(bytes, 4 + ch * 2)?;
|
||
let width = bytes[516 + ch] as usize;
|
||
widths.push(width);
|
||
if offset < 0 || width == 0 {
|
||
glyphs.push(None);
|
||
continue;
|
||
}
|
||
let start = 772 + offset as usize;
|
||
let len = width * height as usize;
|
||
let data = bytes
|
||
.get(start..start + len)
|
||
.ok_or_else(|| anyhow::anyhow!("truncated font glyph {ch}"))?;
|
||
glyphs.push(Some(Sprite {
|
||
width,
|
||
height: height as usize,
|
||
pixels: data
|
||
.iter()
|
||
.map(|v| if *v == 0 { TRANSPARENT } else { *v as u16 })
|
||
.collect(),
|
||
}));
|
||
}
|
||
Ok(Self {
|
||
height: height as usize,
|
||
glyphs,
|
||
widths,
|
||
})
|
||
}
|
||
pub fn text(&self, canvas: &mut Canvas, x: i32, y: i32, text: &str, color: u16) {
|
||
let mut cursor = x;
|
||
for ch in bitmap_chars(text) {
|
||
if let Some(g) = &self.glyphs[ch as usize] {
|
||
let mut glyph = g.clone();
|
||
for p in &mut glyph.pixels {
|
||
if *p != TRANSPARENT {
|
||
*p = if color > TRANSPARENT {
|
||
color
|
||
} else {
|
||
(*p + color.saturating_sub(1)).min(255)
|
||
};
|
||
}
|
||
}
|
||
canvas.blit(
|
||
&nearest(&glyph, canvas.scale),
|
||
cursor * canvas.scale as i32,
|
||
y * canvas.scale as i32,
|
||
);
|
||
}
|
||
cursor += self.widths[ch as usize] as i32 + 1;
|
||
if cursor > (canvas.image.width / canvas.scale) as i32 - 6 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
/// Logical pixel width, including the same one-pixel glyph spacing as `text`.
|
||
pub fn text_width(&self, text: &str) -> usize {
|
||
bitmap_chars(text)
|
||
.map(|byte| self.widths[byte as usize] + 1)
|
||
.sum()
|
||
}
|
||
pub fn height(&self) -> usize {
|
||
self.height
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod font_label_tests {
|
||
use super::*;
|
||
#[test]
|
||
fn unicode_mod_punctuation_draws_and_measures_like_supported_glyphs() {
|
||
let font = BitmapFont {
|
||
height: 1,
|
||
glyphs: (0..256)
|
||
.map(|index| {
|
||
Some(Sprite {
|
||
width: 1,
|
||
height: 1,
|
||
pixels: vec![index as u16],
|
||
})
|
||
})
|
||
.collect(),
|
||
widths: vec![1; 256],
|
||
};
|
||
let mut unicode = Canvas::new(2);
|
||
let mut ascii = Canvas::new(2);
|
||
let label = "Iron Coast — ‘Stormwall’…";
|
||
let normalized = "Iron Coast - 'Stormwall'...";
|
||
font.text(&mut unicode, 4, 4, label, 1);
|
||
font.text(&mut ascii, 4, 4, normalized, 1);
|
||
assert_eq!(unicode.image.pixels, ascii.image.pixels);
|
||
assert_eq!(font.text_width(label), font.text_width(normalized));
|
||
assert_eq!(font.text_width("未知"), font.text_width("??"));
|
||
}
|
||
}
|