This commit is contained in:
Morten Olsen
2023-05-19 14:51:17 +02:00
commit b574c375af
89 changed files with 11574 additions and 0 deletions

61
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Deploy static content to Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js environment
uses: actions/setup-node@v3
with:
node-version: 16
registry-url: 'https://registry.npmjs.org'
- name: Setup corepack
run: corepack enable
- name: setup pnpm config
run: pnpm config set store-dir $PNPM_CACHE_FOLDER
- name: Install
run: pnpm install
- name: Build
run: pnpm run build
env:
ASSET_URL: 'https://mortenolsen.pro/shipped'
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: './packages/website/dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.turbo/
/packages/*/dist
/.pnpm-store

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
store-dir=.pnpm-store

5
jest.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "shipped",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "turbo build",
"dev": "turbo dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/jest": "^29.5.1",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"turbo": "^1.9.8",
"typescript": "^5.0.4"
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2019", "DOM"],
"target": "ES2019",
"module": "CommonJS",
"moduleResolution": "Node"
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "NodeNext"
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "@shipped/config",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"checkJs": true,
"allowJs": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"allowSyntheticDefaultImports": true
},
"include": [],
"ts-node": {
"files": true
}
}

View 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"
}
}

View 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';

View 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 }

View 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 }

View File

@@ -0,0 +1,8 @@
import { Position, Size } from "./math"
type Effect = {
position: Position;
size: Size;
}
export type { Effect }

View File

@@ -0,0 +1,10 @@
type PortMapTile = {
type: 'port';
id: string;
};
type MapTile = {
type: 'water' | 'land';
} | PortMapTile;
export type { MapTile, PortMapTile };

View File

@@ -0,0 +1,8 @@
type Position = {
x: number;
y: number;
};
type Size = number;
export type { Position, Size }

View File

@@ -0,0 +1,9 @@
import { Position } from "./math";
type Port = {
id: string;
fuelPrice: number;
amount: number;
}
export type { Port }

View 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 }

View 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 };

View 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 }

View 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 };

View 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 };

View File

@@ -0,0 +1,10 @@
{
"extends": "@shipped/config/esm",
"compilerOptions": {
"outDir": "dist/esm",
"declarationDir": "./dist/esm/types"
},
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@shipped/config/cjs",
"compilerOptions": {
"outDir": "dist/cjs",
"declarationDir": "./dist/cjs/types"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,42 @@
{
"name": "@shipped/fleet-map",
"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:^",
"@types/react": "^18.0.28",
"react": "^18.2.0",
"react-native-svg": "^13.9.0",
"typescript": "^5.0.4"
},
"dependencies": {
"@shipped/engine": "workspace:^",
"eventemitter3": "^5.0.1",
"styled-components": "6.0.0-rc.1"
}
}

View File

@@ -0,0 +1,67 @@
import { EventEmitter } from 'eventemitter3';
import { MapTile, Vessel, Port } from '@shipped/engine';
type Update = {
delta: State;
hash: string;
}
type ConnectionEvents = {
sync: (input: Sync) => void;
update: (input: Update) => void;
}
abstract class Connection extends EventEmitter<ConnectionEvents> {}
type BridgeEvents = {
update: () => void;
}
type World = {
map: MapTile[][];
size: { width: number, height: number };
}
type State = {
vessels: Vessel[];
ports: Port[];
}
type Sync = {
world: World;
state: State;
}
class Bridge extends EventEmitter<BridgeEvents> {
#connection: Connection
#state?: State;
#world?: World;
constructor(connection: Connection) {
super();
this.#connection = connection;
this.#connection.on('sync', this.#onSync);
this.#connection.on('update', this.#onUpdate);
}
public get state() {
return this.#state;
}
public get world() {
return this.#world;
}
#onSync = (input: Sync) => {
this.#state = input.state;
this.#world = input.world;
this.emit('update');
}
#onUpdate = (input: Update) => {
this.#state = input.delta;
this.emit('update');
}
}
export { Bridge, Connection };

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
import { Bridge } from "."
type BridgeContextValue = {
bridge?: Bridge;
}
type BridgeContextProviderProps = {
bridge?: Bridge;
children: React.ReactNode;
}
const BridgeContext = createContext<BridgeContextValue>(undefined as any);
const BridgeProvider: React.FC<BridgeContextProviderProps> = ({ bridge, children }) => {
return (
<BridgeContext.Provider value={{ bridge }}>
{children}
</BridgeContext.Provider>
)
}
export { BridgeContext, BridgeProvider };

View File

@@ -0,0 +1,41 @@
import { useContext, useEffect, useState } from "react"
import { BridgeContext } from "./context"
const useBridgeState = () => {
const { bridge } = useContext(BridgeContext);
const [state, setState] = useState(bridge?.state);
useEffect(() => {
if (!bridge) return;
const update = () => {
setState(bridge.state);
}
bridge.on('update', update);
return () => {
bridge.off('update', update);
};
}, [bridge]);
return state;
}
const useBridgeWorld = () => {
const { bridge } = useContext(BridgeContext);
const [state, setState] = useState(bridge?.world);
useEffect(() => {
if (!bridge) return;
const update = () => {
setState(bridge.world);
}
bridge.on('update', update);
return () => {
bridge.off('update', update);
};
}, [bridge]);
return state;
}
export {
useBridgeState,
useBridgeWorld,
};

View File

@@ -0,0 +1,3 @@
export { BridgeProvider } from './context';
export { useBridgeState, useBridgeWorld } from './hooks';
export { Bridge, Connection } from './bridge';

View File

@@ -0,0 +1,13 @@
export {
FleetMap,
} from './ui';
export {
VesselInfo,
} from './ui/vessel';
export {
Bridge,
Connection,
BridgeProvider,
useBridgeState,
useBridgeWorld,
} from './bridge';

View File

@@ -0,0 +1,19 @@
import { FC } from "react";
import { FleetMapWorld } from "./world";
import { FleetMapState } from "./state";
import { useBridgeWorld } from "../bridge";
const FleetMap: FC = () => {
const world = useBridgeWorld();
if (!world) return null;
return (
<svg viewBox={`0 0 ${world.size.width} ${world.size.height}`}>
<FleetMapWorld />
<FleetMapState />
</svg>
)
};
export { FleetMap }

View File

@@ -0,0 +1,71 @@
import { FC, Fragment } from "react";
import { Vessel, getVesselStats } from "@shipped/engine";
import { useBridgeState, useBridgeWorld } from "../bridge";
const getStatusColor = (vessel: Vessel) => {
if (vessel.fuel.current <= 0) return "#e74c3c";
if (vessel.fuel.current / vessel.fuel.capacity <= 0.2) return "#f39c12";
return "#fefefe";
}
const FleetMapState: FC = () => {
const world = useBridgeWorld();
const state = useBridgeState();
if (!world || !state) return null;
const cellWidth = world.size.width / world.map[0].length;
const cellHeight = world.size.height / world.map.length;
return (
<>
{state.vessels.map((vessel, i) => (
<g key={i}>
{vessel.plan && vessel.plan.length > 0 && (
<>
<path
d={`M${vessel.position.x * cellWidth + (cellWidth / 2)},${vessel.position.y * cellHeight + (cellHeight / 2)}L${vessel.plan.map(({ x, y }) => `${x * cellWidth + (cellWidth / 2)},${y * cellHeight + (cellHeight / 2)}`).join("L")}`}
style={{ opacity: 0.3, transition: "transform 1s" }}
stroke="#0a3d62"
strokeLinejoin="round"
strokeDasharray="0.4 0.4"
strokeLinecap="round"
strokeWidth={0.2}
fill="none"
/>
{vessel.plan.map(({ x, y }, i) => (
<circle
opacity={0.6}
key={i}
cx={x * cellWidth + (cellWidth / 2)}
cy={y * cellHeight + (cellHeight / 2)}
r={0.15}
fill="#0a3d62"
/>
))}
</>
)}
</g>
))}
{state.vessels.map((vessel, i) => (
<g key={i}>
<g
onMouseEnter={() => console.log(vessel)}
transform={`translate(${vessel.position.x * cellWidth + (cellWidth / 2)},${vessel.position.y * cellHeight + (cellHeight / 2)}) rotate(${(vessel.direction || 0) * (180/Math.PI)})`}
style={{ transition: "transform 0.3s" }}
>
<path
d="M-1.5,-.5 L1.0,-.5 L1.5,0 L1.0,.5, L-1.5,.5 Z"
stroke="#666"
strokeWidth={0.1}
fill={getStatusColor(vessel)}
/>
</g>
</g>
))}
</>
)
};
export { FleetMapState }

View File

@@ -0,0 +1,96 @@
import { Vessel } from "@shipped/engine"
import styled from "styled-components";
type Props = {
vessel: Vessel;
}
const Wrapper = styled.div`
display: flex;
flex-direction: column;
margin-bottom: 1rem;
flex-wrap: wrap;
`;
const Card = styled.div`
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
border-bottom: 0.5px solid #fff;
`;
const Value = styled.div`
font-size: 1rem;
font-weight: 700;
color: #fff;
`;
const Label = styled.div`
font-size: 0.5rem;
font-weight: 700;
color: #fff;
text-transform: uppercase;
flex: 1;
`;
const Unit = styled.span`
font-size: 0.75rem;
font-weight: 700;
color: #fff;
`;
const Header = styled.div`
font-size: 1.5rem;
font-weight: 700;
color: #fff;
margin-top: 1rem;
`;
function numberWithCommas(x: number) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
const VesselInfo: React.FC<Props> = ({ vessel }) => {
return (
<Wrapper>
<Header>Stats</Header>
<Card>
<Label>Fuel</Label>
<Value>{(vessel.fuel.current / vessel.fuel.capacity * 100).toFixed(2)}<Unit>%</Unit></Value>
</Card>
<Card>
<Label>Cash</Label>
<Value>{numberWithCommas(vessel.cash)}<Unit>$</Unit></Value>
</Card>
<Card>
<Label>Goods</Label>
<Value>{vessel.goods}<Unit>kg</Unit></Value>
</Card>
<Card>
<Label>Power</Label>
<Value>{(vessel.power * 100).toFixed(1)}<Unit>%</Unit></Value>
</Card>
<Card>
<Label>Score</Label>
<Value>{(vessel.cash / (vessel.score.fuelUsed / vessel.score.rounds)).toFixed(0)}<Unit>$/F/R</Unit></Value>
</Card>
<Header>History</Header>
<Card>
<Label>Distance travelled</Label>
<Value>{(vessel.score.distanceTravelled).toFixed(1)}</Value>
</Card>
<Card>
<Label>Fuel used</Label>
<Value>{(vessel.score.fuelUsed).toFixed(1)}</Value>
</Card>
<Card>
<Label>Rounds</Label>
<Value>{vessel.score.rounds}</Value>
</Card>
</Wrapper>
)
};
export { VesselInfo }

View File

@@ -0,0 +1,39 @@
import { FC } from "react";
import { useBridgeWorld } from "../bridge";
const getTileColor = (type: string) => {
if (type === "land") return "#78e08f";
return "#e58e26";
}
const inset = 0.07;
const FleetMapWorld: FC = () => {
const world = useBridgeWorld();
if (!world) return null;
const cellWidth = world.size.width / world.map[0].length;
const cellHeight = world.size.height / world.map.length;
return (
<>
<rect width={world.size.width * cellWidth} height={world.size.height * cellHeight} fill="#6a89cc" />
{world.map.map((row, y) => row.map((cell, x) => ( cell.type === 'water' ? null :
<rect
key={`${x},${y}`}
x={x * cellWidth + inset}
y={y * cellHeight + inset}
rx={0.2}
width={cellWidth - inset * 2}
height={cellHeight - inset * 2}
stroke="#666"
strokeWidth={0.1}
fill={getTileColor(cell.type)}
/>
))).flat()}
</>
)
};
export { FleetMapWorld }

View File

@@ -0,0 +1,10 @@
{
"extends": "@shipped/config/esm",
"compilerOptions": {
"outDir": "dist/esm",
"declarationDir": "./dist/esm/types"
},
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@shipped/config/cjs",
"compilerOptions": {
"outDir": "dist/cjs",
"declarationDir": "./dist/cjs/types"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,48 @@
{
"name": "@shipped/playground",
"version": "1.0.0",
"description": "",
"scripts": {
"build:esm": "tsc -p tsconfig.json",
"build": "pnpm build:esm"
},
"keywords": [],
"author": "",
"license": "GPL-3.0-or-later",
"types": "./dist/esm/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:^",
"@types/pathfinding": "^0.0.6",
"@types/react": "^18.0.28",
"@types/styled-components": "^5.1.26",
"react": "^18.2.0",
"typescript": "^5.0.4"
},
"dependencies": {
"@monaco-editor/react": "^4.5.1",
"@shipped/engine": "workspace:^",
"@shipped/fleet-map": "workspace:^",
"eventemitter3": "^5.0.1",
"pathfinding": "^0.4.18",
"styled-components": "6.0.0-rc.1"
},
"peerDependencies": {
"react": "^18.2.0"
}
}

View File

@@ -0,0 +1,123 @@
import { FC, createContext, useCallback, useEffect, useState } from "react"
import { Bridge, BridgeProvider, Connection } from "@shipped/fleet-map";
import { StateOptions, Vessel } from "@shipped/engine";
class WorkerConnection extends Connection {
#worker: Worker;
constructor(worker: Worker) {
super();
this.#worker = worker;
this.#worker.addEventListener('message', this.#onMessage);
}
#onMessage = (event: MessageEvent) => {
const { type, payload } = JSON.parse(event.data);
switch (type) {
case 'sync': {
this.emit('sync', payload);
break;
}
case 'update': {
this.emit('update', payload);
break;
}
}
}
}
type PlaygroundContextValue = {
bridge?: Bridge;
running?: boolean;
errors: string[];
run: (script: string, data?: Partial<Omit<Vessel, 'captain'>>) => void;
launch: (script: string, data?: Partial<Omit<Vessel, 'captain'>>) => void;
stop: () => void;
};
type PlaygroundProviderProps = {
children: React.ReactNode;
map?: StateOptions;
createWorker: () => Worker;
onRun?: () => void;
};
const PlaygroundContext = createContext<PlaygroundContextValue>(undefined as any);
const PlaygroundProvider: FC<PlaygroundProviderProps> = ({ children, map, onRun, createWorker }) => {
const [worker, setWorker] = useState<Worker>();
const [bridge, setBridge] = useState<Bridge>();
const [running, setRunning] = useState<boolean>(false);
const run = useCallback((script: string, data?: Partial<Omit<Vessel, 'captain'>>) => {
setRunning(false);
const run = async () => {
const nextWorker = createWorker();
nextWorker.addEventListener('message', (event) => {
const { type } = JSON.parse(event.data);
switch (type) {
case 'setup': {
setRunning(true);
nextWorker.postMessage(JSON.stringify({ type: 'run', payload: {
script,
data,
}}));
break;
}
}
});
const connection = new WorkerConnection(nextWorker);
const bridge = new Bridge(connection);
nextWorker.postMessage(JSON.stringify({ type: 'setup', payload: map }));
if (onRun) {
onRun();
}
setWorker(nextWorker);
setBridge(bridge);
}
run();
}, [map]);
const launch = useCallback((script: string, data?: Partial<Omit<Vessel, 'captain'>>) => {
const run = async () => {
if (!worker) {
return;
}
worker.postMessage(JSON.stringify({ type: 'run', payload: {
script,
data,
}}));
}
run();
}, [worker]);
const stop = useCallback(() => {
worker?.terminate();
setWorker(undefined);
}, [worker]);
useEffect(() => {
return () => {
stop();
}
}, []);
return (
<PlaygroundContext.Provider value={{
bridge,
launch,
running,
run,
stop,
errors: [],
}}>
<BridgeProvider bridge={bridge}>
{children}
</BridgeProvider>
</PlaygroundContext.Provider>
)
}
export { PlaygroundContext, PlaygroundProvider };

View File

@@ -0,0 +1,102 @@
import styled from 'styled-components';
import { FC, useEffect, useRef, useState } from "react";
import MonacoEditor from '@monaco-editor/react';
import { usePlaygroundRun } from "../hooks";
import { FleetMap } from "@shipped/fleet-map";
import { typings } from './types';
type Props = {
value: string,
onValueChange: (value: string) => void,
className?: string,
onRun?: () => void,
}
const Wrapper = styled.div`
position: relative;
`;
const EditorWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
const Button = styled.div`
position: absolute;
bottom: 10px;
right: 10px;
z-index: 1;
color: #222;
background: #badc58;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
`;
const Editor: FC<Props> = ({ className, value, onValueChange, onRun }) => {
const ref = useRef<HTMLDivElement>(null);
const [editor, setEditor] = useState<any>(null);
useEffect(() => {
if (!ref.current || !editor) return;
console.log('init')
const observer = new ResizeObserver(() => {
console.log('changed')
editor.layout();
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [editor]);
return (
<Wrapper className={className} ref={ref}>
<EditorWrapper>
<MonacoEditor
value={value}
path="file:///src/index.tsx"
onChange={(value) => onValueChange(value || '')}
beforeMount={(monaco) => {
for (let [path, typing] of Object.entries(typings)) {
monaco.languages.typescript.typescriptDefaults.addExtraLib(
typing,
`file:///node_modules/${path}`,
)
}
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext,
allowNonTsExtensions: true,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
module: monaco.languages.typescript.ModuleKind.CommonJS,
noEmit: true,
esModuleInterop: true,
typeRoots: ["node_modules/@types"]
});
}}
onMount={(editor) => {
setEditor(editor);
}}
theme="vs-dark"
language="typescript"
height="100%"
width="100%"
options={{
minimap: {
enabled: false
}
}}
/>
</EditorWrapper>
{onRun && (
<Button
onClick={() => onRun()}
>
Run
</Button>
)}
</Wrapper>
)
};
export { Editor };

View File

@@ -0,0 +1,41 @@
// eslint-disable @typescript-eslint/ban-ts-comment
// @ts-ignore
import pathfinding from '@types/pathfinding/index.d.ts?raw';
// @ts-ignore
import engine from '@shipped/engine/dist/esm/types/index.d.ts?raw';
// @ts-ignore
import engineVessel from '@shipped/engine/dist/esm/types/types/vessel.d.ts?raw';
// @ts-ignore
import engineCaptain from '@shipped/engine/dist/esm/types/types/captain.d.ts?raw';
// @ts-ignore
import engineMap from '@shipped/engine/dist/esm/types/types/map.d.ts?raw';
// @ts-ignore
import enginePort from '@shipped/engine/dist/esm/types/types/port.d.ts?raw';
// @ts-ignore
import engineMath from '@shipped/engine/dist/esm/types/types/math.d.ts?raw';
// @ts-ignore
import engineUtilsMath from '@shipped/engine/dist/esm/types/utils/math.d.ts?raw';
// @ts-ignore
import engineUtilsMap from '@shipped/engine/dist/esm/types/utils/map.d.ts?raw';
// @ts-ignore
import engineUtilsPort from '@shipped/engine/dist/esm/types/utils/port.d.ts?raw';
// @ts-ignore
import engineUtilsVessel from '@shipped/engine/dist/esm/types/utils/vessel.d.ts?raw';
const typings = {
pathfinding,
'@shipped/engine/types/captain.d.ts': engineCaptain,
'@shipped/engine/types/vessel.d.ts': engineVessel,
'@shipped/engine/types/map.d.ts': engineMap,
'@shipped/engine/types/port.d.ts': enginePort,
'@shipped/engine/types/math.d.ts': engineMath,
'@shipped/engine/utils/math.d.ts': engineUtilsMath,
'@shipped/engine/utils/map.d.ts': engineUtilsMap,
'@shipped/engine/utils/port.d.ts': engineUtilsPort,
'@shipped/engine/utils/vessel.d.ts': engineUtilsVessel,
'@shipped/engine/index.d.ts': engine,
}
export { typings };

View File

@@ -0,0 +1,19 @@
import { useContext } from "react"
import { PlaygroundContext } from "./context";
const usePlaygroundRun = () => {
const { run } = useContext(PlaygroundContext);
return run;
}
const usePlaygroundRunning = () => {
const { running } = useContext(PlaygroundContext);
return running;
}
const usePlaygroundLaunch = () => {
const { launch } = useContext(PlaygroundContext);
return launch;
}
export { usePlaygroundRun, usePlaygroundRunning, usePlaygroundLaunch }

View File

@@ -0,0 +1,3 @@
export { PlaygroundProvider } from './context';
export { usePlaygroundRun, usePlaygroundLaunch, usePlaygroundRunning } from './hooks';
export { Editor } from './editor';

View File

@@ -0,0 +1,100 @@
/// <reference lib="webworker" />
import PF from 'pathfinding';
import { State, calculatePrice } from '@shipped/engine';
import { transpileModule, ModuleKind } from 'typescript';
const dependencies = {
pathfinding: PF,
'@shipped/engine': {
calculatePrice,
},
};
declare const self: DedicatedWorkerGlobalScope;
let state: State;
const setup = (payload: any) => {
if (state) {
state.removeAllListeners();
}
state = new State(payload);
state.on('update', () => {
self.postMessage(JSON.stringify({ type: 'update', payload: {
delta: state.getState(),
}}));
});
self.postMessage(JSON.stringify({ type: 'sync', payload: {
state: state.getState(),
world: state.getWorld(),
}}));
self.postMessage(JSON.stringify({ type: 'setup' }));
};
let captainId = 0;
self.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data)
switch (type) {
case 'setup': {
setup(payload);
const update = () => {
state.update();
setTimeout(update, 100);
}
update();
break;
}
case 'run': {
const { outputText: script } = transpileModule(payload.script, {
compilerOptions: {
module: ModuleKind.CommonJS,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
});
const exports = {} as any;
const module = { exports };
const api = {
module,
exports,
require: (name: string) => {
const m = dependencies[name as keyof typeof dependencies];
return m;
},
};
const fn = new Function(...Object.keys(api), script);
fn(...Object.values(api));
state.addCaptain(`captain-${captainId++}`, {
command: module.exports.default || module.exports,
})
const waterTiles = state.getWorld().map.map((row, y) => row.map((tile, x) => ({ x, y, tile })).filter(({ tile }) => tile.type === 'water')).flat();
const start = waterTiles[Math.floor(Math.random() * waterTiles.length)];
state.addVessel({
captain: `captain-${captainId - 1}`,
position: {
x: start.x,
y: start.y,
},
plan: [],
data: {},
direction: 0,
power: 1,
cash: 100000,
fuel: {
current: 100000,
capacity: 100000,
},
score: {
fuelUsed: 0,
distanceTravelled: 0,
rounds: 0,
},
goods: 0,
...payload.data,
})
break;
}
}
};

View File

@@ -0,0 +1,10 @@
{
"extends": "@shipped/config/esm",
"compilerOptions": {
"outDir": "dist/esm",
"declarationDir": "./dist/esm/types"
},
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,14 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

24
packages/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shipped</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.5.1",
"@octokit/rest": "^19.0.8",
"@shipped/engine": "workspace:^",
"@shipped/fleet-map": "workspace:^",
"@shipped/playground": "workspace:^",
"eventemitter3": "^5.0.1",
"isomorphic-fetch": "^3.0.0",
"jsondiffpatch": "^0.4.1",
"localforage": "^1.10.0",
"match-sorter": "^6.3.1",
"pathfinding": "^0.4.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.7",
"react-router-dom": "^6.11.2",
"sort-by": "^1.2.0"
},
"devDependencies": {
"@mdx-js/rollup": "^2.3.0",
"@types/object-hash": "^3.0.2",
"@types/pathfinding": "^0.0.6",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/react-syntax-highlighter": "^15.5.6",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"postcss": "^8.4.23",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.2",
"vite": "^4.3.2",
"vite-plugin-comlink": "^3.0.5"
}
}

View File

@@ -0,0 +1,8 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,43 @@
import { Features } from './pages/features';
import { Frontpage } from './pages/frontpage';
import { Game } from './pages/game';
import { Page } from './ui/page'
import {
createHashRouter,
RouterProvider,
} from "react-router-dom";
const pageImports = import.meta.glob("./pages/articles/**/main.mdx");
const pages: any = Object.entries(pageImports).map(([path, page]) => ({
path: path.replace("./pages/articles/", "").replace("/main.mdx", ""),
element: <Page content={page as any} />,
}))
const router = createHashRouter([
{
path: "/",
element: <Frontpage />,
},
{
path: "/features",
element: <Features />,
},
{
path: "/articles",
children: pages,
},
{
path: "/game",
element: <Game />,
}
]);
function App() {
return (
<>
<RouterProvider router={router} />
</>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,111 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
}
body {
@apply bg-gray-100;
@apply font-sans;
@apply text-base;
@apply text-gray-900;
@apply leading-normal;
@apply antialiased;
}
h1 {
@apply text-4xl;
@apply font-bold;
@apply text-gray-900;
@apply mb-4;
@apply mt-8;
@apply leading-tight;
@apply tracking-tight;
@apply sm:text-5xl;
}
p > code {
@apply bg-gray-200;
}
article {
@apply max-w-6xl;
@apply w-full;
@apply mx-auto;
@apply py-8;
@apply bg-white;
@apply shadow;
@apply rounded;
@apply mb-8;
& > h1, & > h2, & > h3, & > h4, & > h5, & > h6 {
@apply font-bold;
@apply text-gray-900;
@apply max-w-3xl;
@apply w-full;
@apply mx-auto;
@apply px-4
}
& > h1 {
@apply text-6xl;
@apply sm:text-5xl;
@apply mb-8;
}
& > h2 {
@apply text-4xl;
@apply sm:text-4xl;
@apply mb-8;
}
& > h3 {
@apply mb-4;
@apply text-2xl;
}
& > h4 {
@apply text-xl;
}
& > h5 {
@apply text-lg;
}
& > h6 {
@apply text-base;
}
& > p {
@apply mb-4;
@apply max-w-3xl;
@apply w-full;
@apply mx-auto;
@apply px-4
}
& > ul {
@apply mb-4;
@apply max-w-3xl;
@apply w-full;
@apply mx-auto;
@apply list-disc;
@apply list-inside;
@apply px-4
}
}
.button {
@apply bg-transparent;
@apply hover:bg-blue-500;
@apply text-blue-700;
@apply font-semibold;
@apply hover:text-white;
@apply py-2;
@apply px-4;
@apply border;
@apply border-blue-500;
@apply hover:border-transparent;
@apply rounded;
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,26 @@
import { Bottom } from '@/ui/base/bottom';
import { createMap } from "@shipped/engine";
import { Playground } from "@/ui/playground";
import { VesselInfo } from "@/ui/playground/utils/vessel";
# Example
This is an example bot which will select one of the five nearest port at random and sail to it.
Once it reached the port it will do a refuel, and if the goods there are cheaper than the ones it has on board it will buy them. Otherwise it will sell its goods.
<Playground
script={import('./script.ts?raw')}
utils={(
<>
<VesselInfo />
</>
)}
/>
<Bottom
links={[
{ title: 'Play', href: '/game' },
{ title: 'Ideas', href: '/features' },
]}
/>

View File

@@ -0,0 +1,92 @@
import { MapTile, PortMapTile, CaptainAI, CaptainCommand, calculatePrice } from "@shipped/engine";
import PF from 'pathfinding';
// We start by defining a function that will be called every turn
const captain: CaptainAI = ({ vessel, map, currentPort }) => {
// Then we create an array to hold any commands we want to send to the vessel
const commands: CaptainCommand[] = [];
// If the vessel doesn't have a plan, give it one
if (!vessel.plan || vessel.plan.length === 0) {
// We arrived at a port, so we should fuel up
commands.push({
type: 'fuel-up',
});
if (currentPort) {
// Get the current price of goods at the port
const goodsPrice = calculatePrice(currentPort);
// And the amount we've paid for goods
const paidPrice = parseFloat(vessel.data.paid || '0');
// If the price is higher than what we paid, and we have goods, sell them
if (goodsPrice > paidPrice && vessel.goods > 0) {
commands.push({
type: 'sell',
amount: vessel.goods,
});
// If the price is lower than what we paid, and we have space, buy them
} else {
commands.push({
type: 'buy',
amount: 10,
});
commands.push({
type: 'record',
name: 'paid',
data: goodsPrice.toString(),
});
}
}
// We get our list of port tiles
const portTiles = findPorts(map);
// And calculate the distance to each one
const withDistance = portTiles.map((port) => ({
...port,
distance: Math.sqrt(
Math.pow(port.x - vessel.position.x, 2) +
Math.pow(port.y - vessel.position.y, 2)
),
}));
// We select the 5 closest
const closestPort = withDistance.sort((a, b) => a.distance - b.distance).slice(0, 5);
// And pick a random one
const randomPort = closestPort[Math.floor(Math.random() * closestPort.length)];
const grid = new PF.Grid(
map.map((row) => row.map((tile) => tile.type !== 'land' ? 0 : 1))
);
const finder = new PF.AStarFinder();
// We then use A* to find a path to it
const path = PF.Util.compressPath(finder.findPath(
Math.round(vessel.position.x),
Math.round(vessel.position.y),
randomPort.x,
randomPort.y,
grid,
));
commands.push({
type: 'update-plan',
// And then update the plan to navigate to our random port
plan: path.map(([x, y]) => ({ x, y })),
});
}
// Hand of the wheel
return commands;
}
// Since we only have a tile map, we want to convert it into a list
// of all the port tiles
const findPorts = (tiles: MapTile[][]) => {
const portTiles = tiles.map((row, y) => row.map((tile, x) => ({
x,
y,
tile: tile as PortMapTile,
}))).flat().filter(({ tile }) => tile.type === 'port');
return portTiles;
};
export default captain;

View File

@@ -0,0 +1,20 @@
import { Bottom } from '@/ui/base/bottom';
# Introduction
Congratulations, you just completed you Maritime 101 and is now ready to start your new **ocean empire**!
The first thing you do is spend your life savings buying a (_very_) used vessel. You then spend the next few months fixing it up and getting it ready for your first voyage. You are now ready to set sail!
But the ship and the repair means that you are already out of money, so you can not afford to hire on a crew.
**Not to worry**; you once fixed you VCR with only minor electrical burns, so setting up a ship for remote control should be a piece of cake.
But remote controlling it requires a connection, and maritime connectivity is expensive. You have to find a way to control the ship from on board the ship it self, you need a **bot**!
Luckily you have extensive knowladge in Excel macros, so you are just the right person for the job - **Good luck!**
<Bottom
links={[
{ title: 'Next: The game', href: '/articles/rules/intro' },
]}
/>

View File

@@ -0,0 +1,50 @@
import { Bottom } from '@/ui/base/bottom';
import { createMap } from "@shipped/engine";
import { Playground } from "@/ui/playground";
import { VesselInfo } from "@/ui/playground/utils/vessel";
export const map = createMap(30, 10, {
ports: {
'25,3': {},
'7,5': {},
'5,7': {},
}
});
# Fuel
A ship needs fuel to move. Your ship will start with a full tank of fuel, which will depleted as you move around the map.
Once your fuel is depleted, you will be unable to move, and the ship will be stuck.
To refuel you need to visit a port. Port are black dots on the map. Once you are in a port, you can issue a `{ type: 'fuel-up', amount?: number }` command to refuel your ship.
If you do not specify an amount, your ship will be refueled to full.
If you do not have enough cash to refuel the requested amount, the command will be ignored.
You can calulate the price of fuel as `desiredAmount * currentPort.fuelPrice`.
Different ports can have different fuel prices, and you can use the `ports` property to look at prices on different ports.
To find the location of a port, you can use the `map` property. which contains all the tiles of the map. here the tile will have a `type` of `port` and an `id` of the port.
### Visibility
when you are at a port the `ports` property will contain all ports on the map, and their fuel prices.
When you are not at a port, the `ports` property will only contain the ports that are within your ships visibility range (`vessel.stats.visibility`).
<Playground
map={map}
script={import('./script.ts?raw')}
utils={(
<>
<VesselInfo />
</>
)}
/>
<Bottom
links={[
{ title: 'Next: Example', href: '/articles/full' },
]}
/>

View File

@@ -0,0 +1,44 @@
import { MapTile, PortMapTile, CaptainAI, CaptainCommand } from "@shipped/engine";
// We start by defining a function that will be called every turn
const captain: CaptainAI = ({ vessel, map, currentPort }) => {
// Then we create an array to hold any commands we want to send to the vessel
const commands: CaptainCommand[] = [];
// If the vessel doesn't have a plan, give it one
if (!vessel.plan || vessel.plan.length === 0) {
// If we're at a port, we want to fuel up
if (currentPort) {
commands.push({
type: 'fuel-up',
});
}
// We get our list of port tiles
const portTiles = findPorts(map);
// And pick a random one
const randomPort = portTiles[Math.floor(Math.random() * portTiles.length)];
commands.push({
type: 'update-plan',
// And then update the plan to navigate to it
plan: [randomPort],
});
}
// Hand of the wheel
return commands;
}
// Since we only have a tile map, we want to convert it into a list
// of all the port tiles
const findPorts = (tiles: MapTile[][]) => {
const portTiles = tiles.map((row, y) => row.map((tile, x) => ({
x,
y,
tile: tile as PortMapTile,
}))).flat().filter(({ tile }) => tile.type === 'port');
return portTiles;
};
export default captain;

View File

@@ -0,0 +1,30 @@
import { Bottom } from '@/ui/base/bottom';
# The game
_Shipped_ is a game where you build bots, which controls vessels on the sea, battling for supremacy. You win by making the bot which can earn the most money, in the shortest amount of time, emitting the least CO2.
## The rules
The game is played in a succession of rounds. In each round your bot is giving information about the vessel, and the world around it. Based on this information, your bot must decide what to do next.
Bots can perform a multitude of actions, for instance:
### Anywhere
- Create a planned route for the vessel to follow
- Change the speed of the vessel
### At ports
- Fuel the vessel
- Buy goods
- Sell goods
## Scoring
The exact score calulation is TBD but will be a combination of profit and CO2 emissions.
<Bottom
links={[
{ title: 'Next: Planning a route', href: '/articles/rules/move' },
]}
/>

View File

@@ -0,0 +1,27 @@
import { Bottom } from '@/ui/base/bottom';
import { createMap } from "@shipped/engine";
import { Playground } from "@/ui/playground";
export const map = {
size: { width: 10, height: 10 },
}
# Land
Unfortunatly the world is not only water. There are also land tiles. Land tiles are not passable by ships.
You can use the `map` property to check which tiles are land.
It is generally a good idea to avoid sailing unto land as your ship would get stuck.
The bot below find connecting square tiles, check if they are water tiles and sails to a random one.
<Playground
map={map}
script={import('./script.ts?raw')}
/>
<Bottom
links={[
{ title: 'Next: Fuel', href: '/articles/rules/fuel' },
]}
/>

View File

@@ -0,0 +1,18 @@
import { CaptainAI } from "@shipped/engine";
const captain: CaptainAI = ({ vessel, map }) => {
if (!vessel.plan || vessel.plan.length === 0) {
const connectedPassableTiles = [[0, -1], [1, 0], [0, 1], [-1, 0]].map(([ x, y ]) => ({
x: vessel.position.x + x,
y: vessel.position.y + y,
})).filter(({ x, y }) => map[y]?.[x] && map[y][x].type !== 'land')
const randomTile = connectedPassableTiles[Math.floor(Math.random() * connectedPassableTiles.length)];
return [{
type: 'update-plan',
plan: [randomTile],
}];
}
}
export default captain;

View File

@@ -0,0 +1,31 @@
import { Bottom } from '@/ui/base/bottom';
import { createMap } from "@shipped/engine";
import { Playground } from "@/ui/playground";
export const map = createMap(10, 3);
export const vessel = {
position: { x: 1, y: 1 },
}
# Planning a route
So the first thing that you bot needs to learn is how to move the vessel around.
Everything the bot does, is done by returning a set of commands from its AI function.
There are different commands, but they all take the form of <code>{`{ type: string, [prop: string]: any }`}</code>.
To move the ship the Captain needs to provide a plan in the form of a series of waypoints, which the ship will follow.
The captain can at anytime give a new plan, and the ship will then start to follow the updated plan
<Playground
map={map}
vessel={vessel}
script={import('./script.ts?raw')}
/>
<Bottom
links={[
{ title: 'Next: Land', href: '/articles/rules/land' },
]}
/>

View File

@@ -0,0 +1,18 @@
import { CaptainAI } from "@shipped/engine";
// We start by defining a function that will be called every turn
const captain: CaptainAI = ({ vessel }) => {
// If the vessel already have a plan we don't want to do anything
if (vessel.plan && vessel.plan.length > 0) {
return;
}
// If the vessel doesn't have a plan, give it one
return [{
type: 'update-plan',
// This is a plan to move to the tile at (8, 1)
plan: [{ x: 8, y: 1 }],
}];
}
export default captain;

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { Octokit } from '@octokit/rest'
import { Frame } from "@/ui/frame";
import ReactMarkdown from 'react-markdown'
const getData = async () => {
const octokit = new Octokit();
const { data } = await octokit.request('GET /repos/{owner}/{repo}/issues', {
owner: 'morten-olsen',
repo: 'shipped',
labels: 'idea',
state: 'open',
});
return data;
}
type Data = NonNullable<typeof getData extends () => Promise<infer T> ? T : never>;
const Features = () => {
const [features, setFeatures] = useState<Data>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const run = async () => {
const data = await getData();
setFeatures(data);
setLoading(false);
}
run();
}, []);
return (
<Frame>
<div className="container mx-auto">
<h1 className="text-4xl font-bold">Ideas</h1>
<p className="text-gray-700 text-base">This is a list of ideas for features that could be added to the game. If you see something you like, please give it a thumbs up!</p>
<div className="mt-4">
{loading && <p>Loading...</p>}
{!loading && features.map((feature) => (
<div key={feature.id} className="rounded overflow-hidden shadow-md mb-10 px-6 px-4 bg-white">
<a href={feature.html_url}>
<h2 className="font-bold text-xl my-2">{feature.title}</h2>
</a>
{feature.body && <ReactMarkdown className="text-gray-700 text-base">{feature.body}</ReactMarkdown>}
<div className="flex flex-wrap mt-2 mb-4">
{feature.reactions && feature.reactions['+1'] > 0 && (
<span className="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mt-2">
{feature.reactions?.['+1']} 👍
</span>
)}
{feature.labels?.map((label) => (
<span key={typeof label === 'string' ? label : label.id} className="inline-block border border-gray-500 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mt-2">
{typeof label === 'string' ? label : label.name}
</span>
))}
</div>
</div>
))}
</div>
</div>
</Frame>
);
}
export { Features }

