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

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/**/*"]
}