commit fe885a9e2486c40a06d23db7cc941c8c7531c8bc Author: paulaboks Date: Tue Feb 24 15:36:10 2026 -0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/ASSETS.md b/ASSETS.md new file mode 100644 index 0000000..4d06527 --- /dev/null +++ b/ASSETS.md @@ -0,0 +1,52 @@ +shout out to kenney + +# Tiny town + +https://kenney.nl/assets/tiny-town + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# Roguelike + +https://kenney.nl/assets/roguelike-rpg-pack + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# UI + +https://kenney.nl/assets/ui-pack-pixel-adventure + +license: [Creative Commons CC0](https://creativecommons.org/publicdomain/zero/1.0/) + +# m6x11.ttf + +Font by Daniel Linssen (https://managore.itch.io) + +https://managore.itch.io/m6x11 (https://web.archive.org/web/20260217032856/https://managore.itch.io/m6x11) + +license: `free to use with attribution` + +# Tiny wonder farm + +https://butterymilk.itch.io/tiny-wonder-farm-asset-pack +(https://web.archive.org/web/20260204234306/https://butterymilk.itch.io/tiny-wonder-farm-asset-pack) + +file: assets/sprites/player.png + +license: + +``` +There's two versions, free and paid (premium): + +Free: It includes a limited number of items available for free. + +You are free to use sprites included in the pack in any of your non-commercial projects as well as edit the sprites and +add something new! + +Premium: It includes everything shown from the showcase, all tilemaps, sprites, characters and more! + +You are free to use sprites included in the pack in any of your commercial or non-commercial projects as well as edit +the sprites and add something new! + +You can't resell the sprites, even if changes were made. You cannot use it in any projects related to NFT." +``` diff --git a/assets/fonts/m6x11.ttf b/assets/fonts/m6x11.ttf new file mode 100644 index 0000000..cb21224 Binary files /dev/null and b/assets/fonts/m6x11.ttf differ diff --git a/assets/sprites/player.png b/assets/sprites/player.png new file mode 100644 index 0000000..ce81e5f Binary files /dev/null and b/assets/sprites/player.png differ diff --git a/assets/sprites/roguelike.png b/assets/sprites/roguelike.png new file mode 100644 index 0000000..79b1332 Binary files /dev/null and b/assets/sprites/roguelike.png differ diff --git a/assets/sprites/tiny_town.png b/assets/sprites/tiny_town.png new file mode 100644 index 0000000..1655d1d Binary files /dev/null and b/assets/sprites/tiny_town.png differ diff --git a/assets/sprites/ui.png b/assets/sprites/ui.png new file mode 100644 index 0000000..6c8f7d2 Binary files /dev/null and b/assets/sprites/ui.png differ diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..9304d4d --- /dev/null +++ b/build.ts @@ -0,0 +1,40 @@ +import { copy } from "@std/fs"; + +const BUILD_FOLDER = "build"; + +function clear_folder() { + Deno.removeSync(BUILD_FOLDER, { recursive: true }); + Deno.mkdir(BUILD_FOLDER); +} + +function build_assets() { + copy("assets", `${BUILD_FOLDER}/assets`); +} + +async function build_client() { + const _result = await Deno.bundle({ + entrypoints: ["./client/index.html"], + outputDir: `${BUILD_FOLDER}/client`, + platform: "browser", + minify: false, + }); +} + +let last_build = 0; + +if (import.meta.main) { + const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true }); + for await (const event of watcher) { + const now = performance.now(); + if (now - last_build < 500) { + continue; + } + if (["create", "modify", "rename", "remove"].includes(event.kind)) { + last_build = now; + console.log("Rebuilding..."); + clear_folder(); + build_assets(); + await build_client(); + } + } +} diff --git a/client/assets.ts b/client/assets.ts new file mode 100644 index 0000000..b04cbfd --- /dev/null +++ b/client/assets.ts @@ -0,0 +1,59 @@ +type Asset = HTMLImageElement | HTMLAudioElement; + +export class AssetManager { + static instance = new AssetManager(); + + assets: Record = {}; + loading_promises: Promise[] = []; + + constructor() {} + + load(key: string, src: string) { + if (this.assets[key]) { + return; + } + + if (src.endsWith(".png")) { + const img = new Image(); + const promise = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + }); + img.src = src; + this.assets[key] = img; + this.loading_promises.push(promise); + } else if (src.endsWith(".ogg")) { + const audio = new Audio(); + const promise = new Promise((resolve, reject) => { + audio.addEventListener( + "canplaythrough", + () => resolve(), + { once: true }, + ); + audio.addEventListener( + "error", + () => reject(new Error(`Failed to load audio: ${src}`)), + { once: true }, + ); + }); + + audio.src = src; + audio.load(); + this.assets[key] = audio; + this.loading_promises.push(promise); + } + } + + async load_all() { + await Promise.all(this.loading_promises); + this.loading_promises = []; + } + + get(key: string): T { + const asset = this.assets[key]; + if (!asset) { + throw new Error(`Asset not found: ${key}`); + } + return asset as T; + } +} diff --git a/client/client_world.ts b/client/client_world.ts new file mode 100644 index 0000000..7c87a3a --- /dev/null +++ b/client/client_world.ts @@ -0,0 +1,73 @@ +import { Entity, World } from "$/common/ecs/mod.ts"; +import { MovementSystem } from "$/common/systems/movement_system.ts"; +import { RenderSystem } from "$/client/systems/render_system.ts"; +import { PlayerControlsSystem } from "$/client/systems/player_controls.ts"; +import { DebugUI } from "$/client/debug_ui.ts"; +import { DebugSystem } from "$/client/systems/debug_system.ts"; +import { create_player } from "$/client/player.ts"; +import { Tilemap } from "$/client/components/tilemap.ts"; +import { AssetManager } from "$/client/assets.ts"; +import { TileEditorSystem } from "$/client/systems/tile_editor_system.ts"; +import { InventorySystem } from "$/client/systems/inventory_system.ts"; + +export class ClientWorld extends World { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + + debugging = false; + + constructor(canvas: HTMLCanvasElement) { + super(); + + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Could not get CanvasRenderingContext2D"); + } + this.ctx = ctx; + ctx.imageSmoothingEnabled = false; + + const resize = () => { + canvas.width = self.innerWidth; + canvas.height = self.innerHeight; + + canvas.style.width = canvas.width + "px"; + canvas.style.height = canvas.height + "px"; + + this.ctx.imageSmoothingEnabled = false; + }; + + self.addEventListener("resize", resize); + resize(); + + canvas.addEventListener("contextmenu", function (event) { + event.preventDefault(); + }); + + DebugUI.initialize(this.ctx); + + const tilemap = new Entity("backgroundtiles"); + tilemap.add( + new Tilemap( + AssetManager.instance.get("bworld:roguelike"), + 16, + [], + 2, + 1, + ), + ); + this.add_entity(tilemap); + + const player = create_player(); + this.add_entity(player); + + this.add_system(new InventorySystem()); + this.add_system(new PlayerControlsSystem(player)); + this.add_system(new MovementSystem()); + + // render systems + this.add_system(new RenderSystem(this.ctx)); + this.add_system(new DebugSystem()); + this.add_system(new TileEditorSystem()); + } +} diff --git a/client/components/camera.ts b/client/components/camera.ts new file mode 100644 index 0000000..362f401 --- /dev/null +++ b/client/components/camera.ts @@ -0,0 +1,15 @@ +import { Component } from "$/common/ecs/mod.ts"; + +export class Camera extends Component { + active = true; + x: number; + y: number; + zoom: number; + + constructor(x = 0, y = 0, zoom = 1) { + super(); + this.x = x; + this.y = y; + this.zoom = zoom; + } +} diff --git a/client/components/inventory.ts b/client/components/inventory.ts new file mode 100644 index 0000000..c2073cf --- /dev/null +++ b/client/components/inventory.ts @@ -0,0 +1,121 @@ +import { Component } from "$/common/ecs/mod.ts"; +import { SLOT_SIZE } from "$/common/constants.ts"; + +export class ItemStack { + type_id: string; + amount: number; + max_amount: number; + + constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { + this.type_id = type_id; + this.amount = amount; + this.max_amount = max_amount; + } + + clone(): ItemStack { + return new ItemStack(this.type_id, this.amount, this.max_amount); + } +} + +export class ContainerSlot { + #item_stack: ItemStack | undefined; + + has_item() { + return this.#item_stack !== undefined; + } + + set_item(item_stack: ItemStack | undefined) { + this.#item_stack = item_stack; + } + + get_item() { + return this.#item_stack; + } + + get type_id() { + return this.#item_stack?.type_id; + } + + get amount() { + return this.#item_stack?.amount; + } + + get max_amount() { + return this.#item_stack?.max_amount; + } +} + +export class Container { + #slots: ContainerSlot[] = []; + readonly size: number; + + constructor(size: number) { + this.size = size; + for (let i = 0; i < size; i += 1) { + this.#slots.push(new ContainerSlot()); + } + } + + add_item(item_stack: ItemStack) { + // TODO: merge stacks + for (const slot of this.#slots) { + if (!slot.has_item()) { + slot.set_item(item_stack); + return; + } + } + // TODO: drop item on ground + console.error("Failed to place item in container"); + } + + get_item(slot: number): ItemStack | undefined { + return this.#slots[slot].get_item(); + } + + get_slot(slot: number): ContainerSlot { + return this.#slots[slot]; + } +} + +export interface ContainerLayout { + slots: { type: string; x: number; y: number }[]; + offset_x: number; + offset_y: number; +} + +export class Inventory extends Component { + container: Container; + + is_open: boolean = false; + layout: ContainerLayout; + + hovering_slot: number = -1; + + constructor(container: Container, layout: ContainerLayout) { + super(); + this.container = container; + this.layout = layout; + } +} + +export class PlayerInventory extends Inventory { + owner_id: string; + holding_item: ItemStack | undefined; + + constructor(owner_id: string) { + const container = new Container(9 * 4); + const layout: ContainerLayout = { + slots: [], + offset_x: 10, + offset_y: 10, + }; + + for (let row = 0; row < 4; row += 1) { + for (let column = 0; column < 9; column += 1) { + layout.slots.push({ type: "*", x: column * SLOT_SIZE, y: row * SLOT_SIZE }); + } + } + super(container, layout); + this.owner_id = owner_id; + } +} diff --git a/client/components/player_controls.ts b/client/components/player_controls.ts new file mode 100644 index 0000000..d660034 --- /dev/null +++ b/client/components/player_controls.ts @@ -0,0 +1,16 @@ +import { Component } from "$/common/ecs/mod.ts"; +import { KeyCode } from "$/client/input_manager.ts"; + +export class PlayerControls extends Component { + move_speed: number = 100; + + // Keys + move_up: KeyCode = "KeyW"; + move_down: KeyCode = "KeyS"; + move_left: KeyCode = "KeyA"; + move_right: KeyCode = "KeyD"; + + open_inventory: KeyCode = "KeyE"; + + open_debug: KeyCode = "F3"; +} diff --git a/client/components/sprite.ts b/client/components/sprite.ts new file mode 100644 index 0000000..38910fe --- /dev/null +++ b/client/components/sprite.ts @@ -0,0 +1,86 @@ +import { Component } from "$/common/ecs/mod.ts"; +import { AssetManager } from "$/client/assets.ts"; + +export class Sprite extends Component { + image: HTMLImageElement; + width: number; + height: number; + source_x: number; + source_y: number; + source_width: number; + source_height: number; + flip_x = false; + flip_y = false; + + constructor( + image: HTMLImageElement | string, + width: number, + height: number, + source_x = 0, + source_y = 0, + source_width = width, + source_height = height, + ) { + super(); + if (typeof image === "string") { + this.image = AssetManager.instance.get(image); + } else { + this.image = image; + } + this.width = width; + this.height = height; + this.source_x = source_x; + this.source_y = source_y; + this.source_width = source_width; + this.source_height = source_height; + } +} + +interface AnimatedSpritePiece { + source_x: number[]; + source_y: number[]; + source_width: number; + source_height: number; + duration: number; +} + +export class AnimatedSprite extends Component { + image: HTMLImageElement; + width: number; + height: number; + flip_x = false; + flip_y = false; + + current_state: string; + states: Record; + timer = 0; + animation_frame = 0; + + constructor( + image: HTMLImageElement | string, + width: number, + height: number, + states: Record, + initial_state: string, + ) { + super(); + if (typeof image === "string") { + this.image = AssetManager.instance.get(image); + } else { + this.image = image; + } + this.width = width; + this.height = height; + + this.states = states; + this.current_state = initial_state; + } + + set_state(state: string) { + if (this.current_state !== state) { + this.current_state = state; + this.timer = 0; + this.animation_frame = 0; + } + } +} diff --git a/client/components/tilemap.ts b/client/components/tilemap.ts new file mode 100644 index 0000000..3826007 --- /dev/null +++ b/client/components/tilemap.ts @@ -0,0 +1,37 @@ +import { Component } from "$/common/ecs/mod.ts"; + +interface Tile { + x: number; + y: number; + index: number; +} + +export class Tilemap extends Component { + image: HTMLImageElement; + tile_size: number; + rows: number; + columns: number; + tiles: Tile[]; + scale: number; + margin: number; + + selected_tile = 0; + editing = false; + + constructor( + image: HTMLImageElement, + tile_size: number, + tiles: Tile[], + scale: number = 1, + margin: number = 0, + ) { + super(); + this.image = image; + this.tile_size = tile_size; + this.columns = Math.floor((image.width + margin) / (tile_size + margin)); + this.rows = Math.floor((image.height + margin) / (tile_size + margin)); + this.tiles = tiles; + this.scale = scale; + this.margin = margin; + } +} diff --git a/client/debug_ui.ts b/client/debug_ui.ts new file mode 100644 index 0000000..dfbaf74 --- /dev/null +++ b/client/debug_ui.ts @@ -0,0 +1,542 @@ +import { point_inside_rec } from "$/common/utils.ts"; +import { InputManager } from "$/client/input_manager.ts"; +import { draw_text, measure_text } from "$/client/text_rendering.ts"; + +const HEADER_HEIGHT = 24; +const RESIZE_SIZE = 12; + +const PADDING = 4; + +interface WindowState { + x: number; + y: number; + width: number; + height: number; + dragging: boolean; + resizing: boolean; + scroll_y: number; + content_height: number; +} + +interface TextInputState { + value: string; + caret: number; + focused: boolean; + last_blink: number; + show_caret: boolean; + key_repeat_timer: number; +} + +export class DebugUI { + static ctx: CanvasRenderingContext2D; + + static windows = new Map(); + static current_window: WindowState | undefined = undefined; + + static text_inputs = new Map(); + static active_text_input: string | undefined = undefined; + + static open_sections = new Map(); + + // cursor as in like for drawing + static cursor_x = 0; + static cursor_y = 0; + static content_width = 0; + + static initialize(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + } + + static begin(title: string, x: number, y: number, width = 300, height = 300) { + let win = this.windows.get(title); + if (!win) { + win = { + x, + y, + width, + height, + dragging: false, + resizing: false, + scroll_y: 0, + content_height: 0, + }; + this.windows.set(title, win); + } + + this.current_window = win; + + // header + const mouse = InputManager.get_mouse_position(); + const delta = InputManager.get_mouse_delta(); + + const hovering_header = point_inside_rec(mouse.x, mouse.y, win.x, win.y, win.width, HEADER_HEIGHT); + + if (hovering_header && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + win.dragging = true; + } + + if (!InputManager.is_mouse_down(0)) { + win.dragging = false; + win.resizing = false; + } + + if (win.dragging) { + win.x += delta.x; + win.y += delta.y; + } + + // resize square + const hovering_resize = point_inside_rec( + mouse.x, + mouse.y, + win.x + win.width - RESIZE_SIZE, + win.y + win.height - RESIZE_SIZE, + RESIZE_SIZE, + RESIZE_SIZE, + ); + + if (hovering_resize && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + win.resizing = true; + } + + if (win.resizing) { + win.width += delta.x; + win.height += delta.y; + + win.width = Math.max(150, win.width); + win.height = Math.max(120, win.height); + } + + // scrolling + const wheel = -InputManager.get_wheel_delta(); + + const hovering_content = point_inside_rec(mouse.x, mouse.y, win.x, win.y, win.width, win.height); + + if (hovering_content) { + win.scroll_y -= wheel * 0.7; + } + + const max_scroll = Math.max(0, win.content_height - (win.height - HEADER_HEIGHT - 10)); + win.scroll_y = Math.max(0, Math.min(win.scroll_y, max_scroll)); + + // render + this.ctx.save(); + + // transparent background + this.ctx.fillStyle = "rgba(25,25,25,0.95)"; + this.ctx.fillRect(win.x, win.y, win.width, win.height); + + this.ctx.strokeStyle = "#aaa"; + this.ctx.strokeRect(win.x, win.y, win.width, win.height); + + // header and title + this.ctx.fillStyle = "#333"; + this.ctx.fillRect(win.x, win.y, win.width, HEADER_HEIGHT); + + draw_text(this.ctx, title, win.x + 8, win.y + 4, 2); + + // resize grip + this.ctx.fillStyle = hovering_resize ? "#888" : "#666"; + this.ctx.fillRect( + win.x + win.width - RESIZE_SIZE, + win.y + win.height - RESIZE_SIZE, + RESIZE_SIZE, + RESIZE_SIZE, + ); + + // clip it ! + this.ctx.beginPath(); + this.ctx.rect( + win.x, + win.y + HEADER_HEIGHT, + win.width, + win.height - HEADER_HEIGHT, + ); + this.ctx.clip(); + + // move cursor_x to match the windows x and add some padding + // same idea for cursor_y but with scrolling + this.cursor_x = win.x + PADDING * 2; + this.cursor_y = win.y + HEADER_HEIGHT + PADDING * 2 - win.scroll_y; + // 20 padding (to count for scrollbar) + this.content_width = win.width - 20; + + win.content_height = 0; + } + + static end() { + if (!this.current_window) { + return; + } + + // see how far we've come to save content height + this.current_window.content_height = this.cursor_y - this.current_window.y + this.current_window.scroll_y; + + this.#draw_scrollbar(); + + this.ctx.restore(); + + this.current_window = undefined; + } + + static advance(height: number) { + if (!this.current_window) { + return; + } + this.cursor_y += height; + // maybe dont reset on every advance? eh whatever + this.cursor_x = this.current_window.x + PADDING * 2; + } + + static separator() { + this.ctx.strokeStyle = "#555"; + this.ctx.beginPath(); + this.ctx.moveTo(this.cursor_x, this.cursor_y); + this.ctx.lineTo(this.cursor_x + this.content_width, this.cursor_y); + this.ctx.stroke(); + + this.cursor_y += PADDING * 2; + } + + static text(content: string) { + draw_text(this.ctx, content, this.cursor_x, this.cursor_y, 2); + this.advance(20); + } + + static button(label: string): boolean { + const height = 24; + const x = this.cursor_x; + const y = this.cursor_y; + const width = this.content_width; + + const mouse = InputManager.get_mouse_position(); + const hovered = point_inside_rec(mouse.x, mouse.y, x, y, width, height); + + this.ctx.fillStyle = hovered ? "#666" : "#444"; + this.ctx.fillRect(x, y, width, height); + + this.ctx.strokeStyle = "white"; + this.ctx.strokeRect(x, y, width, height); + + this.ctx.fillStyle = "white"; + draw_text(this.ctx, label, x + 6, y + 4, 2); + + this.advance(height + PADDING); + + if (hovered && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + return true; + } + + return false; + } + + static checkbox(label: string, value: boolean): boolean { + // shoutout to booleans + const size = 16; + const x = this.cursor_x; + const y = this.cursor_y; + + const mouse = InputManager.get_mouse_position(); + const hovered = point_inside_rec(mouse.x, mouse.y, x, y, size, size); + + this.ctx.fillStyle = "#222"; + this.ctx.fillRect(x, y, size, size); + this.ctx.strokeStyle = "white"; + this.ctx.strokeRect(x, y, size, size); + + if (value) { + this.ctx.fillStyle = "white"; + this.ctx.fillRect(x + 4, y + 4, size - 8, size - 8); + } + + this.ctx.fillStyle = "white"; + draw_text(this.ctx, label, x + size + 6, y, 2); + + this.advance(size + PADDING); + + if (hovered && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + return !value; + } + + return value; + } + + static slider_float( + label: string, + value: number, + min: number, + max: number, + ): number { + const height = 20; + const x = this.cursor_x; + const y = this.cursor_y; + const width = this.content_width; + + const mouse = InputManager.get_mouse_position(); + const hovered = point_inside_rec(mouse.x, mouse.y, x, y, width, height); + + if (hovered && InputManager.is_mouse_down(0)) { + const t = (mouse.x - x) / width; + value = min + (max - min) * Math.min(Math.max(t, 0), 1); + } + + this.ctx.fillStyle = "#333"; + this.ctx.fillRect(x, y, width, height); + + const percent = (value - min) / (max - min); + this.ctx.fillStyle = "#0a84ff"; + this.ctx.fillRect(x, y, width * percent, height); + + this.ctx.strokeStyle = "white"; + this.ctx.strokeRect(x, y, width, height); + + this.ctx.fillStyle = "white"; + draw_text(this.ctx, `${label}: ${value.toFixed(2)}`, x + 5, y + 2, 2); + + this.advance(height + PADDING); + + return value; + } + + static progress_bar(value: number) { + const height = 16; + const x = this.cursor_x; + const y = this.cursor_y; + const width = this.content_width; + + this.ctx.fillStyle = "#333"; + this.ctx.fillRect(x, y, width, height); + + this.ctx.fillStyle = "#366992"; + this.ctx.fillRect(x, y, width * value, height); + + this.ctx.strokeStyle = "white"; + this.ctx.strokeRect(x, y, width, height); + + this.advance(height + PADDING); + } + + static collapsing_header(label: string): boolean { + // i only did the ## for ids on this one cause its the only one that mattered + const open = this.open_sections.get(label) ?? false; + + const clicked = this.button((open ? "- " : "+ ") + label.replace(/##.*/, "")); + if (clicked) { + this.open_sections.set(label, !open); + return !open; + } + + return open; + } + + static combo( + label: string, + index: number, + options: string[], + ): number { + if (this.button(`${label}: ${options[index]}`)) { + index = (index + 1) % options.length; + } + return index; + } + + static text_input(label: string, value: string): string { + // yeah just use the text widget + this.text(label); + + const id = label; + + let state = this.text_inputs.get(id); + if (!state) { + // initial state + state = { + value, + caret: value.length, + focused: false, + last_blink: performance.now(), + show_caret: true, + key_repeat_timer: 0, + }; + this.text_inputs.set(id, state); + } + // update on every call + state.value = value; + + const height = 24; + const x = this.cursor_x; + const y = this.cursor_y; + const width = this.content_width; + + const mouse = InputManager.get_mouse_position(); + const hovered = point_inside_rec(mouse.x, mouse.y, x, y, width, height); + + // set focus and move caret to end + if (hovered && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + this.active_text_input = id; + state.focused = true; + state.caret = value.length; + } else if (InputManager.is_mouse_pressed(0) && !hovered) { + if (this.active_text_input === id) { + state.focused = false; + this.active_text_input = undefined; + } + } + + const is_active = this.active_text_input === id; + + if (is_active) { + this.#handle_text_editing(state); + } + + // overkill? + if (is_active) { + const now = performance.now(); + if (now - state.last_blink > 500) { + state.show_caret = !state.show_caret; + state.last_blink = now; + } + } else { + state.show_caret = false; + } + + // draw box + this.ctx.fillStyle = is_active ? "#555" : "#333"; + this.ctx.fillRect(x, y, width, height); + + // highlight + this.ctx.strokeStyle = is_active ? "#0a84ff" : "#aaa"; + this.ctx.strokeRect(x, y, width, height); + + const text_x = x + 6; + + draw_text(this.ctx, state.value, x + 6, y + 4, 2); + + // caret ! + if (is_active && state.show_caret) { + const before_text = state.value.substring(0, state.caret); + const caret_x = text_x + measure_text(this.ctx, before_text, 2); + + this.ctx.strokeStyle = "white"; + this.ctx.beginPath(); + this.ctx.moveTo(caret_x, y + 4); + this.ctx.lineTo(caret_x, y + height - 4); + this.ctx.stroke(); + } + + this.cursor_y += height + PADDING; + + return state.value; + } + + static float_input(label: string, value: number) { + // TODO: do something better when input needs to be empty + const float_value = parseFloat(this.text_input(label, String(value))); + if (Number.isNaN(float_value)) { + return 0; + } + return float_value; + } + + static is_inside_windows(x: number, y: number) { + if (!this.current_window) { + return false; + } + return point_inside_rec( + x, + y, + this.current_window.x, + this.current_window.y, + this.current_window.width, + this.current_window.height, + ); + } + + static #draw_scrollbar() { + const win = this.current_window!; + const content_visible_height = win.height - HEADER_HEIGHT; + + if (win.content_height <= content_visible_height) { + return; + } + + const scrollbarWidth = 6; + const x = win.x + win.width - scrollbarWidth - 2; + const y = win.y + HEADER_HEIGHT; + const h = content_visible_height - 24; + + const ratio = content_visible_height / win.content_height; + const thumbHeight = h * ratio; + + const maxScroll = win.content_height - content_visible_height; + const thumbY = y + (win.scroll_y / maxScroll) * (h - thumbHeight); + + // track + this.ctx.fillStyle = "#222"; + this.ctx.fillRect(x, y, scrollbarWidth, h); + + // thumb + this.ctx.fillStyle = "#888"; + this.ctx.fillRect(x, thumbY, scrollbarWidth, thumbHeight); + } + + static #handle_text_editing(state: TextInputState) { + const now = performance.now(); + const repeat_delay = 400; + const repeat_rate = 40; + + function allow_repeat(): boolean { + if (InputManager.is_key_pressed("Backspace")) { + state.key_repeat_timer = now; + return true; + } + + if (InputManager.is_key_down("Backspace")) { + if (now - state.key_repeat_timer > repeat_delay) { + state.key_repeat_timer = now - (repeat_delay - repeat_rate); + return true; + } + } + + return false; + } + + if (allow_repeat()) { + if (state.caret > 0) { + state.value = state.value.slice(0, state.caret - 1) + state.value.slice(state.caret); + state.caret -= 1; + } + } + + if (InputManager.is_key_pressed("Delete")) { + state.value = state.value.slice(0, state.caret) + state.value.slice(state.caret + 1); + } + + if (InputManager.is_key_pressed("ArrowLeft")) { + state.caret = Math.max(0, state.caret - 1); + } + + if (InputManager.is_key_pressed("ArrowRight")) { + state.caret = Math.min(state.value.length, state.caret + 1); + } + + if (InputManager.is_key_pressed("Home")) { + state.caret = 0; + } + + if (InputManager.is_key_pressed("End")) { + state.caret = state.value.length; + } + + const typed = InputManager.get_typed_characters(); + + for (const char of typed) { + state.value = state.value.slice(0, state.caret) + char + state.value.slice(state.caret); + state.caret += 1; + } + } +} diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..d67a65b --- /dev/null +++ b/client/index.html @@ -0,0 +1,36 @@ + + + + + + bworld + + + + + + + diff --git a/client/input_manager.ts b/client/input_manager.ts new file mode 100644 index 0000000..adaf0cf --- /dev/null +++ b/client/input_manager.ts @@ -0,0 +1,200 @@ +export type KeyCode = + // letters + | "KeyA" + | "KeyB" + | "KeyC" + | "KeyD" + | "KeyE" + | "KeyF" + | "KeyG" + | "KeyH" + | "KeyI" + | "KeyJ" + | "KeyK" + | "KeyL" + | "KeyM" + | "KeyN" + | "KeyO" + | "KeyP" + | "KeyQ" + | "KeyR" + | "KeyS" + | "KeyT" + | "KeyU" + | "KeyV" + | "KeyW" + | "KeyX" + | "KeyY" + | "KeyZ" + // top numbers + | "Digit0" + | "Digit1" + | "Digit2" + | "Digit3" + | "Digit4" + | "Digit5" + | "Digit6" + | "Digit7" + | "Digit8" + | "Digit9" + // arrows + | "ArrowUp" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + // controls + | "Space" + | "Escape" + | "Enter" + | "Tab" + | "ShiftLeft" + | "ShiftRight" + | "ControlLeft" + | "ControlRight" + | "AltLeft" + | "AltRight" + | "Home" + | "End" + | "Backspace" + | "Delete" + // f + | "F1" + | "F2" + | "F3" + | "F4" + | "F5" + | "F6" + | "F7" + | "F8" + | "F9" + | "F10" + | "F11" + | "F12"; + +export class InputManager { + static keys_down = new Set(); + static keys_pressed = new Set(); + static keys_released = new Set(); + + static mouse_buttons_down = new Set(); + static mouse_buttons_pressed = new Set(); + static mouse_buttons_released = new Set(); + static mouse_buttons_consumed = new Set(); + + static mouse_x = 0; + static mouse_y = 0; + static mouse_delta_x = 0; + static mouse_delta_y = 0; + static wheel_delta = 0; + + static typed_characters = new Set(); + + static initialize(canvas: HTMLCanvasElement) { + self.addEventListener("keydown", (e) => { + e.preventDefault(); + if (!this.keys_down.has(e.code)) { + this.keys_pressed.add(e.code); + } + this.keys_down.add(e.code); + if (e.key.length === 1) { + this.typed_characters.add(e.key); + } + }); + + self.addEventListener("keyup", (e) => { + e.preventDefault(); + this.keys_down.delete(e.code); + this.keys_released.add(e.code); + }); + + self.addEventListener("mousedown", (e) => { + e.preventDefault(); + if (!this.mouse_buttons_down.has(e.button)) { + this.mouse_buttons_pressed.add(e.button); + } + this.mouse_buttons_down.add(e.button); + }); + + self.addEventListener("mouseup", (e) => { + e.preventDefault(); + this.mouse_buttons_down.delete(e.button); + this.mouse_buttons_released.add(e.button); + }); + + self.addEventListener("mousemove", (e) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const new_x = e.clientX - rect.left; + const new_y = e.clientY - rect.top; + + this.mouse_delta_x += new_x - this.mouse_x; + this.mouse_delta_y += new_y - this.mouse_y; + + this.mouse_x = new_x; + this.mouse_y = new_y; + }); + + self.addEventListener("wheel", (e) => { + this.wheel_delta += e.deltaY; + }); + } + + static is_key_down(code: KeyCode): boolean { + return this.keys_down.has(code); + } + + static is_key_pressed(code: KeyCode): boolean { + return this.keys_pressed.has(code); + } + + static is_key_released(code: KeyCode): boolean { + return this.keys_released.has(code); + } + + static is_mouse_down(button: number): boolean { + return this.mouse_buttons_down.has(button); + } + + static is_mouse_pressed(button: number): boolean { + return this.mouse_buttons_pressed.has(button) && + !this.mouse_buttons_consumed.has(button); + } + + static is_mouse_released(button: number): boolean { + return this.mouse_buttons_released.has(button); + } + + static get_mouse_position() { + return { x: this.mouse_x, y: this.mouse_y }; + } + + static get_mouse_delta() { + return { x: this.mouse_delta_x, y: this.mouse_delta_y }; + } + + static get_wheel_delta(): number { + return this.wheel_delta; + } + + static consume_mouse(button: number) { + this.mouse_buttons_consumed.add(button); + } + + static get_typed_characters(): string[] { + const chars = [...this.typed_characters]; + this.typed_characters.clear(); + return chars; + } + + static update() { + this.keys_pressed.clear(); + this.keys_released.clear(); + this.mouse_buttons_pressed.clear(); + this.mouse_buttons_released.clear(); + this.mouse_delta_x = 0; + this.mouse_delta_y = 0; + this.wheel_delta = 0; + this.typed_characters.clear(); + this.mouse_buttons_consumed.clear(); + } +} diff --git a/client/main.ts b/client/main.ts new file mode 100644 index 0000000..7d63f5b --- /dev/null +++ b/client/main.ts @@ -0,0 +1,76 @@ +import { AssetManager } from "./assets.ts"; +import { ClientWorld } from "./client_world.ts"; +import { InputManager } from "./input_manager.ts"; + +export class ClientLoop { + running = false; + last_time = 0; + world: ClientWorld; + + frame_count = 0; + last_fps_time = 0; + + constructor(world: ClientWorld) { + this.world = world; + } + + start() { + this.running = true; + const now = performance.now(); + this.last_time = now; + this.frame_count = 0; + this.last_fps_time = this.last_time; + requestAnimationFrame((time) => this.loop(time)); + } + + stop() { + this.running = false; + } + + loop(time: number) { + if (!this.running) { + return; + } + + const delta = (time - this.last_time) / 1000; + + const ctx = this.world.ctx; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + this.world.update(delta); + + this.frame_count += 1; + const now = performance.now(); + if (now - this.last_fps_time >= 1000) { + const fps = (this.frame_count * 1000) / (now - this.last_fps_time); + console.log(`FPS: ${fps.toFixed(2)}`); + this.frame_count = 0; + this.last_fps_time = now; + } + + InputManager.update(); + + this.last_time = time; + requestAnimationFrame((time) => this.loop(time)); + } +} + +const canvas = document.getElementById("game") as HTMLCanvasElement; +if (!canvas) { + throw Error("Canvas was not found"); +} + +InputManager.initialize(canvas); + +AssetManager.instance.load("bworld:player", "/assets/sprites/player.png"); +AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png"); +AssetManager.instance.load("bworld:tiny_town", "/assets/sprites/tiny_town.png"); +AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png"); + +await AssetManager.instance.load_all(); + +const client_world = new ClientWorld(canvas); + +const loop = new ClientLoop(client_world); +loop.start(); + +console.log("Game started"); diff --git a/client/player.ts b/client/player.ts new file mode 100644 index 0000000..139bf07 --- /dev/null +++ b/client/player.ts @@ -0,0 +1,39 @@ +import { Position } from "$/common/components/position.ts"; +import { Velocity } from "$/common/components/velocity.ts"; +import { Entity } from "$/common/ecs/mod.ts"; +import { Camera } from "$/client/components/camera.ts"; +import { ItemStack, PlayerInventory } from "$/client/components/inventory.ts"; +import { PlayerControls } from "$/client/components/player_controls.ts"; +import { AnimatedSprite } from "$/client/components/sprite.ts"; + +export function create_player() { + const player = new Entity("player"); + player.add(new Position(10, 10)); + player.add(new Velocity(0, 0)); + player.add( + new AnimatedSprite("bworld:player", 72, 72, { + "idle": { + source_x: [0, 24], + source_y: [0, 0], + source_width: 24, + source_height: 24, + duration: 60, + }, + "running": { + source_x: [0, 24, 48, 72, 96, 120, 144, 168], + source_y: [24, 24, 24, 24, 24, 24, 24, 24], + source_width: 24, + source_height: 24, + duration: 20, + }, + }, "idle"), + ); + player.add(new PlayerControls()); + // TODO: actual player ids + player.add(new PlayerInventory(player.id)); + player.get(PlayerInventory)!.container.add_item(new ItemStack("bworld:pickaxe", 1, 1)); + player.get(PlayerInventory)!.container.add_item(new ItemStack("bworld:bomb", 5)); + player.get(PlayerInventory)!.container.add_item(new ItemStack("bworld:bomb", 64)); + player.add(new Camera(-100, -100)); + return player; +} diff --git a/client/systems/debug_system.ts b/client/systems/debug_system.ts new file mode 100644 index 0000000..eba9964 --- /dev/null +++ b/client/systems/debug_system.ts @@ -0,0 +1,61 @@ +import { System } from "$/common/ecs/mod.ts"; +import { ClientWorld } from "$/client/client_world.ts"; +import { DebugUI } from "$/client/debug_ui.ts"; + +export class DebugSystem extends System { + constructor() { + super(); + } + + update(world: ClientWorld, _delta: number): void { + if (!world.debugging) { + return; + } + + DebugUI.ctx.save(); + DebugUI.ctx.resetTransform(); + + DebugUI.begin("Entities", 10, 10, 300); + + for (const entity of world.get_entities()) { + if (DebugUI.collapsing_header("Entity - " + entity.id)) { + for (const component of entity.get_all()) { + if (DebugUI.collapsing_header(`${component.constructor.name}##${entity.id}`)) { + this.render_component(component); + } + } + } + } + + DebugUI.end(); + DebugUI.ctx.restore(); + } + + // deno-lint-ignore no-explicit-any + render_component(component: any) { + for (const key in component) { + if (key === "__component") { + continue; + } + if (typeof component[key] === "number") { + component[key] = DebugUI.float_input( + key, + component[key], + ); + } else if (typeof component[key] === "string") { + component[key] = DebugUI.text_input( + key, + component[key], + ); + } else if (typeof component[key] === "boolean") { + component[key] = DebugUI.checkbox( + key, + component[key], + ); + } else { + DebugUI.text(`${key}: ${JSON.stringify(component[key])}`); + } + DebugUI.separator(); + } + } +} diff --git a/client/systems/inventory_system.ts b/client/systems/inventory_system.ts new file mode 100644 index 0000000..8130038 --- /dev/null +++ b/client/systems/inventory_system.ts @@ -0,0 +1,140 @@ +import { System, World } from "$/common/ecs/mod.ts"; +import { point_inside_rec } from "$/common/utils.ts"; +import { SLOT_SIZE } from "$/common/constants.ts"; +import { Container, PlayerInventory } from "$/client/components/inventory.ts"; +import { InputManager } from "$/client/input_manager.ts"; + +export class InventorySystem extends System { + update(world: World, _delta: number): void { + for (const entity of world.get_entities()) { + const player_inventory = entity.get(PlayerInventory); + + if (player_inventory) { + player_inventory.hovering_slot = -1; + const container = player_inventory.container; + if (player_inventory.is_open) { + for (const [index, slot] of player_inventory.layout.slots.entries()) { + const mouse = InputManager.get_mouse_position(); + const slot_x = slot.x + player_inventory.layout.offset_x; + const slot_y = slot.y + player_inventory.layout.offset_y; + const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE); + if (hovering) { + player_inventory.hovering_slot = index; + + if (InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + this.handle_left_click(player_inventory, container, index); + return; + } + + if (InputManager.is_mouse_pressed(2)) { + InputManager.consume_mouse(2); + this.handle_right_click(player_inventory, container, index); + return; + } + } + } + } else { + if (player_inventory.holding_item) { + container.add_item(player_inventory.holding_item); + player_inventory.holding_item = undefined; + } + } + + if (player_inventory.holding_item?.amount === 0) { + player_inventory.holding_item = undefined; + } + } + } + } + + switch_item_with_holding(player_inventory: PlayerInventory, index: number) { + const container_slot = player_inventory.container.get_slot(index); + const original_holding_item = player_inventory.holding_item; + player_inventory.holding_item = container_slot.get_item(); + container_slot.set_item(original_holding_item); + } + + handle_left_click(player_inventory: PlayerInventory, container: Container, index: number) { + const holding = player_inventory.holding_item; + const slot = container.get_slot(index); + const slot_item = slot.get_item(); + + // if you aren't holding anything + // "swap" with nothing on your hand (pick it up) + if (!holding) { + this.switch_item_with_holding(player_inventory, index); + return; + } + + // if you are holding something and slot type equals holding type + // try to add to stack + if (slot_item && slot.type_id === holding.type_id) { + const space_left = slot.max_amount! - slot_item.amount!; + const amount_to_add = Math.min(space_left, holding.amount); + + slot_item.amount += amount_to_add; + holding.amount -= amount_to_add; + + return; + } + + // if something on hand but not the same + // swap + this.switch_item_with_holding(player_inventory, index); + } + + handle_right_click(player_inventory: PlayerInventory, container: Container, index: number) { + const holding = player_inventory.holding_item; + const slot = container.get_slot(index); + const slot_item = slot.get_item(); + + // if holding something + if (holding) { + // and slot type equals holding type + // add 1 to matching stack + if (slot_item && slot.type_id === holding.type_id) { + if (slot_item.amount < slot_item.max_amount!) { + slot_item.amount += 1; + holding.amount -= 1; + } + return; + } + + // (actually the same as the last one but creates a new one techinically) + // place 1 into empty slot + if (!slot_item) { + const new_item = holding.clone(); + new_item.amount = 1; + slot.set_item(new_item); + holding.amount -= 1; + return; + } + + // if something on hand but not the same + // swap + this.switch_item_with_holding(player_inventory, index); + return; + } + + // if player is holding nothing and clicks nothing, nothing happens + if (!slot_item) { + return; + } + + // pick up half of the stack + const original_amount = slot_item.amount; + const half = Math.floor(original_amount / 2); + + slot_item.amount = half; + + const picked_up = slot_item.clone(); + picked_up.amount = original_amount - half; + + player_inventory.holding_item = picked_up; + + if (slot_item.amount === 0) { + slot.set_item(undefined); + } + } +} diff --git a/client/systems/player_controls.ts b/client/systems/player_controls.ts new file mode 100644 index 0000000..1c0b949 --- /dev/null +++ b/client/systems/player_controls.ts @@ -0,0 +1,84 @@ +import { Entity, System } from "$/common/ecs/mod.ts"; +import { Velocity } from "$/common/components/velocity.ts"; +import { InputManager } from "../input_manager.ts"; +import { AnimatedSprite } from "../components/sprite.ts"; +import { ClientWorld } from "../client_world.ts"; +import { PlayerControls } from "../components/player_controls.ts"; +import { PlayerInventory } from "../components/inventory.ts"; +import { Camera } from "../components/camera.ts"; +import { Position } from "../../common/components/position.ts"; + +export class PlayerControlsSystem extends System { + player: Entity; + + constructor(player: Entity) { + super(); + this.player = player; + } + + update(world: ClientWorld, _delta: number): void { + const velocity = this.player.get(Velocity)!; + const controls = this.player.get(PlayerControls)!; + + if (InputManager.is_key_down(controls.move_left)) { + velocity.vx = -controls.move_speed; + } else if (InputManager.is_key_down(controls.move_right)) { + velocity.vx = controls.move_speed; + } else { + velocity.vx = 0; + } + + if (InputManager.is_key_down(controls.move_up)) { + velocity.vy = -controls.move_speed; + } else if (InputManager.is_key_down(controls.move_down)) { + velocity.vy = controls.move_speed; + } else { + velocity.vy = 0; + } + + const animated_sprite = this.player.get(AnimatedSprite)!; + + if ( + InputManager.is_key_down(controls.move_up) || InputManager.is_key_down(controls.move_down) || + InputManager.is_key_down(controls.move_left) || InputManager.is_key_down(controls.move_right) + ) { + animated_sprite.set_state("running"); + } else { + animated_sprite.set_state("idle"); + } + + if (InputManager.is_key_down(controls.move_left)) { + animated_sprite.flip_x = false; + } else if (InputManager.is_key_down(controls.move_right)) { + animated_sprite.flip_x = true; + } + + if (InputManager.is_key_pressed(controls.open_inventory)) { + const inventory = this.player.get(PlayerInventory)!; + inventory.is_open = !inventory.is_open; + } + + if (InputManager.is_key_pressed(controls.open_debug)) { + world.debugging = !world.debugging; + } + + const position = this.player.get(Position)!; + const camera = this.player.get(Camera)!; + + camera.x = Math.round(position.x); + camera.y = Math.round(position.y); + + // --- APPLY BOUNDS --- + /*if (camera.bounds) { + camera.x = Math.max( + camera.bounds.min_x, + Math.min(camera.x, camera.bounds.max_x), + ); + + camera.y = Math.max( + camera.bounds.min_y, + Math.min(camera.y, camera.bounds.max_y), + ); + }*/ + } +} diff --git a/client/systems/render_system.ts b/client/systems/render_system.ts new file mode 100644 index 0000000..f650e17 --- /dev/null +++ b/client/systems/render_system.ts @@ -0,0 +1,75 @@ +import { System } from "$/common/ecs/mod.ts"; +import { World } from "$/common/ecs/world.ts"; +import { Position } from "$/common/components/position.ts"; +import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts"; +import { Tilemap } from "$/client/components/tilemap.ts"; +import { PlayerInventory } from "$/client/components/inventory.ts"; +import { Camera } from "$/client/components/camera.ts"; + +import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts"; +import { render_tilemap } from "./rendering/tilemap.ts"; +import { render_player_inventory } from "./rendering/player.ts"; + +export class RenderSystem extends System { + ctx: CanvasRenderingContext2D; + + constructor(ctx: CanvasRenderingContext2D) { + super(); + this.ctx = ctx; + } + + update(world: World, _delta: number): void { + this.ctx.save(); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + + const camera_entity = world.get_entities().values().find((e) => e.get(Camera)?.active); + const camera = camera_entity?.get(Camera); + + if (!camera) { + return; + } + + for (const entity of world.get_entities()) { + this.ctx.save(); + this.ctx.translate( + Math.floor(this.ctx.canvas.width / 2), + Math.floor(this.ctx.canvas.height / 2), + ); + + this.ctx.scale(camera.zoom, camera.zoom); + + this.ctx.translate( + -Math.floor(camera.x), + -Math.floor(camera.y), + ); + + const position = entity.get(Position); + const sprite = entity.get(Sprite); + + if (sprite && position) { + render_sprite(this.ctx, sprite, position); + } + + const animated_sprite = entity.get(AnimatedSprite); + + if (animated_sprite && position) { + render_animated_sprite(this.ctx, animated_sprite, position); + } + + const tilemap = entity.get(Tilemap); + + if (tilemap) { + render_tilemap(this.ctx, tilemap); + } + + // Ui + this.ctx.restore(); + + const player_inventory = entity.get(PlayerInventory); + + if (player_inventory && player_inventory.is_open) { + render_player_inventory(this.ctx, player_inventory); + } + } + } +} diff --git a/client/systems/rendering/player.ts b/client/systems/rendering/player.ts new file mode 100644 index 0000000..3bf166a --- /dev/null +++ b/client/systems/rendering/player.ts @@ -0,0 +1,62 @@ +import { SLOT_SIZE } from "$/common/constants.ts"; +import { AssetManager } from "$/client/assets.ts"; +import { PlayerInventory } from "$/client/components/inventory.ts"; +import { InputManager } from "$/client/input_manager.ts"; +import { draw_item, draw_nine_slice } from "./render_utils.ts"; + +export function render_player_inventory(ctx: CanvasRenderingContext2D, player_inventory: PlayerInventory) { + const PADDING = 10; + const ui = AssetManager.instance.get("bworld:ui"); + const layout = player_inventory.layout.slots; + + draw_nine_slice( + ctx, + ui, + 160, + 0, + 16, + 16, + 4, + 4, + 4, + 4, + 0, + 0, + PADDING * 2 + SLOT_SIZE * 9, + PADDING * 2 + SLOT_SIZE * 4, + ); + + for (const [index, slot] of layout.entries()) { + const x = slot.x + PADDING; + const y = slot.y + PADDING; + draw_nine_slice( + ctx, + ui, + player_inventory.hovering_slot === index ? 19 * 16 : 160 + 32, + player_inventory.hovering_slot === index ? 16 : 0, + 16, + 16, + 4, + 4, + 4, + 4, + x, + y, + SLOT_SIZE, + SLOT_SIZE, + ); + } + for (const [index, slot] of layout.entries()) { + const x = slot.x + PADDING; + const y = slot.y + PADDING; + const item = player_inventory.container.get_item(index); + if (item) { + draw_item(ctx, item, x, y); + } + } + + if (player_inventory.holding_item) { + const mouse = InputManager.get_mouse_position(); + draw_item(ctx, player_inventory.holding_item, mouse.x, mouse.y); + } +} diff --git a/client/systems/rendering/render_utils.ts b/client/systems/rendering/render_utils.ts new file mode 100644 index 0000000..d297885 --- /dev/null +++ b/client/systems/rendering/render_utils.ts @@ -0,0 +1,142 @@ +import { SLOT_SIZE } from "$/common/constants.ts"; +import { get_sprite_region } from "$/common/utils.ts"; +import { AssetManager } from "$/client/assets.ts"; +import { ItemStack } from "$/client/components/inventory.ts"; +import { draw_text } from "$/client/text_rendering.ts"; + +export function draw_nine_slice( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + source_x: number, + source_y: number, + source_width: number, + source_height: number, + left: number, + right: number, + top: number, + bottom: number, + x: number, + y: number, + width: number, + height: number, +) { + const center_width = source_width - left - right; + const center_height = source_height - top - bottom; + + const dest_center_width = width - left - right; + const dest_center_height = height - top - bottom; + + // top left + ctx.drawImage(image, source_x, source_y, left, top, x, y, left, top); + + // top right + ctx.drawImage(image, source_x + source_width - right, source_y, right, top, x + width - right, y, right, top); + + // bottom left + ctx.drawImage( + image, + source_x, + source_y + source_height - bottom, + left, + bottom, + x, + y + height - bottom, + left, + bottom, + ); + + // bottom right + ctx.drawImage( + image, + source_x + source_width - right, + source_y + source_height - bottom, + right, + bottom, + x + width - right, + y + height - bottom, + right, + bottom, + ); + + // top + ctx.drawImage(image, source_x + left, source_y, center_width, top, x + left, y, dest_center_width, top); + + // bottom + ctx.drawImage( + image, + source_x + left, + source_y + source_height - bottom, + center_width, + bottom, + x + left, + y + height - bottom, + dest_center_width, + bottom, + ); + + // left + ctx.drawImage(image, source_x, source_y + top, left, center_height, x, y + top, left, dest_center_height); + + // right + ctx.drawImage( + image, + source_x + source_width - right, + source_y + top, + right, + center_height, + x + width - right, + y + top, + right, + dest_center_height, + ); + + // center ! + ctx.drawImage( + image, + source_x + left, + source_y + top, + center_width, + center_height, + x + left, + y + top, + dest_center_width, + dest_center_height, + ); +} + +export function draw_item(ctx: CanvasRenderingContext2D, item: ItemStack, x: number, y: number) { + const sprite_region = get_sprite_region(item.type_id); + ctx.drawImage( + AssetManager.instance.get("bworld:tiny_town"), + sprite_region.x * 16, + sprite_region.y * 16, + 16, + 16, + x + 3, + y + 3, + SLOT_SIZE - 6, + SLOT_SIZE - 6, + ); + if (item.max_amount !== 1) { + draw_text( + ctx, + String(item.amount), + x + SLOT_SIZE + 2 - 4, + y + SLOT_SIZE + 2 - 4, + 2, + "#3f3f3f", + "bottom", + "right", + ); + draw_text( + ctx, + String(item.amount), + x + SLOT_SIZE - 4, + y + SLOT_SIZE - 4, + 2, + "white", + "bottom", + "right", + ); + } +} diff --git a/client/systems/rendering/sprites.ts b/client/systems/rendering/sprites.ts new file mode 100644 index 0000000..3853018 --- /dev/null +++ b/client/systems/rendering/sprites.ts @@ -0,0 +1,76 @@ +import { Position } from "$/common/components/position.ts"; +import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts"; + +export function render_sprite(ctx: CanvasRenderingContext2D, sprite: Sprite, position: Position) { + ctx.save(); + + ctx.translate( + Math.floor(sprite.flip_x ? position.x + sprite.width : position.x), + Math.floor(sprite.flip_y ? position.y + sprite.height : position.y), + ); + ctx.scale( + sprite.flip_x ? -1 : 1, + sprite.flip_y ? -1 : 1, + ); + + ctx.drawImage( + sprite.image, + sprite.source_x, + sprite.source_y, + sprite.source_width, + sprite.source_height, + 0, + 0, + sprite.width, + sprite.height, + ); + ctx.restore(); +} + +export function render_animated_sprite( + ctx: CanvasRenderingContext2D, + animated_sprite: AnimatedSprite, + position: Position, +) { + ctx.save(); + + const current_animation = animated_sprite.states[animated_sprite.current_state]; + if (!current_animation) { + console.error(`Missing animation for state ${animated_sprite.current_state}`); + return; + } + + ctx.translate( + Math.floor(animated_sprite.flip_x ? position.x + animated_sprite.width : position.x), + Math.floor(animated_sprite.flip_y ? position.y + animated_sprite.height : position.y), + ); + + ctx.scale( + animated_sprite.flip_x ? -1 : 1, + animated_sprite.flip_y ? -1 : 1, + ); + + ctx.drawImage( + animated_sprite.image, + current_animation.source_x[animated_sprite.animation_frame], + current_animation.source_y[animated_sprite.animation_frame], + current_animation.source_width, + current_animation.source_height, + 0, + 0, + animated_sprite.width, + animated_sprite.height, + ); + + animated_sprite.timer += 1; + if (animated_sprite.timer >= current_animation.duration) { + animated_sprite.timer = 0; + animated_sprite.animation_frame += 1; + + if (animated_sprite.animation_frame >= current_animation.source_x.length) { + animated_sprite.animation_frame = 0; + } + } + + ctx.restore(); +} diff --git a/client/systems/rendering/tilemap.ts b/client/systems/rendering/tilemap.ts new file mode 100644 index 0000000..6770f09 --- /dev/null +++ b/client/systems/rendering/tilemap.ts @@ -0,0 +1,30 @@ +import { Tilemap } from "$/client/components/tilemap.ts"; + +export function render_tilemap(ctx: CanvasRenderingContext2D, tilemap: Tilemap) { + ctx.save(); + ctx.translate( + -Math.floor(ctx.canvas.width / 2), + -Math.floor(ctx.canvas.height / 2), + ); + + for (const tile of tilemap.tiles) { + const tx = tile.index % tilemap.columns; + const ty = Math.floor(tile.index / tilemap.columns); + const sx = tx * tilemap.tile_size + (tx * tilemap.margin); + const sy = ty * tilemap.tile_size + (ty * tilemap.margin); + + ctx.drawImage( + tilemap.image, + sx, + sy, + tilemap.tile_size, + tilemap.tile_size, + tile.x * tilemap.tile_size * tilemap.scale, + tile.y * tilemap.tile_size * tilemap.scale, + tilemap.tile_size * tilemap.scale, + tilemap.tile_size * tilemap.scale, + ); + } + + ctx.restore(); +} diff --git a/client/systems/tile_editor_system.ts b/client/systems/tile_editor_system.ts new file mode 100644 index 0000000..f51f354 --- /dev/null +++ b/client/systems/tile_editor_system.ts @@ -0,0 +1,99 @@ +import { System } from "$/common/ecs/mod.ts"; +import { point_inside_rec } from "$/common/utils.ts"; +import { ClientWorld } from "$/client/client_world.ts"; +import { Camera } from "$/client/components/camera.ts"; +import { Tilemap } from "$/client/components/tilemap.ts"; +import { DebugUI } from "$/client/debug_ui.ts"; +import { InputManager } from "$/client/input_manager.ts"; + +export class TileEditorSystem extends System { + constructor() { + super(); + } + + update(world: ClientWorld, _delta: number): void { + if (!world.debugging) { + return; + } + + DebugUI.ctx.save(); + DebugUI.ctx.setTransform(1, 0, 0, 1, 0, 0); + + const ctx = world.ctx; + + const camera_entity = world.get_entities().values().find((e) => e.get(Camera)?.active); + const camera = camera_entity?.get(Camera); + if (!camera) { + return; + } + + for (const entity of world.get_entities()) { + const tilemap = entity.get(Tilemap); + + if (!tilemap) { + continue; + } + + const scaled_size = tilemap.tile_size * tilemap.scale; + const mouse = InputManager.get_mouse_position(); + + DebugUI.begin("Tile Editor", 500, 10, 300); + DebugUI.progress_bar(0.5); + + if (DebugUI.button("Save")) { + navigator.clipboard.writeText(JSON.stringify(tilemap)); + } + + for (let i = 0; i < tilemap.rows; i += 1) { + for (let j = 0; j < tilemap.columns; j += 1) { + const x = DebugUI.cursor_x + j * scaled_size; + const y = DebugUI.cursor_y; + + const hovered = point_inside_rec(mouse.x, mouse.y, x, y, scaled_size, scaled_size); + + if (DebugUI.is_inside_windows(mouse.x, mouse.y) && hovered && InputManager.is_mouse_pressed(0)) { + InputManager.consume_mouse(0); + tilemap.selected_tile = i * tilemap.columns + j; + } + + ctx.drawImage( + tilemap.image, + j * tilemap.tile_size + (j * tilemap.margin), + i * tilemap.tile_size + (i * tilemap.margin), + tilemap.tile_size, + tilemap.tile_size, + x, + y, + scaled_size, + scaled_size, + ); + } + DebugUI.advance(scaled_size); + } + + DebugUI.end(); + + if (tilemap.editing) { + const world_x = mouse.x + camera.x; + const world_y = mouse.y + camera.y; + + const tx = Math.floor(world_x / (tilemap.tile_size * tilemap.scale)); + const ty = Math.floor(world_y / (tilemap.tile_size * tilemap.scale)); + if (InputManager.is_mouse_pressed(0)) { + const maybe_tile = tilemap.tiles.find((tile) => tile.x === tx && tile.y === ty); + if (maybe_tile?.index !== tilemap.selected_tile) { + tilemap.tiles.push({ x: tx, y: ty, index: tilemap.selected_tile }); + } + } else if (InputManager.is_mouse_pressed(1)) { + InputManager.consume_mouse(1); + const index = tilemap.tiles.findLastIndex((tile) => tile.x === tx && tile.y === ty); + if (index !== -1) { + tilemap.tiles.splice(index, 1); + } + } + } + } + + DebugUI.ctx.restore(); + } +} diff --git a/client/text_rendering.ts b/client/text_rendering.ts new file mode 100644 index 0000000..b3ccfb6 --- /dev/null +++ b/client/text_rendering.ts @@ -0,0 +1,23 @@ +const TEXT_SCALE = 8; + +export function measure_text(ctx: CanvasRenderingContext2D, text: string, scale = 1): number { + ctx.font = `${scale * TEXT_SCALE}px m6x11`; + return ctx.measureText(text).width; +} + +export function draw_text( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + scale = 1, + color = "white", + baseline: CanvasTextBaseline = "top", + align: CanvasTextAlign = "left", +) { + ctx.textBaseline = baseline; + ctx.textAlign = align; + ctx.font = `${scale * TEXT_SCALE}px m6x11`; + ctx.fillStyle = color; + ctx.fillText(text, Math.floor(x), Math.floor(y)); +} diff --git a/common/components/position.ts b/common/components/position.ts new file mode 100644 index 0000000..55cbc49 --- /dev/null +++ b/common/components/position.ts @@ -0,0 +1,12 @@ +import { Component } from "$/common/ecs/component.ts"; + +export class Position extends Component { + x: number; + y: number; + + constructor(x: number, y: number) { + super(); + this.x = x; + this.y = y; + } +} diff --git a/common/components/velocity.ts b/common/components/velocity.ts new file mode 100644 index 0000000..6e272f8 --- /dev/null +++ b/common/components/velocity.ts @@ -0,0 +1,12 @@ +import { Component } from "$/common/ecs/component.ts"; + +export class Velocity extends Component { + vx: number; + vy: number; + + constructor(vx: number, vy: number) { + super(); + this.vx = vx; + this.vy = vy; + } +} diff --git a/common/constants.ts b/common/constants.ts new file mode 100644 index 0000000..82ed2e0 --- /dev/null +++ b/common/constants.ts @@ -0,0 +1,11 @@ +export const SLOT_SIZE = 18 * 3; + +export interface SpriteRegion { + x: number; + y: number; +} + +export const ITEM_SPRITES: Record = { + "bworld:pickaxe": { x: 7, y: 9 }, + "bworld:bomb": { x: 9, y: 8 }, +}; diff --git a/common/ecs/component.ts b/common/ecs/component.ts new file mode 100644 index 0000000..b634aaa --- /dev/null +++ b/common/ecs/component.ts @@ -0,0 +1,3 @@ +export abstract class Component { + __component = true; +} diff --git a/common/ecs/entity.ts b/common/ecs/entity.ts new file mode 100644 index 0000000..e4ba0a4 --- /dev/null +++ b/common/ecs/entity.ts @@ -0,0 +1,28 @@ +import type { Component } from "./component.ts"; + +// deno-lint-ignore no-explicit-any +type ComponentConstructor = new (...args: any[]) => T; + +export class Entity { + id: string; + // deno-lint-ignore no-explicit-any + components = new Map, Component>(); + active = true; + + constructor(id: string = crypto.randomUUID()) { + this.id = id; + } + + add(component: T): T { + this.components.set(component.constructor as ComponentConstructor, component); + return component; + } + + get(type: ComponentConstructor): T | undefined { + return this.components.get(type) as T; + } + + get_all(): Iterable { + return this.components.values(); + } +} diff --git a/common/ecs/mod.ts b/common/ecs/mod.ts new file mode 100644 index 0000000..2249d13 --- /dev/null +++ b/common/ecs/mod.ts @@ -0,0 +1,4 @@ +export * from "./component.ts"; +export * from "./entity.ts"; +export * from "./system.ts"; +export * from "./world.ts"; diff --git a/common/ecs/system.ts b/common/ecs/system.ts new file mode 100644 index 0000000..c455073 --- /dev/null +++ b/common/ecs/system.ts @@ -0,0 +1,5 @@ +import type { World } from "./world.ts"; + +export abstract class System { + abstract update(world: World, delta: number): void; +} diff --git a/common/ecs/world.ts b/common/ecs/world.ts new file mode 100644 index 0000000..ffca0e5 --- /dev/null +++ b/common/ecs/world.ts @@ -0,0 +1,34 @@ +import type { Entity } from "./entity.ts"; +import type { System } from "./system.ts"; + +export class World { + entities = new Set(); + systems = new Set(); + tags = new Map(); + + add_entity(entity: Entity) { + this.entities.add(entity); + } + + add_system(system: System) { + this.systems.add(system); + } + + add_tag(tag: string, entities: Entity[]) { + this.tags.set(tag, entities); + } + + get_tag(tag: string) { + return this.tags.get(tag); + } + + update(delta: number) { + for (const system of this.systems) { + system.update(this, delta); + } + } + + get_entities() { + return this.entities; + } +} diff --git a/common/systems/movement_system.ts b/common/systems/movement_system.ts new file mode 100644 index 0000000..78a4cc2 --- /dev/null +++ b/common/systems/movement_system.ts @@ -0,0 +1,19 @@ +import { System, World } from "$/common/ecs/mod.ts"; +import { Position } from "$/common/components/position.ts"; +import { Velocity } from "$/common/components/velocity.ts"; + +export class MovementSystem extends System { + update(world: World, delta: number): void { + for (const entity of world.get_entities()) { + const position = entity.get(Position); + const velocity = entity.get(Velocity); + + if (position && velocity) { + position.x += velocity.vx * delta; + position.y += velocity.vy * delta; + position.x = Math.round(position.x); + position.y = Math.round(position.y); + } + } + } +} diff --git a/common/utils.ts b/common/utils.ts new file mode 100644 index 0000000..e34f0c7 --- /dev/null +++ b/common/utils.ts @@ -0,0 +1,19 @@ +import { ITEM_SPRITES, SpriteRegion } from "./constants.ts"; + +export function point_inside_rec( + point_x: number, + point_y: number, + rec_x: number, + rec_y: number, + rec_w: number, + rec_h: number, +) { + return point_x > rec_x && + point_x < rec_x + rec_w && + point_y > rec_y && + point_y < rec_y + rec_h; +} + +export function get_sprite_region(id: string): SpriteRegion { + return ITEM_SPRITES[id] ?? { x: 0, y: 0 }; +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f535d5a --- /dev/null +++ b/deno.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "build": "deno run -A build.ts", + "serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build" + }, + "compilerOptions": { + "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable"] + }, + "unstable": ["bundle"], + "fmt": { + "useTabs": true, + "indentWidth": 4, + "lineWidth": 120, + "newLineKind": "lf" + }, + "imports": { + "$/": "./", + "@std/fs": "jsr:@std/fs@^1.0.23" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..0ca6179 --- /dev/null +++ b/deno.lock @@ -0,0 +1,31 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/fs@^1.0.23": "1.0.23", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/path@^1.1.4": "1.1.4" + }, + "jsr": { + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/fs@^1.0.23" + ] + } +}