Compare commits

..

16 Commits

Author SHA1 Message Date
cha0s
3d4d29625d feat: tile layer chunking 2024-07-11 01:22:43 -05:00
cha0s
41baef2571 dev: main entity info on dashboard 2024-07-10 23:40:11 -05:00
cha0s
532792595a refactor: layer proxy mutation 2024-07-10 17:27:20 -05:00
cha0s
9f33e4df8a refactor: edge cam logic commented for now 2024-07-10 16:54:04 -05:00
cha0s
4397011b25 refactor: no layer masks for now 2024-07-10 16:48:36 -05:00
cha0s
f9bf9096ee refactor: min scale 1 2024-07-10 16:08:15 -05:00
cha0s
466e80aa23 fun: generate a forest 2024-07-10 14:59:07 -05:00
cha0s
10d6638452 fix: stamps and indices 2024-07-10 14:38:19 -05:00
cha0s
c848b07668 refactor: noise 2024-07-10 14:17:53 -05:00
cha0s
ed5d12b4a8 perf: filter application 2024-07-10 14:16:47 -05:00
cha0s
e0a30ddb0b refactor: support > 65536 spaces 2024-07-10 14:15:05 -05:00
cha0s
348a24d8a2 perf: resource cache 2024-07-10 14:14:10 -05:00
cha0s
c344b65534 feat: status 2024-07-10 14:12:48 -05:00
cha0s
ccb0be9dd9 refactor: tele responsibility 2024-07-09 19:36:48 -05:00
cha0s
8e4fd46959 fun: sprites 2024-07-09 18:45:01 -05:00
cha0s
8907658710 refactor: sprite gen polish 2024-07-09 18:44:50 -05:00
29 changed files with 705 additions and 144 deletions

View File

@ -25,8 +25,21 @@ export default class ClientEcs extends Ecs {
return cache.get(uri);
}
async readJson(uri) {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
const key = ['$$json', uri].join(':');
if (!cache.has(key)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(key, promise);
this.readAsset(uri)
.then((chars) => {
resolve(chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {});
})
.catch(reject);
}
return cache.get(key);
}
async readScript(uri, context = {}) {
const code = await this.readAsset(uri);

View File

@ -1,3 +1,5 @@
export const CHUNK_SIZE = 32;
export const CLIENT_LATENCY = 0;
export const CLIENT_PREDICTION = true;

147
app/create-forest.js Normal file
View File

@ -0,0 +1,147 @@
import alea from 'alea';
import {createNoise2D} from 'simplex-noise';
import {createRandom, Generator} from '@/util/math.js';
import createEcs from './create-ecs.js';
const seed = 42069;
const prng = alea(seed);
const rawNoise = createNoise2D(prng);
const noise = (x, y) => (1 + rawNoise(x, y)) / 2;
const random = createRandom(seed);
const indexComputer = (indices, randomizer) => (
(position) => indices[Math.floor(indices.length * randomizer(position))]
);
const [w, h] = [256, 256];
const Forest = new Generator({
calculate: indexComputer(
[1, 3, 4, 10, 11, 12], // grass
({x, y}) => noise(x * 30, y * 30),
),
covers: () => true,
size: {w, h},
children: [
new Generator({
calculate: indexComputer(
[342, 456], // dirt
({x, y}) => noise(x * 30, y * 30),
),
covers: ({x, y}) => noise(x * (1 / 16), y * (1 / 16)) < 0.5,
size: {w, h},
children: [
new Generator({
calculate: indexComputer(
[407, 408, 423, 424], // stone path
({x, y}) => noise(x * 30, y * 30),
),
covers: ({x, y}) => noise(x * (1 / 16), y * (1 / 16)) < 0.1625,
size: {w, h},
}),
],
}),
new Generator({
calculate: indexComputer(
[103, 104], // water
({x, y}) => noise(x * 30, y * 30),
),
covers: ({x, y}) => noise(x * (1 / 32), y * (1 / 32)) < 0.3,
size: {w, h},
}),
],
});
export default async function createForest(Ecs) {
const ecs = createEcs(Ecs);
const area = {x: w, y: h};
const master = ecs.get(await ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Ticking: {},
TileLayers: {
layers: [
{
area,
data: Array(w * h).fill(0),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
},
{
area,
data: Array(w * h).fill(0),
source: '/assets/tileset.json',
tileSize: {x: 16, y: 16},
},
],
},
Time: {},
}));
Forest.generate();
const layer0 = master.TileLayers.layers[0];
layer0.data = Forest.matrix;
for (let y = 0; y < h; ++y) {
for (let x = 0; x < w; ++x) {
const dirt = Forest.children[0].matrix[y * w + x];
if (dirt) {
layer0.data[y * w + x] = dirt;
}
const stone = Forest.children[0].children[0].matrix[y * w + x];
if (stone) {
layer0.data[y * w + x] = stone;
}
const water = Forest.children[1].matrix[y * w + x];
if (water) {
layer0.data[y * w + x] = water;
}
}
}
const entityPosition = (x, y) => ({
x: (8 + (x * 16) + random() * 8 - 4),
y: (8 + (y * 16) + random() * 8 - 4),
});
for (let y = 0; y < h; ++y) {
for (let x = 0; x < w; ++x) {
let v = noise(x * (1 / 24), y * (1 / 24));
if (v > 0.2) {
v = noise(x * (1 / 7), y * (1 / 7));
if (v < 0.15) {
await ecs.create({
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.7,
source: '/assets/ambient/shrub.json',
},
VisibleAabb: {},
});
}
else if (v < 0.17) {
await ecs.create({
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.875,
source: '/assets/ambient/tree.json',
},
VisibleAabb: {},
});
}
v = noise(x * (1 / 12), y * (1 / 12));
if (v < 0.08) {
await ecs.create({
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.7,
source: '/assets/ambient/flower.json',
},
VisibleAabb: {},
});
}
}
}
}
return ecs;
}

