summaryrefslogtreecommitdiff
path: root/pixel-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'pixel-client/src')
-rw-r--r--pixel-client/src/game.rs344
-rw-r--r--pixel-client/src/helper.rs11
-rw-r--r--pixel-client/src/main.rs105
-rw-r--r--pixel-client/src/network.rs123
-rw-r--r--pixel-client/src/render/misc.rs17
-rw-r--r--pixel-client/src/render/mod.rs158
-rw-r--r--pixel-client/src/render/sprite.rs76
-rw-r--r--pixel-client/src/tilemap.rs117
8 files changed, 951 insertions, 0 deletions
diff --git a/pixel-client/src/game.rs b/pixel-client/src/game.rs
new file mode 100644
index 00000000..7d8e466a
--- /dev/null
+++ b/pixel-client/src/game.rs
@@ -0,0 +1,344 @@
+/*
+ Hurry Curry! - a game about cooking
+ Copyright 2024 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+*/
+use crate::{
+ helper::Vec2InterpolateExt,
+ render::{
+ misc::MiscTextures,
+ sprite::{Sprite, SpriteDraw},
+ AtlasLayout, SpriteRenderer,
+ },
+ tilemap::Tilemap,
+};
+use hurrycurry_protocol::{
+ glam::{IVec2, Vec2},
+ movement::MovementBase,
+ ClientGamedata, ItemIndex, ItemLocation, PacketC, PacketS, PlayerID, TileIndex,
+};
+use log::{info, warn};
+use sdl2::{
+ keyboard::{KeyboardState, Scancode},
+ rect::Rect,
+};
+use std::collections::{HashMap, HashSet, VecDeque};
+
+pub struct Game {
+ data: ClientGamedata,
+ tiles: HashMap<IVec2, Tile>,
+ tilemap: Tilemap,
+ collision_map: HashSet<IVec2>,
+ players: HashMap<PlayerID, Player>,
+ my_id: PlayerID,
+
+ camera_center: Vec2,
+ misc_textures: MiscTextures,
+ item_sprites: Vec<Sprite>,
+ movement_send_cooldown: f32,
+ interacting: bool,
+ score: Score,
+}
+
+#[derive(Debug, Default)]
+pub struct Score {
+ points: i64,
+ demands_failed: usize,
+ demands_completed: usize,
+ time_remaining: f32,
+}
+
+pub struct Tile {
+ _kind: TileIndex,
+ item: Option<Item>,
+}
+
+pub struct Player {
+ movement: MovementBase,
+ item: Option<Item>,
+ _name: String,
+ _character: i32,
+}
+
+pub struct Item {
+ position: Vec2,
+ kind: ItemIndex,
+ progress: Option<(f32, bool)>,
+}
+
+impl Game {
+ pub fn new(layout: &AtlasLayout) -> Self {
+ Self {
+ tiles: HashMap::new(),
+ players: HashMap::new(),
+ tilemap: Tilemap::default(),
+ my_id: PlayerID(0),
+ data: ClientGamedata::default(),
+ collision_map: HashSet::new(),
+ movement_send_cooldown: 0.,
+ misc_textures: MiscTextures::init(layout),
+ item_sprites: Vec::new(),
+ interacting: false,
+ score: Score::default(),
+ camera_center: Vec2::ZERO,
+ }
+ }
+
+ pub fn packet_in(&mut self, packet: PacketC, renderer: &mut SpriteRenderer) {
+ match packet {
+ PacketC::Init { id } => self.my_id = id,
+ PacketC::Data { data } => {
+ self.tilemap.init(&data.tile_names, renderer.atlas_layout());
+ self.item_sprites = data
+ .item_names
+ .iter()
+ .map(|name| {
+ Sprite::new(
+ renderer
+ .atlas_layout()
+ .get(&format!("{name}+a"))
+ .copied()
+ .unwrap_or_else(|| {
+ warn!("no sprite for item {name:?}");
+ Rect::new(0, 0, 32, 24)
+ }),
+ Vec2::new(0., 0.0),
+ 0.1,
+ )
+ })
+ .collect();
+ self.data = data;
+ }
+ PacketC::UpdateMap {
+ tile,
+ kind,
+ neighbors,
+ } => {
+ if let Some(kind) = kind {
+ self.tiles.insert(
+ tile,
+ Tile {
+ _kind: kind,
+ item: None,
+ },
+ );
+ if self.data.tile_collide[kind.0] {
+ self.collision_map.remove(&tile);
+ } else {
+ self.collision_map.insert(tile);
+ }
+ } else {
+ self.tiles.remove(&tile);
+ self.collision_map.remove(&tile);
+ }
+ self.tilemap.set(tile, kind, neighbors);
+ }
+ PacketC::AddPlayer {
+ id,
+ position,
+ character,
+ name,
+ } => {
+ info!("add player {} {name:?}", id.0);
+ self.players.insert(
+ id,
+ Player {
+ _character: character,
+ _name: name,
+ item: None,
+ movement: MovementBase {
+ position,
+ facing: Vec2::X,
+ rotation: 0.,
+ velocity: Vec2::ZERO,
+ boosting: false,
+ stamina: 0.,
+ },
+ },
+ );
+ }
+ PacketC::RemovePlayer { id } => {
+ info!("remove player {}", id.0);
+ self.players.remove(&id);
+ }
+ PacketC::Position {
+ player,
+ pos,
+ rot,
+ boosting,
+ } => {
+ if player != self.my_id {
+ if let Some(p) = self.players.get_mut(&player) {
+ p.movement.position = pos;
+ p.movement.rotation = rot;
+ p.movement.boosting = boosting;
+ }
+ }
+ }
+ PacketC::MoveItem { from, to } => *self.get_item(to) = self.get_item(from).take(),
+ PacketC::SetItem { location, item } => {
+ *self.get_item(location) = item.map(|kind| Item {
+ kind,
+ position: Vec2::ZERO,
+ progress: None,
+ })
+ }
+ PacketC::SetProgress {
+ item,
+ progress,
+ warn,
+ } => {
+ self.get_item(item).as_mut().unwrap().progress = progress.map(|s| (s, warn));
+ }
+ PacketC::Collide {
+ player: _,
+ force: _,
+ } => (),
+ PacketC::Communicate { .. } => {
+ // TODO
+ }
+ PacketC::ServerMessage { text: _ } => {
+ // TODO
+ }
+ PacketC::Score {
+ points,
+ demands_failed,
+ demands_completed,
+ time_remaining,
+ } => {
+ self.score.points = points;
+ self.score.demands_completed = demands_completed;
+ self.score.demands_failed = demands_failed;
+ self.score.time_remaining = time_remaining.unwrap_or(-1.);
+ }
+ PacketC::SetIngame { state: _, lobby: _ } => {
+ // TODO
+ }
+ PacketC::Error { message } => {
+ warn!("server error: {message:?}")
+ }
+ _ => (),
+ }
+ }
+
+ pub fn get_item(&mut self, location: ItemLocation) -> &mut Option<Item> {
+ match location {
+ ItemLocation::Tile(pos) => &mut self.tiles.get_mut(&pos).unwrap().item,
+ ItemLocation::Player(pid) => &mut self.players.get_mut(&pid).unwrap().item,
+ }
+ }
+
+ pub fn tick(&mut self, dt: f32, keyboard: &KeyboardState, packet_out: &mut VecDeque<PacketS>) {
+ let mut direction = IVec2::new(
+ keyboard.is_scancode_pressed(Scancode::D) as i32
+ - keyboard.is_scancode_pressed(Scancode::A) as i32,
+ keyboard.is_scancode_pressed(Scancode::S) as i32
+ - keyboard.is_scancode_pressed(Scancode::W) as i32,
+ )
+ .as_vec2();
+ let boost = keyboard.is_scancode_pressed(Scancode::K);
+ let interact = keyboard.is_scancode_pressed(Scancode::Space)
+ | keyboard.is_scancode_pressed(Scancode::J);
+
+ if interact {
+ direction *= 0.;
+ }
+
+ self.movement_send_cooldown -= dt;
+ let send_movement = self.movement_send_cooldown < 0.;
+ if send_movement {
+ self.movement_send_cooldown += 0.04
+ }
+
+ self.score.time_remaining -= dt;
+ self.score.time_remaining -= self.score.time_remaining.max(0.);
+
+ if interact != self.interacting {
+ if interact {
+ packet_out.push_back(PacketS::Interact {
+ pos: Some(self.players[&self.my_id].movement.get_interact_target()),
+ });
+ } else {
+ packet_out.push_back(PacketS::Interact { pos: None });
+ }
+ self.interacting = interact;
+ }
+
+ if let Some(player) = self.players.get_mut(&self.my_id) {
+ let movement_packet = player
+ .movement
+ .update(&self.collision_map, direction, boost, dt);
+ if send_movement {
+ packet_out.push_back(movement_packet);
+ }
+
+ self.camera_center.exp_to(player.movement.position, dt * 5.);
+ }
+
+ for (_pid, player) in &mut self.players {
+ if let Some(item) = &mut player.item {
+ item.position = player.movement.position
+ }
+ }
+ for (pos, tile) in &mut self.tiles {
+ if let Some(item) = &mut tile.item {
+ item.position = pos.as_vec2() + 0.5
+ }
+ }
+ }
+
+ pub fn draw(&self, ctx: &mut SpriteRenderer) {
+ ctx.set_view(-self.camera_center + (ctx.size / ctx.get_scale() / 2.), 1.);
+
+ self.tilemap.draw(ctx);
+
+ for p in self.players.values() {
+ ctx.draw_world(self.misc_textures.player.at(p.movement.position));
+ if let Some(item) = &p.item {
+ item.draw(ctx, &self.item_sprites, &self.misc_textures)
+ }
+ }
+ for tile in self.tiles.values() {
+ if let Some(item) = &tile.item {
+ item.draw(ctx, &self.item_sprites, &self.misc_textures)
+ }
+ }
+ }
+}
+
+impl Item {
+ pub fn draw(&self, ctx: &mut SpriteRenderer, item_sprites: &[Sprite], misc: &MiscTextures) {
+ ctx.draw_world(item_sprites[self.kind.0].at(self.position));
+ if let Some((progress, warn)) = self.progress {
+ let (bg, fg) = if warn {
+ ([100, 0, 0, 200], [255, 0, 0, 200])
+ } else {
+ ([0, 100, 0, 200], [0, 255, 0, 200])
+ };
+ ctx.draw_world(SpriteDraw::overlay(
+ misc.solid,
+ self.position + Vec2::new(-0.5, -1.3),
+ Vec2::new(1., 0.2),
+ Some(bg),
+ ));
+ ctx.draw_world(SpriteDraw::overlay(
+ misc.solid,
+ self.position + Vec2::new(-0.5, -1.3),
+ Vec2::new(progress, 0.2),
+ Some(fg),
+ ))
+ }
+ }
+}
diff --git a/pixel-client/src/helper.rs b/pixel-client/src/helper.rs
new file mode 100644
index 00000000..9654f519
--- /dev/null
+++ b/pixel-client/src/helper.rs
@@ -0,0 +1,11 @@
+use hurrycurry_protocol::glam::Vec2;
+
+pub trait Vec2InterpolateExt {
+ fn exp_to(&mut self, target: Vec2, dt: f32);
+}
+impl Vec2InterpolateExt for Vec2 {
+ fn exp_to(&mut self, target: Vec2, dt: f32) {
+ self.x = target.x + (self.x - target.x) * (-dt).exp();
+ self.y = target.y + (self.y - target.y) * (-dt).exp();
+ }
+}
diff --git a/pixel-client/src/main.rs b/pixel-client/src/main.rs
new file mode 100644
index 00000000..e3aaa5cc
--- /dev/null
+++ b/pixel-client/src/main.rs
@@ -0,0 +1,105 @@
+/*
+ Hurry Curry! - a game about cooking
+ Copyright 2024 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+*/
+use game::Game;
+use hurrycurry_protocol::glam::Vec2;
+use network::Network;
+use render::SpriteRenderer;
+use sdl2::{
+ event::Event,
+ keyboard::{KeyboardState, Keycode},
+ pixels::Color,
+};
+use std::time::{Duration, Instant};
+
+pub mod game;
+pub mod helper;
+pub mod network;
+pub mod render;
+pub mod tilemap;
+
+fn main() {
+ env_logger::init_from_env("LOG");
+
+ let sdl_context = sdl2::init().unwrap();
+
+ let video_subsystem = sdl_context.video().unwrap();
+ let window = video_subsystem
+ .window("Hurry Curry! Light Client", 1280, 720)
+ .position_centered()
+ .resizable()
+ .build()
+ .map_err(|e| e.to_string())
+ .unwrap();
+
+ let mut canvas = window
+ .into_canvas()
+ .accelerated()
+ .build()
+ .map_err(|e| e.to_string())
+ .unwrap();
+ let texture_creator = canvas.texture_creator();
+
+ let mut net = Network::connect("ws://127.0.0.1/").unwrap();
+ let mut renderer = SpriteRenderer::init(&texture_creator);
+ let mut game = Game::new(&renderer.atlas_layout());
+
+ net.queue_out.push_back(hurrycurry_protocol::PacketS::Join {
+ name: "light".to_string(),
+ character: 0,
+ });
+
+ let mut events = sdl_context.event_pump().unwrap();
+
+ let mut last_tick = Instant::now();
+
+ canvas.set_logical_size(320, 240).unwrap();
+
+ 'mainloop: loop {
+ net.poll();
+
+ let (width, height) = canvas.logical_size();
+ renderer.size = Vec2::new(width as f32, height as f32);
+
+ for packet in net.queue_in.drain(..) {
+ game.packet_in(packet, &mut renderer);
+ }
+
+ let keyboard = KeyboardState::new(&events);
+ let dt = last_tick.elapsed().min(Duration::from_secs_f32(1. / 30.));
+ game.tick(dt.as_secs_f32(), &keyboard, &mut net.queue_out);
+ last_tick += dt;
+
+ game.draw(&mut renderer);
+
+ canvas.set_draw_color(Color::BLACK);
+ canvas.clear();
+ renderer.submit(&mut canvas);
+ canvas.present();
+
+ for event in events.poll_iter() {
+ match event {
+ Event::Quit { .. }
+ | Event::KeyDown {
+ keycode: Option::Some(Keycode::Escape),
+ ..
+ } => break 'mainloop,
+ _ => {}
+ }
+ }
+ }
+}
diff --git a/pixel-client/src/network.rs b/pixel-client/src/network.rs
new file mode 100644
index 00000000..ed160773
--- /dev/null
+++ b/pixel-client/src/network.rs
@@ -0,0 +1,123 @@
+/*
+ Hurry Curry! - a game about cooking
+ Copyright 2024 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+*/
+use anyhow::Result;
+use hurrycurry_protocol::{PacketC, PacketS, BINCODE_CONFIG};
+use log::{debug, warn};
+use std::{collections::VecDeque, net::TcpStream};
+use tungstenite::{
+ client::{uri_mode, IntoClientRequest},
+ client_tls_with_config,
+ handshake::client::Request,
+ stream::{MaybeTlsStream, Mode},
+ util::NonBlockingError,
+ Message, WebSocket,
+};
+
+pub struct Network {
+ sock: WebSocket<MaybeTlsStream<TcpStream>>,
+ pub queue_in: VecDeque<PacketC>,
+ pub queue_out: VecDeque<PacketS>,
+}
+
+impl Network {
+ pub fn connect(addr: &str) -> Result<Self> {
+ let (parts, _) = addr.into_client_request().unwrap().into_parts();
+ let mut builder = Request::builder()
+ .uri(parts.uri.clone().clone())
+ .method(parts.method.clone())
+ .version(parts.version);
+ *builder.headers_mut().unwrap() = parts.headers.clone();
+ let request = builder.body(()).unwrap();
+
+ let host = request.uri().host().unwrap();
+ let host = if host.starts_with('[') {
+ &host[1..host.len() - 1]
+ } else {
+ host
+ };
+ let port = request
+ .uri()
+ .port_u16()
+ .unwrap_or(match uri_mode(request.uri())? {
+ Mode::Plain => 27032,
+ Mode::Tls => 443,
+ });
+ let stream = TcpStream::connect((host, port))?;
+ stream.set_nodelay(true).unwrap();
+
+ let (mut sock, _) = client_tls_with_config(request, stream, None, None).unwrap();
+
+ match sock.get_mut() {
+ MaybeTlsStream::Plain(s) => s.set_nonblocking(true).unwrap(),
+ MaybeTlsStream::Rustls(s) => s.sock.set_nonblocking(true).unwrap(),
+ _ => todo!(),
+ };
+
+ Ok(Self {
+ sock,
+ queue_in: VecDeque::new(),
+ queue_out: VecDeque::new(),
+ })
+ }
+ pub fn poll(&mut self) {
+ loop {
+ self.queue_in.extend(match self.sock.read() {
+ Ok(Message::Text(packet)) => match serde_json::from_str(&packet) {
+ Ok(packet) => {
+ debug!("<- {packet:?}");
+ Some(packet)
+ }
+ Err(e) => {
+ warn!("invalid json packet: {e:?}");
+ None
+ }
+ },
+ Ok(Message::Binary(packet)) => {
+ match bincode::decode_from_slice(&packet, BINCODE_CONFIG) {
+ Ok((packet, _)) => {
+ debug!("<- {packet:?}");
+ Some(packet)
+ }
+ Err(e) => {
+ warn!("invalid bincode packet: {e:?}");
+ None
+ }
+ }
+ }
+ Ok(_) => None,
+ Err(e) => {
+ if let Some(e) = e.into_non_blocking() {
+ warn!("{e:?}");
+ None
+ } else {
+ break;
+ }
+ }
+ });
+ }
+
+ for packet in self.queue_out.drain(..) {
+ debug!("-> {packet:?}");
+ self.sock
+ .write(Message::Text(serde_json::to_string(&packet).unwrap()))
+ .unwrap();
+ }
+
+ self.sock.flush().unwrap();
+ }
+}
diff --git a/pixel-client/src/render/misc.rs b/pixel-client/src/render/misc.rs
new file mode 100644
index 00000000..9f866568
--- /dev/null
+++ b/pixel-client/src/render/misc.rs
@@ -0,0 +1,17 @@
+use super::{sprite::Sprite, AtlasLayout};
+use hurrycurry_protocol::glam::Vec2;
+use sdl2::rect::Rect;
+
+pub struct MiscTextures {
+ pub player: Sprite,
+ pub solid: Rect,
+}
+
+impl MiscTextures {
+ pub fn init(layout: &AtlasLayout) -> Self {
+ MiscTextures {
+ player: Sprite::new(*layout.get("player+a").unwrap(), Vec2::Y * 0.3, 0.5 + 0.3),
+ solid: *layout.get("solid+a").unwrap(),
+ }
+ }
+}
diff --git a/pixel-client/src/render/mod.rs b/pixel-client/src/render/mod.rs
new file mode 100644
index 00000000..a2aea365
--- /dev/null
+++ b/pixel-client/src/render/mod.rs
@@ -0,0 +1,158 @@
+/*
+ Hurry Curry! - a game about cooking
+ Copyright 2024 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+*/
+pub mod misc;
+pub mod sprite;
+
+use hurrycurry_protocol::glam::Vec2;
+use sdl2::{
+ pixels::PixelFormatEnum,
+ rect::{FRect, Rect},
+ render::{BlendMode, Canvas, Texture, TextureAccess, TextureCreator},
+ video::{Window, WindowContext},
+};
+use sprite::SpriteDraw;
+use std::collections::HashMap;
+
+pub struct SpriteRenderer<'a> {
+ metadata: AtlasLayout,
+
+ pub size: Vec2,
+ texture: Texture<'a>,
+
+ view_scale: Vec2,
+ view_offset: Vec2,
+
+ sprites: Vec<SpriteDraw>,
+}
+
+pub type AtlasLayout = HashMap<String, Rect>;
+
+impl<'a> SpriteRenderer<'a> {
+ pub fn init(texture_creator: &'a TextureCreator<WindowContext>) -> Self {
+ let palette = include_str!("../../assets/palette.csv")
+ .split('\n')
+ .filter(|l| !l.is_empty())
+ .map(|s| {
+ let mut toks = s.split(",");
+ (
+ toks.next().unwrap().chars().next().unwrap(),
+ [
+ toks.next().unwrap().parse::<u8>().unwrap(),
+ toks.next().unwrap().parse::<u8>().unwrap(),
+ toks.next().unwrap().parse::<u8>().unwrap(),
+ toks.next().unwrap().parse::<u8>().unwrap(),
+ ],
+ )
+ })
+ .collect::<HashMap<_, _>>();
+
+ let mut texels = vec![255; 1024 * 1024 * 4];
+
+ for (y, line) in include_str!("../../assets/atlas.ta").lines().enumerate() {
+ if line.is_empty() {
+ continue;
+ }
+ for (x, char) in line.chars().enumerate() {
+ let color = palette.get(&char).unwrap();
+ texels[(y * 1024 + x) * 4 + 0] = color[3];
+ texels[(y * 1024 + x) * 4 + 1] = color[2];
+ texels[(y * 1024 + x) * 4 + 2] = color[1];
+ texels[(y * 1024 + x) * 4 + 3] = color[0];
+ }
+ }
+
+ let mut texture = texture_creator
+ .create_texture(
+ Some(PixelFormatEnum::RGBA8888),
+ TextureAccess::Streaming,
+ 1024,
+ 1024,
+ )
+ .unwrap();
+
+ texture.update(None, &texels, 1024 * 4).unwrap();
+ texture.set_blend_mode(BlendMode::Blend);
+
+ let metadata = include_str!("../../assets/atlas.meta.csv")
+ .lines()
+ .filter(|l| !l.is_empty())
+ .map(|l| {
+ let mut toks = l.split(",");
+ let x: i32 = toks.next().unwrap().parse().unwrap();
+ let y: i32 = toks.next().unwrap().parse().unwrap();
+ let w: u32 = toks.next().unwrap().parse().unwrap();
+ let h: u32 = toks.next().unwrap().parse().unwrap();
+ let name = toks.next().unwrap().to_string();
+ (name, Rect::new(x, y, w, h))
+ })
+ .collect::<HashMap<_, _>>();
+
+ Self {
+ texture,
+ size: Vec2::ONE,
+ metadata,
+ sprites: vec![],
+ view_offset: Vec2::ZERO,
+ view_scale: Vec2::ZERO,
+ }
+ }
+
+ pub fn set_view(&mut self, offset: Vec2, scale: f32) {
+ self.view_offset = offset;
+ self.view_scale = Vec2::new(32., 24.) * scale;
+ }
+ pub fn get_scale(&self) -> Vec2 {
+ self.view_scale
+ }
+
+ #[inline]
+ pub fn atlas_layout(&self) -> &HashMap<String, Rect> {
+ &self.metadata
+ }
+
+ pub fn set_modulation(&mut self, r: u8, g: u8, b: u8, a: u8) {
+ self.texture.set_alpha_mod(a);
+ self.texture.set_color_mod(r, g, b);
+ }
+ pub fn reset_modulation(&mut self) {
+ self.set_modulation(255, 255, 255, 255)
+ }
+
+ pub fn draw_world(&mut self, sprite: SpriteDraw) {
+ self.sprites.push(SpriteDraw {
+ tint: sprite.tint,
+ z_order: sprite.z_order,
+ src: sprite.src,
+ dst: FRect::new(
+ ((sprite.dst.x + self.view_offset.x) * self.view_scale.x).round(),
+ ((sprite.dst.y + self.view_offset.y) * self.view_scale.y).round(),
+ (sprite.dst.w * self.view_scale.x).round(),
+ (sprite.dst.h * self.view_scale.y).round(),
+ ),
+ })
+ }
+
+ pub fn submit(&mut self, canvas: &mut Canvas<Window>) {
+ self.sprites.sort();
+ for SpriteDraw { src, dst, tint, .. } in self.sprites.drain(..) {
+ self.texture.set_color_mod(tint[0], tint[1], tint[2]);
+ self.texture.set_alpha_mod(tint[3]);
+ canvas.copy_f(&self.texture, src, dst).unwrap();
+ }
+ }
+}
diff --git a/pixel-client/src/render/sprite.rs b/pixel-client/src/render/sprite.rs
new file mode 100644
index 00000000..711f45bf
--- /dev/null
+++ b/pixel-client/src/render/sprite.rs
@@ -0,0 +1,76 @@
+use hurrycurry_protocol::glam::Vec2;
+use sdl2::rect::{FRect, Rect};
+
+pub struct Sprite {
+ z_offset: f32,
+ src: Rect,
+ relative_dst: FRect,
+}
+
+impl Sprite {
+ pub fn new(src: Rect, anchor: Vec2, elevation: f32) -> Self {
+ let relative_dst = FRect::new(
+ anchor.x - (src.w as f32) / 32. / 2.,
+ anchor.y - (src.h as f32) / 24.,
+ (src.w as f32) / 32.,
+ (src.h as f32) / 24.,
+ );
+ Self {
+ z_offset: elevation,
+ src,
+ relative_dst,
+ }
+ }
+ pub fn new_tile(src: Rect) -> Self {
+ Self::new(src, Vec2::new(0.5, 1.0), 0.5)
+ }
+ pub fn at(&self, pos: Vec2) -> SpriteDraw {
+ SpriteDraw {
+ z_order: ((self.z_offset + pos.y) * 24.) as i32,
+ src: self.src,
+ dst: FRect::new(
+ self.relative_dst.x + pos.x,
+ self.relative_dst.y + pos.y,
+ self.relative_dst.w,
+ self.relative_dst.h,
+ ),
+ tint: [0xff; 4],
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct SpriteDraw {
+ pub tint: [u8; 4],
+ pub z_order: i32,
+ pub src: Rect,
+ pub dst: FRect,
+}
+
+impl SpriteDraw {
+ pub fn overlay(src: Rect, pos: Vec2, size: Vec2, tint: Option<[u8; 4]>) -> Self {
+ Self {
+ dst: FRect::new(pos.x, pos.y, size.x, size.y),
+ src,
+ tint: tint.unwrap_or([0xff; 4]),
+ z_order: i32::MAX,
+ }
+ }
+}
+
+impl Ord for SpriteDraw {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.z_order.cmp(&other.z_order)
+ }
+}
+impl PartialOrd for SpriteDraw {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(&other))
+ }
+}
+impl Eq for SpriteDraw {}
+impl PartialEq for SpriteDraw {
+ fn eq(&self, other: &Self) -> bool {
+ self.z_order == other.z_order && self.src == other.src && self.dst == other.dst
+ }
+}
diff --git a/pixel-client/src/tilemap.rs b/pixel-client/src/tilemap.rs
new file mode 100644
index 00000000..768f79ba
--- /dev/null
+++ b/pixel-client/src/tilemap.rs
@@ -0,0 +1,117 @@
+/*
+ Hurry Curry! - a game about cooking
+ Copyright 2024 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+*/
+use hurrycurry_protocol::{glam::IVec2, TileIndex};
+use log::warn;
+use sdl2::rect::Rect;
+use std::collections::{HashMap, HashSet};
+
+use crate::render::{
+ sprite::{Sprite, SpriteDraw},
+ SpriteRenderer,
+};
+
+#[derive(Default)]
+pub struct Tilemap {
+ connect_group_by_tile: Vec<Option<usize>>,
+ connect_members_by_group: Vec<HashSet<Option<TileIndex>>>,
+ tile_srcs: Vec<[Rect; 16]>,
+ tiles: HashMap<IVec2, SpriteDraw>,
+}
+
+impl Tilemap {
+ pub fn init(&mut self, tile_names: &[String], sprite_rects: &HashMap<String, Rect>) {
+ let tile_index = tile_names
+ .iter()
+ .enumerate()
+ .map(|(t, i)| (i.to_string(), t))
+ .collect::<HashMap<_, _>>();
+ self.connect_group_by_tile = vec![None; tile_names.len()];
+ self.connect_members_by_group = include_str!("../assets/connect.csv")
+ .lines()
+ .enumerate()
+ .map(|(gid, line)| {
+ line.split(",")
+ .flat_map(|tile| tile_index.get(tile).copied())
+ .map(|ti| {
+ self.connect_group_by_tile[ti] = Some(gid);
+ Some(TileIndex(ti))
+ })
+ .collect::<HashSet<_>>()
+ })
+ .collect::<Vec<_>>();
+
+ self.tile_srcs = tile_names
+ .iter()
+ .map(|name| {
+ let fallback = sprite_rects
+ .get(&format!("{name}+a"))
+ .copied()
+ .unwrap_or_else(|| {
+ warn!("no sprite for tile {name:?}");
+ Rect::new(0, 0, 0, 0)
+ });
+
+ [
+ sprite_rects.get(&format!("{name}+")),
+ sprite_rects.get(&format!("{name}+w")),
+ sprite_rects.get(&format!("{name}+e")),
+ sprite_rects.get(&format!("{name}+we")),
+ sprite_rects.get(&format!("{name}+n")),
+ sprite_rects.get(&format!("{name}+wn")),
+ sprite_rects.get(&format!("{name}+en")),
+ sprite_rects.get(&format!("{name}+wen")),
+ sprite_rects.get(&format!("{name}+s")),
+ sprite_rects.get(&format!("{name}+ws")),
+ sprite_rects.get(&format!("{name}+es")),
+ sprite_rects.get(&format!("{name}+wes")),
+ sprite_rects.get(&format!("{name}+ns")),
+ sprite_rects.get(&format!("{name}+wns")),
+ sprite_rects.get(&format!("{name}+ens")),
+ sprite_rects.get(&format!("{name}+wens")),
+ ]
+ .map(|e| e.copied().unwrap_or(fallback))
+ })
+ .collect();
+ }
+
+ pub fn set(&mut self, pos: IVec2, tile: Option<TileIndex>, neighbors: [Option<TileIndex>; 4]) {
+ let Some(tile) = tile else {
+ self.tiles.remove(&pos);
+ return;
+ };
+
+ let mut idx = 0;
+ if let Some(gid) = self.connect_group_by_tile[tile.0] {
+ let cgroup = &self.connect_members_by_group[gid];
+ idx |= 0b0100 * (cgroup.contains(&neighbors[0])) as usize;
+ idx |= 0b0001 * (cgroup.contains(&neighbors[1])) as usize;
+ idx |= 0b1000 * (cgroup.contains(&neighbors[2])) as usize;
+ idx |= 0b0010 * (cgroup.contains(&neighbors[3])) as usize;
+ }
+
+ let src = self.tile_srcs[tile.0][idx];
+ self.tiles
+ .insert(pos, Sprite::new_tile(src).at(pos.as_vec2()));
+ }
+
+ pub fn draw(&self, ctx: &mut SpriteRenderer) {
+ for &sprite in self.tiles.values() {
+ ctx.draw_world(sprite);
+ }
+ }
+}