1
0
Fork 0
This commit is contained in:
Pabloader 2024-06-26 16:07:41 +00:00
parent b0bcae89ef
commit cdf915a779
22 changed files with 416 additions and 146 deletions

2
.gitignore vendored
View File

@ -173,5 +173,3 @@ dist
# Finder (MacOS) folder config
.DS_Store
public/build

BIN
bun.lockb

Binary file not shown.

View File

@ -1,14 +1,17 @@
{
"name": "binario",
"module": "index.ts",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"type": "module",
"scripts": {
"start": "bun --hot src/server.ts"
},
"type": "module"
"dependencies": {
"classnames": "2.5.1",
"preact": "10.22.0"
},
"devDependencies": {
"@types/bun": "latest",
"bun-lightningcss": "0.2.0",
"typescript": "5.5.2"
}
}

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Binario</title>
<style>
html, body, #c {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script src="build/index.js"></script>
</body>
</html>

BIN
src/assets/img/conveyor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

BIN
src/assets/img/select.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

BIN
src/assets/img/trash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

42
src/assets/index.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Binario</title>
<style>
:root {
--slot-size: 64px;
--color-bg-select: rgba(0, 0, 0, 0.1);
--color-bg-select: rgba(0, 200, 0, 0.1);
--color-border-select: rgb(0, 200, 0);
}
html, body, #c {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
font-family: sans-serif;
}
#controls {
position: fixed;
width: calc(var(--slot-size) * 9);
height: var(--slot-size);
border: 1px solid gray;
background: white;
bottom: 10px;
left: 0;
right: 0;
display: flex;
margin: auto;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="controls"></div>
$SCRIPT$
</body>
</html>

39
src/assets/ui.module.css Normal file
View File

@ -0,0 +1,39 @@
.button {
display: flex;
justify-content: center;
align-items: center;
width: calc(var(--slot-size) - 6px);
margin: 3px;
border: 3px solid gray;
cursor: pointer;
position: relative;
}
.button:hover {
background-color: var(--color-bg-select);
}
.button.active {
background-color: var(--color-bg-select);
border-color: var(--color-border-select);
}
.button.disabled {
opacity: 0.3;
pointer-events: none;
}
.icon {
width: 100%;
height: 100%;
object-fit: cover;
}
.keyBinding {
font-weight: bold;
color: black;
position: absolute;
top: 5px;
right: 5px;
text-shadow: rgb(255, 255, 255) 1px 0px 0px, rgb(255, 255, 255) 0.540302px 0.841471px 0px, rgb(255, 255, 255) -0.416147px 0.909297px 0px, rgb(255, 255, 255) -0.989992px 0.14112px 0px, rgb(255, 255, 255) -0.653644px -0.756802px 0px, rgb(255, 255, 255) 0.283662px -0.958924px 0px, rgb(255, 255, 255) 0.96017px -0.279415px 0px;
}

20
src/dataUrlPlugin.ts Normal file
View File

@ -0,0 +1,20 @@
import { plugin, type BunPlugin } from "bun";
const dataUrlPlugin: BunPlugin = {
name: "Data-url loader",
async setup(build) {
build.onLoad({ filter: /\.(png)$/ }, async (args) => {
const arrayBuffer = await Bun.file(args.path).arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
contents: `data:image/png;base64,${buffer.toString('base64')}`,
loader: 'text',
};
});
}
};
plugin(dataUrlPlugin);
export default dataUrlPlugin;

View File