View File

@ -15,7 +15,7 @@ export default async function createPlayer(id) {
},
Controlled: {},
Direction: {direction: 2},
Ecs: {path: ['homesteads', `${id}`].join('/')},
Ecs: {path: ['forests', `${id}`].join('/')},
Emitter: {},
Forces: {},
Interacts: {},

View File

@ -2,7 +2,7 @@ import Component from '@/ecs/component.js';
export default class AreaSize extends Component {
static properties = {
x: {type: 'uint16'},
y: {type: 'uint16'},
x: {type: 'uint32'},
y: {type: 'uint32'},
};
}

View File

@ -1,18 +1,36 @@
import {CHUNK_SIZE} from '@/constants.js';
import Component from '@/ecs/component.js';
import {floodwalk2D, ortho, removeCollinear} from '@/util/math.js';
import vector2d from './helpers/vector-2d';
class LayerProxy {
$$sourceJson;
$$chunks = [];
$$sourceJson = {};
constructor(instance, Component, index) {
this.instance = instance;
this.Component = Component;
this.index = index;
this.$$chunks = Array(
Math.ceil(this.area.x / CHUNK_SIZE) * Math.ceil(this.area.y / CHUNK_SIZE)
).fill(0).map(() => ({}));
}
get area() {
return this.layer.area;
}
clone() {
const {$$chunks, $$sourceJson} = this.instance.$$layersProxies[this.index];
const proxy = new LayerProxy(this.instance, this.Component, this.index);
proxy.$$chunks = [...$$chunks];
proxy.$$sourceJson = $$sourceJson;
return proxy;
}
compute(index) {
const cx = Math.floor((index % this.area.x) / CHUNK_SIZE);
const cy = Math.floor(Math.floor(index / this.area.x) / CHUNK_SIZE);
const ax = Math.ceil(this.area.x / CHUNK_SIZE);
return cy * ax + cx;
}
get data() {
return this.layer.data;
}
@ -119,23 +137,66 @@ class LayerProxy {
}
export default class TileLayers extends Component {
async insertMany(entities) {
for (const [id, {layerChange}] of entities) {
async createMany(entities) {
for (const [, {layerChange, layers}] of entities) {
if (layers) {
for (const layer of layers) {
const area = layer.area.x * layer.area.y;
if (layer.data.length < area) {
for (let i = 0; i < area; ++i) {
if (!layer.data[i]) {
layer.data[i] = 0;
}
}
}
}
}
if (layerChange) {
const component = this.get(id);
const {layers} = component;
for (const layerIndex in layerChange) {
for (const calculated in layerChange[layerIndex]) {
const tile = layerChange[layerIndex][calculated];
layers[layerIndex].data[calculated] = tile;
}
layers[layerIndex] = {...layers[layerIndex]};
component.$$layersProxies[layerIndex] = new LayerProxy(component, this, layerIndex);
await component.$$layersProxies[layerIndex].load();
}
}
}
return super.insertMany(entities);
return super.createMany(entities);
}
async insertMany(entities) {
for (const [, {layers}] of entities) {
if (layers) {
for (const layer of layers) {
const area = layer.area.x * layer.area.y;
if (layer.data.length < area) {
for (let i = 0; i < area; ++i) {
if (!layer.data[i]) {
layer.data[i] = 0;
}
}
}
}
}
}
await super.insertMany(entities);
for (const [id, {layerChange}] of entities) {
if (layerChange) {
const component = this.get(id);
const {layers} = component;
for (const layerIndex in layerChange) {
const proxy = component.$$layersProxies[layerIndex].clone();
const chunksChanged = new Set();
for (const tileIndex in layerChange[layerIndex]) {
chunksChanged.add(proxy.compute(tileIndex));
const tile = layerChange[layerIndex][tileIndex];
layers[layerIndex].data[tileIndex] = tile;
}
for (const chunkChanged of chunksChanged) {
proxy.$$chunks[chunkChanged] = {};
}
component.$$layersProxies[layerIndex] = proxy;
}
}
}
}
instanceFromSchema() {
return class TileLayersInstance extends super.instanceFromSchema() {

View File

@ -1,5 +1,8 @@
// import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
// const [hx, hy] = [RESOLUTION.x / 2, RESOLUTION.y / 2];
export default class FollowCamera extends System {
static queries() {
@ -24,6 +27,11 @@ export default class FollowCamera extends System {
updateCamera(portion, entity) {
const {Camera, Position} = entity;
if (Camera && Position) {
// const {AreaSize: {x, y}} = this.ecs.get(1);
// const [px, py] = [
// Math.max(hx, Math.min(Math.round(Position.x), x - hx)),
// Math.max(hy, Math.min(Math.round(Position.y), y - hy)),
// ];
const [px, py] = [
Math.round(Position.x),
Math.round(Position.y),

View File

@ -1,4 +1,5 @@
import {
CHUNK_SIZE,
RESOLUTION,
TPS,
} from '@/constants.js';
@ -7,10 +8,17 @@ import {decode, encode} from '@/packets/index.js';
import Script from '@/util/script.js';
import createEcs from './create-ecs.js';
import createForest from './create-forest.js';
import createHomestead from './create-homestead.js';
import createHouse from './create-house.js';
import createPlayer from './create-player.js';
import {LRUCache} from 'lru-cache';
const cache = new LRUCache({
max: 128,
});
export default class Engine {
connectedPlayers = new Map();
@ -38,11 +46,35 @@ export default class Engine {
return engine.frame;
}
async readAsset(uri) {
return server.readAsset(uri);
if (!cache.has(uri)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(uri, promise);
server.readAsset(uri)
.then(resolve)
.catch(reject);
}
return cache.get(uri);
}
async readJson(uri) {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
const key = ['$$json', uri].join(':');
if (!cache.has(key)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(key, promise);
this.readAsset(uri)
.then((chars) => {
resolve(chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {});
})
.catch(reject);
}
return cache.get(key);
}
async readScript(uri, context) {
if (!uri) {
@ -61,7 +93,10 @@ export default class Engine {
// remove entity link to connection to start queueing actions and pause updates
delete connectedPlayer.entity;
// forget previous state
connectedPlayer.memory.clear();
connectedPlayer.memory = {
chunks: new Map(),
nearby: new Set(),
};
// inform client of the upcoming change
server.send(
connection,
@ -183,7 +218,10 @@ export default class Engine {
{
entity: ecs.get(entity),
id,
memory: new Set(),
memory: {
chunks: new Map(),
nearby: new Set(),
},
},
);
}
@ -230,6 +268,11 @@ export default class Engine {
['houses', `${id}`].join('/'),
house,
);
const forest = await createForest(this.Ecs, id);
await this.saveEcs(
['forests', `${id}`].join('/'),
forest,
);
buffer = await createPlayer(id);
await this.server.writeData(
['players', `${id}`].join('/'),
@ -323,12 +366,14 @@ export default class Engine {
y1: y0 + (RESOLUTION.y * 2),
});
// Master entity.
nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values());
const master = ecs.get(1);
nearby.add(master);
const lastNearby = new Set(memory.nearby.values());
const firstUpdate = 0 === lastNearby.size;
for (const entity of nearby) {
const {id} = entity;
lastMemory.delete(id);
if (!memory.has(id)) {
lastNearby.delete(id);
if (!memory.nearby.has(id)) {
update[id] = entity.toJSON();
if (mainEntityId === id) {
update[id].MainEntity = {};
@ -337,12 +382,91 @@ export default class Engine {
else if (ecs.diff[id]) {
update[id] = ecs.diff[id];
}
memory.add(id);
memory.nearby.add(id);
}
for (const id of lastMemory) {
memory.delete(id);
for (const id of lastNearby) {
memory.nearby.delete(id);
update[id] = false;
}
// Tile layer chunking
const {TileLayers} = master;
const {layers} = TileLayers;
let layerChange;
for (const i in layers) {
const layer = TileLayers.layer(i);
const cx = CHUNK_SIZE * layer.tileSize.x;
const cy = CHUNK_SIZE * layer.tileSize.y;
const rx = 1 + Math.ceil((RESOLUTION.x * 2) / cx);
const ry = 1 + Math.ceil((RESOLUTION.y * 2) / cy);
const lx = Math.floor((entity.Position.x - RESOLUTION.x) / cx);
const ly = Math.floor((entity.Position.y - RESOLUTION.y) / cy);
const ax = Math.ceil(layer.area.x / CHUNK_SIZE);
for (let wy = 0; wy < ry; ++wy) {
for (let wx = 0; wx < rx; ++wx) {
const iy = wy + ly;
const ix = wx + lx;
if (
ix >= 0
&& iy >= 0
&& ix < Math.ceil(layer.area.x / CHUNK_SIZE)
&& iy < Math.ceil(layer.area.y / CHUNK_SIZE)
) {
const chunk = iy * ax + ix;
if (!memory.chunks.has(i)) {
memory.chunks.set(i, new Set());
}
if (!memory.chunks.get(i).has(chunk)) {
memory.chunks.get(i).add(chunk);
if (!layerChange) {
layerChange = {};
}
if (!layerChange[i]) {
layerChange[i] = {};
}
for (let y = 0; y < CHUNK_SIZE; ++y) {
for (let x = 0; x < CHUNK_SIZE; ++x) {
const ty = (iy * CHUNK_SIZE) + y;
const tx = (ix * CHUNK_SIZE) + x;
if (
tx < 0
|| ty < 0
|| tx >= layers[i].area.x
|| ty >= layers[i].area.y
) {
continue;
}
const computed = ((iy * CHUNK_SIZE) + y) * layers[i].area.x + ((ix * CHUNK_SIZE) + x);
layerChange[i][computed] = layers[i].data[computed];
}
}
}
}
}
}
}
if (firstUpdate && update['1']) {
const {TileLayers} = update['1'];
if (TileLayers) {
const layersUpdate = [];
const {layers} = TileLayers;
for (const l in layers) {
layersUpdate[l] = {
...layers[l],
data: [],
};
}
update['1'].TileLayers = {layers: layersUpdate};
}
}
if (layerChange) {
if (!update['1']) {
update['1'] = {};
}
if (!update['1'].TileLayers) {
update['1'].TileLayers = {};
}
update['1'].TileLayers.layerChange = layerChange;
}
return update;
}

View File

@ -2,6 +2,7 @@ import {del, get, set} from 'idb-keyval';
import {encode} from '@/packets/index.js';
import '../../create-forest.js';
import '../../create-homestead.js';
import '../../create-player.js';
@ -92,6 +93,18 @@ if (import.meta.hot) {
// await engine.server.writeData('players/0', (new TextEncoder()).encode(JSON.stringify(player)));
resolver.resolve();
});
import.meta.hot.accept('../../create-forest.js', async ({default: createForest}) => {
const resolver = createResolver();
resolvers.push(resolver);
await beforeResolver;
delete engine.ecses['forests/0'];
await engine.server.removeData('forests/0');
const last = performance.now();
const forest = await createForest(engine.Ecs, '0');
console.log((performance.now() - last) / 1000);
await engine.saveEcs('forests/0', forest);
resolver.resolve();
});
import.meta.hot.accept('../../create-homestead.js', async ({default: createHomestead}) => {
const resolver = createResolver();
resolvers.push(resolver);
@ -99,7 +112,6 @@ if (import.meta.hot) {
// delete engine.ecses['homesteads/0'];
// await engine.server.removeData('homesteads/0');
// const homestead = await createHomestead(engine.Ecs, '0');
// homestead.get(2).Ecs.path = 'houses/0';
// await engine.saveEcs('homesteads/0', homestead);
resolver.resolve();
});

View File

@ -1,6 +1,10 @@
import {useState} from 'react';
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
import 'react-tabs/style/react-tabs.css';
import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import styles from './devtools.module.css';
import Tiles from './devtools/tiles.jsx';
@ -10,15 +14,22 @@ export default function Devtools({
eventsChannel,
setApplyFilters,
}) {
const [ecs] = useEcs();
const [mainEntity] = useMainEntity();
const [mainEntityJson, setMainEntityJson] = useState({});
useEcsTick(() => {
if (!ecs || !mainEntity) {
return;
}
setMainEntityJson(ecs.get(mainEntity).toJSON());
}, [ecs, mainEntity]);
return (
<div className={styles.devtools}>
<Tabs>
<TabList>
<Tab>Dashboard</Tab>
<Tab>Tiles</Tab>
</TabList>
<TabPanel>
<div className={styles.dashboard}>
<form>
@ -35,9 +46,9 @@ export default function Devtools({
</label>
</div>
</form>
<pre><code><small>{JSON.stringify(mainEntityJson, null, 2)}</small></code></pre>
</div>
</TabPanel>
<TabPanel>
<Tiles
eventsChannel={eventsChannel}

View File

@ -11,6 +11,7 @@ export default function Tiles({eventsChannel}) {
const wrapperRef = useRef();
const imageRef = useRef();
const imageRect = useRect(imageRef);
const [indices, setIndices] = useState([]);
const [selection, setSelection] = useState({x: 0, y: 0, w: 1, h: 1});
const [moveStart, setMoveStart] = useState();
const [layer, setLayer] = useState(0);
@ -35,17 +36,19 @@ export default function Tiles({eventsChannel}) {
if (at.x < 0 || at.y < 0 || at.x >= area.x || at.y >= area.y) {
return;
}
const payload = {
brush,
layer,
stamp: {
at,
data: stamp,
},
}
client.send({
type: 'AdminAction',
payload: {type: 'paint', value: payload},
payload: {
type: 'paint',
value: {
brush,
layer,
stamp: {
at,
data: stamp,
},
},
},
});
}
eventsChannel.addListener('click', onClick);
@ -64,6 +67,7 @@ export default function Tiles({eventsChannel}) {
const {sourceJson, tileSize} = TileLayers.layer(0);
const {w, h} = sourceJson.meta.size;
const factor = (imageRect?.width ?? 1) / w;
const wt = w / tileSize.x;
return (
<div className={styles.tiles}>
<form>
@ -125,8 +129,14 @@ export default function Tiles({eventsChannel}) {
const y = Math.floor((c.y - top) / (tileSize.y * factor));
setMoveStart({x, y});
setSelection({x, y, w: 1, h: 1});
setStamp([[y * wt + x]]);
setIndices([y * wt + x]);
}}
onMouseMove={(event) => {
if (0 === event.buttons) {
setMoveStart();
return;
}
if (!moveStart) {
return;
}
@ -145,21 +155,39 @@ export default function Tiles({eventsChannel}) {
);
const mx = Math.min(sx, x);
const my = Math.min(sy, y);
setSelection({x: mx, y: my, w: Math.abs(sx - x) + 1, h: Math.abs(sy - y) + 1});
}}
onMouseUp={() => {
setMoveStart();
const sw = Math.abs(sx - x) + 1;
const sh = Math.abs(sy - y) + 1;
const newSelection = {
x: mx,
y: my,
w: sw,
h: sh,
};
if (
selection.x === newSelection.x
&& selection.y === newSelection.y
&& selection.w === newSelection.w
&& selection.h === newSelection.h
) {
return;
}
setSelection(newSelection);
const stamp = [];
const {x, y, w: sw, h: sh} = selection;
const tw = w / tileSize.x;
for (let iy = 0; iy < sh; ++iy) {
const row = [];
for (let ix = 0; ix < sw; ++ix) {
row.push((y + iy) * tw + x + ix);
row.push((my + iy) * wt + mx + ix);
}
stamp.push(row);
}
setStamp(stamp);
const indices = [];
for (let sy = 0; sy < newSelection.h; ++sy) {
for (let sx = 0; sx < newSelection.w; ++sx) {
indices.push(((newSelection.y + sy) * wt) + (newSelection.x + sx));
}
}
setIndices(indices);
}}
className={styles.selectionWrapper}
ref={wrapperRef}
@ -179,6 +207,25 @@ export default function Tiles({eventsChannel}) {
src={TileLayers.layer(0).source.replace('.json', '.png')}
/>
</div>
<div
className={styles.status}
>
<code className={styles.selectionStatus}>
Sel:
{' {'}
{selection.x}{', '}
{selection.y}{', '}
{selection.w}{', '}
{selection.h}
{'}'}
</code>
<code>
Idx:
{' ['}
{indices.join(', ')}
{']'}
</code>
</div>
</div>
);
}

View File

@ -28,3 +28,16 @@
background-color: #ffffff44;
position: absolute;
}
.status {
align-items: end;
display: flex;
font-size: 0.6rem;
gap: 32px;
justify-content: space-between;
margin: 8px;
}
.selectionStatus {
white-space: nowrap;
}

View File

@ -1,14 +1,10 @@
import {simplex2D} from '@leodeslf/simplex-noise';
import {Texture} from '@pixi/core';
import {Container} from '@pixi/react';
import {Sprite} from '@pixi/sprite';
import {useEffect, useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import {usePacket} from '@/context/client.js';
import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import {bresenham, TAU} from '@/util/math.js';
import Entities from './entities.jsx';
import TargetingGhost from './targeting-ghost.jsx';
@ -32,60 +28,13 @@ function calculateDarkness(hour) {
return Math.floor(darkness * 1000) / 1000;
}
function createLayerMask(layer) {
const {area, hulls, tileSize} = layer;
const canvas = window.document.createElement('canvas');
if (0 === hulls.length) {
return Texture.from(canvas);
}
[canvas.width, canvas.height] = [area.x * tileSize.x, area.y * tileSize.y];
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
for (let i = 0; i < hulls.length; ++i) {
const hull = [...hulls[i]];
hull.push(hull[0]);
ctx.beginPath();
ctx.moveTo(hull[0].x, hull[0].y);
for (let j = 0; j < hull.length - 1; j++) {
const p0 = hull[j + 0];
const p1 = hull[j + 1];
const points = bresenham(p0, p1);
const isReversed = (
(p0.x > p1.x && points[0].x < points[points.length - 1].x)
|| (p0.y > p1.y && points[0].y < points[points.length - 1].y)
);
for (
let k = (isReversed ? points.length - 1 : 0);
(isReversed ? k >= 0 : k < points.length);
k += (isReversed ? -1 : 1)
) {
const {x, y} = points[k];
const ANGLE_SCALE = 1000;
const WALK_SCALE = 10;
const MAGNITUDE = ((tileSize.x + tileSize.y) / 2) * simplex2D(x * 100, y * 100);
const w = simplex2D(x * WALK_SCALE, y * WALK_SCALE);
const r = TAU * simplex2D(x * ANGLE_SCALE, y * ANGLE_SCALE)
ctx.lineTo(
x + Math.cos(r) * w * MAGNITUDE,
y + -Math.sin(r) * w * MAGNITUDE,
);
}
}
ctx.closePath();
ctx.fill();
}
return Texture.from(canvas);
}
export default function Ecs({applyFilters, scale}) {
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const [filters, setFilters] = useState([]);
const [mainEntity] = useMainEntity();
const [hour, setHour] = useState(10);
const [night, setNight] = useState();
const [mask, setMask] = useState();
useEffect(() => {
async function buildNightFilter() {
const {ColorMatrixFilter} = await import('@pixi/filter-color-matrix');
@ -131,12 +80,6 @@ export default function Ecs({applyFilters, scale}) {
if (update.Time) {
setHour(Math.round(ecs.get(1).Time.hour * 60) / 60);
}
if (update.TileLayers) {
const layer1 = ecs.get(1).TileLayers.layer(1);
if (layer1) {
setMask(new Sprite(createLayerMask(layer1)));
}
}
}
updatedEntities[id] = ecs.get(id);
if (update.Emitter?.emit) {
@ -149,6 +92,13 @@ export default function Ecs({applyFilters, scale}) {
}
setEntities(updatedEntities);
}, [ecs, entities, mainEntity]);
useEffect(() => {
setFilters(
applyFilters
? [night]
: [],
);
}, [applyFilters, night])
if (!ecs || !mainEntity) {
return false;
}
@ -162,10 +112,6 @@ export default function Ecs({applyFilters, scale}) {
const {TileLayers, Water: WaterEcs} = ecs.get(1);
const layer0 = TileLayers.layer(0);
const layer1 = TileLayers.layer(1);
const filters = [];
if (applyFilters && night) {
filters.push(night);
}
const [cx, cy] = [
Math.round((Camera.x * scale) - RESOLUTION.x / 2),
Math.round((Camera.y * scale) - RESOLUTION.y / 2),
@ -185,7 +131,6 @@ export default function Ecs({applyFilters, scale}) {
/>
{layer1 && (
<TileLayer
mask={mask}
filters={filters}
tileLayer={layer1}
/>
@ -193,7 +138,6 @@ export default function Ecs({applyFilters, scale}) {
</Container>
{WaterEcs && (
<Water
mask={mask}
tileLayer={layer0}
water={WaterEcs.water}
/>

View File

@ -40,7 +40,7 @@ export default function Entities({entities, filters}) {
const isHighlightedInteraction = id == willInteractWith;
renderables.push(
<Entity
filters={filters.concat(isHighlightedInteraction ? interactionFilters : [])}
filters={isHighlightedInteraction ? interactionFilters : null}
entity={entities[id]}
key={id}
/>
@ -48,6 +48,7 @@ export default function Entities({entities, filters}) {
}
return (
<Container
filters={filters}
sortableChildren
>
{renderables}

View File

@ -3,42 +3,64 @@ import {PixiComponent} from '@pixi/react';
import '@pixi/spritesheet'; // NECESSARY!
import {CompositeTilemap} from '@pixi/tilemap';
import {CHUNK_SIZE} from '@/constants.js';
import {useAsset} from '@/context/assets.js';
const TileLayerInternal = PixiComponent('TileLayer', {
create: () => {
create: ({tileLayer}) => {
const container = new Container();
container.addChild(new CompositeTilemap());
const cy = Math.ceil(tileLayer.area.y / CHUNK_SIZE);
const cx = Math.ceil(tileLayer.area.x / CHUNK_SIZE);
for (let iy = 0; iy < cy; ++iy) {
for (let ix = 0; ix < cx; ++ix) {
const tilemap = new CompositeTilemap();
tilemap.x = tileLayer.tileSize.x * CHUNK_SIZE * ix;
tilemap.y = tileLayer.tileSize.y * CHUNK_SIZE * iy;
container.addChild(tilemap);
}
}
return container;
},
applyProps: (container, {mask: oldMask, tileLayer: oldTileLayer}, props) => {
const {asset, mask, tileLayer} = props;
applyProps: (container, {tileLayer: oldTileLayer}, props) => {
const {asset, tileLayer} = props;
const extless = tileLayer.source.slice('/assets/'.length, -'.json'.length);
const {textures} = asset;
if (tileLayer === oldTileLayer) {
return;
}
if (oldMask) {
container.removeChildAt(1);
container.mask = undefined;
}
if (mask) {
container.addChild(mask);
container.mask = mask;
}
const tilemap = container.children[0];
tilemap.clear();
let i = 0;
for (let y = 0; y < tileLayer.area.y; ++y) {
for (let x = 0; x < tileLayer.area.x; ++x) {
tilemap.tile(textures[`${extless}/${tileLayer.data[i++]}`], tileLayer.tileSize.x * x, tileLayer.tileSize.y * y);
for (const i in tileLayer.$$chunks) {
if (!oldTileLayer || oldTileLayer.$$chunks[i] !== tileLayer.$$chunks[i]) {
const tilemap = container.children[i];
tilemap.clear();
const ax = Math.ceil(tileLayer.area.x / CHUNK_SIZE);
const cy = Math.floor(i / ax);
const cx = i % ax;
for (let y = 0; y < CHUNK_SIZE; ++y) {
for (let x = 0; x < CHUNK_SIZE; ++x) {
const ty = (cy * CHUNK_SIZE) + y;
const tx = (cx * CHUNK_SIZE) + x;
if (
tx < 0
|| ty < 0
|| tx >= tileLayer.area.x
|| ty >= tileLayer.area.y
) {
continue;
}
tilemap.tile(
textures[`${extless}/${tileLayer.data[ty * tileLayer.area.x + tx]}`],
tileLayer.tileSize.x * x,
tileLayer.tileSize.y * y,
);
}
}
}
}
},
})
});
export default function TileLayer(props) {
const {mask, tileLayer} = props;
const {tileLayer} = props;
const asset = useAsset(tileLayer.source);
if (!asset) {
return false;
@ -48,7 +70,6 @@ export default function TileLayer(props) {
<TileLayerInternal
{...props}
asset={asset}
mask={mask}
/>
</>
);

View File

@ -98,7 +98,7 @@ export default function Ui({disconnected}) {
switch (payload) {
case '-':
if ('keyDown' === type) {
setScale((scale) => scale > 1 ? scale - 1 : 0.666);
setScale((scale) => scale > 1 ? scale - 1 : 1);
}
break;
case '=':

View File

@ -1,3 +1,6 @@
import alea from 'alea';
import {createNoise2D as createSimplexNoise2D} from 'simplex-noise';
export const {
abs,
acos,
@ -110,6 +113,14 @@ export function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
export function createNoise2D(seed = 0) {
return createSimplexNoise2D(createRandom(seed));
}
export function createRandom(seed = 0) {
return alea(seed);
}
export function distance({x: lx, y: ly}, {x: rx, y: ry}) {
const xd = lx - rx;
const yd = ly - ry;
@ -167,6 +178,50 @@ export function floodwalk2D(eligible, data, {x, y, w, h}, {diagonal = false} = {
return points;
}
export class Generator {
constructor({
calculate,
children = [],
covers,
size: {w, h},
}) {
this.calculate = calculate;
this.children = children;
this.covers = covers;
this.size = {w, h};
this.matrix = new Array(w * h).fill(0);
}
compute(i, position) {
if (!this.covers(position)) {
return;
}
this.matrix[i] = this.calculate(position);
if (!this.children) {
return;
}
for (let j = 0; j < this.children.length; j++) {
this.children[j].compute(i, position);
}
}
generate() {
const {w, h} = this.size;
let i = 0;
const position = {x: 0, y: 0};
for (let y = 0; y < h; ++y) {
for (let x = 0; x < w; ++x) {
this.compute(i++, position);
position.x += 1;
}
position.x -= w;
position.y += 1;
}
}
}
export function intersects(l, r) {
if (l.x0 > r.x1) return false;
if (l.y0 > r.y1) return false;

56
app/util/noise.js Normal file
View File

@ -0,0 +1,56 @@
// import {Texture} from '@pixi/core';
// import {Sprite} from '@pixi/sprite';
import {bresenham, createNoise2D, TAU} from '@/util/math.js';
const simplex2D = createNoise2D();
// TODO - 3d noise around a circumference of length polygon perimeter
export function createLayerMask(layer) {
const {area, hulls, tileSize} = layer;
const canvas = window.document.createElement('canvas');
if (0 === hulls.length) {
return Texture.from(canvas);
}
[canvas.width, canvas.height] = [area.x * tileSize.x, area.y * tileSize.y];
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
for (let i = 0; i < hulls.length; ++i) {
const hull = [...hulls[i]];
hull.push(hull[0]);
ctx.beginPath();
ctx.moveTo(hull[0].x, hull[0].y);
for (let j = 0; j < hull.length - 1; j++) {
const p0 = hull[j + 0];
const p1 = hull[j + 1];
const points = bresenham(p0, p1);
const isReversed = (
(p0.x > p1.x && points[0].x < points[points.length - 1].x)
|| (p0.y > p1.y && points[0].y < points[points.length - 1].y)
);
for (
let k = (isReversed ? points.length - 1 : 0);
(isReversed ? k >= 0 : k < points.length);
k += (isReversed ? -1 : 1)
) {
const {x, y} = points[k];
const ANGLE_SCALE = 1000;
const WALK_SCALE = 10;
const MAGNITUDE = ((tileSize.x + tileSize.y) / 2) * simplex2D(x * 100, y * 100);
const w = simplex2D(x * WALK_SCALE, y * WALK_SCALE);
const r = TAU * simplex2D(x * ANGLE_SCALE, y * ANGLE_SCALE)
ctx.lineTo(
x + Math.cos(r) * w * MAGNITUDE,
y + -Math.sin(r) * w * MAGNITUDE,
);
}
}
ctx.closePath();
ctx.fill();
}
return Texture.from(canvas);
}

43
package-lock.json generated
View File

@ -6,7 +6,6 @@
"": {
"name": "silphius-next",
"dependencies": {
"@leodeslf/simplex-noise": "^1.0.0",
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/filter-adjustment": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
@ -20,6 +19,7 @@
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"acorn": "^8.12.0",
"alea": "^1.0.1",
"compression": "^1.7.4",
"express": "^4.18.2",
"idb-keyval": "^6.2.1",
@ -30,6 +30,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-tabs": "^6.0.2",
"simplex-noise": "^4.0.1",
"ws": "^8.17.0"
},
"devDependencies": {
@ -51,6 +52,7 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
@ -3026,11 +3028,6 @@
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@leodeslf/simplex-noise": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@leodeslf/simplex-noise/-/simplex-noise-1.0.0.tgz",
"integrity": "sha512-hsp+CfDnT9jxUjDUqiV2+eLkkdAKz6szlpcyMyXvrgs+LO8a0Gepyri1V+c6FbOfqc3ReWB282GtIE8y3Idi1A=="
},
"node_modules/@mdx-js/mdx": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-2.3.0.tgz",
@ -7452,6 +7449,11 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz",
"integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA=="
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -11355,6 +11357,21 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz",
"integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==",
"dev": true,
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -15196,6 +15213,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dev": true,
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -16488,6 +16514,11 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
"node_modules/simplex-noise": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.1.tgz",
"integrity": "sha512-zl/+bdSqW7HJOQ0oDbxrNYaF4F5ik0i7M6YOYmEoIJNtg16NpvWaTTM1Y7oV/7T0jFljawLgYPS81Uu2rsfo1A=="
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",

View File

@ -13,7 +13,6 @@
"test": "vitest app"
},
"dependencies": {
"@leodeslf/simplex-noise": "^1.0.0",
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/filter-adjustment": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.2",
@ -27,6 +26,7 @@
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"acorn": "^8.12.0",
"alea": "^1.0.1",
"compression": "^1.7.4",
"express": "^4.18.2",
"idb-keyval": "^6.2.1",
@ -37,6 +37,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-tabs": "^6.0.2",
"simplex-noise": "^4.0.1",
"ws": "^8.17.0"
},
"devDependencies": {
@ -58,6 +59,7 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"

View File

@ -0,0 +1 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":128,"h":160},"spriteSourceSize":{"x":0,"y":0,"w":128,"h":160},"sourceSize":{"w":128,"h":160}}},"meta":{"format":"RGBA8888","image":"./blossom.png","scale":1,"size":{"w":128,"h":160}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":32,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":32,"h":32},"sourceSize":{"w":32,"h":32}}},"meta":{"format":"RGBA8888","image":"./flower.png","scale":1,"size":{"w":32,"h":32}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":32,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":32,"h":32},"sourceSize":{"w":32,"h":32}}},"meta":{"format":"RGBA8888","image":"./shrub.png","scale":1,"size":{"w":32,"h":32}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":96,"h":160},"spriteSourceSize":{"x":0,"y":0,"w":96,"h":160},"sourceSize":{"w":96,"h":160}}},"meta":{"format":"RGBA8888","image":"./tree.png","scale":1,"size":{"w":96,"h":160}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -6,16 +6,25 @@ import {basename, dirname, extname, join} from 'node:path';
import imageSize from 'image-size';
const tileset = process.argv[2];
const w = parseInt(process.argv[3]);
const h = parseInt(process.argv[4]);
let w = parseInt(process.argv[3]);
let h = parseInt(process.argv[4]);
const {width, height} = imageSize(tileset);
if (0 === w) {
w = width;
}
if (0 === h) {
h = height;
}
const total = (width / w) * (height / h);
const json = {
frames: {},
meta: {
format: 'RGBA8888',
image: tileset,
image: ['.', basename(tileset)].join('/'),
scale: 1,
size: {w: width, h: height},
},
@ -26,7 +35,7 @@ const extlessPath = join(dirname(tileset), basename(tileset, extname(tileset)));
let i = 0;
for (let y = 0; y < height; y += h) {
for (let x = 0; x < width; x += w) {
json.frames[join(extlessPath, `${i++}`)] = {
json.frames[1 === total ? '' : join(extlessPath, `${i++}`)] = {
frame: {x, y, w, h},
spriteSourceSize: {x: 0, y: 0, w, h},
sourceSize: {w, h},