mirror of
https://github.com/morten-olsen/shipped.git
synced 2026-02-07 23:26:23 +01:00
init
This commit is contained in:
14
packages/website/.eslintrc.cjs
Normal file
14
packages/website/.eslintrc.cjs
Normal 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
24
packages/website/.gitignore
vendored
Normal 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?
|
||||
13
packages/website/index.html
Normal file
13
packages/website/index.html
Normal 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>
|
||||
50
packages/website/package.json
Normal file
50
packages/website/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/website/postcss.config.js
Normal file
8
packages/website/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
packages/website/public/vite.svg
Normal file
1
packages/website/public/vite.svg
Normal 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 |
43
packages/website/src/App.tsx
Normal file
43
packages/website/src/App.tsx
Normal 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
|
||||
1
packages/website/src/assets/react.svg
Normal file
1
packages/website/src/assets/react.svg
Normal 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 |
111
packages/website/src/index.css
Normal file
111
packages/website/src/index.css
Normal 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;
|
||||
}
|
||||
10
packages/website/src/main.tsx
Normal file
10
packages/website/src/main.tsx
Normal 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>,
|
||||
)
|
||||
26
packages/website/src/pages/articles/full/main.mdx
Normal file
26
packages/website/src/pages/articles/full/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
92
packages/website/src/pages/articles/full/script.ts
Normal file
92
packages/website/src/pages/articles/full/script.ts
Normal 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;
|
||||
20
packages/website/src/pages/articles/intro/main.mdx
Normal file
20
packages/website/src/pages/articles/intro/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
50
packages/website/src/pages/articles/rules/fuel/main.mdx
Normal file
50
packages/website/src/pages/articles/rules/fuel/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
44
packages/website/src/pages/articles/rules/fuel/script.ts
Normal file
44
packages/website/src/pages/articles/rules/fuel/script.ts
Normal 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;
|
||||
30
packages/website/src/pages/articles/rules/intro/main.mdx
Normal file
30
packages/website/src/pages/articles/rules/intro/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
27
packages/website/src/pages/articles/rules/land/main.mdx
Normal file
27
packages/website/src/pages/articles/rules/land/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
18
packages/website/src/pages/articles/rules/land/script.ts
Normal file
18
packages/website/src/pages/articles/rules/land/script.ts
Normal 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;
|
||||
31
packages/website/src/pages/articles/rules/move/main.mdx
Normal file
31
packages/website/src/pages/articles/rules/move/main.mdx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
18
packages/website/src/pages/articles/rules/move/script.ts
Normal file
18
packages/website/src/pages/articles/rules/move/script.ts
Normal 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;
|
||||
65
packages/website/src/pages/features/index.tsx
Normal file
65
packages/website/src/pages/features/index.tsx
Normal 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 }
|
||||
62
packages/website/src/pages/frontpage/index.tsx
Normal file
62
packages/website/src/pages/frontpage/index.tsx
Normal 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 }
|
||||
62
packages/website/src/pages/frontpage/script.ts
Normal file
62
packages/website/src/pages/frontpage/script.ts
Normal 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;
|
||||
75
packages/website/src/pages/game/context.tsx
Normal file
75
packages/website/src/pages/game/context.tsx
Normal 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 };
|
||||
21
packages/website/src/pages/game/editor.tsx
Normal file
21
packages/website/src/pages/game/editor.tsx
Normal 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 }
|
||||
27
packages/website/src/pages/game/index.tsx
Normal file
27
packages/website/src/pages/game/index.tsx
Normal 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 }
|
||||
68
packages/website/src/pages/game/sidebar.tsx
Normal file
68
packages/website/src/pages/game/sidebar.tsx
Normal 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 }
|
||||
28
packages/website/src/ui/base/bottom/index.tsx
Normal file
28
packages/website/src/ui/base/bottom/index.tsx
Normal 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 };
|
||||
24
packages/website/src/ui/frame/index.tsx
Normal file
24
packages/website/src/ui/frame/index.tsx
Normal 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 }
|
||||
29
packages/website/src/ui/page/index.tsx
Normal file
29
packages/website/src/ui/page/index.tsx
Normal 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 };
|
||||
61
packages/website/src/ui/playground/index.tsx
Normal file
61
packages/website/src/ui/playground/index.tsx
Normal 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 }
|
||||
29
packages/website/src/ui/playground/utils/cash.tsx
Normal file
29
packages/website/src/ui/playground/utils/cash.tsx
Normal 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 }
|
||||
28
packages/website/src/ui/playground/utils/fuel.tsx
Normal file
28
packages/website/src/ui/playground/utils/fuel.tsx
Normal 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 }
|
||||
29
packages/website/src/ui/playground/utils/goods.tsx
Normal file
29
packages/website/src/ui/playground/utils/goods.tsx
Normal 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 }
|
||||
0
packages/website/src/ui/playground/utils/index.ts
Normal file
0
packages/website/src/ui/playground/utils/index.ts
Normal file
17
packages/website/src/ui/playground/utils/vessel.tsx
Normal file
17
packages/website/src/ui/playground/utils/vessel.tsx
Normal 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
7
packages/website/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
1
packages/website/src/worker.ts
Normal file
1
packages/website/src/worker.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@shipped/playground/dist/esm/runner/index.js'
|
||||
11
packages/website/tailwind.config.js
Normal file
11
packages/website/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
29
packages/website/tsconfig.json
Normal file
29
packages/website/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
packages/website/tsconfig.node.json
Normal file
10
packages/website/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
packages/website/vite.config.ts
Normal file
18
packages/website/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user