@ -1,14 +1,16 @@
import Graphics from "./graphics";
import UI from "./ui";
import World from "./world";
import { prevent } from "./utils";
import World, { TileType } from "./world";
export default class Game {
private running = false;
private mouseDown: false | number = false;
private graphics;
private world;
private ui;
constructor(private canvas: HTMLCanvasElement) {
constructor(private canvas: HTMLCanvasElement, controls: HTMLElement) {
window.addEventListener('resize', this.onResize);
this.onResize();
@ -20,13 +22,12 @@ export default class Game {
canvas.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('keypress', this.onKeyPress);
document.addEventListener('contextmenu', prevent);
document.addEventListener('select', prevent);
document.addEventListener('selectstart', prevent);
this.graphics = new Graphics(canvas);
this.world = new World();
}
async load() {
this.ui = new UI(controls);
}
private onResize = () => {
@ -60,7 +61,7 @@ export default class Game {
const pos = this.graphics.screenToWorld([event.clientX, event.clientY]);
if (event.button === 0) {
this.world.placeTile(pos, TileType.CONVEYOR);
// this.world.placeTile(pos, TileType.CONVEYOR); TODO place selected tile from hotbar
} else if (event.button === 2) {
this.world.removeTile(pos);
}
@ -93,10 +94,10 @@ export default class Game {
private loop = () => {
this.graphics.clear();
this.graphics.drawGrid();
this.graphics.drawWorld(this.world);
this.graphics.drawHighlight();
this.graphics.drawGrid();
this.graphics.debug();

View File

@ -1,6 +1,7 @@
import { renderers, type NullRenderer, type Renderer } from "./renderer";
import { exp, trunc } from "./utils";
import type World from "./world";
import { TileType } from "./world";
import { type Tile } from "./world";
const initialTileSize = 32;
@ -53,11 +54,11 @@ export default class Graphics {
}
debug() {
const p00 = this.worldToScreen([0, 0]);
const p11 = exp`${this.worldToScreen([1, 1])} - ${p00}`;
// const p00 = this.worldToScreen([0, 0]);
// const p11 = exp`${this.worldToScreen([1, 1])} - ${p00}`;
this.context.fillStyle = 'red';
this.context.fillRect(...p00, ...p11);
// this.context.fillStyle = 'red';
// this.context.fillRect(...p00, ...p11);
}
drawGrid() {
@ -79,13 +80,15 @@ export default class Graphics {
}
drawHighlight() {
this.drawTile(this.highlighted, ctx => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
this.drawTile(this.highlighted, (ctx) => {
ctx.fillStyle = getComputedStyle(this.canvas).getPropertyValue("--color-bg-select");
ctx.fillRect(0, 0, 1, 1);
});
}
drawTile(position: Point, renderer: (ctx: CanvasRenderingContext2D) => void) {
drawTile<T extends Tile>(position: Point, renderer: Renderer<T>, tile: T): void;
drawTile<T extends null>(position: Point, renderer: NullRenderer): void;
drawTile<T extends Tile>(position: Point, renderer: Renderer<T> | NullRenderer, tile?: T): void {
this.context.save();
// TODO skip drawing if outside screen
@ -93,7 +96,11 @@ export default class Graphics {
this.context.translate(screenPosition[0], screenPosition[1]);
this.context.scale(this.tileSize, this.tileSize);
renderer(this.context);
if (tile) {
renderer(this.context, tile);
} else {
(renderer as NullRenderer)(this.context);
}
this.context.restore();
}
@ -106,13 +113,9 @@ export default class Graphics {
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const tile = world.getTile([x, y]);
if (tile?.type === TileType.SOURCE) {
this.drawTile([x, y], ctx => {
ctx.fillStyle = '#bbffff';
ctx.fillRect(0, 0, 1, 1);
ctx.fillStyle = 'black';
ctx.fillText(tile.resource.toString(2), 0.5, 0.65);
});
if (tile) {
const renderer = renderers[tile.type] as Renderer<Tile>;
this.drawTile([x, y], renderer, tile);
}
}
}

29
src/game/renderer.ts Normal file
View File

@ -0,0 +1,29 @@
import { type Tile, TileType } from "./world";
export type Renderer<T extends Tile> = (ctx: CanvasRenderingContext2D, tile: T) => void;
export type NullRenderer = (ctx: CanvasRenderingContext2D) => void;
type Renderers = {
[K in Tile['type']]: Renderer<Extract<Tile, { type: K }>>
}
export const renderers: Renderers = {
[TileType.SOURCE]: (ctx, tile) => {
ctx.fillStyle = '#bbffff7f';
ctx.fillRect(0, 0, 1, 1);
ctx.fillStyle = 'black';
ctx.fillText(tile.resource.toString(2), 0.5, 0.65);
},
[TileType.DESTINATION]: (ctx, tile) => {
if (tile.center) {
ctx.fillStyle = '#bbffbb';
ctx.fillRect(-2, -2, 5, 5);
ctx.fillStyle = 'black';
ctx.fillText('Deploy', 0.5, 0.65);
}
},
[TileType.EXTRACTOR]: (ctx, tile) => {
},
[TileType.CONVEYOR]: (ctx, tile) => {
}
};

102
src/game/ui.tsx Normal file
View File

@ -0,0 +1,102 @@
import React, { render } from 'preact';
import cn from 'classnames';
import { range } from './utils';
import { TileType } from './world';
import styles from '../assets/ui.module.css';
import selectIcon from '../assets/img/select.png';
import conveyorIcon from '../assets/img/conveyor.png';
import extractorIcon from '../assets/img/extractor.png';
import trashIcon from '../assets/img/trash.png';
enum ToolType {
SELECT,
EXTRACTOR,
CONVEYOR,
DELETE = 9,
}
interface Tool {
type: ToolType;
title: string;
icon: string;
tileType?: TileType;
}
const TOOLS: (Tool|null)[] = [
{
type: ToolType.SELECT,
title: 'Select',
icon: selectIcon,
},
{
type: ToolType.EXTRACTOR,
title: 'Extractor',
icon: extractorIcon,
tileType: TileType.EXTRACTOR,
},
{
type: ToolType.CONVEYOR,
title: 'Conveyor',
icon: conveyorIcon,
tileType: TileType.CONVEYOR,
},
null, // 4
null, // 5
null, // 6
null, // 7
null, // 8
{
type: ToolType.DELETE,
title: 'Delete',
icon: trashIcon,
},
];
export default class UI {
private currentTool: Tool = TOOLS[0]!;
constructor(private root: HTMLElement) {
this.render();
}
render() {
const fragment = <>
{range(9).map(i => (
<Button
keyBinding={i + 1}
tool={TOOLS[i]}
onClick={() => this.onToolSelect(TOOLS[i])}
active={this.currentTool.type === TOOLS[i]?.type}
/>
))}
</>;
render(fragment, this.root);
}
onToolSelect = (tool: Tool | null) => {
if (tool) {
this.currentTool = tool;
this.render();
}
}
get selectedTool() {
return this.currentTool;
}
}
interface ButtonProps {
keyBinding: number;
active: boolean;
tool: Tool | null;
onClick?: () => void;
}
function Button({ keyBinding, active, tool, onClick }: ButtonProps) {
return (
<div class={cn([styles.button, active && styles.active, !tool && styles.disabled])} onClick={() => onClick?.()}>
<img src={tool?.icon} class={styles.icon} />
<div class={styles.keyBinding}>{keyBinding}</div>
</div>
);
}

View File

@ -1,12 +1,12 @@
type Operand = Point | number;
type Operation = (a: number, b: number) => number;
function op(a: Operand, b: Operand, fn: Operation): Operand {
if (!Array.isArray(a) && !Array.isArray(b)) return fn(a, b);
if (Array.isArray(a) && !Array.isArray(b)) return a.map((x) => fn(x, b)) as Point;
if (!Array.isArray(a) && Array.isArray(b)) return b.map((x) => fn(a, x)) as Point;
if (Array.isArray(a) && Array.isArray(b)) return a.map((x, i) => fn(x, b[i])) as Point;
return 0;
const aArray = Array.isArray(a);
const bArray = Array.isArray(b);
if (aArray) {
return (bArray ? a.map((x, i) => fn(x, b[i])): a.map((x) => fn(x, b))) as Point;
}
return (bArray ? b.map((x) => fn(a, x)): fn(a, b)) as Point;
}
const operations: Record<string, [Operation, number]> = {
@ -103,4 +103,5 @@ export const cyrb32 = (seed: number, ...parts: number[]) => {
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
return h1;
};
export const sinHash = (...data: number[]) => data.reduce((hash, n) => Math.sin((hash * 123.12 + n) * 756.12), 0) / 2 + 0.5;
export const sinHash = (...data: number[]) => data.reduce((hash, n) => Math.sin((hash * 123.12 + n) * 756.12), 0) / 2 + 0.5;
export const range = (size: number | string) => Object.keys((new Array(+size)).fill(0)).map(k => +k);

104
src/game/world.ts Normal file
View File

@ -0,0 +1,104 @@
import { cyrb32 } from "./utils";
export enum TileType {
DESTINATION,
SOURCE,
EXTRACTOR,
CONVEYOR,
}
export enum Direction {
NONE,
NORTH,
EAST,
SOUTH,
WEST,
}
type Resource = number;
interface Port {
direction: Direction;
buffer?: Resource;
}
interface BaseTile {
outputs: Port[];
inputs: Port[];
}
interface TileDestination extends BaseTile {
type: TileType.DESTINATION;
center: boolean;
}
interface TileSource {
type: TileType.SOURCE;
resource: Resource;
}
interface TileExtractor extends BaseTile {
type: TileType.EXTRACTOR;
source: TileSource;
}
interface TileConveyor extends BaseTile {
type: TileType.CONVEYOR;
}
export type Tile = TileDestination | TileSource | TileExtractor | TileConveyor;
const id = (point: Point) => ((Math.floor(point[0]) & 0xFFFF) << 16) | Math.floor(point[1]) & 0xFFFF;
export default class World {
private world = new Map<number, Tile>();
constructor(private seed: number = Math.random() * 2e9) {
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const inputs: Port[] = [];
if (y == 0) inputs.push({ direction: Direction.NORTH });
if (y == 4) inputs.push({ direction: Direction.SOUTH });
if (x == 0) inputs.push({ direction: Direction.WEST });
if (x == 4) inputs.push({ direction: Direction.EAST });
this.placeTile([x - 2, y - 2], {
type: TileType.DESTINATION,
outputs: [],
inputs,
center: x == 2 && y == 2,
});
}
}
}
placeTile(position: Point, tile: Tile) {
// TODO select correct type
this.world.set(id(position), tile);
}
removeTile(position: Point) {
// TODO restore correct type if needed
this.world.delete(id(position));
}
getTile(position: Point): Tile | null {
const [x, y] = position;
const pid = id(position);
const tile = this.world.get(pid);
if (tile) return tile;
if (Math.abs(x) >= 5 && Math.abs(y) >= 5) {
const hash = cyrb32(this.seed, ...position);
if ((hash & 0xFF) === 42) {
const resource = (hash >> 12) & 0xF;
const newTile: Tile = { type: TileType.SOURCE, resource };
this.world.set(pid, newTile);
return newTile;
}
}
return null;
}
}

View File

@ -1,10 +1,10 @@
import Game from './game';
import Game from './game/game';
async function main() {
const canvas = document.getElementById('c');
if (canvas && 'getContext' in canvas && typeof canvas.getContext === 'function') {
const game = new Game(canvas as HTMLCanvasElement);
await game.load();
const controls = document.getElementById('controls');
if (canvas instanceof HTMLCanvasElement && controls instanceof HTMLElement) {
const game = new Game(canvas, controls);
game.run();
} else {
alert('Something wrong with your canvas!');

View File

@ -1,6 +1,9 @@
import Bun from 'bun';
import path from 'path';
import dataUrlPlugin from './dataUrlPlugin';
import lightningcss from 'bun-lightningcss'
Bun.serve({
async fetch(req) {
const url = new URL(req.url);
@ -9,21 +12,29 @@ Bun.serve({
case '':
case '/':
case 'index.html':
return new Response(Bun.file(path.resolve(import.meta.dir, '..', 'public', 'index.html')));
case 'index.js':
const html = await Bun.file(path.resolve(import.meta.dir, 'assets', 'index.html')).text();
const bundle = await Bun.build({
entrypoints: [path.resolve(import.meta.dir, 'index.ts')],
sourcemap: 'inline',
publicPath: '/build/',
// minify: true,
define: {
global: 'window',
}
},
plugins: [
dataUrlPlugin,
lightningcss(),
]
});
if (bundle.success && bundle.outputs.length === 1) {
return new Response(bundle.outputs[0]);
const script = await bundle.outputs[0].text();
return new Response(html.replace('$SCRIPT$', `<script>${script}</script>`), {
headers: {
'Content-Type': 'text/html;charset=utf-8'
}
});
} else {
console.error('Multiple assets: ', bundle.outputs, 'or fail: ', !bundle.success);
console.error('Multiple assets: ', bundle.outputs, 'or fail: ', !bundle.success, bundle);
}
return new Response(null, { status: 500 });
default:

11
src/types.d.ts vendored
View File

@ -1,2 +1,11 @@
type Point = [number, number];
type Rect = [number, number, number, number];
type Rect = [number, number, number, number];
declare module "*.png" {
const content: string;
export default content;
}
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}

View File

@ -1,72 +0,0 @@
import { cyrb32, sinHash } from "./utils";
export enum TileType {
SOURCE,
EXTRACTOR,
CONVEYOR,
}
export enum Direction {
NONE,
NORTH,
EAST,
SOUTH,
WEST,
}
interface BaseTile {
outputs: Direction[];
inputs: Direction[];
}
interface TileSource {
type: TileType.SOURCE;
resource: number;
}
interface TileExtractor extends BaseTile {
type: TileType.EXTRACTOR;
source: TileSource;
}
interface TileConveyor extends BaseTile {
type: TileType.CONVEYOR;
}
type Tile = TileExtractor | TileSource | TileConveyor;
const id = (point: Point) => ((Math.floor(point[0]) & 0xFFFF) << 16) | Math.floor(point[1]) & 0xFFFF;
export default class World {
private world = new Map<number, Tile>();
constructor(private seed: number = Math.random() * 2e9) {
}
placeTile(position: Point, type: TileType) {
// TODO select correct type
this.world.set(id(position), { type: TileType.SOURCE, resource: (Math.random() * 0xF) & 0xF });
}
removeTile(position: Point) {
// TODO restore correct type if needed
this.world.delete(id(position));
}
getTile(position: Point): Tile | null {
const pid = id(position);
const tile = this.world.get(pid);
if (tile) return tile;
const hash = cyrb32(this.seed, ...position);
if ((hash & 0x1FF) === 42) {
const resource = (hash >> 12) & 0xF;
const newTile: Tile = { type: TileType.SOURCE, resource };
this.world.set(pid, newTile);
return newTile;
}
return null;
}
}

View File

@ -6,12 +6,13 @@
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
@ -22,6 +23,6 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
}
}