View File

@@ -0,0 +1,62 @@
import { Link } from "react-router-dom";
import { FleetMap } from "@shipped/fleet-map";
import { PlaygroundProvider, usePlaygroundLaunch, usePlaygroundRun, usePlaygroundRunning } from "@shipped/playground"
import { useEffect } from "react";
import script from './script.ts?raw';
const createWorker = () => new Worker(new URL('../../worker.ts', import.meta.url), { type: 'module' });
const Demo = () => {
const run = usePlaygroundRun();
const running = usePlaygroundRunning();
const launch = usePlaygroundLaunch();
useEffect(() => {
run(script);
}, [run]);
useEffect(() => {
if (!running) {
return;
}
for (let i = 0; i < 7; i++) {
launch(script);
}
}, [running, launch]);
return (
<FleetMap />
)
}
const Frontpage = () => {
return (
<PlaygroundProvider createWorker={createWorker}>
<div className="fixed top-0 left-0 right-0 bottom-0 bg-slate-600 -z-10">
<div className="opacity-50">
<Demo />
</div>
</div>
<div className="flex flex-col items-center justify-center h-full" style={{ backdropFilter: 'blur(2px)' }}>
<div className="bg-white flex flex-col items-center justify-center p-10 shadow-2xl rounded-lg">
<h1 className='text-8xl tracking-widest uppercase font-thin'>Shipped</h1>
<p className='text-2xl pb-8 font-light'>Launch your bot based shipping empire</p>
<div className='flex flex-row space-x-4'>
<Link to="/articles/intro">
<button className="button">
Learn
</button>
</Link>
<Link to="/game">
<button className="button">
Play
</button>
</Link>
</div>
</div>
</div>
</PlaygroundProvider>
);
}
export { Frontpage }

