mirror of
https://github.com/morten-olsen/shipped.git
synced 2026-02-07 23:26:23 +01:00
init
This commit is contained in:
37
packages/engine/package.json
Normal file
37
packages/engine/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@shipped/engine",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build:cjs": "tsc -p tsconfig.json",
|
||||
"build:esm": "tsc -p tsconfig.esm.json",
|
||||
"build": "pnpm build:cjs && pnpm build:esm"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"types": "./dist/cjs/types/index.d.ts",
|
||||
"main": "./dist/esm/index.js",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"_exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/esm/types/index.d.ts",
|
||||
"default": "./dist/esm/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/cjs/types/index.d.ts",
|
||||
"default": "./dist/cjs/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shipped/config": "workspace:^",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1"
|
||||
}
|
||||
}
|
||||
31
packages/engine/src/index.ts
Normal file
31
packages/engine/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export {
|
||||
State,
|
||||
type StateOptions,
|
||||
} from './state';
|
||||
export type {
|
||||
MapTile,
|
||||
PortMapTile,
|
||||
} from './types/map';
|
||||
export {
|
||||
Position,
|
||||
} from './types/math';
|
||||
export type {
|
||||
Vessel
|
||||
} from './types/vessel';
|
||||
export type {
|
||||
Port
|
||||
} from './types/port';
|
||||
export type {
|
||||
CaptainAI,
|
||||
CaptainCommand,
|
||||
CaptainView,
|
||||
} from './types/captain';
|
||||
export {
|
||||
getVesselStats,
|
||||
} from './utils/vessel';
|
||||
export {
|
||||
createMap,
|
||||
} from './utils/map';
|
||||
export {
|
||||
calculatePrice,
|
||||
} from './utils/port';
|
||||
190
packages/engine/src/state/index.ts
Normal file
190
packages/engine/src/state/index.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { Vessel } from "../types/vessel"
|
||||
import { move } from "../utils/math";
|
||||
import { getCurrentTile, getVesselTravelDelta, getVisiblePorts } from "../utils/vessel";
|
||||
import { Captain, CaptainCommand } from "../types/captain";
|
||||
import { Port } from "../types/port";
|
||||
import { calculatePrice, findPort } from "../utils/port";
|
||||
import { Effect } from "../types/effects";
|
||||
import { MapTile } from "../types/map";
|
||||
|
||||
type StateEvents = {
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
type StateOptions = {
|
||||
size?: { width: number, height: number };
|
||||
map?: MapTile[][];
|
||||
ports?: Port[];
|
||||
}
|
||||
|
||||
class State extends EventEmitter<StateEvents> {
|
||||
#size: { width: number, height: number };
|
||||
#vessels: Vessel[] = [];
|
||||
#ports: Port[] = [];
|
||||
#effects: Effect[] = [];
|
||||
#captains: Record<string, Captain> = {};
|
||||
#map: MapTile[][] = [];
|
||||
|
||||
constructor(options: StateOptions = {}) {
|
||||
super();
|
||||
this.#size = options.size || { width: 50, height: 50 };
|
||||
this.#ports = options.ports || new Array(10).fill(0).map((_, i) => ({
|
||||
id: `port-${i}`,
|
||||
fuelPrice: 1,
|
||||
amount: Math.round(Math.random() * 100),
|
||||
}));
|
||||
this.#map = options.map || this.#generateMap();
|
||||
}
|
||||
|
||||
#generateMap = () => {
|
||||
const map: MapTile[][] = [];
|
||||
const getData = () => {
|
||||
const draw = Math.random();
|
||||
if (draw < 0.01 && this.#ports.length > 0) return { type: 'port', id: this.#ports[Math.floor(Math.random() * this.#ports.length)].id };
|
||||
if (draw < 0.2) return { type: 'land' };
|
||||
return { type: 'water' };
|
||||
}
|
||||
|
||||
for (let x = 0; x < Math.round(this.#size.width / 1); x++) {
|
||||
map[x] = [];
|
||||
for (let y = 0; y < Math.round(this.#size.height / 1); y++) {
|
||||
map[x][y] = {
|
||||
...getData() as any,
|
||||
};
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
#transact = (vessel: Vessel, amount: number) => {
|
||||
if (vessel.cash + amount < 0) return false;
|
||||
vessel.cash += amount;
|
||||
return true;
|
||||
};
|
||||
|
||||
#applyCommand = (vessel: Vessel, command: CaptainCommand) => {
|
||||
const tile = getCurrentTile(vessel, this.#map);
|
||||
switch (command.type) {
|
||||
case "update-plan":
|
||||
vessel.plan = command.plan;
|
||||
break;
|
||||
case "update-power":
|
||||
vessel.power = command.power;
|
||||
break;
|
||||
case "fuel-up": {
|
||||
const port = findPort(tile, this.#ports);
|
||||
if (!port) return;
|
||||
const amount = command.amount ?? vessel.fuel.capacity - vessel.fuel.current;
|
||||
const cost = amount * port.fuelPrice;
|
||||
if (this.#transact(vessel, -cost)) {
|
||||
vessel.fuel.current += amount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "buy": {
|
||||
const port = findPort(tile, this.#ports);
|
||||
if (!port) return;
|
||||
if (command.amount > port.amount) return;
|
||||
const cost = calculatePrice(port) * command.amount;
|
||||
if (this.#transact(vessel, -cost)) {
|
||||
vessel.goods += command.amount;
|
||||
port.amount -= command.amount;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sell": {
|
||||
const port = findPort(tile, this.#ports);
|
||||
if (!port) return;
|
||||
if (command.amount > vessel.goods) return;
|
||||
const cost = calculatePrice(port) * command.amount;
|
||||
this.#transact(vessel, cost);
|
||||
vessel.goods -= command.amount;
|
||||
port.amount += command.amount;
|
||||
break;
|
||||
}
|
||||
case 'record': {
|
||||
vessel.data[command.name] = command.data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addPort = (port: Port) => {
|
||||
this.#ports.push(port);
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
public addVessel = (vessel: Vessel) => {
|
||||
this.#vessels.push(vessel);
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
public addCaptain = (id: string, captain: Captain) => {
|
||||
this.#captains[id] = captain;
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
public addEffect = (effect: Effect) => {
|
||||
this.#effects.push(effect);
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
public update = () => {
|
||||
for (const vessel of this.#vessels) {
|
||||
const captain = this.#captains[vessel.captain];
|
||||
if (!captain) continue;
|
||||
const currentTile = getCurrentTile(vessel, this.#map);
|
||||
if (currentTile.type === 'land') continue;
|
||||
const currentPort = findPort(currentTile, this.#ports);
|
||||
const visiblePorts = currentPort ? this.#ports : getVisiblePorts(vessel, this.#map, this.#ports);
|
||||
const commands = captain.command({
|
||||
vessel,
|
||||
currentPort,
|
||||
ports: visiblePorts,
|
||||
size: this.#size,
|
||||
map: this.#map,
|
||||
});
|
||||
if (commands) {
|
||||
for (const command of commands) {
|
||||
this.#applyCommand(vessel, command);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const next = vessel.plan?.[0];
|
||||
if (next && vessel.fuel.current > 0) {
|
||||
const delta = getVesselTravelDelta(vessel);
|
||||
const moved = move(vessel.position, next, delta.speed);
|
||||
vessel.fuel.current -= delta.fuel;
|
||||
vessel.score.fuelUsed += delta.fuel;
|
||||
vessel.score.distanceTravelled += moved.travelled;
|
||||
|
||||
vessel.position = moved.position;
|
||||
vessel.direction = moved.direction;
|
||||
if (moved.reached) {
|
||||
vessel.plan?.shift();
|
||||
}
|
||||
}
|
||||
vessel.score.rounds++;
|
||||
}
|
||||
this.emit("update");
|
||||
}
|
||||
|
||||
public getState = () => {
|
||||
return {
|
||||
vessels: this.#vessels,
|
||||
ports: this.#ports,
|
||||
}
|
||||
};
|
||||
|
||||
public getWorld = () => {
|
||||
return {
|
||||
size: this.#size,
|
||||
map: this.#map,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { StateOptions };
|
||||
export { State }
|
||||
42
packages/engine/src/types/captain.ts
Normal file
42
packages/engine/src/types/captain.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { MapTile } from "./map";
|
||||
import { Port } from "./port";
|
||||
import { Vessel } from "./vessel";
|
||||
|
||||
type CaptainCommandList = [{
|
||||
type: 'update-plan',
|
||||
plan: Vessel['plan']
|
||||
}, {
|
||||
type: 'update-power',
|
||||
power: Vessel['power']
|
||||
}, {
|
||||
type: 'fuel-up',
|
||||
amount?: number,
|
||||
}, {
|
||||
type: 'buy',
|
||||
amount: number,
|
||||
}, {
|
||||
type: 'sell',
|
||||
amount: number,
|
||||
}, {
|
||||
type: 'record',
|
||||
name: string,
|
||||
data: string,
|
||||
}]
|
||||
|
||||
type CaptainCommand = CaptainCommandList[number]
|
||||
|
||||
type CaptainView = {
|
||||
size: { width: number, height: number };
|
||||
currentPort?: Port;
|
||||
vessel: Vessel;
|
||||
ports: Port[];
|
||||
map: MapTile[][];
|
||||
}
|
||||
|
||||
type CaptainAI = (view: CaptainView) => CaptainCommand[] | void;
|
||||
|
||||
type Captain = {
|
||||
command: CaptainAI;
|
||||
}
|
||||
|
||||
export type { Captain, CaptainCommand, CaptainView, CaptainAI }
|
||||
8
packages/engine/src/types/effects.ts
Normal file
8
packages/engine/src/types/effects.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Position, Size } from "./math"
|
||||
|
||||
type Effect = {
|
||||
position: Position;
|
||||
size: Size;
|
||||
}
|
||||
|
||||
export type { Effect }
|
||||
10
packages/engine/src/types/map.ts
Normal file
10
packages/engine/src/types/map.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
type PortMapTile = {
|
||||
type: 'port';
|
||||
id: string;
|
||||
};
|
||||
|
||||
type MapTile = {
|
||||
type: 'water' | 'land';
|
||||
} | PortMapTile;
|
||||
|
||||
export type { MapTile, PortMapTile };
|
||||
8
packages/engine/src/types/math.ts
Normal file
8
packages/engine/src/types/math.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type Size = number;
|
||||
|
||||
export type { Position, Size }
|
||||
9
packages/engine/src/types/port.ts
Normal file
9
packages/engine/src/types/port.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Position } from "./math";
|
||||
|
||||
type Port = {
|
||||
id: string;
|
||||
fuelPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export type { Port }
|
||||
23
packages/engine/src/types/vessel.ts
Normal file
23
packages/engine/src/types/vessel.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Position } from "./math"
|
||||
|
||||
type Vessel = {
|
||||
position: Position;
|
||||
plan: Position[];
|
||||
data: Record<string, string>;
|
||||
direction: number;
|
||||
power: number;
|
||||
cash: number;
|
||||
fuel: {
|
||||
current: number;
|
||||
capacity: number;
|
||||
};
|
||||
score: {
|
||||
fuelUsed: number;
|
||||
distanceTravelled: number;
|
||||
rounds: number;
|
||||
};
|
||||
captain: string;
|
||||
goods: number;
|
||||
}
|
||||
|
||||
export type { Vessel }
|
||||
51
packages/engine/src/utils/map.ts
Normal file
51
packages/engine/src/utils/map.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { MapTile } from "../types/map";
|
||||
import { Port } from "../types/port";
|
||||
|
||||
type CreateMapOptions = {
|
||||
ports?: {
|
||||
[key: `${number},${number}`]: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const createMap = (width: number, height: number, options: CreateMapOptions = {}) => {
|
||||
const { ports } = options;
|
||||
const generatedPorts: Record<string, Port> = {};
|
||||
const generatedMap = new Array(height)
|
||||
.fill(0)
|
||||
.map(() => new Array(width)
|
||||
.fill(0)
|
||||
.map(() => ({
|
||||
type: 'water',
|
||||
} as MapTile))
|
||||
);
|
||||
|
||||
for (const [key, port] of Object.entries(ports ?? {})) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const id = port.name ?? `${x},${y}`;
|
||||
if (!generatedPorts[id]) {
|
||||
generatedPorts[id] = {
|
||||
id,
|
||||
fuelPrice: 1,
|
||||
amount: 100,
|
||||
};
|
||||
}
|
||||
generatedMap[y][x] = {
|
||||
type: 'port',
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
map: generatedMap,
|
||||
ports: Object.values(generatedPorts),
|
||||
size: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { createMap };
|
||||
35
packages/engine/src/utils/math.ts
Normal file
35
packages/engine/src/utils/math.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Position } from "../types/math";
|
||||
|
||||
const distance = (a: Position, b: Position) => {
|
||||
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
|
||||
}
|
||||
|
||||
const direction = (a: Position, b: Position) => {
|
||||
return Math.atan2(b.y - a.y, b.x - a.x);
|
||||
}
|
||||
|
||||
const intersect = (aposition: Position, asize: number, bposition: Position, bsize: number) => {
|
||||
const dist = distance(aposition, bposition);
|
||||
return dist < asize + bsize;
|
||||
}
|
||||
|
||||
const move = (a: Position, b: Position, length: number) => {
|
||||
const dist = distance(a, b);
|
||||
const dir = direction(a, b);
|
||||
if (dist < length) return {
|
||||
position: b,
|
||||
direction: dir,
|
||||
reached: true,
|
||||
travelled: dist,
|
||||
};
|
||||
const x = a.x + Math.cos(dir) * Math.min(dist, length);
|
||||
const y = a.y + Math.sin(dir) * Math.min(dist, length);
|
||||
return {
|
||||
position: { x, y },
|
||||
travelled: length,
|
||||
direction: dir,
|
||||
reached: false,
|
||||
};
|
||||
}
|
||||
|
||||
export { distance, direction, move, intersect }
|
||||
14
packages/engine/src/utils/port.ts
Normal file
14
packages/engine/src/utils/port.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MapTile } from "../types/map";
|
||||
import { Port } from "../types/port";
|
||||
|
||||
const calculatePrice = (port: Port) => {
|
||||
return Math.max(100 - port.amount, 1);
|
||||
}
|
||||
|
||||
const findPort = (tile: MapTile, ports: Port[]) => {
|
||||
const id = tile.type === 'port' ? tile.id : undefined;
|
||||
if (!id) return;
|
||||
return ports.find(p => p.id === id);
|
||||
};
|
||||
|
||||
export { calculatePrice, findPort };
|
||||
39
packages/engine/src/utils/vessel.ts
Normal file
39
packages/engine/src/utils/vessel.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { MapTile } from "../types/map";
|
||||
import { Port } from "../types/port";
|
||||
import { Vessel } from "../types/vessel";
|
||||
import { intersect } from "./math";
|
||||
|
||||
const getVesselStats = (vessel: Vessel) => {
|
||||
return {
|
||||
visibility: 500,
|
||||
energy: 1,
|
||||
power: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentTile = (vessel: Vessel, map: MapTile[][]) => {
|
||||
const x = Math.max(Math.floor(vessel.position.x), 0);
|
||||
const y = Math.max(Math.floor(vessel.position.y), 0);
|
||||
return map[y][x];
|
||||
};
|
||||
|
||||
const getVisiblePorts = (vessel: Vessel, map: MapTile[][], ports: Port[]) => {
|
||||
const tiles = map.map((row, x) => row.filter(t => t.type === 'port').map((tile, y) => ({ tile, x, y }))).flat();
|
||||
const stats = getVesselStats(vessel);
|
||||
const visibleTiles = tiles.filter((port) => intersect(vessel.position, stats.visibility, { x: port.x, y: port.y }, 1));
|
||||
const ids = [...new Set(visibleTiles.map(t => t.tile.type === 'port' ? t.tile.id : ''))];
|
||||
return ports.filter(p => ids.includes(p.id));
|
||||
};
|
||||
|
||||
const getVesselTravelDelta = (vessel: Vessel) => {
|
||||
const stats = getVesselStats(vessel);
|
||||
const speed = stats.power * vessel.power * 0.1;
|
||||
|
||||
const fuel = stats.energy * vessel.power;
|
||||
return {
|
||||
speed,
|
||||
fuel,
|
||||
}
|
||||
}
|
||||
|
||||
export { getVesselStats, getVesselTravelDelta, getVisiblePorts, getCurrentTile };
|
||||
10
packages/engine/tsconfig.esm.json
Normal file
10
packages/engine/tsconfig.esm.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@shipped/config/esm",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/esm",
|
||||
"declarationDir": "./dist/esm/types"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
8
packages/engine/tsconfig.json
Normal file
8
packages/engine/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@shipped/config/cjs",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/cjs",
|
||||
"declarationDir": "./dist/cjs/types"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user