Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e72f0cc9a |
@@ -7,6 +7,9 @@ const nextConfig = {
|
||||
experimental: {
|
||||
reactCompiler: true,
|
||||
ppr: "incremental",
|
||||
turbo: {
|
||||
|
||||
}
|
||||
},
|
||||
serverExternalPackages: ["typeorm", "better-sqlite3"],
|
||||
reactStrictMode: true,
|
||||
|
||||
3173
package-lock.json
generated
3173
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
66
package.json
66
package.json
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:analyse": "ANALYZE=true npm run build",
|
||||
"start": "next start",
|
||||
@@ -11,51 +11,59 @@
|
||||
"lint:fix": "next lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.712.0",
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@dimforge/rapier2d-compat": "^0.14.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@mdx-js/loader": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@next/bundle-analyzer": "^15.1.0",
|
||||
"@next/mdx": "^15.1.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-virtual": "^3.11.1",
|
||||
"@trpc/client": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "^11.0.0-rc.660",
|
||||
"@trpc/server": "^11.0.0-rc.660",
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@next/mdx": "^15.1.7",
|
||||
"@pixi-essentials/object-pool": "^1.0.1",
|
||||
"@pixi/events": "^7.4.2",
|
||||
"@tailwindcss/postcss": "^4.0.8",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@trpc/client": "^11.0.0-rc.802",
|
||||
"@trpc/react-query": "^11.0.0-rc.802",
|
||||
"@trpc/server": "^11.0.0-rc.802",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.14.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "beta",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"client-only": "^0.0.1",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.0",
|
||||
"exif-reader": "^2.0.1",
|
||||
"framer-motion": "^11.14.4",
|
||||
"glob": "^11.0.0",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-next": "^15.1.7",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"exif-reader": "^2.0.2",
|
||||
"framer-motion": "^12.4.7",
|
||||
"glob": "^11.0.1",
|
||||
"million": "^3.1.11",
|
||||
"next": "15.1.1-canary.5",
|
||||
"next": "15.2.0-canary.69",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"postcss": "^8.4.49",
|
||||
"pixi-filters": "^6.1.0",
|
||||
"pixi-viewport": "^6.0.3",
|
||||
"pixi.js": "^8.8.0",
|
||||
"postcss": "^8.5.3",
|
||||
"radash": "^12.1.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-zoom-pan-pinch": "^3.6.1",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.33.5",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"tailwind-scrollbar": "^4.0.0",
|
||||
"tailwindcss": "^4.0.8",
|
||||
"typescript": "^5.7.3",
|
||||
"yet-another-react-lightbox": "^3.21.7",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
9
src/app/(root)/games/map/page.tsx
Normal file
9
src/app/(root)/games/map/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Wrapper from "./wrapper";
|
||||
|
||||
export default function Game(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Wrapper/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/(root)/games/map/wrapper.tsx
Normal file
16
src/app/(root)/games/map/wrapper.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React from "react";
|
||||
|
||||
const MapGen = dynamic<Record<string, never>>(() => import('../../../../games/games/MapGen/MapGenWrapper').then((module) => module.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Wrapper(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<MapGen/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
src/app/(root)/games/player/page.tsx
Normal file
9
src/app/(root)/games/player/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Wrapper from "./wrapper";
|
||||
|
||||
export default function Game(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Wrapper/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/(root)/games/player/wrapper.tsx
Normal file
16
src/app/(root)/games/player/wrapper.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React from "react";
|
||||
|
||||
const TestGame = dynamic<Record<string, never>>(() => import('../../../../games/games/TestGame/TestGameWrapper').then((module) => module.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Wrapper(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<TestGame/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
|
||||
82
src/games/games/MapGen/MapGenApp.ts
Normal file
82
src/games/games/MapGen/MapGenApp.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Control from '@/control/Control';
|
||||
import Tile, { TileDir } from './Tile';
|
||||
import Vector from '@/games/lib/utils/Vector';
|
||||
import { min, shuffle } from 'radash';
|
||||
|
||||
const grid = new Vector(64, 256);
|
||||
|
||||
/**
|
||||
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||
*/
|
||||
class TestGameApp extends BaseGameApp {
|
||||
private _control: Control;
|
||||
private _tileGrid: Tile[][] = Array.from({ length: grid.x }).map(() => Array.from({ length: grid.y }));
|
||||
private _allTiles: Tile[] = Array.from({ length: grid.x * grid.y });
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
super(canvas);
|
||||
this._control = new Control();
|
||||
}
|
||||
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await super._start(canvas);
|
||||
Registry.register('Control', this._control);
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
this._tileGrid[x][y] = new Tile({ x, y });
|
||||
this._allTiles[x + y * grid.x] = this._tileGrid[x][y];
|
||||
this._viewport.addChild(this._tileGrid[x][y]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
if (x > 0) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x - 1][y], TileDir.west);
|
||||
}
|
||||
if (x < grid.x - 1) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x + 1][y], TileDir.east);
|
||||
}
|
||||
|
||||
if (y > 0) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x][y - 1], TileDir.north);
|
||||
}
|
||||
if (y < grid.y - 1) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x][y + 1], TileDir.south);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
this._tileGrid[x][y].rescore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
await super._update(timeDelta);
|
||||
|
||||
while (true) {
|
||||
|
||||
const activeTiles = this._allTiles.filter((tile) => !tile.selected && tile.score > 0);
|
||||
if (activeTiles.length === 0) {
|
||||
break;
|
||||
}
|
||||
const lowestScore = min(activeTiles, t => t.score)!.score;
|
||||
const lowestTiles = activeTiles.filter((tile) => tile.score === lowestScore);
|
||||
const selectedTile = shuffle(lowestTiles)[0];
|
||||
selectedTile.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TestGameApp;
|
||||
24
src/games/games/MapGen/MapGenWrapper.tsx
Normal file
24
src/games/games/MapGen/MapGenWrapper.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import MapGenApp from './MapGenApp';
|
||||
|
||||
/**
|
||||
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||
*/
|
||||
function TestGameWrapper(): React.ReactElement {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const gameCreated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameCreated.current) {
|
||||
console.log('Creating game');
|
||||
gameCreated.current = true;
|
||||
new MapGenApp(canvas.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas ref={canvas} />
|
||||
);
|
||||
}
|
||||
|
||||
export default TestGameWrapper;
|
||||
212
src/games/games/MapGen/Tile.ts
Normal file
212
src/games/games/MapGen/Tile.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import Container from "@/games/lib/visual/Container";
|
||||
import Graphics from "@/games/lib/visual/Graphics";
|
||||
import Text from "@/games/lib/visual/Text";
|
||||
|
||||
const TileTypes = {
|
||||
grass: "grass",
|
||||
dirt: "dirt",
|
||||
rock: "rock",
|
||||
iron: "iron",
|
||||
copper: "copper",
|
||||
};
|
||||
|
||||
export enum TileDir {
|
||||
north,
|
||||
east,
|
||||
south,
|
||||
west
|
||||
};
|
||||
|
||||
type TileData = {
|
||||
available: boolean,
|
||||
colour: number
|
||||
}
|
||||
|
||||
const tileData: { [key in keyof typeof TileTypes]: TileData } = {
|
||||
grass: {
|
||||
available: false,
|
||||
colour: 0x117c13
|
||||
},
|
||||
rock: {
|
||||
available: false,
|
||||
colour: 0xc6bfb8
|
||||
},
|
||||
dirt: {
|
||||
available: false,
|
||||
colour: 0x402905
|
||||
},
|
||||
copper: {
|
||||
available: false,
|
||||
colour: 0xb87333
|
||||
},
|
||||
iron: {
|
||||
available: false,
|
||||
colour: 0xa19d94
|
||||
},
|
||||
}
|
||||
|
||||
export default class Tile extends Container {
|
||||
private static readonly size = {
|
||||
x: 30,
|
||||
y: 30
|
||||
}
|
||||
private _graphic: Graphics = new Graphics();
|
||||
private _point: { x: number, y: number };
|
||||
private _text: Text = new Text({
|
||||
position: {
|
||||
x: Tile.size.x / 2,
|
||||
y: Tile.size.y / 2,
|
||||
}
|
||||
});
|
||||
private _choices = structuredClone(tileData);
|
||||
private _selected: null | keyof typeof tileData = null;
|
||||
public score = 0;
|
||||
public neighbors: Tile[] = [];
|
||||
|
||||
|
||||
constructor(point: { x: number, y: number }) {
|
||||
super({
|
||||
position: {
|
||||
x: point.x * Tile.size.x,
|
||||
y: point.y * Tile.size.y,
|
||||
}
|
||||
});
|
||||
|
||||
this._point = point;
|
||||
this.addChild(this._graphic, this._text);
|
||||
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||
this._text.text = "-1";
|
||||
}
|
||||
|
||||
link(tile: Tile, dir: TileDir) {
|
||||
this.neighbors[dir] = tile;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._graphic.clear();
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||
Object.values(this._choices).forEach((choice) => choice.available = false);
|
||||
this._text.text = "-1";
|
||||
this._point = {
|
||||
x: -1,
|
||||
y: -1
|
||||
}
|
||||
this._selected = null;
|
||||
}
|
||||
|
||||
_calculateAvailable() {
|
||||
if (!this.neighbors[TileDir.north]) {
|
||||
this._choices.grass.available = true;
|
||||
this._choices.dirt.available = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.neighbors.forEach((neighbor) => {
|
||||
if (neighbor.selected === 'grass') {
|
||||
this._choices.dirt.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'dirt') {
|
||||
this._choices.dirt.available = true;
|
||||
this._choices.rock.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'rock') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.iron.available = true;
|
||||
this._choices.copper.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'iron') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.iron.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'copper') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.copper.available = true;
|
||||
}
|
||||
})
|
||||
|
||||
if (this._point.y < 32) {
|
||||
this._choices.iron.available = false;
|
||||
}
|
||||
if (this._point.y < 64) {
|
||||
this._choices.copper.available = false;
|
||||
this._choices.dirt.available = true;
|
||||
this._choices.rock.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
rescore(): number {
|
||||
if (this._selected) {
|
||||
return 0;
|
||||
}
|
||||
this._calculateAvailable();
|
||||
|
||||
this.score = Object.values(this._choices).reduce((count, choice) => {
|
||||
return choice.available ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
this._text.text = this.score;
|
||||
return this.score;
|
||||
}
|
||||
|
||||
select(): void {
|
||||
|
||||
//const available = Object.entries(this._choices).filter((choice) => choice[1].available);
|
||||
//this._selected = shuffle(available)[0][0] as keyof typeof tileData;
|
||||
const available = Object.entries(this._choices).filter((choice) => choice[1].available) as [keyof typeof TileTypes, TileData][];
|
||||
|
||||
const weightedAvailable = available.map(([tileType, tileData]) => {
|
||||
const weightedEntry = {
|
||||
type: tileType,
|
||||
weight: 4
|
||||
};
|
||||
|
||||
if (tileType === 'grass') {
|
||||
weightedEntry.weight = 8;
|
||||
}
|
||||
else if (tileType === 'dirt') {
|
||||
if (this._point.y === 0) {
|
||||
weightedEntry.weight = 2;
|
||||
} else {
|
||||
weightedEntry.weight += 20 - Math.floor(((Math.min(this._point.y, 63) / 64) * 20) + 3);
|
||||
}
|
||||
}
|
||||
else if (tileType === 'iron') {
|
||||
weightedEntry.weight = 3;
|
||||
}
|
||||
else if (tileType === 'copper') {
|
||||
weightedEntry.weight = 1;
|
||||
}
|
||||
|
||||
return weightedEntry;
|
||||
});
|
||||
|
||||
const totalWeight = weightedAvailable.reduce((weight, avail) => weight + avail.weight, 0);
|
||||
let pick = Math.floor(Math.random() * totalWeight)
|
||||
|
||||
for (let i = 0; i < weightedAvailable.length; i++) {
|
||||
if (pick < weightedAvailable[i].weight) {
|
||||
this._selected = weightedAvailable[i].type;
|
||||
break;
|
||||
}
|
||||
pick -= weightedAvailable[i].weight;
|
||||
}
|
||||
if (this._selected === null) {
|
||||
throw new Error('Selection not done');
|
||||
}
|
||||
|
||||
this._graphic.clear();
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: this._choices[this._selected].colour });
|
||||
this._text.text = "";
|
||||
|
||||
this.neighbors.forEach((neighbor) => neighbor.rescore());
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this._selected;
|
||||
}
|
||||
}
|
||||
41
src/games/games/TestGame/TestGameApp.ts
Normal file
41
src/games/games/TestGame/TestGameApp.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Control from '@/control/Control';
|
||||
|
||||
import Player from './components/Player/Player';
|
||||
import Trees from './components/Trees';
|
||||
import Walls from './components/Walls/Walls';
|
||||
|
||||
/**
|
||||
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||
*/
|
||||
class TestGameApp extends BaseGameApp {
|
||||
private _player!: Player;
|
||||
private _control: Control;
|
||||
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
super(canvas);
|
||||
this._control = new Control();
|
||||
}
|
||||
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await super._start(canvas);
|
||||
Registry.register('Control', this._control);
|
||||
this._viewport.addChild(new Trees({ numberOfTrees: 200 }));
|
||||
this._player = this._viewport.addChild(new Player());
|
||||
this._viewport.addChild(new Walls());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
this._player.update(timeDelta);
|
||||
await super._update(timeDelta);
|
||||
}
|
||||
}
|
||||
|
||||
export default TestGameApp;
|
||||
24
src/games/games/TestGame/TestGameWrapper.tsx
Normal file
24
src/games/games/TestGame/TestGameWrapper.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import TestGameApp from './TestGameApp';
|
||||
|
||||
/**
|
||||
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||
*/
|
||||
function TestGameWrapper(): React.ReactElement {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const gameCreated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameCreated.current) {
|
||||
console.log('Creating game');
|
||||
gameCreated.current = true;
|
||||
new TestGameApp(canvas.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas ref={canvas} />
|
||||
);
|
||||
}
|
||||
|
||||
export default TestGameWrapper;
|
||||
132
src/games/games/TestGame/components/Player/Player.ts
Normal file
132
src/games/games/TestGame/components/Player/Player.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Rapier from '@/utils/Rapier';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Vector from '@/utils/Vector';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
|
||||
import BaseAttack from './attack/BaseAttack';
|
||||
import Forward from './attack/Forward';
|
||||
|
||||
import type { Viewport } from '@/visual/pixi';
|
||||
import type Control from '@/control/Control';
|
||||
import { assign } from 'radash';
|
||||
|
||||
type PlayerSettings = ConstructorParameters<typeof Collidable>[0]
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Player extends Collidable {
|
||||
private _control: Control;
|
||||
private _characterGraphics: Graphics;
|
||||
|
||||
private _attacks: BaseAttack[] = [];
|
||||
private _rapier: Rapier;
|
||||
private _collider: InstanceType<Rapier['rapier']['Collider']>;
|
||||
private _characterController: InstanceType<Rapier['rapier']['KinematicCharacterController']>;
|
||||
|
||||
constructor(settings?: PlayerSettings) {
|
||||
super(assign(structuredClone(settings ?? {}), {
|
||||
position: {
|
||||
x: 960,
|
||||
y: 540
|
||||
}
|
||||
}));
|
||||
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._control = Registry.fetch<Control>('Control');
|
||||
|
||||
this._characterGraphics = viewport.addChild(new Graphics());
|
||||
this._characterGraphics.setStrokeStyle({
|
||||
color: 0x00DD00,
|
||||
width: 1
|
||||
}).regularPoly(-10, -10, 20, 3).fill(0x00AA00).stroke();
|
||||
this._characterGraphics.pivot.set(-10, -10);
|
||||
this.addChild(this._characterGraphics);
|
||||
|
||||
viewport.follow(this, { radius: 250 });
|
||||
viewport.addChild(this);
|
||||
|
||||
const projectileContainer = new Container();
|
||||
viewport.addChild(projectileContainer);
|
||||
const forwardAttack = new Forward(projectileContainer);
|
||||
this._attacks.push(forwardAttack);
|
||||
this.addChild(forwardAttack);
|
||||
|
||||
const trianglePoints = {
|
||||
a: new Vector(0, -20),
|
||||
b: new Vector(10 * Math.sqrt(3), 10),
|
||||
c: new Vector(-10 * Math.sqrt(3), 10)
|
||||
};
|
||||
this._collider = this._rapier.createCollider(this, 'triangle', trianglePoints);
|
||||
this._collider.setRestitution(0);
|
||||
this._collider.setTranslation(this.position);
|
||||
|
||||
this._rapier.addColliderMemberGroups(this._collider, 'player');
|
||||
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'enemyProjectile', 'wall');
|
||||
|
||||
this._characterController = this._rapier.world.createCharacterController(0.01);
|
||||
|
||||
}
|
||||
|
||||
update(timeDelta: number): void {
|
||||
let impulse = new Vector(0, 0);
|
||||
let speed = 250;
|
||||
|
||||
if (this._control.isDown('up')) {
|
||||
impulse.y -= 1;
|
||||
}
|
||||
if (this._control.isDown('down')) {
|
||||
impulse.y += 1;
|
||||
}
|
||||
if (this._control.isDown('left')) {
|
||||
impulse.x -= 1;
|
||||
}
|
||||
if (this._control.isDown('right')) {
|
||||
impulse.x += 1;
|
||||
}
|
||||
if (this._control.isDown('shift')) {
|
||||
speed *= 4;
|
||||
}
|
||||
|
||||
if (impulse.magnitude() > 0) {
|
||||
const normImpulse = impulse.normalize();
|
||||
this.rotation = Math.atan2(normImpulse.y, normImpulse.x) - Math.atan2(1, 0) + Math.PI;
|
||||
impulse = impulse.normalize().multiplyScalar(speed);
|
||||
}
|
||||
|
||||
this._characterController.computeColliderMovement(
|
||||
this._collider,
|
||||
impulse.multiplyScalar(timeDelta)
|
||||
);
|
||||
|
||||
const correctedMovement = this._characterController.computedMovement();
|
||||
this.position.add(correctedMovement);
|
||||
// console.log('impulse', impulse);
|
||||
// console.log('corrected', correctedMovement)
|
||||
//this._collider.setTranslation(this.position);
|
||||
|
||||
|
||||
if (this._control.isDown(' ')) {
|
||||
const modifiedRotation = this.rotation - (Math.PI / 2);
|
||||
const currentVector = new Vector(Math.cos(modifiedRotation), Math.sin(modifiedRotation));
|
||||
this._attacks[0].fire({
|
||||
playerVector: currentVector
|
||||
});
|
||||
}
|
||||
|
||||
this._attacks.forEach((attack) => {
|
||||
attack.update(timeDelta);
|
||||
});
|
||||
}
|
||||
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
if (!colliding) {
|
||||
console.log('player no longer colliding');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -0,0 +1,20 @@
|
||||
import Container from '../../../../../lib/visual/Container';
|
||||
import type Vector from '../../../../../lib/utils/Vector';
|
||||
|
||||
type fireState = {
|
||||
playerVector: Vector
|
||||
}
|
||||
|
||||
abstract class BaseAttack extends Container {
|
||||
protected _projectileContainer: Container;
|
||||
|
||||
constructor(projectileContainer: Container) {
|
||||
super();
|
||||
this._projectileContainer = projectileContainer;
|
||||
}
|
||||
abstract fire(state: fireState): void;
|
||||
abstract update(timeDelta: number): void;
|
||||
}
|
||||
|
||||
|
||||
export default BaseAttack;
|
||||
159
src/games/games/TestGame/components/Player/attack/Forward.ts
Normal file
159
src/games/games/TestGame/components/Player/attack/Forward.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Pool from '@/utils/Pool';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Vector from '@/utils/Vector';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
|
||||
import BaseAttack from './BaseAttack';
|
||||
|
||||
import type Rapier from '@/utils/Rapier';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class ForwardProjectile extends Collidable {
|
||||
private _speed: number = 600;
|
||||
private _size: number = 5;
|
||||
private _graphic: Graphics;
|
||||
|
||||
private _lifeTime: {
|
||||
lifeTime: number,
|
||||
lifeTimeRemaining: number
|
||||
} = {
|
||||
lifeTime: 2500,
|
||||
lifeTimeRemaining: 0
|
||||
};
|
||||
private _collider!: InstanceType<Rapier['rapier']['Collider']>;
|
||||
private _rigidBody!: InstanceType<Rapier['rapier']['RigidBody']>;
|
||||
private _rapier: Rapier;
|
||||
private _alive: boolean = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._graphic = new Graphics();
|
||||
this._graphic.circle(this._size / 2, this._size / 2, this._size).fill(0xAA0000);
|
||||
this.addChild(this._graphic);
|
||||
this.pivot.set(this._size / 2, this._size / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
alive(): boolean {
|
||||
return this._lifeTime.lifeTimeRemaining > 0 && this._alive;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
fire(vector: Vector): void {
|
||||
this._alive = true;
|
||||
this._addPhysics();
|
||||
this._rigidBody.setTranslation(this.position, true);
|
||||
this._rigidBody.setLinvel(vector.multiplyScalar(this._speed), true);
|
||||
this._lifeTime.lifeTimeRemaining = this._lifeTime.lifeTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param timeDelta - TODO
|
||||
*/
|
||||
update(timeDelta: number): void {
|
||||
this.position.copyFrom(this._rigidBody.translation());
|
||||
|
||||
if (this._lifeTime.lifeTimeRemaining > 0) {
|
||||
this._lifeTime.lifeTimeRemaining = Math.max(0, this._lifeTime.lifeTimeRemaining - timeDelta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets called when we've collided with something.
|
||||
* Use to kill the projectile.
|
||||
*
|
||||
* @param collided - The Collidable this projectile has hit
|
||||
* @param colliding - Are we currently colliding?
|
||||
*/
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
if (colliding) {
|
||||
this._alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _addPhysics(): void {
|
||||
this._rigidBody = this._rapier.createRigidBody(this, 'Dynamic', false, true);
|
||||
this._collider = this._rapier.createCollider(this, 'ball', { radius: this._size }, this._rigidBody);
|
||||
this._collider.setRestitution(0);
|
||||
this._rapier.addColliderMemberGroups(this._collider, 'playerProjectile');
|
||||
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'wall');
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removePhysics(): void {
|
||||
this._rapier.removeRigidBody(this._rigidBody);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Forward extends BaseAttack {
|
||||
private _projectilePool: Pool<ForwardProjectile> = new Pool(ForwardProjectile, 50);
|
||||
private _activeProjectiles: ForwardProjectile[] = [];
|
||||
private _coolDown: {
|
||||
coolDown: number,
|
||||
coolDownRemaining: number
|
||||
} = {
|
||||
coolDown: 250,
|
||||
coolDownRemaining: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
fire(state: Parameters<BaseAttack['fire']>[0]): void {
|
||||
if (this._coolDown.coolDownRemaining > 0) {
|
||||
return;
|
||||
}
|
||||
const newBullet = this._projectilePool.allocate();
|
||||
|
||||
const newPoint = this._projectileContainer.toLocal(new Vector(0, 0), this);
|
||||
newBullet.position.copyFrom(newPoint);
|
||||
this._projectileContainer.addChild(newBullet);
|
||||
this._activeProjectiles.push(newBullet);
|
||||
|
||||
newBullet.fire(state.playerVector.clone());
|
||||
|
||||
this._coolDown.coolDownRemaining = this._coolDown.coolDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
update(timeDelta: number): void {
|
||||
if (this._coolDown.coolDownRemaining > 0) {
|
||||
this._coolDown.coolDownRemaining = Math.max(0, this._coolDown.coolDownRemaining - timeDelta);
|
||||
}
|
||||
|
||||
this._activeProjectiles = this._activeProjectiles.filter((projectile) => {
|
||||
projectile.update(timeDelta);
|
||||
const alive = projectile.alive();
|
||||
if (!alive) {
|
||||
projectile.removePhysics();
|
||||
this._projectileContainer.removeChild(projectile);
|
||||
this._projectilePool.release(projectile);
|
||||
}
|
||||
return alive;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Forward;
|
||||
|
||||
31
src/games/games/TestGame/components/Trees.ts
Normal file
31
src/games/games/TestGame/components/Trees.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import Pool from '@/utils/Pool';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
type TreesSettings = ConstructorParameters<typeof Container>[0] & {
|
||||
numberOfTrees: number
|
||||
}
|
||||
|
||||
class Trees extends Container {
|
||||
private readonly _allTrees: Graphics[] = new Array<Graphics>(200);
|
||||
private readonly _treePool: Pool<Graphics>;
|
||||
constructor(settings: TreesSettings) {
|
||||
super(settings);
|
||||
this._treePool = new Pool(Graphics, settings.numberOfTrees);
|
||||
|
||||
for (let i = 0; i < settings.numberOfTrees; i++) {
|
||||
const tree = this.addChild(this._treePool.allocate());
|
||||
const treeColour = ((Math.random() * 80) + 70);
|
||||
tree.regularPoly(-10, 10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour - 10) / 255, 0]) });
|
||||
tree.regularPoly(-10, 0, 20, 3).fill({ color: new PIXI.Color([0, treeColour / 255, 0]) });
|
||||
tree.regularPoly(-10, -10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour + 10) / 255, 0]) });
|
||||
tree.position.set(Math.random() * 1920, Math.random() * 1080);
|
||||
tree.cullable = true;
|
||||
this._allTrees.push(tree);
|
||||
}
|
||||
this.cullable = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default Trees;
|
||||
76
src/games/games/TestGame/components/Walls/Walls.ts
Normal file
76
src/games/games/TestGame/components/Walls/Walls.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
import type Rapier from '@/utils/Rapier';
|
||||
|
||||
type WallsSettings = ConstructorParameters<typeof Container>[0] & {
|
||||
numberOfTrees: number
|
||||
}
|
||||
|
||||
type wallData = {
|
||||
graphic: Graphics,
|
||||
collider: InstanceType<Rapier['rapier']['Collider']>,
|
||||
rigidBody: InstanceType<Rapier['rapier']['RigidBody']>,
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Walls extends Collidable {
|
||||
private _rapier: Rapier;
|
||||
private _walls: wallData[] = [];
|
||||
|
||||
constructor(settings?: WallsSettings) {
|
||||
super(settings);
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._walls.push(this._createWall(10, 1110, -10, 540));
|
||||
this._walls.push(this._createWall(10, 1110, 1930, 540));
|
||||
this._walls.push(this._createWall(1950, 10, 960, -10));
|
||||
this._walls.push(this._createWall(1950, 10, 960, 1090));
|
||||
|
||||
this._walls.forEach((wall) => {
|
||||
this.addChild(wall.graphic);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param width - TODO
|
||||
* @param height
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
private _createWall(width: number, height: number, x: number, y: number): wallData {
|
||||
const wall = new Graphics();
|
||||
wall.rect(-width / 2, -height / 2, width, height).fill({ color: new PIXI.Color([0, 0, 0]) });
|
||||
wall.position.set(x, y);
|
||||
|
||||
|
||||
const rigidBody = this._rapier.createRigidBody(this, 'Fixed');
|
||||
const collider = this._rapier.createCollider(this, 'cuboid', { halfHeight: height / 2, halfWidth: width / 2 }, rigidBody);
|
||||
collider.setRestitution(0);
|
||||
|
||||
rigidBody.setTranslation(this.position.add({ x, y }), true);
|
||||
|
||||
this._rapier.addColliderMemberGroups(collider, 'wall');
|
||||
this._rapier.addColliderFilterGroups(collider, 'player', 'enemy', 'enemyProjectile', 'playerProjectile');
|
||||
|
||||
return {
|
||||
graphic: wall,
|
||||
collider,
|
||||
rigidBody
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default Walls;
|
||||
7
src/games/lib/control/Collidable.ts
Normal file
7
src/games/lib/control/Collidable.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Container from '@/visual/Container';
|
||||
|
||||
abstract class Collidable extends Container {
|
||||
abstract collide(collided: Collidable, colliding: boolean): void;
|
||||
}
|
||||
|
||||
export default Collidable;
|
||||
87
src/games/lib/control/Control.ts
Normal file
87
src/games/lib/control/Control.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
type state = 'down' | 'up';
|
||||
|
||||
// - TODO - Pull this from the actual game creating the controls
|
||||
const GameControls = {
|
||||
down: 'down',
|
||||
up: 'up',
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
shift: 'shift',
|
||||
' ': ' ',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Abstraction for game controls, the game should say what controls it expects and then we can remap the actual controls separately.
|
||||
* i.e. Both "ArrowDown" and "Down" from the keyboard listener should be the games "down" event.
|
||||
* Also handles when the keys are pressed or not.
|
||||
*/
|
||||
class Control {
|
||||
readonly gameControls = GameControls;
|
||||
private _controlMap: { [key: string]: keyof typeof GameControls } = {
|
||||
ArrowDown: GameControls.down,
|
||||
Down: GameControls.down,
|
||||
ArrowUp: GameControls.up,
|
||||
Up: GameControls.up,
|
||||
ArrowLeft: GameControls.left,
|
||||
Left: GameControls.left,
|
||||
ArrowRight: GameControls.right,
|
||||
Right: GameControls.right,
|
||||
Shift: GameControls.shift,
|
||||
' ': GameControls[' '],
|
||||
};
|
||||
|
||||
private _controlState: Map<keyof typeof GameControls, {
|
||||
state: state,
|
||||
|
||||
consumed: boolean
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
this._setKeys();
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (this._controlState.get(this._controlMap[event.key])?.state !== 'down') {
|
||||
this._controlState.set(this._controlMap[event.key], { state: 'down', consumed: false });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (event) => {
|
||||
if (this._controlState.get(this._controlMap[event.key])?.state !== 'up') {
|
||||
this._controlState.set(this._controlMap[event.key], { state: 'up', consumed: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _setKeys(): void {
|
||||
Object.keys(GameControls).forEach((key) => {
|
||||
this._controlState.set(key as keyof typeof GameControls, { state: 'up', consumed: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param key - TODO
|
||||
*/
|
||||
isDown(key: keyof typeof GameControls): boolean {
|
||||
return this._controlState.get(key)?.state === 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a down key event once
|
||||
*
|
||||
* @param key - TODO
|
||||
*/
|
||||
onceDown(key: keyof typeof GameControls): boolean {
|
||||
const keyState = this._controlState.get(key);
|
||||
if (keyState && keyState.state === 'down' && !keyState.consumed) {
|
||||
keyState.consumed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default Control;
|
||||
96
src/games/lib/core/BaseGameApp/BaseGameApp.ts
Normal file
96
src/games/lib/core/BaseGameApp/BaseGameApp.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { PIXI, Viewport } from '@/visual/pixi';
|
||||
import Text from '@/visual/Text';
|
||||
import Rapier from '@/utils/Rapier';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Ticker from '@/utils/Ticker';
|
||||
import Vector from '@/utils/Vector';
|
||||
import config from './BaseGameAppConfig';
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class BaseGameApp {
|
||||
protected readonly _app: PIXI.Application;
|
||||
protected readonly _ticker: Ticker = new Ticker();
|
||||
protected _fps: Text = new Text(config.fps);
|
||||
protected _viewport!: Viewport;
|
||||
protected _rapier!: Rapier;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
if (canvas === null) {
|
||||
throw new Error('No canvas');
|
||||
}
|
||||
|
||||
this._app = new PIXI.Application();
|
||||
|
||||
void this._start(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await this._app.init({
|
||||
backgroundColor: 0x999999,
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
resizeTo: canvas.parentElement as HTMLElement
|
||||
});
|
||||
|
||||
this._app.ticker.autoStart = false;
|
||||
this._app.ticker.stop();
|
||||
|
||||
this._viewport = new Viewport({
|
||||
screenWidth: this._app.canvas.width,
|
||||
screenHeight: this._app.canvas.height,
|
||||
worldWidth: 1920,
|
||||
worldHeight: 1080,
|
||||
passiveWheel: false,
|
||||
noTicker: true,
|
||||
events: this._app.renderer.events
|
||||
});
|
||||
Registry.register('Viewport', this._viewport);
|
||||
this._rapier = new Rapier(new Vector(0, 0));
|
||||
Registry.register('Rapier', this._rapier);
|
||||
|
||||
this._viewport.ensureVisible(0, 0, 1920, 1080, true);
|
||||
this._viewport.fitWorld(true);
|
||||
this._viewport.drag();
|
||||
|
||||
this._app.stage.addChild(this._viewport);
|
||||
this._app.stage.addChild(this._fps);
|
||||
|
||||
await this._rapier.setup();
|
||||
|
||||
this._ticker.ticker(1000 / 60, this._update.bind(this), this._updateComplete.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
* - TODO - Figure out if this should be called before or after game updates
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
this._rapier.debugRender();
|
||||
this._viewport.update(timeDelta);
|
||||
this._rapier.update();
|
||||
this._fps.text = this._ticker.currentTicksPerSecond;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the update is complete, kick off a render
|
||||
*
|
||||
* @param updated - Was there actually an update in the last raf?
|
||||
*/
|
||||
protected async _updateComplete(updated: boolean): Promise<void> {
|
||||
this._viewport.resize(this._app.canvas.width, this._app.canvas.height);
|
||||
this._viewport.fitWorld(true);
|
||||
this._app.renderer.render(this._app.stage);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseGameApp;
|
||||
10
src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
Normal file
10
src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const BaseGameAppConfig = {
|
||||
fps: {
|
||||
position: {
|
||||
x: 20,
|
||||
y: 20
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
export default BaseGameAppConfig;
|
||||
31
src/games/lib/utils/Pool.ts
Normal file
31
src/games/lib/utils/Pool.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ObjectPoolFactory } from '@pixi-essentials/object-pool';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Pool<T> {
|
||||
private _pool: ObjectPoolFactory<T>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(ctor: new (...args: any[]) => T, reserve: number) {
|
||||
// Unsure why this needs the cast, probably because of the generic
|
||||
this._pool = ObjectPoolFactory.build(ctor) as ObjectPoolFactory<T>;
|
||||
this._pool.reserve(reserve);
|
||||
this._pool.startGC();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
allocate(): T {
|
||||
return this._pool.allocate();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
release(object: T): void {
|
||||
this._pool.release(object);
|
||||
}
|
||||
}
|
||||
|
||||
export default Pool;
|
||||
223
src/games/lib/utils/Rapier.ts
Normal file
223
src/games/lib/utils/Rapier.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import StoredPromise from './StoredPromise';
|
||||
import Vector from './Vector';
|
||||
import Collidable from '../control/Collidable';
|
||||
import _rapier from '@dimforge/rapier2d-compat';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import Registry from '@/utils/Registry';
|
||||
import { PIXI, type Viewport } from '@/visual/pixi';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
* https://rapier.rs/docs/user_guides/javascript/colliders#overview
|
||||
*/
|
||||
type colliderTypes = {
|
||||
ball: {
|
||||
radius: number
|
||||
},
|
||||
cuboid: {
|
||||
halfHeight: number,
|
||||
halfWidth: number
|
||||
},
|
||||
capsule: {
|
||||
halfHeight: number,
|
||||
radius: number
|
||||
},
|
||||
triangle: {
|
||||
a: Vector,
|
||||
b: Vector,
|
||||
c: Vector
|
||||
}
|
||||
}
|
||||
|
||||
const colliderGroups = {
|
||||
'wall': 0b0000_0000_0000_0001,
|
||||
'player': 0b0000_0000_0001_0000,
|
||||
'enemy': 0b0000_0000_0010_0000,
|
||||
'playerProjectile': 0b0000_0001_0000_0000,
|
||||
'enemyProjectile': 0b0000_0010_0000_0000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Rapier {
|
||||
private _settingUp!: StoredPromise<void>;
|
||||
private _world!: _rapier.World;
|
||||
private _eventQueue!: _rapier.EventQueue;
|
||||
private _rigidBodyMap: Map<number, Collidable> = new Map();
|
||||
private _colliderMap: Map<number, Collidable> = new Map();
|
||||
private _debugLines: Graphics;
|
||||
|
||||
constructor(gravity: Vector) {
|
||||
void this._setup(gravity);
|
||||
this._debugLines = new Graphics();
|
||||
}
|
||||
|
||||
get rapier(): typeof _rapier {
|
||||
return _rapier;
|
||||
}
|
||||
|
||||
get world(): _rapier.World {
|
||||
return this._world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Needed as rapier2d is a webasm module that needs to be async imported directly
|
||||
*/
|
||||
private async _setup(gravity: Vector): Promise<void> {
|
||||
this._settingUp = new StoredPromise();
|
||||
await this.rapier.init()
|
||||
// - TODO - Change integration params dt to ticker speed
|
||||
this._world = new this.rapier.World(gravity);
|
||||
this._eventQueue = new this.rapier.EventQueue(true);
|
||||
|
||||
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||
viewport.addChild(this._debugLines);
|
||||
|
||||
this._settingUp.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
async setup(): Promise<void> {
|
||||
await this._settingUp.promise;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
createRigidBody(container: Collidable, type: keyof typeof _rapier.RigidBodyType, sleep: boolean = true, CCD: boolean = false): _rapier.RigidBody {
|
||||
const rigidBodyDesc = new this.rapier.RigidBodyDesc(this.rapier.RigidBodyType[type])
|
||||
.setCanSleep(sleep)
|
||||
.setCcdEnabled(CCD);
|
||||
|
||||
const rigidBody = this._world.createRigidBody(rigidBodyDesc);
|
||||
this._rigidBodyMap.set(rigidBody.handle, container);
|
||||
return rigidBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
createCollider<T extends keyof colliderTypes, U extends colliderTypes[T]>(container: Collidable, type: T, settings: U, rigidBody?: _rapier.RigidBody): _rapier.Collider {
|
||||
let colliderDesc: _rapier.ColliderDesc;
|
||||
if (type === 'ball') {
|
||||
const castSettings = (settings as colliderTypes['ball']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Ball(castSettings.radius));
|
||||
} else if (type === 'capsule') {
|
||||
const castSettings = (settings as colliderTypes['capsule']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Capsule(castSettings.halfHeight, castSettings.radius));
|
||||
} else if (type === 'cuboid') {
|
||||
const castSettings = (settings as colliderTypes['cuboid']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Cuboid(castSettings.halfWidth, castSettings.halfHeight));
|
||||
} else if (type === 'triangle') {
|
||||
const castSettings = (settings as colliderTypes['triangle']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Triangle(castSettings.a, castSettings.b, castSettings.c));
|
||||
} else {
|
||||
throw new Error(`Cannot create collider of type ${type}`);
|
||||
}
|
||||
colliderDesc.setActiveEvents(this.rapier.ActiveEvents.COLLISION_EVENTS);
|
||||
colliderDesc.setActiveCollisionTypes(this.rapier.ActiveCollisionTypes.DEFAULT);
|
||||
|
||||
const collider = this._world.createCollider(colliderDesc, rigidBody);
|
||||
collider.setCollisionGroups(0);
|
||||
collider.setFriction(0);
|
||||
|
||||
this._colliderMap.set(collider.handle, container);
|
||||
|
||||
return collider;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removeRigidBody(rigidBody: _rapier.RigidBody): void {
|
||||
if (rigidBody) {
|
||||
this._world.removeRigidBody(rigidBody);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removeCollider(collider: _rapier.Collider): void {
|
||||
if (collider) {
|
||||
this._world.removeCollider(collider, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the collider to member groups.
|
||||
* A member group is what groups we're in (i.e. so other groups can collide with us).
|
||||
*
|
||||
* @param collider - The collider to add the groups to
|
||||
* @param groups - The groups to add to
|
||||
*/
|
||||
addColliderMemberGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||
const bitmask = this._getColliderGroupsBitmask(groups) << 16;
|
||||
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the collider to filter groups.
|
||||
* A filter group is what groups we can collide with.
|
||||
*
|
||||
* @param collider - The collider to add the groups to
|
||||
* @param groups - The groups to add to
|
||||
*/
|
||||
addColliderFilterGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||
const bitmask = this._getColliderGroupsBitmask(groups);
|
||||
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _getColliderGroupsBitmask(groups: (keyof typeof colliderGroups)[]): number {
|
||||
return groups.reduce((bitmask, group) => {
|
||||
return bitmask | colliderGroups[group];
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
update(): void {
|
||||
this._world.step(this._eventQueue);
|
||||
this._eventQueue.drainCollisionEvents((handle1, handle2, colliding) => {
|
||||
const container1 = this._colliderMap.get(handle1);
|
||||
const container2 = this._colliderMap.get(handle2);
|
||||
if (container1 && container2) {
|
||||
container1.collide(container2, colliding);
|
||||
container2.collide(container1, colliding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debugRender(): void {
|
||||
const { vertices, colors } = this._world.debugRender();
|
||||
|
||||
this._debugLines.clear();
|
||||
|
||||
for (let i = 0; i < vertices.length / 4; i += 1) {
|
||||
let color = new PIXI.Color([
|
||||
colors[i * 8],
|
||||
colors[i * 8 + 1],
|
||||
colors[i * 8 + 2],
|
||||
]);
|
||||
this._debugLines.setStrokeStyle({
|
||||
color: color,
|
||||
width: 2,
|
||||
alignment: 0.5,
|
||||
})
|
||||
this._debugLines.moveTo(vertices[i * 4], vertices[i * 4 + 1]);
|
||||
this._debugLines.lineTo(vertices[i * 4 + 2], vertices[i * 4 + 3]);
|
||||
this._debugLines.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Rapier;
|
||||
32
src/games/lib/utils/Registry.ts
Normal file
32
src/games/lib/utils/Registry.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Registry {
|
||||
static _objects: Map<string, unknown> = new Map();
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param name - TODO
|
||||
* @param obj - TODO
|
||||
*/
|
||||
static register<T>(name: string, obj: T): void {
|
||||
this._objects.set(name, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param name - TODO
|
||||
*/
|
||||
static fetch<T>(name: string): T {
|
||||
const obj = this._objects.get(name) as T;
|
||||
if (obj) {
|
||||
return obj;
|
||||
} else {
|
||||
throw new Error(`Cannot find ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Registry;
|
||||
31
src/games/lib/utils/StoredPromise.ts
Normal file
31
src/games/lib/utils/StoredPromise.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
class StoredPromise<T = void> {
|
||||
promise: null | Promise<T>;
|
||||
private _resolve: null | (( value: T | PromiseLike<T> ) => void);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _reject: null | (( reason?: any ) => void);
|
||||
constructor() {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
resolve(value: T | PromiseLike<T>): void {
|
||||
this._resolve?.(value);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
reject( reason?: any ): void {
|
||||
this._reject?.(reason);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.promise = null;
|
||||
this._resolve = null;
|
||||
this._reject = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default StoredPromise;
|
||||
75
src/games/lib/utils/Ticker.ts
Normal file
75
src/games/lib/utils/Ticker.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A ticker class to call a callback on a set time\
|
||||
* Is catch-up tick based so will call update multiple times
|
||||
*/
|
||||
class Ticker {
|
||||
private _tickUpdateCb: (timeDelta: number) => Promise<void> = async () => {};
|
||||
private _tickCompleteCb: (updated: boolean) => Promise<void> = async () => {};
|
||||
private _voidUpdate: ( frameTime: number ) => void;
|
||||
private _tickCounter: number[] = [];
|
||||
private _tickTime = 0;
|
||||
private _lastFrameTime = 0;
|
||||
private _currentTotalDelta = 0;
|
||||
private _ticksPerSecond = 0;
|
||||
|
||||
constructor() {
|
||||
this._voidUpdate = (frameTime: number): void => {
|
||||
void this._update(frameTime);
|
||||
};
|
||||
}
|
||||
|
||||
get currentTicksPerSecond(): number {
|
||||
return this._ticksPerSecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ticker to update every tickTime\
|
||||
* Will use requestAnimationFrame and catch up if needed\
|
||||
* Use the tick callback for logic updates and the complete callback for visual representations of that logic
|
||||
*/
|
||||
ticker(tickTime: number, tickCb: (timeDelta: number) => Promise<void>, completeCb: (updated: boolean) => Promise<void>): void {
|
||||
this._tickTime = tickTime;
|
||||
this._lastFrameTime = performance.now();
|
||||
this._tickUpdateCb = tickCb;
|
||||
this._tickCompleteCb = completeCb;
|
||||
requestAnimationFrame(this._voidUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update loop on raf, call the update multiple times to catch up, then call raf again
|
||||
*/
|
||||
private async _update(currentFrameTime: number): Promise<void> {
|
||||
const delta = currentFrameTime - this._lastFrameTime;
|
||||
this._lastFrameTime = currentFrameTime;
|
||||
this._currentTotalDelta += delta;
|
||||
|
||||
let updated = false;
|
||||
/**
|
||||
* - TODO - This min number probably needs to change, and reset the time differently
|
||||
* This way will reset on the next frame, do we want to allow a min frame for update/render purposes but run all the ticks?
|
||||
* Probably depends on the game.
|
||||
*/
|
||||
let ticks = Math.min(Math.floor(this._currentTotalDelta / this._tickTime), 100);
|
||||
this._currentTotalDelta = this._currentTotalDelta % this._tickTime;
|
||||
while (ticks > 0) {
|
||||
updated = true;
|
||||
ticks -= 1;
|
||||
await this._tickUpdateCb(this._tickTime);
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
this._tickCounter.push(currentFrameTime);
|
||||
const minusOneSecond = currentFrameTime - 1000;
|
||||
while (this._tickCounter[0] < minusOneSecond) {
|
||||
this._tickCounter.shift();
|
||||
}
|
||||
|
||||
this._ticksPerSecond = this._tickCounter.length;
|
||||
}
|
||||
await this._tickCompleteCb(updated);
|
||||
|
||||
requestAnimationFrame(this._voidUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
export default Ticker;
|
||||
7
src/games/lib/utils/Vector.ts
Normal file
7
src/games/lib/utils/Vector.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
class Vector extends PIXI.Point {
|
||||
|
||||
}
|
||||
|
||||
export default Vector;
|
||||
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
Normal file
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module '@pixi-essentials/object-pool' {
|
||||
class ObjectPoolFactory<T> {
|
||||
static build: (ctor: new (args: unknown[]) => T) => ObjectPoolFactory<T>;
|
||||
reserve: (reserve: number) => void;
|
||||
startGC: () => void;
|
||||
allocate: () => T;
|
||||
release: (obj: T) => void;
|
||||
}
|
||||
}
|
||||
23
src/games/lib/visual/Container.ts
Normal file
23
src/games/lib/visual/Container.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Vector from '@/utils/Vector';
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type ContainerSettings = VisualBaseSettings;
|
||||
|
||||
class Container extends PIXI.Container {
|
||||
constructor(settings?: ContainerSettings) {
|
||||
super();
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch this container's parent to a new parent, keeping it's positioning
|
||||
*/
|
||||
addToNewParent(newParent: Container): void {
|
||||
const localPoint = newParent.toLocal(new Vector(0, 0), this);
|
||||
this.position.set(localPoint.x, localPoint.y);
|
||||
newParent.addChild(this);
|
||||
}
|
||||
}
|
||||
|
||||
export default Container;
|
||||
13
src/games/lib/visual/Graphics.ts
Normal file
13
src/games/lib/visual/Graphics.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type GraphicsSettings = VisualBaseSettings;
|
||||
|
||||
class Graphics extends PIXI.Graphics {
|
||||
constructor(settings?: GraphicsSettings ) {
|
||||
super();
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graphics;
|
||||
29
src/games/lib/visual/Text.ts
Normal file
29
src/games/lib/visual/Text.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { assign } from 'radash';
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type TextSettings = VisualBaseSettings & {
|
||||
text?: string | number,
|
||||
style?: string
|
||||
}
|
||||
|
||||
class Text extends PIXI.Text {
|
||||
static styles: { [key: string]: (Partial<PIXI.TextStyle> | PIXI.TextStyle) } = {
|
||||
default: {
|
||||
fontSize: 16,
|
||||
fill: 0x000000,
|
||||
align: 'center',
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
constructor(settings?: TextSettings, style?: Partial<PIXI.TextStyle> | PIXI.TextStyle) {
|
||||
super({
|
||||
text: settings?.text,
|
||||
style: assign(Text.styles[settings?.style ?? 'default'], style ?? {})
|
||||
})
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default Text;
|
||||
93
src/games/lib/visual/VisualBase.ts
Normal file
93
src/games/lib/visual/VisualBase.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PIXI } from './pixi';
|
||||
|
||||
type pointOrNumber = {
|
||||
x: number,
|
||||
y: number
|
||||
} | number | undefined | PIXI.Point;
|
||||
|
||||
type VisualBaseSettings = {
|
||||
angle?: number,
|
||||
pivot?: pointOrNumber
|
||||
position?: {
|
||||
x: number,
|
||||
y: number
|
||||
},
|
||||
rotation?: number,
|
||||
scale?: pointOrNumber
|
||||
skew?: pointOrNumber,
|
||||
width?: number,
|
||||
height?: number,
|
||||
alpha?: number,
|
||||
visible?: boolean,
|
||||
interactive?: boolean,
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class VisualBase {
|
||||
static applySettings<T extends PIXI.Container>(applyTo: T, settings?: VisualBaseSettings): void {
|
||||
if (settings?.angle) {
|
||||
applyTo.angle = settings.angle;
|
||||
}
|
||||
|
||||
if (settings?.position) {
|
||||
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||
}
|
||||
|
||||
if (settings?.rotation) {
|
||||
applyTo.rotation = settings.rotation;
|
||||
}
|
||||
|
||||
if (settings?.interactive) {
|
||||
applyTo.interactive = settings.interactive;
|
||||
}
|
||||
|
||||
if (settings?.zIndex) {
|
||||
applyTo.zIndex = settings.zIndex;
|
||||
}
|
||||
|
||||
if (settings?.alpha) {
|
||||
applyTo.alpha = settings.alpha;
|
||||
}
|
||||
|
||||
if (settings?.visible) {
|
||||
applyTo.visible = settings.visible;
|
||||
}
|
||||
|
||||
if (settings?.position) {
|
||||
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||
}
|
||||
|
||||
applyPointPrimitiveOrObject('pivot', applyTo, settings?.pivot);
|
||||
applyPointPrimitiveOrObject('scale', applyTo, settings?.scale);
|
||||
applyPointPrimitiveOrObject('skew', applyTo, settings?.skew);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param prop - TODO
|
||||
* @param applyTo - TODO
|
||||
* @param setting - TODO
|
||||
*/
|
||||
function applyPointPrimitiveOrObject<T extends PIXI.Container>(prop: 'skew' | 'pivot' | 'scale', applyTo: T, setting: pointOrNumber): void {
|
||||
if (setting) {
|
||||
if (typeof setting === 'object') {
|
||||
applyTo[prop].set(setting.x, setting.y);
|
||||
} else {
|
||||
applyTo[prop].set(setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VisualBase
|
||||
};
|
||||
|
||||
export type {
|
||||
VisualBaseSettings
|
||||
};
|
||||
11
src/games/lib/visual/pixi.ts
Normal file
11
src/games/lib/visual/pixi.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// - TODO - Clean this up
|
||||
import * as PIXI from 'pixi.js';
|
||||
import * as Events from '@pixi/events';
|
||||
import { Viewport } from 'pixi-viewport';
|
||||
|
||||
import 'pixi.js/math-extras';
|
||||
|
||||
// Filters
|
||||
import * as Filters from 'pixi-filters';
|
||||
|
||||
export { PIXI, Events, Viewport, Filters };
|
||||
@@ -27,7 +27,19 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
],
|
||||
"@/control/*": [
|
||||
"./src/games/lib/control/*"
|
||||
],
|
||||
"@/core/*": [
|
||||
"./src/games/lib/core/*"
|
||||
],
|
||||
"@/utils/*": [
|
||||
"./src/games/lib/utils/*"
|
||||
],
|
||||
"@/visual/*": [
|
||||
"./src/games/lib/visual/*"
|
||||
],
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user