View File

@@ -0,0 +1,62 @@
import { MapTile, PortMapTile, CaptainAI, CaptainCommand } from "@shipped/engine";
import PF from 'pathfinding';
// We start by defining a function that will be called every turn
const captain: CaptainAI = ({ vessel, map }) => {
// Then we create an array to hold any commands we want to send to the vessel
const commands: CaptainCommand[] = [];
// If the vessel doesn't have a plan, give it one
if (!vessel.plan || vessel.plan.length === 0) {
commands.push({
type: 'fuel-up',
});
// We get our list of port tiles
const portTiles = findPorts(map);
const withDistance = portTiles.map((port) => ({
...port,
distance: Math.sqrt(
Math.pow(port.x - vessel.position.x, 2) +
Math.pow(port.y - vessel.position.y, 2)
),
}));
const closestPort = withDistance.sort((a, b) => a.distance - b.distance).slice(0, 5);
// And pick a random one
const randomPort = closestPort[Math.floor(Math.random() * closestPort.length)];
const grid = new PF.Grid(
map.map((row) => row.map((tile) => tile.type !== 'land' ? 0 : 1))
);
const finder = new PF.AStarFinder();
const path = PF.Util.compressPath(finder.findPath(
Math.round(vessel.position.x),
Math.round(vessel.position.y),
randomPort.x,
randomPort.y,
grid,
));
commands.push({
type: 'update-plan',
// And then update the plan to navigate to it
plan: path.map(([x, y]) => ({ x, y })),
});
}
// Hand of the wheel
return commands;
}
// Since we only have a tile map, we want to convert it into a list
// of all the port tiles
const findPorts = (tiles: MapTile[][]) => {
const portTiles = tiles.map((row, y) => row.map((tile, x) => ({
x,
y,
tile: tile as PortMapTile,
}))).flat().filter(({ tile }) => tile.type === 'port');
return portTiles;
};
export default captain;

