Initial commit

This commit is contained in:
2026-02-24 15:36:10 -03:00
commit fe885a9e24
42 changed files with 2383 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
build/
+52
View File
@@ -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."
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+40
View File
@@ -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();
}
}
}
+59
View File
@@ -0,0 +1,59 @@
type Asset = HTMLImageElement | HTMLAudioElement;
export class AssetManager {
static instance = new AssetManager();
assets: Record<string, Asset> = {};
loading_promises: Promise<void>[] = [];
constructor() {}
load(key: string, src: string) {
if (this.assets[key]) {
return;
}
if (src.endsWith(".png")) {
const img = new Image();
const promise = new Promise<void>((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<void>((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<T>(key: string): T {
const asset = this.assets[key];
if (!asset) {
throw new Error(`Asset not found: ${key}`);
}
return asset as T;
}
}
+73
View File
@@ -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());
}
}
+15
View File
@@ -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;
}
}
+121
View File
@@ -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;
}
}
+16
View File
@@ -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";
}
+86
View File
@@ -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<HTMLImageElement>(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<string, AnimatedSpritePiece>;
timer = 0;
animation_frame = 0;
constructor(
image: HTMLImageElement | string,
width: number,
height: number,
states: Record<string, AnimatedSpritePiece>,
initial_state: string,
) {
super();
if (typeof image === "string") {
this.image = AssetManager.instance.get<HTMLImageElement>(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;
}
}
}
+37
View File
@@ -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;
}
}
+542
View File
@@ -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<string, WindowState>();
static current_window: WindowState | undefined = undefined;
static text_inputs = new Map<string, TextInputState>();
static active_text_input: string | undefined = undefined;
static open_sections = new Map<string, boolean>();
// 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;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bworld</title>
<style>
/*
Font by Daniel Linssen (https://managore.itch.io)
https://managore.itch.io/m6x11
*/
@font-face {
font-family: "m6x11";
src: url("/assets/fonts/m6x11.ttf");
font-weight: normal;
font-style: normal;
font-smooth: never;
}
html, body {
width: 100vw;
height: 100vh;
margin: 0;
overflow: hidden;
}
canvas {
display: block;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script src="main.ts" type="module"></script>
</body>
</html>
+200
View File
@@ -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<string>();
static keys_pressed = new Set<string>();
static keys_released = new Set<string>();
static mouse_buttons_down = new Set<number>();
static mouse_buttons_pressed = new Set<number>();
static mouse_buttons_released = new Set<number>();
static mouse_buttons_consumed = new Set<number>();
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<string>();
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();
}
}
+76
View File
@@ -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");
+39
View File
@@ -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;
}
+61
View File
@@ -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();
}
}
}
+140
View File
@@ -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);
}
}
}
+84
View File
@@ -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),
);
}*/
}
}
+75
View File
@@ -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);
}
}
}
}
+62
View File
@@ -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<HTMLImageElement>("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);
}
}
+142
View File
@@ -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",
);
}
}
+76
View File
@@ -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();
}
+30
View File
@@ -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();
}
+99
View File
@@ -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();
}
}
+23
View File
@@ -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));
}
+12
View File
@@ -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;
}
}
+12
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
export const SLOT_SIZE = 18 * 3;
export interface SpriteRegion {
x: number;
y: number;
}
export const ITEM_SPRITES: Record<string, SpriteRegion> = {
"bworld:pickaxe": { x: 7, y: 9 },
"bworld:bomb": { x: 9, y: 8 },
};
+3
View File
@@ -0,0 +1,3 @@
export abstract class Component {
__component = true;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Component } from "./component.ts";
// deno-lint-ignore no-explicit-any
type ComponentConstructor<T extends Component> = new (...args: any[]) => T;
export class Entity {
id: string;
// deno-lint-ignore no-explicit-any
components = new Map<ComponentConstructor<any>, Component>();
active = true;
constructor(id: string = crypto.randomUUID()) {
this.id = id;
}
add<T extends Component>(component: T): T {
this.components.set(component.constructor as ComponentConstructor<T>, component);
return component;
}
get<T extends Component>(type: ComponentConstructor<T>): T | undefined {
return this.components.get(type) as T;
}
get_all(): Iterable<Component> {
return this.components.values();
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./component.ts";
export * from "./entity.ts";
export * from "./system.ts";
export * from "./world.ts";
+5
View File
@@ -0,0 +1,5 @@
import type { World } from "./world.ts";
export abstract class System {
abstract update(world: World, delta: number): void;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Entity } from "./entity.ts";
import type { System } from "./system.ts";
export class World {
entities = new Set<Entity>();
systems = new Set<System>();
tags = new Map<string, Entity[]>();
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;
}
}
+19
View File
@@ -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);
}
}
}
}
+19
View File
@@ -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 };
}
+20
View File
@@ -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"
}
}
Generated
+31
View File
@@ -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"
]
}
}