Compare commits
25 Commits
811b27da46
...
1dcae8d8c6
Author | SHA1 | Date |
---|---|---|
HyperOnion | 1dcae8d8c6 | |
HyperOnion | e3b91c2341 | |
HyperOnion | 03e5e03665 | |
HyperOnion | 7845e87e05 | |
HyperOnion | 3851a9f67a | |
HyperOnion | 56bfd99796 | |
HyperOnion | 7a86687941 | |
HyperOnion | d5564b5dfe | |
HyperOnion | 2e3396c751 | |
HyperOnion | e1abb85dc9 | |
HyperOnion | a2fd4440bf | |
HyperOnion | 3e84eefaba | |
HyperOnion | e0d4c823d1 | |
HyperOnion | 0b7fe53553 | |
HyperOnion | ec1b4f6bea | |
HyperOnion | 4b1ae32bb1 | |
HyperOnion | 4a858f5592 | |
HyperOnion | b92951a4cc | |
HyperOnion | 8cd629bc5e | |
HyperOnion | 4fefdfdbb2 | |
HyperOnion | 99104f6557 | |
HyperOnion | 5f2c64f0e0 | |
HyperOnion | 33f980bc7c | |
HyperOnion | e4ea5ee3ae | |
HyperOnion | 7341877447 |
|
@ -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.
|
Binary file not shown.
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
30
js/main.mjs
30
js/main.mjs
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
71
js/world.mjs
71
js/world.mjs
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue