From 217277051d75ccdb71a7885a8e33c5c400421fcb Mon Sep 17 00:00:00 2001 From: Daniel Thompson Date: Mon, 5 Feb 2024 19:03:28 +0000 Subject: [PATCH] Move the starting grid into the tiled data. --- src/assets/level1.tmx | 216 +++++++++++++++++++++++++++++++++++++- src/assets/mod.rs | 25 ++++- src/dashboard.rs | 5 +- src/geometry.rs | 78 +++++++++----- src/main.rs | 120 +++------------------ src/objectmap.rs | 114 +++++++++++++------- src/physics.rs | 32 +++--- src/tilemap.rs | 2 +- tiled/level1.tmx | 20 +++- tiled/racer.tiled-session | 35 +++--- tiled/vehicles.tsx | 79 ++++++++++++++ 11 files changed, 517 insertions(+), 209 deletions(-) create mode 100644 tiled/vehicles.tsx diff --git a/src/assets/level1.tmx b/src/assets/level1.tmx index dbd0e3c..c600589 100644 --- a/src/assets/level1.tmx +++ b/src/assets/level1.tmx @@ -1,5 +1,5 @@ - + @@ -636,6 +636,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,3,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, @@ -692,4 +890,20 @@ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + + + + + + + + diff --git a/src/assets/mod.rs b/src/assets/mod.rs index f786ace..e25505b 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -22,10 +22,31 @@ impl bevy::prelude::Plugin for Plugin { embedded_asset!(app, p, "speeddial.png"); embedded_asset!(app, p, "speedneedle.png"); - racepack_png!(app, p, "Cars/car_red_5.png"); + racepack_png!(app, p, "Cars/car_black_1.png"); + racepack_png!(app, p, "Cars/car_black_2.png"); + racepack_png!(app, p, "Cars/car_black_3.png"); + racepack_png!(app, p, "Cars/car_black_4.png"); + racepack_png!(app, p, "Cars/car_black_5.png"); racepack_png!(app, p, "Cars/car_blue_1.png"); - racepack_png!(app, p, "Cars/car_yellow_3.png"); + racepack_png!(app, p, "Cars/car_blue_2.png"); + racepack_png!(app, p, "Cars/car_blue_3.png"); + racepack_png!(app, p, "Cars/car_blue_4.png"); + racepack_png!(app, p, "Cars/car_blue_5.png"); + racepack_png!(app, p, "Cars/car_green_1.png"); + racepack_png!(app, p, "Cars/car_green_2.png"); + racepack_png!(app, p, "Cars/car_green_3.png"); racepack_png!(app, p, "Cars/car_green_4.png"); + racepack_png!(app, p, "Cars/car_green_5.png"); + racepack_png!(app, p, "Cars/car_red_1.png"); + racepack_png!(app, p, "Cars/car_red_2.png"); + racepack_png!(app, p, "Cars/car_red_3.png"); + racepack_png!(app, p, "Cars/car_red_4.png"); + racepack_png!(app, p, "Cars/car_red_5.png"); + racepack_png!(app, p, "Cars/car_yellow_1.png"); + racepack_png!(app, p, "Cars/car_yellow_2.png"); + racepack_png!(app, p, "Cars/car_yellow_3.png"); + racepack_png!(app, p, "Cars/car_yellow_4.png"); + racepack_png!(app, p, "Cars/car_yellow_5.png"); racepack_png!(app, p, "Objects/arrow_white.png"); racepack_png!(app, p, "Objects/arrow_yellow.png"); diff --git a/src/dashboard.rs b/src/dashboard.rs index 7ad7d70..7187e7c 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -94,7 +94,10 @@ fn update_speedo( player: Query<(&physics::Velocity, With)>, mut speedo: Query<(&mut Transform, With)>, ) { - let (vp, _) = player.single(); + let (vp, _) = match player.iter().next() { + Some(t) => t, + None => return, + }; let (mut needle, _) = speedo.single_mut(); needle.rotation = Quat::from_rotation_z(vp.0.length() / 100.0); diff --git a/src/geometry.rs b/src/geometry.rs index ac72688..696f3e7 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -2,6 +2,7 @@ // Copyright (C) 2023-2024 Daniel Thompson use bevy::{math::vec2, prelude::*}; +use itertools::Itertools; use smallvec::SmallVec; fn same_side(p1: Vec2, p2: Vec2, line: (Vec2, Vec2)) -> bool { @@ -41,9 +42,27 @@ pub fn reflect_against_line(v: Vec2, line: (Vec2, Vec2)) -> Vec2 { v - ((2.0 * v.dot(normal)) * normal) } +pub fn reflect_against_segment(v: Vec2, segment: (Vec2, Vec2, Vec2)) -> Vec2 { + let n1 = (segment.1 - segment.0).perp().normalize(); + let n2 = (segment.2 - segment.1).perp().normalize(); + let semi_normal = (n1 + n2).normalize(); + + v - ((2.0 * v.dot(semi_normal)) * semi_normal) +} + +/// A polygon, represented as a series of points. +/// +/// In principle we could support any number of sides. However the internal +/// representation is private so only shapes supported by the factory +/// functions can ever exist. At present this means all shapes are either +/// rectangles or octagons (meaning the internal SmallVec is always allocated +/// on the stack. +/// +/// Some of the algorithms used require that the polygon be convex. This +/// property is guarantees by all current factory functions. #[derive(Clone, Debug)] pub struct Polygon { - pub shape: SmallVec<[Vec2; 8]>, + shape: SmallVec<[Vec2; 8]>, } impl FromIterator for Polygon { @@ -56,6 +75,7 @@ impl FromIterator for Polygon { impl Polygon { pub fn from_vec(sz: &Vec2) -> Self { + assert!(sz.x > 0. && sz.y > 0.); let (w, h) = (sz.x / 2., sz.y / 2.); [vec2(-w, h), vec2(w, h), vec2(w, -h), vec2(-w, -h)] .into_iter() @@ -67,9 +87,11 @@ impl Polygon { /// The roundness factor is, effectively, the percentage of the /// shortest edge that will be preserved on each side. pub fn from_vec_with_rounding(sz: &Vec2, percent: f32) -> Self { + assert!(sz.x > 0. && sz.y > 0.); + assert!(percent > 0. && percent < 100.); let (w, h) = (sz.x / 2., sz.y / 2.); let m = w.min(h); - let c = m - (m * percent); + let c = m - (0.01 * m * percent); [ vec2(c - w, h), @@ -86,41 +108,43 @@ impl Polygon { } pub fn contains_point(&self, pt: Vec2) -> bool { - let shape = self.shape.as_slice(); - let n = shape.len(); - shape - .windows(3) - .chain(std::iter::once( - [shape[n - 2], shape[n - 1], shape[0]].as_slice(), - )) - .chain(std::iter::once( - [shape[n - 1], shape[0], shape[1]].as_slice(), - )) - .all(|x| same_side(pt, x[0], (x[1], x[2]))) + self.iter_segments() + .all(|(&a, &b, &c)| same_side(pt, a, (b, c))) } pub fn closest_edge_to_point(&self, pt: Vec2) -> (Vec2, Vec2) { - let shape = self.shape.as_slice(); - let n = shape.len(); - shape - .windows(2) - .chain(std::iter::once([shape[n - 1], shape[0]].as_slice())) - .map(|line| (line[0], line[1])) - .min_by(|a, b| { - distance_to_line(pt, *a) - .partial_cmp(&distance_to_line(pt, *b)) + self.iter_lines() + .map(|(&a, &b)| (a, b)) + .min_by(|&a, &b| { + distance_to_line(pt, a) + .partial_cmp(&distance_to_line(pt, b)) .expect("Floating point numbers must be comparable") }) .expect("Shape must not be empty") } pub fn draw(&self, gizmos: &mut Gizmos) { - let shape = self.shape.as_slice(); - let n = shape.len(); - for w in shape.windows(2) { - gizmos.line_2d(w[0], w[1], Color::BLUE); + for (&a, &b) in self.iter_lines() { + gizmos.line_2d(a, b, Color::BLUE); } - gizmos.line_2d(shape[n - 1], shape[0], Color::BLUE); + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.shape.iter() + } + + pub fn iter_lines(&self) -> impl Iterator { + let slice = self.shape.as_slice(); + slice.iter().chain(&slice[0..1]).tuple_windows() + } + + pub fn iter_segments(&self) -> impl Iterator { + let slice = self.shape.as_slice(); + slice.iter().chain(&slice[0..2]).tuple_windows() + } + + pub fn _iter_mut(&mut self) -> std::slice::IterMut<'_, Vec2> { + self.shape.iter_mut() } /// Test whether two rectangles are touching. diff --git a/src/main.rs b/src/main.rs index 58ccd5a..aff6e1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,7 @@ #![allow(clippy::type_complexity)] -use bevy::{ - math::{vec2, vec3}, - prelude::*, - render::camera::ScalingMode, - window, -}; +use bevy::{prelude::*, render::camera::ScalingMode, window}; use bevy_ecs_tilemap::prelude as ecs_tilemap; use clap::Parser; use std::f32::consts::PI; @@ -82,10 +77,7 @@ fn main() { )) .insert_resource(ClearColor(Color::rgb_linear(0.153, 0.682, 0.376))) .insert_resource(args) - .add_systems( - Startup, - (load_maps, spawn_camera, spawn_player, spawn_ai_players), - ) + .add_systems(Startup, (load_maps, spawn_camera)) .add_systems( Update, ( @@ -135,97 +127,6 @@ fn load_maps(mut commands: Commands, asset_server: Res, prefs: Res< }); } -fn spawn_player( - mut commands: Commands, - mut texture_atlas: ResMut>, - asset_server: Res, -) { - let sz = vec2(70., 121.); - let polygon = geometry::Polygon::from_vec_with_rounding(&sz, 0.6); - - let handle = - asset_server.load("embedded://tdr2024/assets/kenney_racing-pack/PNG/Cars/car_red_5.png"); - commands.spawn(( - Player, - Racer::default(), - physics::Angle(0.0), - physics::CollisionBox(polygon), - physics::Velocity(Vec2::new(0.0, 20.0)), - SpriteSheetBundle { - texture_atlas: texture_atlas.add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), - transform: Transform { - translation: vec3(-1000.0, 0.0, 3.0), - scale: Vec3::splat(1.), - ..default() - }, - ..default() - }, - )); -} - -fn spawn_ai_players( - mut commands: Commands, - mut texture_atlas: ResMut>, - asset_server: Res, -) { - let sz = vec2(70., 121.); - let polygon = geometry::Polygon::from_vec_with_rounding(&sz, 0.6); - - let handle = - asset_server.load("embedded://tdr2024/assets/kenney_racing-pack/PNG/Cars/car_blue_1.png"); - commands.spawn(( - Racer::default(), - physics::Angle(PI / 12.0), - physics::CollisionBox(polygon.clone()), - physics::Velocity(Vec2::new(0.0, 20.0)), - SpriteSheetBundle { - texture_atlas: texture_atlas.add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), - transform: Transform { - translation: Vec3::new(0.0, 0.0, 2.0), - scale: Vec3::splat(1.), - ..default() - }, - ..default() - }, - )); - - let handle = - asset_server.load("embedded://tdr2024/assets/kenney_racing-pack/PNG/Cars/car_yellow_3.png"); - commands.spawn(( - Racer::default(), - physics::Angle(PI / 12.0), - physics::CollisionBox(polygon.clone()), - physics::Velocity(Vec2::new(0.0, 20.0)), - SpriteSheetBundle { - texture_atlas: texture_atlas.add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), - transform: Transform { - translation: Vec3::new(-333.3, 0.0, 2.0), - scale: Vec3::splat(1.), - ..default() - }, - ..default() - }, - )); - - let handle = - asset_server.load("embedded://tdr2024/assets/kenney_racing-pack/PNG/Cars/car_green_4.png"); - commands.spawn(( - Racer::default(), - physics::Angle(PI / 12.0), - physics::CollisionBox(polygon.clone()), - physics::Velocity(Vec2::new(0.0, 20.0)), - SpriteSheetBundle { - texture_atlas: texture_atlas.add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), - transform: Transform { - translation: Vec3::new(-666.6, 0.0, 2.0), - scale: Vec3::splat(1.), - ..default() - }, - ..default() - }, - )); -} - fn handle_keyboard( mut query: Query<( &mut physics::Angle, @@ -239,7 +140,10 @@ fn handle_keyboard( ) { let delta = time.delta_seconds(); - let (mut a, mut v, mut t, mut r, _) = query.single_mut(); + let (mut a, mut v, mut t, mut r, _) = match query.iter_mut().next() { + Some(t) => t, + None => return, + }; if r.penalty > 0.0 { r.penalty = if r.penalty < delta { @@ -341,11 +245,11 @@ fn track_player( player: Query<(&Transform, &physics::Velocity, With)>, mut camera: Query<(&mut Transform, With, Without)>, ) { - let (txp, _, _) = player.single(); - - for (mut txc, _, _) in camera.iter_mut() { - txc.translation.x = txp.translation.x; - txc.translation.y = txp.translation.y; - //txc.rotation = txp.rotation; + for (txp, _, _) in player.iter() { + for (mut txc, _, _) in camera.iter_mut() { + txc.translation.x = txp.translation.x; + txc.translation.y = txp.translation.y; + //txc.rotation = txp.rotation; + } } } diff --git a/src/objectmap.rs b/src/objectmap.rs index 830bbea..9c0ff17 100644 --- a/src/objectmap.rs +++ b/src/objectmap.rs @@ -8,8 +8,9 @@ use bevy::{ math::{vec2, vec3}, prelude::*, }; +use std::f32::consts::PI; -use crate::{geometry::Polygon, physics, tilemap}; +use crate::{geometry::Polygon, physics, tilemap, Player, Racer}; #[derive(Default)] pub struct Plugin; @@ -20,12 +21,6 @@ impl bevy::app::Plugin for Plugin { } } -#[derive(Component, Debug)] -pub enum Collider { - Tree, - Block, -} - fn spawn_object( map: &tiled::Map, obj: &tiled::Object, @@ -34,50 +29,93 @@ fn spawn_object( texture_atlas: &mut Assets, asset_server: &AssetServer, ) { + let img_src = img.source.to_str().unwrap(); + + let sz = vec2(img.width as f32, img.height as f32); + let polygon = if img_src.contains("tree") { + Polygon::from_vec_with_rounding(&(sz * 0.5), 40.) + } else if img_src.contains("tires") { + Polygon::from_vec_with_rounding(&sz, 40.) + } else if img_src.contains("car") { + Polygon::from_vec_with_rounding(&sz, 60.) + } else { + Polygon::from_vec(&sz) + }; + let (w, h) = ( (map.width * map.tile_width) as f32, (map.height * map.tile_height) as f32, ); - let (x, y) = ( + + // tiled rotates objects from the bottom-left but bevy rotates objects + // from the centre. that means we need to fix up the translation. + let rotation = Quat::from_rotation_z(-obj.rotation * PI / 4.0); + let shift = Vec3::from((sz / 2.0, 0.0)); + let restore = rotation.mul_vec3(shift); + let translation = vec3( obj.x - ((w - img.width as f32) / 2.0), -obj.y + ((h + img.height as f32) / 2.0), - ); + 2.0, + ) - shift + + restore; + let mut path = std::path::PathBuf::from("embedded://"); path.push(&img.source); - let atlas = TextureAtlas::from_grid( - asset_server.load(path.to_str().expect("tile_path is not UTF-8").to_string()), - vec2(img.width as f32, img.height as f32), - 1, - 1, - None, - None, - ); - - let polygon = if img.source.to_str().unwrap().contains("tree") { - let v = vec2(img.width as f32, img.height as f32) * 0.5; - Polygon::from_vec_with_rounding(&v, 0.4) - } else { - Polygon::from_vec(&vec2(img.width as f32, img.height as f32)) - }; + let handle = asset_server.load(path.to_str().expect("tile_path is not UTF-8").to_string()); - commands.spawn(( - if img.source.to_str().unwrap().contains("tree") { - Collider::Tree + if img_src.contains("car") { + if img_src.contains("red") { + commands.spawn(( + Player, + Racer::default(), + physics::Angle(PI / 12.0), + physics::CollisionBox(polygon.clone()), + physics::Velocity(Vec2::new(0.0, 20.0)), + SpriteSheetBundle { + texture_atlas: texture_atlas + .add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), + transform: Transform { + translation, + rotation, + scale: Vec3::splat(1.), + }, + ..default() + }, + )); } else { - Collider::Block - }, - physics::CollisionBox(polygon), - SpriteSheetBundle { - texture_atlas: texture_atlas.add(atlas), - transform: Transform { - translation: vec3(x, y, 5.0), - scale: Vec3::splat(1.), + commands.spawn(( + Racer::default(), + physics::Angle(PI / 12.0), + physics::CollisionBox(polygon.clone()), + physics::Velocity(Vec2::new(0.0, 20.0)), + SpriteSheetBundle { + texture_atlas: texture_atlas + .add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), + transform: Transform { + translation, + rotation, + scale: Vec3::splat(1.), + }, + ..default() + }, + )); + } + } else { + commands.spawn(( + physics::CollisionBox(polygon), + SpriteSheetBundle { + texture_atlas: texture_atlas + .add(TextureAtlas::from_grid(handle, sz, 1, 1, None, None)), + transform: Transform { + translation: translation + vec3(0., 0., 3.), + rotation, + scale: Vec3::splat(1.), + }, ..default() }, - ..default() - }, - )); + )); + } } /// Grub about in the bowels of the tiled data, iterating over each diff --git a/src/physics.rs b/src/physics.rs index fbd8202..a99a589 100644 --- a/src/physics.rs +++ b/src/physics.rs @@ -115,26 +115,24 @@ pub fn fixed_collision_detection( for (CollisionBox(obj_poly), obj_tf, _) in scenery.iter() { let obj_box = obj_poly.transform(&obj_tf); - // This can be a single if/let - if car_box.shape.iter().any(|pt| obj_box.contains_point(*pt)) { - //car_vel.0 = vec2(-car_vel.0.x, -car_vel.0.y); - let pt = car_box - .shape - .iter() - .find(|pt| obj_box.contains_point(**pt)) - .unwrap(); - let line = obj_box.closest_edge_to_point(*pt); - car_vel.0 = reflect_against_line(car_vel.0, line); - - while car_box.is_touching(&obj_box) { - car_tf.translation += Vec3::from((car_vel.0.normalize(), 0.0)); - car_box = car_poly.transform(&car_tf); + if car_box.is_touching(&obj_box) { + let car_pt = car_box.iter().find(|&&pt| obj_box.contains_point(pt)); + if let Some(&pt) = car_pt { + car_vel.0 = reflect_against_line(car_vel.0, obj_box.closest_edge_to_point(pt)); + } else if let Some((&prev, &pt, &next)) = obj_box + .iter_segments() + .find(|(_, &pt, _)| car_box.contains_point(pt)) + { + car_vel.0 = reflect_against_segment(car_vel.0, (prev, pt, next)); + } else { + unreachable!(); } - } else if obj_box.shape.iter().any(|pt| car_box.contains_point(*pt)) { - car_vel.0 = vec2(-car_vel.0.x, -car_vel.0.y); + let ct = vec2(car_tf.translation.x, car_tf.translation.y); + let ot = vec2(obj_tf.translation.x, obj_tf.translation.y); + let nudge = Vec3::from(((ct - ot).normalize(), 0.0)); while car_box.is_touching(&obj_box) { - car_tf.translation += Vec3::from((car_vel.0.normalize(), 0.0)); + car_tf.translation += nudge; car_box = car_poly.transform(&car_tf); } } diff --git a/src/tilemap.rs b/src/tilemap.rs index 06947aa..2112fab 100644 --- a/src/tilemap.rs +++ b/src/tilemap.rs @@ -233,7 +233,7 @@ pub fn process_loaded_maps( // tilesets on each layer and allows differently-sized tile images in each tileset, // this means we need to load each combination of tileset and layer separately. for (tileset_index, tileset) in tiled_map.map.tilesets().iter().enumerate() { - if tileset.name == "Objects" { + if tileset.name == "Objects" || tileset.name == "Vehicles" { // The "Objects" tileset has mismatched sizes and we need to skip it // when creating a tilemap. continue; diff --git a/tiled/level1.tmx b/tiled/level1.tmx index 0d1e02a..2cb7a4d 100644 --- a/tiled/level1.tmx +++ b/tiled/level1.tmx @@ -1,5 +1,5 @@ - + @@ -7,6 +7,8 @@ + + 199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,199,3,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, @@ -63,4 +65,20 @@ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + + + + + + + + + + + + + diff --git a/tiled/racer.tiled-session b/tiled/racer.tiled-session index c63b38f..6283f73 100644 --- a/tiled/racer.tiled-session +++ b/tiled/racer.tiled-session @@ -1,5 +1,5 @@ { - "activeFile": "level2.tmx", + "activeFile": "level1.tmx", "expandedProjectPaths": [ ], "file.lastUsedOpenFilter": "All Files (*)", @@ -22,36 +22,44 @@ "scaleInEditor": 1 }, "grass.tsx": { - "scaleInDock": 0.8, + "scaleInDock": 0.5, "scaleInEditor": 1 }, "level1.tmx": { - "scale": 0.33, - "selectedLayer": 0, + "expandedObjectLayers": [ + 9 + ], + "scale": 0.5, + "selectedLayer": 2, "viewCenter": { - "x": 3433.333333333333, - "y": 2022.727272727273 + "x": 1544, + "y": 1113 } }, "level2.tmx": { "scale": 0.3252, - "selectedLayer": 1, + "selectedLayer": 2, "viewCenter": { - "x": 1682.041820418204, - "y": 1160.8241082410825 + "x": 2478.4747847478475, + "y": 730.319803198032 } }, "level2.tmx#Asphalt road": { "scaleInDock": 1 }, "objects.tsx": { + "dynamicWrapping": true, + "scaleInDock": 0.5, + "scaleInEditor": 1 + }, + "vehicles.tsx": { "dynamicWrapping": true, "scaleInDock": 1, "scaleInEditor": 1 } }, "last.exportedFilePath": "/home/drt/Projects/daniel-thompson/tdr2024/src/assets", - "last.imagePath": "/home/drt/Projects/daniel-thompson/tdr2024/src/assets/kenney_racing-pack/PNG/Objects", + "last.imagePath": "/home/drt/Projects/daniel-thompson/tdr2024/src/assets/kenney_racing-pack/PNG/Cars", "map.lastUsedExportFilter": "All Files (*)", "openFiles": [ "level1.tmx", @@ -59,10 +67,11 @@ ], "project": "racer.tiled-project", "recentFiles": [ - "grass.tsx", - "objects.tsx", + "level2.tmx", "level1.tmx", - "level2.tmx" + "vehicles.tsx", + "grass.tsx", + "objects.tsx" ], "tileset.lastUsedFormat": "tsx", "tileset.type": 1 diff --git a/tiled/vehicles.tsx b/tiled/vehicles.tsx new file mode 100644 index 0000000..b1f317b --- /dev/null +++ b/tiled/vehicles.tsx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +