Compare commits

...

25 Commits

Author SHA1 Message Date
HyperOnion 1dcae8d8c6 add world utility function for setting tiles to empty. 2023-06-28 15:18:24 +02:00
HyperOnion e3b91c2341 make representation of empty tiles consistent.
previously, world generation would represent empty tiles as the number
0, while other code would expect them to be an object with its `type`
property set to `"empty"`. the object representation is now used
consistently.
2023-06-28 15:18:24 +02:00
HyperOnion 03e5e03665 recursively update tiles within boxes. 2023-06-28 15:18:24 +02:00
HyperOnion 7845e87e05 improve grass growth.
grass now checks all adjacent tiles when attempting to grow.
2023-06-28 15:18:24 +02:00
HyperOnion 3851a9f67a initial grass test.
start working on tile updating code; add grass as test & demo for this.
add temporary "texture" for grass, and keybind for creating it.
2023-06-28 15:18:24 +02:00
HyperOnion 56bfd99796 fix typo in iter_2d.
replace two "for in array" loops with "for of array" loops. the former gives indexes
as strings, whereas the latter gives the actual array items.
2023-06-28 15:18:24 +02:00
HyperOnion 7a86687941 add todo list and todo item. 2023-06-28 15:18:24 +02:00
HyperOnion d5564b5dfe fix readme formatting typo. 2023-06-28 15:18:24 +02:00
HyperOnion 2e3396c751 add various rambles to readme. 2023-06-28 15:18:24 +02:00
HyperOnion e1abb85dc9 rewrite readme info about exiting boxes. 2023-06-28 15:18:24 +02:00
HyperOnion a2fd4440bf don't render box contents if they would be small enough to be invisible. 2023-06-28 15:18:24 +02:00
HyperOnion 3e84eefaba refactor graphics code.
separate tile drawing from box/world drawing, and simplify some of the
math.
2023-06-28 15:18:24 +02:00
HyperOnion e0d4c823d1 track nesting depth in boxes. 2023-06-28 15:18:24 +02:00
HyperOnion 0b7fe53553 fix world save & load bug.
previously, serialization of world data couldn't handle boxes, since
they contain references to their parents, meaning worlds are cyclic.
this commit fixes this, and changes world loading to correctly re-assign
parents to boxes after deserializing them.

this bug is what happens when you commit without testing for more than a
few seconds.
2023-06-28 15:18:24 +02:00
HyperOnion ec1b4f6bea fix worlds with boxes not saving & loading due to cyclic references. 2023-06-28 15:18:24 +02:00
HyperOnion 4b1ae32bb1 add autosave and world reset button. 2023-06-28 15:18:24 +02:00
HyperOnion 4a858f5592 fix unnoticeable bug in world creation code. 2023-06-28 15:18:24 +02:00
HyperOnion b92951a4cc implement saving and loading world data to file. 2023-06-28 15:18:24 +02:00
HyperOnion 8cd629bc5e add keys for more paint colors. 2023-06-28 15:18:24 +02:00
HyperOnion 4fefdfdbb2 add demo video. 2023-06-28 15:18:24 +02:00
HyperOnion 99104f6557 create readme. 2023-06-28 15:18:24 +02:00
HyperOnion 5f2c64f0e0 add more temp controls for experimenting. 2023-06-28 15:18:24 +02:00
HyperOnion 33f980bc7c tiny refactor of entity code. 2023-06-28 15:18:24 +02:00
HyperOnion e4ea5ee3ae handle out of bounds positions in tile manipulation functions. 2023-06-28 15:18:24 +02:00
HyperOnion 7341877447 make box checkerboard floors line up nicely. 2023-06-28 15:18:24 +02:00
13 changed files with 353 additions and 39 deletions

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# overview
the player character is currently the dark green square. creating new tiles creates them where you're standing, so you have to move out of the way to see them.
moving onto boxes makes you enter them. right now, this doesn't change your position; in the future entering a box from e.g. the left side will make you move to its left edge.
moving outside of the edge makes you exit the box you're in, if you're currently inside a box. this also doesn't change your position yet. if you exit at a position where there's another box in the one you exit into, you'll immediately enter that box.
there's a demo video at `/assets/demo_video.webm`.
# controls
- arrow keys: move around.
- QWEASD: paint with RGBYCM colors, respectively.
- ZX: paint with background colors.
- CV: paint with black and white.
- space: create box.
- backspace: delete/clear/empty tiles to your left and right, and where you're standing.
# how to play
run a server in the root directory and open `index.html` in a web browser.
# more info & thoughts
the program currently autosaves to the web browser's `localStorage` every 3 seconds. it also tries to load a world from there when you first load the page.
the term "box" is used in the code both to refer to a set of tiles organized in a certain spatial structure, and to a kind of tile which contains such a space (or any specific instance of that kind of tile). this might sometimes be confusing.
i've noticed 2 different directions in which i might want to take this: a game focused on resource management & renewal; and a toy for drawing stuff.
the latter might make more sense within an [infinite tree](https://www.boristhebrave.com/2023/01/28/infinite-quadtrees-fractal-coordinates/) space, than in this more finite one.
the former feels to me like it wants to be created within the thematic context of life, growth, plants. it would probably be hard to include animals in such a game, in a way that makes them both relevant to gameplay, and doesn't make simulated specieist violence a useful strategy for game progression.
i think such a game would benefit from conserving mass throughout most transformations between different resources. look at [nodecore](https://content.minetest.net/packages/Warr1024/nodecore/) for an example of a game that does this a lot.
i don't know how ease of box acquisition might affect gameplay in such a game. newly created boxes could be empty, which would mean that they only provide more space; they could contain tiles, which would mean that creating new boxes would create new resources.
prepopulated boxes could either contain (on average) more or less mass than what went into creating them.
if less, it makes the effective price of creating a box cheaper, as long as you can afford a certain "threshold cost". if more, it might concentrate strategy around creation, content extraction, and disposal, of boxes. unless the amount of mass created is tiny in comparison to the box creation cost. boxes could potentially also contain worlds which are interesting to explore, and not necessarily easy to extract fungible resources from.
to the extent that mass is conserved, it would make sense gameplay-wise to make transformations of different materials have inverses made up of other transformations. (this would possibly create a [group](https://en.wikipedia.org/wiki/Group_(mathematics)).) the reason for this is that otherwise there would be materials which can't be created from any other materials, and materials which can't be destroyed by turning them into other materials.

1
TODO.md Normal file
View File

@ -0,0 +1 @@
- rewrite import paths to be absolute.

BIN
assets/demo_video.webm Normal file

Binary file not shown.

View File

@ -11,4 +11,8 @@
<body>
<canvas id="canvas" width="729" height="729"></canvas>
<br>
<button id="save">save game to file</button>
<button id="load">load game from file</button>
<button id="restart">create new world</button>
</body>

View File

@ -59,7 +59,5 @@ export function move (entity, d_x, d_y) {
}
function out_of_bounds (entity) {
const { x, y } = entity;
return x < 0 || x >= BOX_SIZE ||
y < 0 || y >= BOX_SIZE
return world.out_of_bounds(entity.x, entity.y);
}

View File

@ -13,7 +13,7 @@ graphics.set_size(canvas.width, canvas.height);
const BASE_SCALE = 81;
export function draw_tile (x, y, size = BASE_SCALE) {
export function draw_rect (x, y, size = BASE_SCALE) {
graphics.fill_rect(x, y, size, size);
}
@ -21,46 +21,58 @@ function checkerboard (x, y) {
return ((x ^ y) & 1) === 0;
}
export function draw_floor (start_x, start_y, scale) {
export function draw_floor (start_x, start_y, scale, invert = false) {
iter_2d(range(0, world.BOX_SIZE - 1), (x, y) => {
if (checkerboard(x, y)) {
if (checkerboard(x, y) !== invert) {
graphics.set_color("#aaa");
} else {
graphics.set_color("#888");
}
const [visual_x, visual_y] = project_pos(x, y, scale);
draw_tile(start_x + visual_x, start_y + visual_y, scale);
draw_rect(x * scale + start_x, y * scale + start_y, scale);
});
}
export function draw_world (box, start_x = 0, start_y = 0, scale = BASE_SCALE) {
draw_floor(start_x, start_y, scale);
if (scale < 1) return;
iter_2d(range(0, world.BOX_SIZE - 1), (x, y) => {
const tile = world.get_tile(box, x, y);
const [visual_x, visual_y] = project_pos(x, y, scale);
draw_floor(start_x, start_y, scale, !checkerboard(start_x, start_y));
switch (tile.type) {
case "box": {
draw_world(tile.box, start_x + visual_x, start_y + visual_y, scale / world.BOX_SIZE);
break;
}
case "paint": {
graphics.set_color(tile.color);
draw_tile(start_x + visual_x, start_y + visual_y, scale);
break;
}
}
world.for_each_tile(box, (x, y, tile) => {
draw_tile(tile, x * scale + start_x, y * scale + start_y, scale);
});
entity.for_each((entity, id) => {
if (entity.box !== box) return;
graphics.set_color(entity.color);
const [visual_x, visual_y] = project_pos(entity.x, entity.y, scale);
draw_tile(start_x + visual_x, start_y + visual_y, scale);
draw_rect(start_x + visual_x, start_y + visual_y, scale);
});
}
function draw_tile (tile, x, y, scale) {
switch (tile.type) {
case "box": {
// recursively draw box contents.
draw_world(tile.box, x, y, scale / world.BOX_SIZE);
break;
}
case "paint": {
graphics.set_color(tile.color);
draw_rect(x, y, scale);
break;
}
// temporary: in the future, textures will exist and tile definitions will exist and keep track of textures for tiles.
case "grass": {
graphics.set_color("#2a0");
draw_rect(x, y, scale);
graphics.set_color("#4c0");
draw_rect(x, y, scale * 0.5);
draw_rect(x + scale / 2, y + scale / 2, scale * 0.5);
break;
}
}
}
function project_pos (x, y, scale) {
return [x, y].map(a => a * scale);
}

View File

@ -1,15 +1,35 @@
import * as graphics from "./graphics.mjs";
import { BOX_SIZE, get_tile } from "./world.mjs";
import { BOX_SIZE, get_tile, get_root } from "./world.mjs";
import { get_player_box } from "./player.mjs";
import { save_in_browser, load_from_browser } from "./save_load.mjs";
import { tick } from "/js/tile_update.mjs";
function render (timestamp) {
load_from_browser();
function render (_delta_time) {
graphics.clear();
graphics.draw_world(get_player_box());
graphics.render();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
let last_time = new Date().getTime();
function main (timestamp) {
let dt_ms = timestamp - last_time;
last_time += dt_ms;
const delta = dt_ms / 1000;
render(delta);
tick(get_root(), delta);
requestAnimationFrame(main);
}
requestAnimationFrame(main);
function autosave () {
save_in_browser();
}
const autosave_interval = setInterval(autosave, 3000);

View File

@ -26,25 +26,54 @@ on_press("ArrowDown", _ => {
});
on_press(" ", _ => {
world.set_tile(player.box, player.x + 1, player.y, {
world.set_tile(player.box, player.x, player.y, {
type: "box",
box: world.create_box(player.box),
});
});
function clear_tile (x, y) {
world.clear_tile(player.box, x, y);
}
on_press("Backspace", _ => {
clear_tile(player.x, player.y);
clear_tile(player.x + 1, player.y);
clear_tile(player.x - 1, player.y);
});
export function get_player_box () {
return player.box;
}
function set_painting_key (key, color) {
function set_place_key (key, tile) {
on_press(key, _ => {
world.set_tile(player.box, player.x, player.y, {
type: "paint",
color,
});
world.set_tile(player.box, player.x, player.y, structuredClone(tile));
});
}
function set_painting_key (key, color) {
set_place_key(key, {
type: "paint",
color,
});
}
// RGB
set_painting_key("q", "#f00");
set_painting_key("w", "#0f0");
set_painting_key("e", "#00f");
// YCM
set_painting_key("a", "#ff0");
set_painting_key("s", "#0ff");
set_painting_key("d", "#f0f");
// background colors
set_painting_key("z", "#888");
set_painting_key("x", "#aaa");
// black and white
set_painting_key("c", "#000");
set_painting_key("v", "#fff");
// grass test
set_place_key("g", {
type: "grass",
});

71
js/save_load.mjs Normal file
View File

@ -0,0 +1,71 @@
import * as world from "./world.mjs";
const DEFAULT_SAVE_NAME = "boxes_save_0";
const get_elem = id => document.getElementById(id);
const create_elem = type => document.createElement(type);
const save_button = get_elem("save");
const load_button = get_elem("load");
const restart_button = get_elem("restart");
save_button.onclick = _ => {
const timestamp = new Date().getTime();
const filename = `boxes_${timestamp}.boxes`;
save_text(filename, generate_save_data());
};
function generate_save_data () {
const root = world.get_root();
return btoa(world.to_json(root));
}
function save_text (filename, text) {
const link = create_elem("a");
const file = new Blob([text], { type: "text/plain" });
link.href = URL.createObjectURL(file);
link.download = filename;
link.click();
}
load_button.onclick = _ => {
const input = create_elem("input");
input.type = "file";
input.addEventListener("change", e => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = e => load_from_text(e.target.result);
reader.onerror = error => alert("couldn't load the file.");
reader.readAsText(file);
});
input.click();
}
function load_from_text (text) {
const data = world.from_json(atob(text));
world.replace_root(data);
}
export function save_in_browser (save_name = DEFAULT_SAVE_NAME) {
const data = generate_save_data();
localStorage.setItem(save_name, data);
}
export function load_from_browser (save_name = DEFAULT_SAVE_NAME) {
if (localStorage.getItem(save_name) === null) return;
const data = localStorage.getItem(save_name);
load_from_text(data);
}
restart_button.onclick = _ => {
if (confirm("are you sure you want to create a new world? this will delete any unsaved creations.")) {
world.replace_root(world.create_new());
}
};

47
js/tile_update.mjs Normal file
View File

@ -0,0 +1,47 @@
import * as world from "/js/world.mjs";
import { get_player_box } from "/js/player.mjs";
import * as random from "/js/utils/random.mjs";
// lots of this code is very hacky temporary for testing things out & getting started with tile updates.
export function tick (box, delta_time) {
world.for_each_tile(box, (x, y, tile) => {
if (tile_tickers.has(tile.type)) {
tile_tickers.get(tile.type)(box, x, y, delta_time);
}
});
}
const tile_tickers = new Map();
const dirs = [
[ -1, 0 ],
[ 1, 0 ],
[ 0, -1 ],
[ 0, 1 ],
];
// this might become terrible for performance eventually.
tile_tickers.set("box", (box, x, y, delta_time) => {
tick(world.get_tile(box, x, y).box, delta_time);
});
tile_tickers.set("grass", (box, x, y, delta_time) => {
const tile = world.get_tile(box, x, y);
if (Math.random() > 0.8 ** delta_time) {
const dir = random.item(dirs);
const spaces = random.shuffle(dirs)
.map(([d_x, d_y]) => [x + d_x, y + d_y]);
for (const [new_x, new_y] of spaces) {
if (world.get_tile(box, new_x, new_y).type === "empty") {
world.set_tile(box, new_x, new_y, {
type: "grass",
});
break;
}
}
};
});

21
js/utils/random.mjs Normal file
View File

@ -0,0 +1,21 @@
export function range (min, max) {
return min + Math.random() * (max - min);
}
export function integer (min, max) {
return Math.floor(range(min, max));
}
export function item (array) {
return array[integer(0, array.length)];
}
export function shuffle (array) {
const copy = [ ...array ];
const result = [];
while (copy.length > 0) {
const index = integer(0, copy.length);
result.push(copy.splice(index, 1)[0]);
}
return result;
}

View File

@ -19,8 +19,8 @@ export function range (start, end) {
}
export function iter_2d (range_1d, callback) {
for (const y in range_1d) {
for (const x in range_1d) {
for (const y of range_1d) {
for (const x of range_1d) {
callback(x, y);
}
}

View File

@ -1,3 +1,5 @@
import { iter_2d, range } from "./utils/range.mjs";
export const BOX_SIZE = 9;
export const CENTER = Math.floor(BOX_SIZE / 2);
@ -5,27 +7,92 @@ export const CENTER = Math.floor(BOX_SIZE / 2);
function create_world (size) {
return {
tiles: Array(BOX_SIZE).fill(0).map(_ => Array(BOX_SIZE).fill(0)),
tiles: Array(size).fill(0).map(_ =>
Array(size).fill(0).map(_ => empty_tile())
),
depth: 0,
};
}
const world = create_world(BOX_SIZE);
export function empty_tile () {
return { type: "empty" };
}
export function create_new () {
return create_world(BOX_SIZE);
}
export function set_tile (world, x, y, tile) {
if (out_of_bounds(x, y)) return;
world.tiles[x][y] = tile;
}
export function get_tile (world, x, y) {
if (out_of_bounds(x, y)) return { type: "out_of_bounds" };
return world.tiles[x][y];
}
export function clear_tile (box, x, y) {
set_tile(box, x, y, empty_tile());
}
export function create_box (parent) {
return {
...create_world(BOX_SIZE),
depth: parent.depth + 1,
parent,
};
}
export function get_root () {
return world;
}
export function out_of_bounds (x, y) {
return x < 0 || x >= BOX_SIZE ||
y < 0 || y >= BOX_SIZE
}
export function for_each_tile (world, callback) {
iter_2d(range(0, BOX_SIZE - 1), (x, y) => {
callback(x, y, get_tile(world, x, y), world);
});
}
export function replace_root (new_world) {
// maybe unnecessary to go over each tile instead of just each column.
for_each_tile(new_world, (x, y, tile) => {
set_tile(world, x, y, tile);
});
}
export function to_json () {
return JSON.stringify(world, (key, value) => {
if (key === "parent") return undefined;
return value;
});
}
function reconstruct_parent (world, parent) {
world.parent = parent;
for_each_tile(world, (x, y, tile) => {
if (tile.type !== "box") return;
reconstruct_parent(tile.box, world);
});
}
export function from_json (json) {
const result = JSON.parse(json, (key, value) => {
if (value === undefined || value === 0 || value === null) return empty_tile();
return value;
});
for_each_tile(result, (x, y, tile) => {
if (tile.type === "box") {
reconstruct_parent(tile.box, result);
}
});
return result;
}