Resumed to project. INIT

This commit is contained in:
2026-06-17 08:23:13 +02:00
commit fd02ed6d8b
51 changed files with 5067 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
[dependencies]
shared = { path = "../shared" }
pbio = { git = "https://codeberg.org/irrlicht/rust-pbio.git" }
bytemuck = { version = "1.24.0", features = ["derive"] }
+72
View File
@@ -0,0 +1,72 @@
use std::fs::File;
use std::io::Read;
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Image {
/// Load an 8-bit indexed TGA (type 1 or type 9) from disk.
pub fn from_tga(path: &str) -> Self {
let mut file = File::open(path).unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
let image_type = data[2];
let width = u16::from_le_bytes([data[12], data[13]]) as u32;
let height = u16::from_le_bytes([data[14], data[15]]) as u32;
let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize;
let colormap_entry_bytes = data[7] as usize / 8;
let pixel_offset = 18 + colormap_entries * colormap_entry_bytes;
let pixel_count = (width * height) as usize;
let mut pixels = vec![0u8; pixel_count];
match image_type {
1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]),
9 => decode_rle(&data[pixel_offset..], &mut pixels),
_ => panic!("unsupported TGA image type: {}", image_type),
}
Image { width, height, pixels }
}
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
pub fn to_tileset(&self) -> Vec<[u8; 64]> {
let w = self.width as usize;
let cols = w / 8;
let rows = self.height as usize / 8;
let mut tiles = Vec::with_capacity(rows * cols);
for row in 0..rows {
for col in 0..cols {
let mut tile = [0u8; 64];
for tr in 0..8 {
for tc in 0..8 {
tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc];
}
}
tiles.push(tile);
}
}
tiles
}
}
fn decode_rle(src: &[u8], dst: &mut [u8]) {
let mut si = 0;
let mut di = 0;
while di < dst.len() {
let header = src[si];
si += 1;
let count = (header & 0x7f) as usize + 1;
if header & 0x80 != 0 {
dst[di..di + count].fill(src[si]);
si += 1;
} else {
dst[di..di + count].copy_from_slice(&src[si..si + count]);
si += count;
}
di += count;
}
}
+124
View File
@@ -0,0 +1,124 @@
mod pixelhelper;
use crate::assets::Image;
use crate::input::{GameAction, InputState};
use crate::net::{EntityInfo, NetClient, NetEvent};
use shared::{chunk_id, player_action};
pub enum GameSignal {
Quit,
}
pub struct Game {
#[allow(dead_code)]
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
net: NetClient,
player_entity_id: u32,
player_pos: (i32, i32),
entities: Vec<EntityInfo>,
}
impl Game {
pub fn start() -> Self {
let image = Image::from_tga("assets/tilesets/overworld.tga");
let tileset = image.to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let server_addr = "127.0.0.1:7777".parse().unwrap();
Game {
tileset,
entity_tileset,
net: NetClient::new(server_addr),
player_entity_id: 0,
player_pos: (16, 16),
entities: Vec::new(),
}
}
pub fn update(&mut self, render_frame: &mut [u8], _dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.button_held(GameAction::Up) { self.net.send_action(player_action::NORTH); }
if input.button_held(GameAction::Down) { self.net.send_action(player_action::SOUTH); }
if input.button_held(GameAction::Left) { self.net.send_action(player_action::WEST); }
if input.button_held(GameAction::Right) { self.net.send_action(player_action::EAST); }
if input.button_pressed(GameAction::Confirm) {
self.net.send_ping();
println!("ping sent");
}
for event in self.net.poll() {
match event {
NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"),
NetEvent::State { tick, player_entity_id } => {
println!("state tick={tick} player_entity_id={player_entity_id}");
self.player_entity_id = player_entity_id;
}
NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
NetEvent::Entity { entities } => {
self.player_pos = entities.iter()
.find(|e| e.id == self.player_entity_id)
.map(|e| (e.pos_x as i32, e.pos_y as i32))
.unwrap_or(self.player_pos);
self.entities = entities;
}
NetEvent::Disconnected => println!("disconnected from server"),
}
}
if input.button_pressed(GameAction::Cancel) {
println!("Goodbye!");
return Some(GameSignal::Quit);
}
self.render_viewport(render_frame);
None
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
const W: usize = 320;
frame.fill(0);
let ox = self.player_pos.0 - 15;
let oy = self.player_pos.1 - 15;
for vy in 0..30i32 {
for vx in 0..30i32 {
let wx = ox + vx;
let wy = oy + vy;
let cid = chunk_id(wx.div_euclid(32) as i16, wy.div_euclid(32) as i16);
let tile_idx = wy.rem_euclid(32) as usize * 32 + wx.rem_euclid(32) as usize;
let tile_id = self.net.chunk_cache.get(&cid)
.map(|c| c.tiles[tile_idx])
.unwrap_or(0);
let px = vx * 8;
let py = vy * 8;
if let Some(tile) = self.tileset.get(tile_id as usize) {
pixelhelper::blit_tile(frame, W, px, py, tile);
} else {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
}
}
}
}
}
// Entity pass
let ox = self.player_pos.0 - 15;
let oy = self.player_pos.1 - 15;
for e in &self.entities {
let vx = e.pos_x as i32 - ox;
let vy = e.pos_y as i32 - oy;
if vx < 0 || vx >= 30 || vy < 0 || vy >= 30 { continue; }
if let Some(tile) = self.entity_tileset.get(e.type_id as usize) {
pixelhelper::blit_tile(frame, W, vx * 8, vy * 8, tile);
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/// Write a single palette index. Out-of-bounds coordinates are silently ignored.
#[allow(dead_code)]
pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
let dst_h = (dst.len() / dst_w) as i32;
if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h {
return;
}
dst[dst_w * y as usize + x as usize] = color;
}
/// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs).
#[allow(dead_code)]
pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h];
for row in 0..h {
let s = src_w * (y + row) + x;
let d = w * row;
out[d..d + w].copy_from_slice(&src[s..s + w]);
}
out
}
/// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy).
/// Clips against all four edges of `dst`; the destination position may be negative.
#[allow(dead_code)]
pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
let src_h = (src.len() / src_w) as i32;
for row in 0..src_h {
let y = dy + row;
if y < 0 || y >= dst_h {
continue;
}
// Clip the horizontal span once per row, then copy it in one shot.
let x0 = dx.max(0);
let x1 = (dx + src_w as i32).min(dst_w_i);
if x0 >= x1 {
continue;
}
let d = dst_w * y as usize + x0 as usize;
let s = (row * src_w as i32 + (x0 - dx)) as usize;
let len = (x1 - x0) as usize;
dst[d..d + len].copy_from_slice(&src[s..s + len]);
}
}
/// Blit an 8×8 tile with transparency: index 0 is skipped.
/// Clips against all four edges of `dst`; the destination position may be negative.
pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
for row in 0..8i32 {
let y = dy + row;
if y < 0 || y >= dst_h {
continue;
}
for col in 0..8i32 {
let x = dx + col;
if x < 0 || x >= dst_w_i {
continue;
}
let px = tile[(row * 8 + col) as usize];
if px != 0 {
dst[dst_w * y as usize + x as usize] = px;
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
use pbio::Key;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameAction {
Up,
Down,
Left,
Right,
Confirm,
Cancel,
}
pub struct InputMap {
bindings: Vec<(Key, GameAction)>,
}
impl InputMap {
pub fn new() -> Self {
Self {
bindings: vec![
(Key::Up, GameAction::Up),
(Key::Down, GameAction::Down),
(Key::Left, GameAction::Left),
(Key::Right, GameAction::Right),
(Key::W, GameAction::Up),
(Key::S, GameAction::Down),
(Key::A, GameAction::Left),
(Key::D, GameAction::Right),
(Key::Enter, GameAction::Confirm),
(Key::Escape, GameAction::Cancel),
],
}
}
pub fn translate(&self, key: Key) -> Option<GameAction> {
self.bindings.iter().find(|(k, _)| *k == key).map(|(_, a)| *a)
}
}
pub struct InputState {
pressed: Vec<GameAction>,
held: Vec<GameAction>,
released: Vec<GameAction>,
}
impl InputState {
pub fn new() -> Self {
Self { pressed: Vec::new(), held: Vec::new(), released: Vec::new() }
}
pub fn push(&mut self, action: GameAction) {
self.pressed.push(action);
if !self.held.contains(&action) {
self.held.push(action);
}
}
pub fn release(&mut self, action: GameAction) {
self.held.retain(|a| *a != action);
if !self.released.contains(&action) {
self.released.push(action);
}
}
pub fn button_pressed(&self, action: GameAction) -> bool {
self.pressed.contains(&action)
}
#[allow(dead_code)]
pub fn button_held(&self, action: GameAction) -> bool {
self.held.contains(&action)
}
#[allow(dead_code)]
pub fn button_released(&self, action: GameAction) -> bool {
self.released.contains(&action)
}
pub fn clear(&mut self) {
self.pressed.clear();
self.released.clear();
}
}
+60
View File
@@ -0,0 +1,60 @@
use pbio::{Event, Platform, PlatformConfig};
use std::time::{Duration, Instant};
mod assets;
mod game;
mod input;
mod net;
fn main() {
let mut plat = Platform::new(PlatformConfig {
title: "Roguelike".into(),
window_size: (800, 600),
framebuffer_size: (320, 240),
aspect_ratio: Some(4.0 / 3.0),
palette: pbio::palette::RGB332,
vsync: true,
mouse_capture: false,
mouse_visible: true,
});
let input_map = input::InputMap::new();
let mut input_state = input::InputState::new();
let mut last_update = Instant::now();
let mut game = game::Game::start();
while !plat.should_close() {
plat.poll_events(Some(Duration::from_millis(20)));
let events: Vec<Event> = plat.drain_events().collect();
for ev in events {
match ev {
Event::Key { key, pressed: true, .. } => {
if let Some(action) = input_map.translate(key) {
input_state.push(action);
}
}
Event::Key { key, pressed: false, .. } => {
if let Some(action) = input_map.translate(key) {
input_state.release(action);
}
}
Event::CloseRequested => plat.request_close(),
_ => {}
}
}
let dt = last_update.elapsed().as_millis();
if dt < 16 { continue; }
last_update = Instant::now();
if let Some(game::GameSignal::Quit) =
game.update(plat.framebuffer_mut(), dt as usize, &input_state)
{
plat.request_close();
}
input_state.clear();
plat.present();
}
}
+208
View File
@@ -0,0 +1,208 @@
use std::collections::HashMap;
use std::mem::size_of;
use std::net::{SocketAddr, UdpSocket};
use std::time::{Duration, Instant};
use bytemuck::bytes_of;
use shared::{ActionPacket, ChunkEntry, ChunkPacket, EntityPacket,
Header, PingPacket, PongPacket, StatePacket,
MAGIC, VERSION, packet_type};
pub struct CachedChunk {
pub version: u16,
pub tiles: Box<[u16; 1024]>, // decoded global tile IDs
}
pub struct EntityInfo {
pub id: u32,
pub type_id: u16,
pub pos_x: i16,
pub pos_y: i16,
pub hp: u16,
pub hp_max: u16,
pub flags: u8,
}
pub enum NetEvent {
Pong { rtt_ms: u64 },
State { tick: u32, player_entity_id: u32 },
Chunk { chunk_id: u32 },
Entity { entities: Vec<EntityInfo> },
Disconnected,
}
const FNV_BASIS: u32 = 2_166_136_261;
const FNV_PRIME: u32 = 16_777_619;
pub struct NetClient {
socket: UdpSocket,
ping_sent_at: Option<Instant>,
action_sequence: u32,
last_action_sent: Instant,
last_state_received: Option<Instant>,
pub chunk_cache: HashMap<u32, CachedChunk>,
last_state_chunks: Option<[ChunkEntry; 9]>,
pending_entities: Vec<EntityInfo>,
last_entity_checksum: u32,
last_state_tick: u32,
entity_checksum_accum: u32,
}
impl NetClient {
pub fn new(server_addr: SocketAddr) -> Self {
let socket = UdpSocket::bind("0.0.0.0:0").expect("bind failed");
socket.connect(server_addr).expect("connect failed");
socket.set_nonblocking(true).expect("set_nonblocking failed");
Self {
socket,
ping_sent_at: None,
action_sequence: 0,
last_action_sent: Instant::now(),
last_state_received: None,
chunk_cache: HashMap::new(),
last_state_chunks: None,
pending_entities: Vec::new(),
last_entity_checksum: 0,
last_state_tick: 0,
entity_checksum_accum: FNV_BASIS,
}
}
pub fn send_action(&mut self, player_action: u16) {
self.action_sequence += 1;
let packet = ActionPacket {
header: Header::new(packet_type::ACTION),
auth_token: 0,
sequence: self.action_sequence,
cache: {
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
if let Some(slots) = self.last_state_chunks {
for (i, slot) in slots.iter().enumerate() {
let cid = slot.chunk_id;
let ver = self.chunk_cache.get(&cid)
.map(|c| c.version).unwrap_or(0);
cache[i] = ChunkEntry { chunk_id: cid, version: ver };
}
}
cache
},
player_action,
};
self.socket.send(bytes_of(&packet)).ok();
self.last_action_sent = Instant::now();
}
pub fn send_ping(&mut self) {
let ping = PingPacket { header: Header::new(packet_type::PING), timestamp_ms: 0 };
self.ping_sent_at = Some(Instant::now());
self.socket.send(bytes_of(&ping)).ok();
}
pub fn poll(&mut self) -> Vec<NetEvent> {
const KEEPALIVE: Duration = Duration::from_secs(2);
const TIMEOUT: Duration = Duration::from_secs(10);
if self.last_action_sent.elapsed() >= KEEPALIVE {
self.send_action(shared::player_action::NOOP);
}
let mut events = Vec::new();
let mut buf = [0u8; 1200];
loop {
match self.socket.recv(&mut buf) {
Ok(n) => {
if n < size_of::<Header>() { continue; }
let header: Header = *bytemuck::from_bytes(&buf[..size_of::<Header>()]);
if header.magic != MAGIC || header.version != VERSION { continue; }
match header.packet_type {
packet_type::PONG if n >= size_of::<PongPacket>() => {
if let Some(sent_at) = self.ping_sent_at.take() {
let rtt_ms = sent_at.elapsed().as_millis() as u64;
events.push(NetEvent::Pong { rtt_ms });
}
}
packet_type::STATE if n >= size_of::<StatePacket>() => {
let pkt: StatePacket = *bytemuck::from_bytes(&buf[..size_of::<StatePacket>()]);
self.last_state_received = Some(Instant::now());
self.last_state_chunks = Some(pkt.chunks);
self.last_entity_checksum = pkt.entity_checksum;
self.last_state_tick = pkt.tick;
self.pending_entities.clear();
self.entity_checksum_accum = FNV_BASIS;
events.push(NetEvent::State { tick: pkt.tick, player_entity_id: pkt.player_entity_id });
}
packet_type::ENTITY if n >= size_of::<EntityPacket>() => {
let pkt: EntityPacket = *bytemuck::from_bytes(&buf[..size_of::<EntityPacket>()]);
if pkt.tick != self.last_state_tick {
// stale datagram from a previous state cycle
} else {
for i in 0..pkt.entity_count as usize {
let e = pkt.entities[i];
for byte in bytemuck::bytes_of(&e) {
self.entity_checksum_accum ^= *byte as u32;
self.entity_checksum_accum =
self.entity_checksum_accum.wrapping_mul(FNV_PRIME);
}
self.pending_entities.push(EntityInfo {
id: e.id,
type_id: e.type_id,
pos_x: e.pos_x,
pos_y: e.pos_y,
hp: e.hp,
hp_max: e.hp_max,
flags: e.entity_flags,
});
}
if pkt.packet_flags & 1 == 0 {
// last datagram in sequence
if self.entity_checksum_accum != self.last_entity_checksum {
self.send_action(shared::player_action::NOOP);
}
events.push(NetEvent::Entity {
entities: std::mem::take(&mut self.pending_entities),
});
}
}
}
packet_type::CHUNK if n >= size_of::<ChunkPacket>() => {
let pkt: ChunkPacket = *bytemuck::from_bytes(&buf[..size_of::<ChunkPacket>()]);
let id = pkt.chunk.chunk_id;
let version = pkt.chunk.version;
let palette = pkt.palette;
let raw = pkt.tiles;
let mut tiles = Box::new([0u16; 1024]);
for i in 0..256usize {
let b0 = raw[i * 3];
let b1 = raw[i * 3 + 1];
let b2 = raw[i * 3 + 2];
let ia = (b0 & 0x3F) as usize;
let ib = ((b0 >> 6) | ((b1 & 0x0F) << 2)) as usize;
let ic = ((b1 >> 4) | ((b2 & 0x03) << 4)) as usize;
let id_ = (b2 >> 2) as usize;
tiles[i * 4] = palette[ia];
tiles[i * 4 + 1] = palette[ib];
tiles[i * 4 + 2] = palette[ic];
tiles[i * 4 + 3] = palette[id_];
}
self.chunk_cache.insert(id, CachedChunk { version, tiles });
events.push(NetEvent::Chunk { chunk_id: id });
}
_ => {}
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
if let Some(t) = self.last_state_received {
if t.elapsed() >= TIMEOUT {
self.last_state_received = None;
events.push(NetEvent::Disconnected);
}
}
events
}
}