View File

@@ -0,0 +1,75 @@
import { createContext, useCallback, useEffect, useMemo, useState } from "react";
type GameContextValue = {
all: Record<string, string>
selected?: string;
setSelected: (name: string) => void;
value?: string;
setValue: (value: string) => void;
create: (name: string, value: string) => void;
remove: (name: string) => void;
}
type GameProviderProps = {
children: React.ReactNode;
};
const GameContext = createContext<GameContextValue>(undefined as any);
const GameProvider: React.FC<GameProviderProps> = ({ children }) => {
const [all, setAll] = useState<Record<string, string>>(
JSON.parse(localStorage.getItem('ai') || '{}'),
);
const [selected, setSelected] = useState<string>();
const create = useCallback((name: string, value: string) => {
setAll((prev) => ({
...prev,
[name]: value,
}));
setSelected(name);
}, [setAll]);
const remove = useCallback((name: string) => {
setAll((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}, [setAll]);
const setValue= useCallback((value: string) => {
if (!selected) return;
setAll((prev) => ({
...prev,
[selected]: value,
}));
}, [selected, setAll]);
const value = useMemo(() => {
if (!selected) return;
return all[selected];
}, [selected, all]);
useEffect(() => {
localStorage.setItem('ai', JSON.stringify(all));
}, [all]);
return (
<GameContext.Provider
value={{
create,
selected,
setSelected,
value,
setValue,
all,
remove,
}}
>
{children}
</GameContext.Provider>
);
};
export { GameContext, GameProvider };

View File

@@ -0,0 +1,21 @@
import { Editor as PlaygroundEditor } from '@shipped/playground';
import { useContext } from 'react';
import { GameContext } from './context';
const Editor = () => {
const { value, setValue, selected } = useContext(GameContext);
if (!selected) return (
<div className='flex-1 flex items-center justify-center'>
Select an AI to edit
</div>
);
return (
<div className='flex-1 h-full'>
<PlaygroundEditor className='h-full' value={value || ''} onValueChange={setValue} />
</div>
);
}
export { Editor }

View File

@@ -0,0 +1,27 @@
import { FleetMap } from "@shipped/fleet-map"
import { PlaygroundProvider } from "@shipped/playground"
import { GameProvider } from "./context";
import { Editor } from "./editor";
import { Sidebar } from "./sidebar";
import { VesselInfo } from "@/ui/playground/utils/vessel";
const createWorker = () => new Worker(new URL('../../worker.ts', import.meta.url), { type: 'module' });
const Game = () => {
return (
<PlaygroundProvider createWorker={createWorker}>
<GameProvider>
<div className="flex h-full w-full bg-slate-600">
<Sidebar />
<Editor />
<div className="flex-1 flex-col bg-slate-800 flex p-4">
<FleetMap />
<VesselInfo />
</div>
</div>
</GameProvider>
</PlaygroundProvider>
)
}
export { Game }

View File

@@ -0,0 +1,68 @@
import { useCallback, useContext } from "react";
import { GameContext } from "./context";
const initial = `
import { CaptainAI, CaptainCommand } from "@shipped/engine";
const captain: CaptainAI = ({ vessel, ports, map, currentPort }) => {
const commands: CaptainCommand[] = [];
// Logic goes here
return commands;
}
export default captain;
`;
const Sidebar: React.FC = () => {
const { all, selected, setSelected, create, remove } = useContext(GameContext);
const createAI = useCallback(() => {
const name = prompt('Name');
if (!name) return;
create(name, initial);
}, [create]);
const removeAI = useCallback((name: string) => {
if (!confirm(`Are you sure you want to delete ${name}?`)) return;
remove(name);
}, [remove]);
return (
<div className='flex flex-col w-64 h-full bg-gray-800'>
<div className='flex-1 overflow-y-auto'>
<ul className='flex flex-col'>
{Object.entries(all).map(([name]) => (
<li
key={name}
className={`flex px-4 py-2 cursor-pointer items-center hover:bg-gray-700 ${selected === name ? 'bg-gray-700' : ''}`}
onClick={() => setSelected(name)}
>
<span className="text-gray-300 mr-2">
{name}
</span>
<div className='flex-1' />
<div className='flex-none'>
<div className='flex items-center justify-center px-2 text-white py-1 bg-red-600 hover:bg-red-500 text-xs rounded-full w-4 h-4' onClick={() => removeAI(name)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" stroke="#fff" />
</svg>
</div>
</div>
</li>
))}
</ul>
</div>
<div className='flex-none'>
<button className='w-full px-4 py-2 bg-gray-700 text-white hover:bg-gray-600' onClick={() => createAI()}>
New
</button>
</div>
</div>
);
}
export { Sidebar }

View File

@@ -0,0 +1,28 @@
import { Link } from 'react-router-dom';
type Props = {
links: {
title: string;
href: string;
}[];
}
const Bottom: React.FC<Props> = ({ links }) => {
return (
<div className="pt-8 flex px-8">
<div className="flex-grow"></div>
{links.map((link) => (
<Link
key={link.title}
to={link.href}
>
<button className="button">
{link.title}
</button>
</Link>
))}
</div>
)
}
export { Bottom };

View File

@@ -0,0 +1,24 @@
import { Link } from "react-router-dom";
type Props = {
children: React.ReactNode;
};
const Frame: React.FC<Props> = ({ children }) => {
return (
<div>
<nav className="flex items-center justify-between flex-wrap bg-teal-500 p-6">
<div className="flex items-center flex-shrink-0 text-white mr-6">
<Link to="/">
<span className="font-semibold text-xl tracking-tight">Shipped</span>
</Link>
</div>
</nav>
<div className="container mx-auto px-4">
{children}
</div>
</div>
)
};
export { Frame }

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { Frame } from "../frame";
type Props = {
content: () => Promise<{ default: (props: any) => JSX.Element}>
}
const Page: React.FC<Props> = ({ content }) => {
const [Component, setComponent] = useState<React.ReactComponentElement<any>>();
useEffect(() => {
const run = async () => {
const component = await content();
setComponent(component.default);
}
run();
}, [content]);
if (!Component) return null;
return (
<Frame>
<article className="my-10">
{Component}
</article>
</Frame>
);
};
export { Page };

View File

@@ -0,0 +1,61 @@
import { StateOptions, Vessel } from '@shipped/engine';
import { FleetMap } from '@shipped/fleet-map';
import { PlaygroundProvider, Editor, usePlaygroundRun } from '@shipped/playground';
import { useCallback, useEffect, useState } from 'react';
const createWorker = () => new Worker(new URL('../../worker.ts', import.meta.url), { type: 'module' });
type Props = {
map?: StateOptions
script?: Promise<{ default: string }>
utils?: any;
vessel?: Partial<Omit<Vessel, 'captain'>>;
};
const PlaygroundView: React.FC<Props> = ({ script, vessel, utils }) => {
const [, setInitialScript] = useState<string>('');
const [currentScript, setCurrentScript] = useState<string>('');
useEffect(() => {
const run = async () => {
if (!script) return;
const raw = await script;
setInitialScript(raw.default);
setCurrentScript(raw.default);
}
run();
}, [script])
const runPlayground = usePlaygroundRun();
const run = useCallback(() => {
runPlayground(currentScript, vessel);
}, [currentScript, runPlayground, vessel]);
return (
<div className='flex flex-col sm:flex-row h-full w-full items-stretch justify-stretch bg-slate-600'>
<div className='flex-1'>
<Editor onRun={run} className='h-full min-h-[400px]' value={currentScript} onValueChange={setCurrentScript} />
</div>
<div className='flex-1'>
<div className='m-4 flex flex-col items-stretch justify-items-stretch'>
<div className='flex-1 rounded-lg overflow-hidden'>
<FleetMap />
</div>
{utils && (
<div className='flex-1'>
{utils}
</div>
)}
</div>
</div>
</div>
);
}
const Playground: React.FC<Props> = (props) => {
const { map } = props;
return (
<PlaygroundProvider createWorker={createWorker} map={map}>
<PlaygroundView {...props} />
</PlaygroundProvider>
);
};
export { Playground }

View File

@@ -0,0 +1,29 @@
import { useMemo } from "react";
import { useBridgeState } from "@shipped/fleet-map";
const CashGauge = () => {
const state = useBridgeState();
const cash = useMemo(() => {
if (!state) return;
const vessel = state.vessels[0];
if (!vessel) return;
return vessel.cash;
}, [state])
if (typeof cash === 'undefined') return null;
return (
<div className='flex flex-col items-center justify-center'>
<div className='text-sm text-white'>
Cash
</div>
<div className='text-sm text-white'>
{cash}
</div>
</div>
)
}
export { CashGauge }

View File

@@ -0,0 +1,28 @@
import { useMemo } from "react";
import { useBridgeState } from "@shipped/fleet-map";
const FuelGauge = () => {
const state = useBridgeState();
const fuel = useMemo(() => {
if (!state) return;
const vessel = state.vessels[0];
if (!vessel) return;
return vessel.fuel;
}, [state])
if (!fuel) return null;
return (
<div className='flex flex-col items-center justify-center'>
<div className='text-sm text-white'>
Fuel
</div>
<div className='text-sm text-white'>
{(fuel.current / fuel.capacity * 100).toFixed(2)}%
</div>
</div>
)
}
export { FuelGauge }

View File

@@ -0,0 +1,29 @@
import { useMemo } from "react";
import { useBridgeState } from "@shipped/fleet-map";
const GoodsGauge = () => {
const state = useBridgeState();
const cash = useMemo(() => {
if (!state) return;
const vessel = state.vessels[0];
if (!vessel) return;
return vessel.goods;
}, [state])
if (typeof cash === 'undefined') return null;
return (
<div className='flex flex-col items-center justify-center'>
<div className='text-sm text-white'>
Goods
</div>
<div className='text-sm text-white'>
{cash}
</div>
</div>
)
}
export { GoodsGauge }

View File

@@ -0,0 +1,17 @@
import { useBridgeState, VesselInfo as FleetMapVesselInfo } from "@shipped/fleet-map";
import { useMemo } from "react";
const VesselInfo = () => {
const state = useBridgeState();
const vessel = useMemo(() => {
return state?.vessels[0];
}, [state]);
if (!vessel) return null;
return (
<FleetMapVesselInfo vessel={vessel} />
);
};
export { VesselInfo }

7
packages/website/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-comlink/client" />
declare module '*.mdx' {
let MDXComponent: (props: any) => JSX.Element
export default MDXComponent
}

View File

@@ -0,0 +1 @@
import '@shipped/playground/dist/esm/runner/index.js'

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@morten-olsen/shipped/*": ["src/engine/types/*"],
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import mdx from "@mdx-js/rollup"
import path from 'path';
const ASSET_URL = process.env.ASSET_URL || '';
// https://vitejs.dev/config/
export default defineConfig({
base: `${ASSET_URL}/`,
plugins: [react(), mdx()],
resolve:{
alias:{
'@' : path.resolve(__dirname, './src'),
'node-fetch': 'isomorphic-fetch',
},
},
})

8664
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- packages/*
- controllers/*
- demo

41
turbo.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**"
],
"inputs": [
"src/**/*.tsx",
"src/**/*.ts",
"./tsconfig.*"
]
},
"test": {
"cache": false
},
"dev": {
"dependsOn": [
"^build"
],
"cache": false
},
"start": {
"dependsOn": [
"^build",
"build"
],
"outputs": [
"dist/**"
],
"inputs": [
"src/**/*.tsx",
"src/**/*.ts",
"./tsconfig.*"
]
}
}
}