initial commit
11
client/.eslintrc.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
// add more generic rule sets here, such as:
|
||||
"eslint:recommended",
|
||||
"plugin:svelte/recommended",
|
||||
],
|
||||
rules: {
|
||||
// override/add rules settings here, such as:
|
||||
// 'svelte/rule-name': 'error'
|
||||
},
|
||||
};
|
24
client/.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?
|
3
client/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
47
client/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Svelte + TS + Vite
|
||||
|
||||
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||
|
||||
## Need an official Svelte framework?
|
||||
|
||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
**Why use this over SvelteKit?**
|
||||
|
||||
- It brings its own routing solution which might not be preferable for some users.
|
||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||
|
||||
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||
|
||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||
|
||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||
|
||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||
|
||||
**Why include `.vscode/extensions.json`?**
|
||||
|
||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||
|
||||
**Why enable `allowJs` in the TS template?**
|
||||
|
||||
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||
|
||||
**Why is HMR not preserving my local component state?**
|
||||
|
||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||
|
||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||
|
||||
```ts
|
||||
// store.ts
|
||||
// An extremely simple external store
|
||||
import { writable } from 'svelte/store'
|
||||
export default writable(0)
|
||||
```
|
19
client/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/kangaroo.svg" />
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>jumpstorm</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<div style="text-align: center">
|
||||
<h1>yeah, unfortunately you need javascript :)</h1>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
70
client/lib/Game.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Entity } from "./entities";
|
||||
import { System } from "./systems";
|
||||
|
||||
export class Game {
|
||||
private entities: Map<number, Entity>;
|
||||
private systems: Map<string, System>;
|
||||
private systemOrder: string[];
|
||||
|
||||
private running: boolean;
|
||||
private lastTimeStamp: number;
|
||||
|
||||
constructor() {
|
||||
this.running = false;
|
||||
this.systemOrder = [];
|
||||
this.systems = new Map();
|
||||
this.entities = new Map();
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.lastTimeStamp = performance.now();
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
public addEntity(entity: Entity) {
|
||||
this.entities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
public getEntity(id: number): Entity {
|
||||
return this.entities.get(id);
|
||||
}
|
||||
|
||||
public removeEntity(id: number) {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
|
||||
public addSystem(system: System) {
|
||||
if (!this.systemOrder.includes(system.name)) {
|
||||
this.systemOrder.push(system.name);
|
||||
}
|
||||
this.systems.set(system.name, system);
|
||||
}
|
||||
|
||||
public getSystem(name: string): System {
|
||||
return this.systems.get(name);
|
||||
}
|
||||
|
||||
public doGameLoop = (timeStamp: number) => {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = timeStamp - this.lastTimeStamp;
|
||||
this.lastTimeStamp = timeStamp;
|
||||
|
||||
const componentEntities = new Map<string, Set<number>>();
|
||||
this.entities.forEach((entity) =>
|
||||
entity.getComponents().forEach((component) => {
|
||||
if (!componentEntities.has(component.name)) {
|
||||
componentEntities.set(component.name, new Set<number>([entity.id]));
|
||||
return;
|
||||
}
|
||||
componentEntities.get(component.name).add(entity.id);
|
||||
})
|
||||
);
|
||||
|
||||
this.systemOrder.forEach((systemName) => {
|
||||
this.systems.get(systemName).update(dt, this.entities, componentEntities);
|
||||
});
|
||||
};
|
||||
}
|
54
client/lib/JumpStorm.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Floor, Player } from "./entities";
|
||||
import { Game } from "./Game";
|
||||
import {
|
||||
WallBounds,
|
||||
FacingDirection,
|
||||
Render,
|
||||
Physics,
|
||||
Input,
|
||||
Collision,
|
||||
} from "./systems";
|
||||
|
||||
export class JumpStorm {
|
||||
private game: Game;
|
||||
|
||||
constructor(ctx: CanvasRenderingContext2D) {
|
||||
this.game = new Game();
|
||||
|
||||
[
|
||||
this.createInputSystem(),
|
||||
new FacingDirection(),
|
||||
new Physics(),
|
||||
new Collision(),
|
||||
new WallBounds(ctx.canvas.width),
|
||||
new Render(ctx),
|
||||
].forEach((system) => this.game.addSystem(system));
|
||||
|
||||
[new Floor(160), new Player()].forEach((entity) =>
|
||||
this.game.addEntity(entity)
|
||||
);
|
||||
}
|
||||
|
||||
public play() {
|
||||
this.game.start();
|
||||
|
||||
const loop = (timestamp: number) => {
|
||||
this.game.doGameLoop(timestamp);
|
||||
requestAnimationFrame(loop); // tail call recursion! /s
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
private createInputSystem(): Input {
|
||||
const inputSystem = new Input();
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (!e.repeat) {
|
||||
inputSystem.keyPressed(e.key);
|
||||
}
|
||||
});
|
||||
window.addEventListener("keyup", (e) => inputSystem.keyReleased(e.key));
|
||||
|
||||
return inputSystem;
|
||||
}
|
||||
}
|
97
client/lib/components/BoundingBox.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import type { Coord2D, Dimension2D } from "../interfaces";
|
||||
import { dotProduct, rotateVector, normalizeVector } from "../utils";
|
||||
|
||||
export class BoundingBox extends Component {
|
||||
public center: Coord2D;
|
||||
public dimension: Dimension2D;
|
||||
public rotation: number;
|
||||
|
||||
constructor(center: Coord2D, dimension: Dimension2D, rotation?: number) {
|
||||
super(ComponentNames.BoundingBox);
|
||||
|
||||
this.center = center;
|
||||
this.dimension = dimension;
|
||||
this.rotation = rotation ?? 0;
|
||||
}
|
||||
|
||||
public isCollidingWith(box: BoundingBox): boolean {
|
||||
const boxes = [this.getVertices(), box.getVertices()];
|
||||
for (const poly of boxes) {
|
||||
for (let i = 0; i < poly.length; ++i) {
|
||||
const [A, B] = [poly[i], poly[(i + 1) % poly.length]];
|
||||
const normal: Coord2D = { x: B.y - A.y, y: A.x - B.x };
|
||||
|
||||
const [[minThis, maxThis], [minBox, maxBox]] = boxes.map((box) =>
|
||||
box.reduce(
|
||||
([min, max], vertex) => {
|
||||
const projection = dotProduct(normal, vertex);
|
||||
return [Math.min(min, projection), Math.max(max, projection)];
|
||||
},
|
||||
[Infinity, -Infinity]
|
||||
)
|
||||
);
|
||||
|
||||
if (maxThis < minBox || maxBox < minThis) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public getVertices(): Coord2D[] {
|
||||
return [
|
||||
{ x: -this.dimension.width / 2, y: -this.dimension.height / 2 },
|
||||
{ x: -this.dimension.width / 2, y: this.dimension.height / 2 },
|
||||
{ x: this.dimension.width / 2, y: this.dimension.height / 2 },
|
||||
{ x: this.dimension.width / 2, y: -this.dimension.height / 2 },
|
||||
]
|
||||
.map((vertex) => rotateVector(vertex, this.rotation))
|
||||
.map((vertex) => {
|
||||
return {
|
||||
x: vertex.x + this.center.x,
|
||||
y: vertex.y + this.center.y,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getAxes() {
|
||||
const corners: Coord2D[] = this.getVerticesRelativeToCenter();
|
||||
const axes: Coord2D[] = [];
|
||||
|
||||
for (let i = 0; i < corners.length; ++i) {
|
||||
const [cornerA, cornerB] = [
|
||||
corners[i],
|
||||
corners[(i + 1) % corners.length],
|
||||
].map((corner) => rotateVector(corner, this.rotation));
|
||||
|
||||
axes.push(
|
||||
normalizeVector({
|
||||
x: cornerB.y - cornerA.y,
|
||||
y: -(cornerB.x - cornerA.x),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return axes;
|
||||
}
|
||||
|
||||
private project(axis: Coord2D): [number, number] {
|
||||
const corners = this.getCornersRelativeToCenter();
|
||||
let [min, max] = [Infinity, -Infinity];
|
||||
|
||||
for (const corner of corners) {
|
||||
const rotated = rotateVector(corner, this.rotation);
|
||||
const translated = {
|
||||
x: rotated.x + this.center.x,
|
||||
y: rotated.y + this.center.y,
|
||||
};
|
||||
const projection = dotProduct(translated, axis);
|
||||
|
||||
min = Math.min(projection, min);
|
||||
max = Math.max(projection, max);
|
||||
}
|
||||
|
||||
return [min, max];
|
||||
}
|
||||
}
|
7
client/lib/components/Collide.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class Collide extends Component {
|
||||
constructor() {
|
||||
super(ComponentNames.Collide);
|
||||
}
|
||||
}
|
7
client/lib/components/Component.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export abstract class Component {
|
||||
public readonly name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
7
client/lib/components/Control.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class Control extends Component {
|
||||
constructor() {
|
||||
super(ComponentNames.Control);
|
||||
}
|
||||
}
|
13
client/lib/components/FacingDirection.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Component, ComponentNames, Sprite } from ".";
|
||||
|
||||
export class FacingDirection extends Component {
|
||||
public readonly facingLeftSprite: Sprite;
|
||||
public readonly facingRightSprite: Sprite;
|
||||
|
||||
constructor(facingLeftSprite: Sprite, facingRightSprite: Sprite) {
|
||||
super(ComponentNames.FacingDirection);
|
||||
|
||||
this.facingLeftSprite = facingLeftSprite;
|
||||
this.facingRightSprite = facingRightSprite;
|
||||
}
|
||||
}
|
17
client/lib/components/Forces.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Accel2D, Force2D } from "../interfaces";
|
||||
import { Component } from "./Component";
|
||||
import { ComponentNames } from ".";
|
||||
|
||||
/**
|
||||
* A list of forces and torque, (in newtons, and newton-meters respectively)
|
||||
* to apply on one Physics system update (after which, they are cleared).
|
||||
*/
|
||||
export class Forces extends Component {
|
||||
public forces: Force2D[];
|
||||
|
||||
constructor(forces?: Force2D[]) {
|
||||
super(ComponentNames.Forces);
|
||||
|
||||
this.forces = forces ?? [];
|
||||
}
|
||||
}
|
13
client/lib/components/Gravity.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ComponentNames, Component } from ".";
|
||||
|
||||
export class Gravity extends Component {
|
||||
private static DEFAULT_TERMINAL_VELOCITY = 5;
|
||||
|
||||
public terminalVelocity: number;
|
||||
|
||||
constructor(terminalVelocity?: number) {
|
||||
super(ComponentNames.Gravity);
|
||||
this.terminalVelocity =
|
||||
terminalVelocity ?? Gravity.DEFAULT_TERMINAL_VELOCITY;
|
||||
}
|
||||
}
|
10
client/lib/components/Jump.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class Jump extends Component {
|
||||
public canJump: boolean;
|
||||
|
||||
constructor() {
|
||||
super(ComponentNames.Jump);
|
||||
this.canJump = false;
|
||||
}
|
||||
}
|
10
client/lib/components/Mass.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class Mass extends Component {
|
||||
public mass: number;
|
||||
|
||||
constructor(mass: number) {
|
||||
super(ComponentNames.Mass);
|
||||
this.mass = mass;
|
||||
}
|
||||
}
|
10
client/lib/components/Moment.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class Moment extends Component {
|
||||
public inertia: number;
|
||||
|
||||
constructor(inertia: number) {
|
||||
super(ComponentNames.Moment);
|
||||
this.inertia = inertia;
|
||||
}
|
||||
}
|
92
client/lib/components/Sprite.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
import type { Dimension2D, DrawArgs, Coord2D } from "../interfaces";
|
||||
|
||||
export class Sprite extends Component {
|
||||
private sheet: HTMLImageElement;
|
||||
|
||||
private spriteImgPos: Coord2D;
|
||||
private spriteImgDimensions: Dimension2D;
|
||||
|
||||
private msPerFrame: number;
|
||||
private msSinceLastFrame: number;
|
||||
private currentFrame: number;
|
||||
private numFrames: number;
|
||||
|
||||
constructor(
|
||||
sheet: HTMLImageElement,
|
||||
spriteImgPos: Coord2D,
|
||||
spriteImgDimensions: Dimension2D,
|
||||
msPerFrame: number,
|
||||
numFrames: number
|
||||
) {
|
||||
super(ComponentNames.Sprite);
|
||||
|
||||
this.sheet = sheet;
|
||||
this.spriteImgPos = spriteImgPos;
|
||||
this.spriteImgDimensions = spriteImgDimensions;
|
||||
this.msPerFrame = msPerFrame;
|
||||
this.numFrames = numFrames;
|
||||
|
||||
this.msSinceLastFrame = 0;
|
||||
this.currentFrame = 0;
|
||||
}
|
||||
|
||||
public update(dt: number) {
|
||||
this.msSinceLastFrame += dt;
|
||||
if (this.msSinceLastFrame >= this.msPerFrame) {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.numFrames;
|
||||
this.msSinceLastFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public draw(ctx: CanvasRenderingContext2D, drawArgs: DrawArgs) {
|
||||
const { center, rotation, tint, opacity } = drawArgs;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(center.x, center.y);
|
||||
if (rotation != 0) {
|
||||
ctx.rotate(rotation * (Math.PI / 180));
|
||||
}
|
||||
ctx.translate(-center.x, -center.y);
|
||||
|
||||
if (opacity) {
|
||||
ctx.globalAlpha = opacity;
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
this.sheet,
|
||||
...this.getSpriteArgs(),
|
||||
...this.getDrawArgs(drawArgs)
|
||||
);
|
||||
|
||||
if (tint) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.globalCompositeOperation = "source-atop";
|
||||
ctx.fillStyle = tint;
|
||||
ctx.fillRect(...this.getDrawArgs(drawArgs));
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private getSpriteArgs(): [sx: number, sy: number, sw: number, sh: number] {
|
||||
return [
|
||||
this.spriteImgPos.x + this.currentFrame * this.spriteImgDimensions.width,
|
||||
this.spriteImgPos.y,
|
||||
this.spriteImgDimensions.width,
|
||||
this.spriteImgDimensions.height,
|
||||
];
|
||||
}
|
||||
|
||||
private getDrawArgs({
|
||||
center,
|
||||
dimension,
|
||||
}: DrawArgs): [dx: number, dy: number, dw: number, dh: number] {
|
||||
return [
|
||||
center.x - dimension.width / 2,
|
||||
center.y - dimension.height / 2,
|
||||
dimension.width,
|
||||
dimension.height,
|
||||
];
|
||||
}
|
||||
}
|
7
client/lib/components/TopCollidable.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class TopCollidable extends Component {
|
||||
constructor() {
|
||||
super(ComponentNames.TopCollidable);
|
||||
}
|
||||
}
|
15
client/lib/components/Velocity.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Velocity2D } from "../interfaces";
|
||||
import { Component } from "./Component";
|
||||
import { ComponentNames } from ".";
|
||||
|
||||
export class Velocity extends Component {
|
||||
public dCartesian: Velocity2D;
|
||||
public dTheta: number;
|
||||
|
||||
constructor(dCartesian: Velocity2D, dTheta: number) {
|
||||
super(ComponentNames.Velocity);
|
||||
|
||||
this.dCartesian = dCartesian;
|
||||
this.dTheta = dTheta;
|
||||
}
|
||||
}
|
7
client/lib/components/WallBounded.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Component, ComponentNames } from ".";
|
||||
|
||||
export class WallBounded extends Component {
|
||||
constructor() {
|
||||
super(ComponentNames.WallBounded);
|
||||
}
|
||||
}
|
15
client/lib/components/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export * from "./Component";
|
||||
export * from "./BoundingBox";
|
||||
export * from "./Velocity";
|
||||
export * from "./Forces";
|
||||
export * from "./Sprite";
|
||||
export * from "./FacingDirection";
|
||||
export * from "./Jump";
|
||||
export * from "./TopCollidable";
|
||||
export * from "./Collide";
|
||||
export * from "./Control";
|
||||
export * from "./WallBounded";
|
||||
export * from "./Gravity";
|
||||
export * from "./Mass";
|
||||
export * from "./Moment";
|
||||
export * from "./names";
|
15
client/lib/components/names.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export namespace ComponentNames {
|
||||
export const Sprite = "Sprite";
|
||||
export const BoundingBox = "BoundingBox";
|
||||
export const Velocity = "Velocity";
|
||||
export const FacingDirection = "FacingDirection";
|
||||
export const Control = "Control";
|
||||
export const Jump = "Jump";
|
||||
export const TopCollidable = "TopCollidable";
|
||||
export const Collide = "Collide";
|
||||
export const WallBounded = "WallBounded";
|
||||
export const Gravity = "Gravity";
|
||||
export const Forces = "Forces";
|
||||
export const Mass = "Mass";
|
||||
export const Moment = "Moment";
|
||||
}
|
40
client/lib/config/assets.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { SpriteSpec } from "./sprites";
|
||||
import { SPRITE_SPECS } from "./sprites";
|
||||
|
||||
export const IMAGES = new Map<string, HTMLImageElement>();
|
||||
|
||||
export const loadSpritesIntoImageElements = (
|
||||
spriteSpecs: Partial<SpriteSpec>[]
|
||||
): Promise<void>[] => {
|
||||
const spritePromises: Promise<void>[] = [];
|
||||
|
||||
for (const spriteSpec of spriteSpecs) {
|
||||
if (spriteSpec.sheet) {
|
||||
const img = new Image();
|
||||
img.src = spriteSpec.sheet;
|
||||
IMAGES.set(spriteSpec.sheet, img);
|
||||
|
||||
spritePromises.push(
|
||||
new Promise((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (spriteSpec.states) {
|
||||
spritePromises.push(
|
||||
...loadSpritesIntoImageElements(Object.values(spriteSpec.states))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return spritePromises;
|
||||
};
|
||||
|
||||
export const loadAssets = () =>
|
||||
Promise.all([
|
||||
...loadSpritesIntoImageElements(
|
||||
Array.from(SPRITE_SPECS.keys()).map((key) => SPRITE_SPECS.get(key))
|
||||
),
|
||||
// TODO: Sound
|
||||
]);
|
34
client/lib/config/constants.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Action } from "../interfaces";
|
||||
|
||||
export namespace KeyConstants {
|
||||
export const KeyActions: Record<string, Action> = {
|
||||
a: Action.MOVE_LEFT,
|
||||
ArrowLeft: Action.MOVE_LEFT,
|
||||
d: Action.MOVE_RIGHT,
|
||||
ArrowRight: Action.MOVE_RIGHT,
|
||||
w: Action.JUMP,
|
||||
ArrowUp: Action.JUMP,
|
||||
};
|
||||
|
||||
export const ActionKeys: Map<Action, string[]> = Object.keys(
|
||||
KeyActions
|
||||
).reduce((acc: Map<Action, string[]>, key) => {
|
||||
const action = KeyActions[key];
|
||||
|
||||
if (acc.has(action)) {
|
||||
acc.get(action).push(key);
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.set(action, [key]);
|
||||
return acc;
|
||||
}, new Map<Action, string[]>());
|
||||
}
|
||||
|
||||
export namespace PhysicsConstants {
|
||||
export const MAX_JUMP_TIME_MS = 150;
|
||||
export const GRAVITY = 0.0075;
|
||||
export const PLAYER_MOVE_VEL = 1;
|
||||
export const PLAYER_JUMP_ACC = -0.01;
|
||||
export const PLAYER_JUMP_INITIAL_VEL = -0.9;
|
||||
}
|
3
client/lib/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./constants";
|
||||
export * from "./assets.ts";
|
||||
export * from "./sprites.ts";
|
49
client/lib/config/sprites.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export enum Sprites {
|
||||
FLOOR,
|
||||
TRAMPOLINE,
|
||||
COFFEE,
|
||||
}
|
||||
|
||||
export interface SpriteSpec {
|
||||
sheet: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frames: number;
|
||||
msPerFrame: number;
|
||||
states?: Record<string | number, Partial<SpriteSpec>>;
|
||||
}
|
||||
|
||||
export const SPRITE_SPECS: Map<Sprites, Partial<SpriteSpec>> = new Map<
|
||||
Sprites,
|
||||
SpriteSpec
|
||||
>();
|
||||
|
||||
const floorSpriteSpec = {
|
||||
height: 40,
|
||||
frames: 3,
|
||||
msPerFrame: 125,
|
||||
states: {},
|
||||
};
|
||||
floorSpriteSpec.states = [40, 80, 120, 160].reduce((acc, cur) => {
|
||||
acc[cur] = {
|
||||
width: cur,
|
||||
sheet: `/assets/floor_tile_${cur}.png`,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
SPRITE_SPECS.set(Sprites.FLOOR, floorSpriteSpec);
|
||||
|
||||
SPRITE_SPECS.set(Sprites.COFFEE, {
|
||||
msPerFrame: 100,
|
||||
width: 60,
|
||||
height: 45,
|
||||
frames: 3,
|
||||
states: {
|
||||
LEFT: {
|
||||
sheet: "/assets/coffee_left.png",
|
||||
},
|
||||
RIGHT: {
|
||||
sheet: "/assets/coffee_right.png",
|
||||
},
|
||||
},
|
||||
});
|
33
client/lib/entities/Entity.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { Component } from "../components";
|
||||
import { ComponentNotFoundError } from "../exceptions";
|
||||
|
||||
export abstract class Entity {
|
||||
private static ID = 0;
|
||||
|
||||
public readonly id: number;
|
||||
public readonly components: Map<string, Component>;
|
||||
|
||||
constructor() {
|
||||
this.id = Entity.ID++;
|
||||
this.components = new Map();
|
||||
}
|
||||
|
||||
public addComponent(component: Component) {
|
||||
this.components.set(component.name, component);
|
||||
}
|
||||
|
||||
public getComponent<T extends Component>(name: string): T {
|
||||
if (!this.hasComponent(name)) {
|
||||
throw new Error("Entity does not have component " + name);
|
||||
}
|
||||
return this.components.get(name) as T;
|
||||
}
|
||||
|
||||
public getComponents(): Component[] {
|
||||
return Array.from(this.components.values());
|
||||
}
|
||||
|
||||
public hasComponent(name: string): boolean {
|
||||
return this.components.has(name);
|
||||
}
|
||||
}
|
31
client/lib/entities/Floor.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config";
|
||||
import { BoundingBox, Sprite } from "../components";
|
||||
import { TopCollidable } from "../components/TopCollidable";
|
||||
import { Entity } from "../entities";
|
||||
|
||||
export class Floor extends Entity {
|
||||
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(Sprites.FLOOR);
|
||||
|
||||
constructor(width: number) {
|
||||
super();
|
||||
|
||||
this.addComponent(
|
||||
new Sprite(
|
||||
IMAGES.get(Floor.spriteSpec.states[width].sheet),
|
||||
{ x: 0, y: 0 },
|
||||
{ width, height: Floor.spriteSpec.height },
|
||||
Floor.spriteSpec.msPerFrame,
|
||||
Floor.spriteSpec.frames
|
||||
)
|
||||
);
|
||||
|
||||
this.addComponent(
|
||||
new BoundingBox(
|
||||
{ x: 300, y: 300 },
|
||||
{ width, height: Floor.spriteSpec.height }
|
||||
)
|
||||
);
|
||||
|
||||
this.addComponent(new TopCollidable());
|
||||
}
|
||||
}
|
68
client/lib/entities/Player.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { Entity } from ".";
|
||||
import { IMAGES, SPRITE_SPECS, Sprites, type SpriteSpec } from "../config";
|
||||
import {
|
||||
Jump,
|
||||
FacingDirection,
|
||||
BoundingBox,
|
||||
Sprite,
|
||||
Velocity,
|
||||
Gravity,
|
||||
WallBounded,
|
||||
Forces,
|
||||
Collide,
|
||||
Control,
|
||||
Mass,
|
||||
Moment,
|
||||
} from "../components";
|
||||
import { PhysicsConstants } from "../config";
|
||||
import { Direction } from "../interfaces";
|
||||
|
||||
export class Player extends Entity {
|
||||
private static MASS: number = 10;
|
||||
private static MOI: number = 1000;
|
||||
|
||||
private static spriteSpec: SpriteSpec = SPRITE_SPECS.get(Sprites.COFFEE);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.addComponent(
|
||||
new BoundingBox(
|
||||
{ x: 300, y: 100 },
|
||||
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height },
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
this.addComponent(new Velocity({ dx: 0, dy: 0 }, 0));
|
||||
|
||||
this.addComponent(new Mass(Player.MASS));
|
||||
this.addComponent(new Moment(Player.MOI));
|
||||
this.addComponent(new Forces());
|
||||
this.addComponent(new Gravity());
|
||||
|
||||
this.addComponent(new Jump());
|
||||
this.addComponent(new Control());
|
||||
|
||||
this.addComponent(new Collide());
|
||||
this.addComponent(new WallBounded());
|
||||
|
||||
this.addFacingDirectionComponents();
|
||||
}
|
||||
|
||||
private addFacingDirectionComponents() {
|
||||
const [leftSprite, rightSprite] = [Direction.LEFT, Direction.RIGHT].map(
|
||||
(direction) =>
|
||||
new Sprite(
|
||||
IMAGES.get(Player.spriteSpec.states[direction].sheet),
|
||||
{ x: 0, y: 0 },
|
||||
{ width: Player.spriteSpec.width, height: Player.spriteSpec.height },
|
||||
Player.spriteSpec.msPerFrame,
|
||||
Player.spriteSpec.frames
|
||||
)
|
||||
);
|
||||
|
||||
this.addComponent(new FacingDirection(leftSprite, rightSprite));
|
||||
this.addComponent(leftSprite); // face Left by default
|
||||
}
|
||||
}
|
3
client/lib/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./Entity";
|
||||
export * from "./Floor";
|
||||
export * from "./Player";
|
5
client/lib/interfaces/Action.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum Action {
|
||||
MOVE_LEFT,
|
||||
MOVE_RIGHT,
|
||||
JUMP,
|
||||
}
|
6
client/lib/interfaces/Direction.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum Direction {
|
||||
UP = "UP",
|
||||
DOWN = "DOWN",
|
||||
LEFT = "LEFT",
|
||||
RIGHT = "RIGHT",
|
||||
}
|
9
client/lib/interfaces/Draw.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Coord2D, Dimension2D } from "./";
|
||||
|
||||
export interface DrawArgs {
|
||||
center: Coord2D;
|
||||
dimension: Dimension2D;
|
||||
tint?: string;
|
||||
opacity?: number;
|
||||
rotation?: number;
|
||||
}
|
5
client/lib/interfaces/LeaderBoardEntry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface LeaderBoardEntry {
|
||||
name: string;
|
||||
score: number;
|
||||
avatar: string;
|
||||
}
|
22
client/lib/interfaces/Vec2.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export interface Coord2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Dimension2D {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Velocity2D {
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
export interface Force2D {
|
||||
fCartesian: {
|
||||
fx: number;
|
||||
fy: number;
|
||||
};
|
||||
torque: number;
|
||||
}
|
5
client/lib/interfaces/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./LeaderBoardEntry";
|
||||
export * from "./Vec2";
|
||||
export * from "./Draw";
|
||||
export * from "./Direction";
|
||||
export * from "./Action";
|
154
client/lib/structures/QuadTree.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import type { Coord2D, Dimension2D } from "../interfaces";
|
||||
import { ComponentNames, BoundingBox } from "../components";
|
||||
import { Entity } from "../entities";
|
||||
|
||||
interface BoxedEntry {
|
||||
id: number;
|
||||
dimension: Dimension2D;
|
||||
center: Coord2D;
|
||||
}
|
||||
|
||||
enum Quadrant {
|
||||
I,
|
||||
II,
|
||||
III,
|
||||
IV,
|
||||
}
|
||||
|
||||
export class QuadTree {
|
||||
private maxLevels: number;
|
||||
private splitThreshold: number;
|
||||
private level: number;
|
||||
private topLeft: Coord2D;
|
||||
private dimension: Dimension2D;
|
||||
|
||||
private children: Map<Quadrant, QuadTree>;
|
||||
private objects: BoxedEntry[];
|
||||
|
||||
constructor(
|
||||
topLeft: Coord2D,
|
||||
dimension: Dimension2D,
|
||||
maxLevels: number,
|
||||
splitThreshold: number,
|
||||
level?: number
|
||||
) {
|
||||
this.children = [];
|
||||
this.objects = [];
|
||||
|
||||
this.maxLevels = maxLevels;
|
||||
this.splitThreshold = splitThreshold;
|
||||
this.level = level ?? 0;
|
||||
}
|
||||
|
||||
public insert(id: number, dimension: Dimension2D, center: Coord2D): void {
|
||||
if (this.hasChildren()) {
|
||||
this.getIndices(boundingBox).forEach((i) =>
|
||||
this.children[i].insert(id, dimension, center)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.objects.push({ id, dimension, center });
|
||||
|
||||
if (
|
||||
this.objects.length > this.splitThreshold &&
|
||||
this.level < this.maxLevels
|
||||
) {
|
||||
if (!this.hasChildren()) {
|
||||
this.performSplit();
|
||||
}
|
||||
this.realignObjects();
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.objects = [];
|
||||
if (this.hasChildren()) {
|
||||
this.children.forEach((child) => child.clear());
|
||||
this.children.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public getNeighborIds(boxedEntry: BoxedEntry): number[] {
|
||||
const neighbors: number[] = this.objects.map(({ id }) => id);
|
||||
|
||||
if (this.hasChildren()) {
|
||||
this.getQuadrants(boxedEntry).forEach((quadrant) => {
|
||||
this.children
|
||||
.get(quadrant)
|
||||
.getNeighborIds(boxedEntry)
|
||||
.forEach((id) => neighbors.push(id));
|
||||
});
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
private performSplit(): void {
|
||||
const halfWidth = this.dimension.width / 2;
|
||||
const halfHeight = this.dimension.height / 2;
|
||||
|
||||
[
|
||||
[Quadrant.I, { x: this.topLeft.x + halfWidth, y: this.topLeft.y }],
|
||||
[Quadrant.II, { ...this.topLeft }],
|
||||
[Quadrant.III, { x: this.topLeft.x, y: this.topLeft.y + halfHeight }],
|
||||
[
|
||||
Quadrant.IV,
|
||||
{ x: this.topLeft.x + halfWidth, y: this.topLeft.y + halfHeight },
|
||||
],
|
||||
].forEach(([quadrant, pos]) => {
|
||||
this.children.set(
|
||||
quadrant,
|
||||
new QuadTree(
|
||||
pos,
|
||||
{ width: halfWidth, height: halfHeight },
|
||||
this.maxLevels,
|
||||
this.splitThreshold,
|
||||
this.level + 1
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getQuandrants(boxedEntry: BoxedEntry): Quadrant[] {
|
||||
const treeCenter: Coord2D = {
|
||||
x: this.topLeft.x + this.dimension.width / 2,
|
||||
y: this.topLeft.y + this.dimension.height / 2,
|
||||
};
|
||||
|
||||
return [
|
||||
[Quadrant.I, (x, y) => x >= treeCenter.x && y < treeCenter.y],
|
||||
[Quadrant.II, (x, y) => x < treeCenter.x && y < treeCenter.y],
|
||||
[Quadrant.III, (x, y) => x < treeCenter.x && y >= treeCenter.y],
|
||||
[Quadrant.IV, (x, y) => x >= treeCenter.x && y >= treeCenter.y],
|
||||
]
|
||||
.filter(
|
||||
([_quadrant, condition]) =>
|
||||
condition(
|
||||
boxedEntry.center.x + boxedEntry.dimension.width / 2,
|
||||
boxedEntry.center.y + boxedEntry.dimension.height / 2
|
||||
) ||
|
||||
condition(
|
||||
boxedEntry.center.x - boxedEntry.dimension.width / 2,
|
||||
boxedEntry.center.y - boxedEntry.dimension.height / 2
|
||||
)
|
||||
)
|
||||
.map(([quadrant]) => quadrant);
|
||||
}
|
||||
|
||||
private realignObjects(): void {
|
||||
this.objects.forEach((boxedEntry) => {
|
||||
this.getQuadrants(boxedEntry).forEach((direction) => {
|
||||
this.children
|
||||
.get(direction)
|
||||
.insert(boxedEntry.id, boxedEntry.dimension, boxedEntry.center);
|
||||
});
|
||||
});
|
||||
|
||||
this.objects = [];
|
||||
}
|
||||
|
||||
private hasChildren() {
|
||||
return this.children && this.children.length > 0;
|
||||
}
|
||||
}
|
1
client/lib/structures/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./QuadTree";
|
214
client/lib/systems/Collision.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { SystemNames, System } from ".";
|
||||
import {
|
||||
Mass,
|
||||
BoundingBox,
|
||||
ComponentNames,
|
||||
Jump,
|
||||
Velocity,
|
||||
Moment,
|
||||
} from "../components";
|
||||
import { PhysicsConstants } from "../config";
|
||||
import { Entity } from "../entities";
|
||||
import type { Dimension2D } from "../interfaces";
|
||||
import { QuadTree } from "../structures";
|
||||
|
||||
export class Collision extends System {
|
||||
private static readonly COLLIDABLE_COMPONENTS = [
|
||||
ComponentNames.Collide,
|
||||
ComponentNames.TopCollidable,
|
||||
];
|
||||
private static readonly QUADTREE_MAX_LEVELS = 10;
|
||||
private static readonly QUADTREE_SPLIT_THRESHOLD = 10;
|
||||
|
||||
private quadTree: QuadTree;
|
||||
|
||||
constructor(screenDimensions: Dimension2D) {
|
||||
super(SystemNames.Collision);
|
||||
|
||||
this.quadTree = new QuadTree(
|
||||
{ x: 0, y: 0 },
|
||||
screenDimensions,
|
||||
Collision.QUADTREE_MAX_LEVELS,
|
||||
Collision.QUADTREE_SPLIT_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
public update(
|
||||
dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
entityComponents: Map<string, Set<number>>
|
||||
) {
|
||||
this.quadTree.clear();
|
||||
|
||||
const entitiesToAddToQuadtree: Entity[] = [];
|
||||
Collision.COLLIDABLE_COMPONENTS.map((componentName) =>
|
||||
entityComponents.get(componentName)
|
||||
).forEach((entityIds: Set<number>) =>
|
||||
entityIds.forEach((id) => {
|
||||
const entity = entityMap.get(id);
|
||||
if (!entity.hasComponent(ComponentNames.BoundingBox)) {
|
||||
return;
|
||||
}
|
||||
entitiesToAddToQuadtree.push(entity);
|
||||
})
|
||||
);
|
||||
|
||||
entitiesToAddToQuadtree.forEach((entity) => {
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
ComponentNames.BoundingBox
|
||||
);
|
||||
|
||||
this.quadTree.insert(
|
||||
entity.id,
|
||||
boundingBox.dimension,
|
||||
boundingBox.center
|
||||
);
|
||||
});
|
||||
|
||||
const collidingEntities = this.getCollidingEntities(
|
||||
entitiesToAddToQuadtree,
|
||||
entityMap
|
||||
);
|
||||
collidingEntities.forEach(([entityAId, entityBId]) => {
|
||||
const [entityA, entityB] = [entityAId, entityBId].map((id) =>
|
||||
entityMap.get(id)
|
||||
);
|
||||
this.performCollision(entityA, entityB);
|
||||
});
|
||||
}
|
||||
|
||||
private performCollision(entityA: Entity, entityB: Entity) {
|
||||
const [entityABoundingBox, entityBBoundingBox] = [entityA, entityB].map(
|
||||
(entity) => entity.getComponent<BoundingBox>(ComponentNames.BoundingBox)
|
||||
);
|
||||
|
||||
let velocity: Velocity;
|
||||
if (entityA.hasComponent(ComponentNames.Velocity)) {
|
||||
velocity = entityA.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
}
|
||||
|
||||
if (
|
||||
entityA.hasComponent(ComponentNames.Collide) &&
|
||||
entityB.hasComponent(ComponentNames.TopCollidable) &&
|
||||
entityABoundingBox.center.y <= entityBBoundingBox.center.y &&
|
||||
velocity &&
|
||||
velocity.dCartesian.dy >= 0 // don't apply floor logic when coming through the bottom
|
||||
) {
|
||||
if (entityBBoundingBox.rotation != 0) {
|
||||
throw new Error(
|
||||
`entity with id ${entityB.id} has TopCollidable component and a non-zero rotation. that is not (yet) supported.`
|
||||
);
|
||||
}
|
||||
|
||||
// remove previous velocity in the y axis
|
||||
velocity.dCartesian.dy = 0;
|
||||
|
||||
// apply normal force
|
||||
if (entityA.hasComponent(ComponentNames.Gravity)) {
|
||||
const mass = entityA.getComponent<Mass>(ComponentNames.Mass).mass;
|
||||
const F_n = -mass * PhysicsConstants.GRAVITY;
|
||||
|
||||
entityA.getComponent<Forces>(ComponentNames.Forces).forces.push({
|
||||
fCartesian: { fy: F_n },
|
||||
});
|
||||
}
|
||||
|
||||
// reset the entities' jump
|
||||
if (entityA.hasComponent(ComponentNames.Jump)) {
|
||||
entityA.getComponent<Jump>(ComponentNames.Jump).canJump = true;
|
||||
}
|
||||
|
||||
entityABoundingBox.center.y =
|
||||
entityBBoundingBox.center.y -
|
||||
entityBBoundingBox.dimension.height / 2 -
|
||||
this.getDyToPushOutOfFloor(entityABoundingBox, entityBBoundingBox);
|
||||
}
|
||||
}
|
||||
|
||||
private getCollidingEntities(
|
||||
collidableEntities: Entity[],
|
||||
entityMap: Map<number, Entity>
|
||||
): [number, number][] {
|
||||
const collidingEntityIds: [number, number] = [];
|
||||
|
||||
for (const entity of collidableEntities) {
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
ComponentNames.BoundingBox
|
||||
);
|
||||
|
||||
this.quadTree
|
||||
.getNeighborIds({
|
||||
id: entity.id,
|
||||
dimension: boundingBox.dimension,
|
||||
center: boundingBox.center,
|
||||
})
|
||||
.filter((neighborId) => neighborId != entity.id)
|
||||
.forEach((neighborId) => {
|
||||
const neighborBoundingBox = entityMap
|
||||
.get(neighborId)
|
||||
.getComponent<BoundingBox>(ComponentNames.BoundingBox);
|
||||
|
||||
if (boundingBox.isCollidingWith(neighborBoundingBox)) {
|
||||
collidingEntityIds.push([entity.id, neighborId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return collidingEntityIds;
|
||||
}
|
||||
|
||||
private getDyToPushOutOfFloor(
|
||||
entityBoundingBox: BoundingBox,
|
||||
floorBoundingBox: BoundingBox
|
||||
): number {
|
||||
// ramblings: https://excalidraw.com/#json=z-xD86Za4a3duZuV2Oky0,KaGe-5iHJu1Si8inEo4GLQ
|
||||
const {
|
||||
rotation,
|
||||
center: { x, y },
|
||||
dimension: { width, height },
|
||||
} = entityBoundingBox;
|
||||
|
||||
let rads = rotation * (Math.PI / 180);
|
||||
if (rads >= Math.PI) {
|
||||
rads -= Math.PI; // we have symmetry so we can skip two cases
|
||||
}
|
||||
|
||||
let boundedCollisionX = 0; // bounded x on the surface from width
|
||||
let clippedX = 0; // x coordinate of the vertex below the surface
|
||||
let outScribedRectangleHeight, dy, dx;
|
||||
|
||||
if (rads <= Math.PI / 2) {
|
||||
dx = (width * Math.cos(rads) - height * Math.sin(rads)) / 2;
|
||||
outScribedRectangleHeight =
|
||||
width * Math.sin(rads) + height * Math.cos(rads);
|
||||
} else if (rads <= Math.PI) {
|
||||
rads -= Math.PI / 2;
|
||||
dx = (height * Math.cos(rads) - width * Math.sin(rads)) / 2;
|
||||
outScribedRectangleHeight =
|
||||
width * Math.cos(rads) + height * Math.sin(rads);
|
||||
}
|
||||
|
||||
if (x >= floorBoundingBox.center.x) {
|
||||
clippedX = x + dx;
|
||||
boundedCollisionX = Math.min(
|
||||
floorBoundingBox.center.x + floorBoundingBox.dimension.width / 2,
|
||||
clippedX
|
||||
);
|
||||
return (
|
||||
outScribedRectangleHeight / 2 -
|
||||
Math.max((clippedX - boundedCollisionX) * Math.tan(rads), 0)
|
||||
);
|
||||
}
|
||||
|
||||
clippedX = x - dx;
|
||||
boundedCollisionX = Math.max(
|
||||
floorBoundingBox.center.x - floorBoundingBox.dimension.width / 2,
|
||||
clippedX
|
||||
);
|
||||
|
||||
return (
|
||||
outScribedRectangleHeight / 2 -
|
||||
Math.max((boundedCollisionX - clippedX) * Math.tan(rads), 0)
|
||||
);
|
||||
}
|
||||
}
|
39
client/lib/systems/FacingDirection.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {
|
||||
ComponentNames,
|
||||
Velocity,
|
||||
FacingDirection as FacingDirectionComponent,
|
||||
} from "../components";
|
||||
import type { Entity } from "../entities";
|
||||
import { System, SystemNames } from "./";
|
||||
|
||||
export class FacingDirection extends System {
|
||||
constructor() {
|
||||
super(SystemNames.FacingDirection);
|
||||
}
|
||||
|
||||
public update(
|
||||
_dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
) {
|
||||
componentEntities
|
||||
.get(ComponentNames.FacingDirection)
|
||||
?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
if (!entity.hasComponent(ComponentNames.Velocity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
const facingDirection = entity.getComponent<FacingDirectionComponent>(
|
||||
ComponentNames.FacingDirection
|
||||
);
|
||||
|
||||
if (velocity.dCartesian.dx > 0) {
|
||||
entity.addComponent(facingDirection.facingRightSprite);
|
||||
} else if (velocity.dCartesian.dx < 0) {
|
||||
entity.addComponent(facingDirection.facingLeftSprite);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
86
client/lib/systems/Input.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Jump,
|
||||
Forces,
|
||||
Acceleration,
|
||||
ComponentNames,
|
||||
Velocity,
|
||||
Mass,
|
||||
} from "../components";
|
||||
import { KeyConstants, PhysicsConstants } from "../config";
|
||||
import type { Entity } from "../entities";
|
||||
import { Action } from "../interfaces";
|
||||
import { System, SystemNames } from "./";
|
||||
|
||||
export class Input extends System {
|
||||
private keys: Set<string>;
|
||||
private actionTimeStamps: Map<Action, number>;
|
||||
|
||||
constructor() {
|
||||
super(SystemNames.Input);
|
||||
|
||||
this.keys = new Set<number>();
|
||||
this.actionTimeStamps = new Map<Action, number>();
|
||||
}
|
||||
|
||||
public keyPressed(key: string) {
|
||||
this.keys.add(key);
|
||||
}
|
||||
|
||||
public keyReleased(key: string) {
|
||||
this.keys.delete(key);
|
||||
}
|
||||
|
||||
private hasSomeKey(keys: string[]): boolean {
|
||||
return keys.some((key) => this.keys.has(key));
|
||||
}
|
||||
|
||||
public update(
|
||||
dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
) {
|
||||
componentEntities.get(ComponentNames.Control)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
if (!entity.hasComponent(ComponentNames.Velocity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
|
||||
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_RIGHT))) {
|
||||
velocity.dCartesian.dx = PhysicsConstants.PLAYER_MOVE_VEL;
|
||||
} else if (
|
||||
this.hasSomeKey(KeyConstants.ActionKeys.get(Action.MOVE_LEFT))
|
||||
) {
|
||||
velocity.dCartesian.dx = -PhysicsConstants.PLAYER_MOVE_VEL;
|
||||
} else {
|
||||
velocity.dCartesian.dx = 0;
|
||||
}
|
||||
});
|
||||
|
||||
componentEntities.get(ComponentNames.Jump)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
const jump = entity.getComponent<Jump>(ComponentNames.Jump);
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
|
||||
if (this.hasSomeKey(KeyConstants.ActionKeys.get(Action.JUMP))) {
|
||||
if (jump.canJump) {
|
||||
this.actionTimeStamps.set(Action.JUMP, performance.now());
|
||||
|
||||
velocity.dCartesian.dy = PhysicsConstants.PLAYER_JUMP_INITIAL_VEL;
|
||||
jump.canJump = false;
|
||||
}
|
||||
|
||||
if (
|
||||
performance.now() - this.actionTimeStamps.get(Action.JUMP) <
|
||||
PhysicsConstants.MAX_JUMP_TIME_MS
|
||||
) {
|
||||
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
|
||||
entity.getComponent<Forces>(ComponentNames.Forces)?.forces.push({
|
||||
fCartesian: { fy: mass * PhysicsConstants.PLAYER_JUMP_ACC },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
94
client/lib/systems/Physics.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { System, SystemNames } from ".";
|
||||
import {
|
||||
Acceleration,
|
||||
BoundingBox,
|
||||
ComponentNames,
|
||||
Forces,
|
||||
Gravity,
|
||||
Velocity,
|
||||
Mass,
|
||||
Jump,
|
||||
} from "../components";
|
||||
import { PhysicsConstants } from "../config";
|
||||
import type { Entity } from "../entities";
|
||||
import type { Force2D } from "../interfaces";
|
||||
|
||||
export class Physics extends System {
|
||||
constructor() {
|
||||
super(SystemNames.Physics);
|
||||
}
|
||||
|
||||
public update(
|
||||
dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
): void {
|
||||
componentEntities.get(ComponentNames.Forces)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
|
||||
const mass = entity.getComponent<Mass>(ComponentNames.Mass).mass;
|
||||
const forces = entity.getComponent<Forces>(ComponentNames.Forces).forces;
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
const inertia = entity.getComponent<Moment>(
|
||||
ComponentNames.Moment
|
||||
).inertia;
|
||||
|
||||
// F_g = mg, applied only until terminal velocity is reached
|
||||
if (entity.hasComponent(ComponentNames.Gravity)) {
|
||||
const gravity = entity.getComponent<Gravity>(ComponentNames.Gravity);
|
||||
if (velocity.dCartesian.dy <= gravity.terminalVelocity) {
|
||||
forces.push({
|
||||
fCartesian: {
|
||||
fy: mass * PhysicsConstants.GRAVITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ma = Σ(F), Iα = Σ(T)
|
||||
const sumOfForces = forces.reduce(
|
||||
(accum: Force2D, { fCartesian, torque }: Force2D) => ({
|
||||
fCartesian: {
|
||||
fx: accum.fCartesian.fx + (fCartesian?.fx ?? 0),
|
||||
fy: accum.fCartesian.fy + (fCartesian?.fy ?? 0),
|
||||
},
|
||||
torque: accum.torque + (torque ?? 0),
|
||||
}),
|
||||
{ fCartesian: { fx: 0, fy: 0 }, torque: 0 }
|
||||
);
|
||||
|
||||
// integrate accelerations
|
||||
const [ddy, ddx] = [
|
||||
sumOfForces.fCartesian.fy,
|
||||
sumOfForces.fCartesian.fx,
|
||||
].map((x) => x / mass);
|
||||
velocity.dCartesian.dx += ddx * dt;
|
||||
velocity.dCartesian.dy += ddy * dt;
|
||||
velocity.dTheta += (sumOfForces.torque * dt) / inertia;
|
||||
// clear the forces
|
||||
entity.getComponent<Forces>(ComponentNames.Forces).forces = [];
|
||||
|
||||
// maybe we fell off the floor
|
||||
if (ddy > 0 && entity.hasComponent(ComponentNames.Jump)) {
|
||||
entity.getComponent<Jump>(ComponentNames.Jump).canJump = false;
|
||||
}
|
||||
});
|
||||
|
||||
componentEntities.get(ComponentNames.Velocity)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
const velocity = entity.getComponent<Velocity>(ComponentNames.Velocity);
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
ComponentNames.BoundingBox
|
||||
);
|
||||
|
||||
// integrate velocity
|
||||
boundingBox.center.x += velocity.dCartesian.dx * dt;
|
||||
boundingBox.center.y += velocity.dCartesian.dy * dt;
|
||||
boundingBox.rotation += velocity.dTheta * dt;
|
||||
boundingBox.rotation =
|
||||
(boundingBox.rotation < 0
|
||||
? 360 + boundingBox.rotation
|
||||
: boundingBox.rotation) % 360;
|
||||
});
|
||||
}
|
||||
}
|
41
client/lib/systems/Render.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { System, SystemNames } from ".";
|
||||
import { BoundingBox, ComponentNames, Sprite } from "../components";
|
||||
import type { Entity } from "../entities";
|
||||
import type { DrawArgs } from "../interfaces";
|
||||
|
||||
export class Render extends System {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
constructor(ctx: CanvasRenderingContext2D) {
|
||||
super(SystemNames.Render);
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
public update(
|
||||
dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
) {
|
||||
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
||||
|
||||
componentEntities.get(ComponentNames.Sprite)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
const sprite = entity.getComponent<Sprite>(ComponentNames.Sprite);
|
||||
sprite.update(dt);
|
||||
|
||||
let drawArgs: DrawArgs;
|
||||
if (entity.hasComponent(ComponentNames.BoundingBox)) {
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
ComponentNames.BoundingBox
|
||||
);
|
||||
|
||||
drawArgs = {
|
||||
center: boundingBox.center,
|
||||
dimension: boundingBox.dimension,
|
||||
rotation: boundingBox.rotation,
|
||||
};
|
||||
}
|
||||
sprite.draw(this.ctx, drawArgs);
|
||||
});
|
||||
}
|
||||
}
|
15
client/lib/systems/System.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Entity } from "../entities";
|
||||
|
||||
export abstract class System {
|
||||
public readonly name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
abstract update(
|
||||
dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
): void;
|
||||
}
|
35
client/lib/systems/WallBounds.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { System, SystemNames } from ".";
|
||||
import { BoundingBox, ComponentNames } from "../components";
|
||||
import type { Entity } from "../entities";
|
||||
|
||||
export class WallBounds extends System {
|
||||
private screenWidth: number;
|
||||
|
||||
constructor(screenWidth: number) {
|
||||
super(SystemNames.WallBounds);
|
||||
|
||||
this.screenWidth = screenWidth;
|
||||
}
|
||||
|
||||
public update(
|
||||
_dt: number,
|
||||
entityMap: Map<number, Entity>,
|
||||
componentEntities: Map<string, Set<number>>
|
||||
) {
|
||||
componentEntities.get(ComponentNames.WallBounded)?.forEach((entityId) => {
|
||||
const entity = entityMap.get(entityId);
|
||||
if (!entity.hasComponent(ComponentNames.BoundingBox)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundingBox = entity.getComponent<BoundingBox>(
|
||||
ComponentNames.BoundingBox
|
||||
);
|
||||
|
||||
boundingBox.center.x = Math.min(
|
||||
this.screenWidth - boundingBox.dimension.width / 2,
|
||||
Math.max(boundingBox.dimension.width / 2, boundingBox.center.x)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
8
client/lib/systems/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from "./names";
|
||||
export * from "./System";
|
||||
export * from "./Render";
|
||||
export * from "./Physics";
|
||||
export * from "./Input";
|
||||
export * from "./FacingDirection";
|
||||
export * from "./Collision";
|
||||
export * from "./WallBounds";
|
8
client/lib/systems/names.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export namespace SystemNames {
|
||||
export const Render = "Render";
|
||||
export const Physics = "Physics";
|
||||
export const FacingDirection = "FacingDirection";
|
||||
export const Input = "Input";
|
||||
export const Collision = "Collision";
|
||||
export const WallBounds = "WallBounds";
|
||||
}
|
4
client/lib/utils/dotProduct.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import type { Coord2D } from "../interfaces";
|
||||
|
||||
export const dotProduct = (vector1: Coord2D, vector2: Coord2D): number =>
|
||||
vector1.x * vector2.x + vector1.y * vector2.y;
|
3
client/lib/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./rotateVector";
|
||||
export * from "./normalizeVector";
|
||||
export * from "./dotProduct";
|
8
client/lib/utils/normalizeVector.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { Coord2D } from "../interfaces";
|
||||
|
||||
export const normalizeVector = (vector: Coord2D): Coord2D => {
|
||||
const { x, y } = vector;
|
||||
const length = Math.sqrt(x * x + y * y);
|
||||
|
||||
return { x: x / length, y: y / length };
|
||||
};
|
15
client/lib/utils/rotateVector.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { Coord2D } from "../interfaces";
|
||||
|
||||
/**
|
||||
* ([[cos(θ), -sin(θ),]) ([x,)
|
||||
* ([sin(θ), cos(θ)] ]) ( y])
|
||||
*/
|
||||
export const rotateVector = (vector: Coord2D, theta: number): Coord2D => {
|
||||
const rads = (theta * Math.PI) / 180;
|
||||
const [cos, sin] = [Math.cos(rads), Math.sin(rads)];
|
||||
|
||||
return {
|
||||
x: vector.x * cos - vector.y * sin,
|
||||
y: vector.x * sin + vector.y * cos,
|
||||
};
|
||||
};
|
2455
client/package-lock.json
generated
Normal file
24
client/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.4",
|
||||
"@tsconfig/svelte": "^4.0.1",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-plugin-svelte": "^2.31.1",
|
||||
"svelte": "^3.59.2",
|
||||
"svelte-check": "^3.3.1",
|
||||
"svelte-routing": "^1.10.0",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.3.9"
|
||||
}
|
||||
}
|
BIN
client/public/assets/coffee_left.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
client/public/assets/coffee_right.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
client/public/assets/floor_tile_120.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
client/public/assets/floor_tile_160.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
client/public/assets/floor_tile_40.png
Normal file
After Width: | Height: | Size: 750 B |
BIN
client/public/assets/floor_tile_80.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
45
client/public/css/colors.css
Normal file
@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--bg: #fbf1c7;
|
||||
--text: #3c3836;
|
||||
--red: #9d0006;
|
||||
--green: #6d790e;
|
||||
--yellow: #b57614;
|
||||
--blue: #075678;
|
||||
--aqua: #57ab7e;
|
||||
--purple: #b16286;
|
||||
--orange: #af3a03;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #282828;
|
||||
--text: #f9f5d7;
|
||||
--red: #fb4934;
|
||||
--green: #b8bb26;
|
||||
--yellow: #fabd2f;
|
||||
--blue: #83a598;
|
||||
--aqua: #8ec07c;
|
||||
--purple: #d3869b;
|
||||
--orange: #d65d0e;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--red);
|
||||
}
|
||||
.green {
|
||||
color: var(--green);
|
||||
}
|
||||
.yellow {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.blue {
|
||||
color: var(--blue);
|
||||
}
|
||||
.aqua {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.purple {
|
||||
color: var(--purple);
|
||||
}
|
||||
.orange {
|
||||
color: var(--orange);
|
||||
}
|
95
client/public/css/style.css
Normal file
@ -0,0 +1,95 @@
|
||||
@import url("./theme.css");
|
||||
@import url("./tf.css");
|
||||
|
||||
@font-face {
|
||||
font-family: "scientifica";
|
||||
src: url("/fonts/scientifica.ttf");
|
||||
}
|
||||
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "scientifica", monospace;
|
||||
transition: background 0.2s ease-in-out;
|
||||
font-smooth: never;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
}
|
||||
a:visited {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-width: 600px;
|
||||
width: 45%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-top: 1px solid var(--yellow);
|
||||
border-bottom: 1px solid var(--yellow);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-decoration: none;
|
||||
}
|
||||
.title:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.centered-game {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.centered-game canvas {
|
||||
display: block;
|
||||
max-height: 90%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--yellow);
|
||||
border-radius: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
33
client/public/css/tf.css
Normal file
@ -0,0 +1,33 @@
|
||||
.tf {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
transition: color 0.3s ease-out;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.tf:before {
|
||||
background: rgb(162, 254, 254);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(162, 254, 254, 1) 0%,
|
||||
rgba(249, 187, 250, 1) 25%,
|
||||
rgba(250, 250, 250, 1) 50%,
|
||||
rgba(249, 187, 250, 1) 75%,
|
||||
rgba(162, 254, 254, 1) 100%
|
||||
);
|
||||
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.tf:hover:before {
|
||||
opacity: 1;
|
||||
}
|
17
client/public/css/theme.css
Normal file
@ -0,0 +1,17 @@
|
||||
@import url("./colors.css");
|
||||
|
||||
.primary {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--blue);
|
||||
}
|
||||
.tertiary {
|
||||
color: var(--purple);
|
||||
}
|
||||
.warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.error {
|
||||
color: var(--red);
|
||||
}
|
BIN
client/public/fonts/CozetteVector.ttf
Normal file
BIN
client/public/fonts/scientifica.ttf
Normal file
18
client/public/img/kangaroo.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="color">
|
||||
<path fill="#6a462f" d="M15.0063,35.5407l-.64,1.78v.01l-1.03,2.84.02-.09-2.1-.09.26-6.04,1.6-3.64a26.7307,26.7307,0,0,0,1.61,4.61v.01C14.8163,35.1307,14.9064,35.3407,15.0063,35.5407Z"/>
|
||||
<path fill="#6a462f" d="M32.3664,57.0307a2.5121,2.5121,0,0,1-2.33,1.57h-3.12a1.9779,1.9779,0,0,0,1.53-1.18l.15-.37a2.0617,2.0617,0,0,0,.15-1.15l-.67-4.43a2.1283,2.1283,0,0,1,1.17-2.22,8.5517,8.5517,0,0,0,2.6-2.18.99.99,0,0,1-.11.23c-.07.09-.14.18-.22.27a2.9535,2.9535,0,0,1-.3.31c-.05.05-.11.1-.16.15-.47.47.59,3.74.7,4.42l.76,3.43A2.0617,2.0617,0,0,1,32.3664,57.0307Z"/>
|
||||
<path fill="#a57939" d="M64.6786,57.1107l-20.7161.21c-9.2877.0047-11.414-7.36-11.5239-7.68a10.6424,10.6424,0,0,1-.8278-2.179c-.02.02.1576-.181.1276-.161-.07.09-.14.18-.22.27a2.9145,2.9145,0,0,1-.3.31c-.05.05-.11.1-.16.15a3.58,3.58,0,0,1-.49.4,3.409,3.409,0,0,1-.33.24,9.6919,9.6919,0,0,1-.99.58,2.1283,2.1283,0,0,0-1.17,2.22l.67,4.43a2.0609,2.0609,0,0,1-.15,1.15l-.15.37a1.9782,1.9782,0,0,1-1.53,1.18,2.6453,2.6453,0,0,1-.29.02h-13.06a.9034.9034,0,0,1-.68-1.5,1.8072,1.8072,0,0,1,1.2-.6l5.6-.46,3.26-.26a1.57,1.57,0,0,0,1.21-.73,1.4836,1.4836,0,0,0,.27-.94l-.04-.94-.27-6.46a9.1724,9.1724,0,0,1-.6-1,9.0318,9.0318,0,0,1-1.02-4.18v-.05q-.51-.3-.99-.63a20.9138,20.9138,0,0,1-3.85-3.36l-.13.19-1.67,2.47-.52,4.73h-2v-4.73l.02-.09,1.37-5.15v-.01l-.32-2.15a.8939.8939,0,0,1,.05.08.5246.5246,0,0,0-.06-.15,20.2076,20.2076,0,0,1-1.93-6.48c-.06-.4-.1-.8-.13-1.21a1.1019,1.1019,0,0,1-.02-.18,3.3887,3.3887,0,0,1-.02-.45c-.02-.28-.02-.57-.02-.86l-3.5-1.3c-.56-.17-1.49-.5623-1.63-1.3422a1.4662,1.4662,0,0,1,.49-1.33l1.58-.9777v-.7a1.0654,1.0654,0,0,1,.38-.82l.66-.56-1.88-2.5a.3811.3811,0,0,1,.43-.59l2.25.76,2.31,1.46,2.5-1.31a3.7236,3.7236,0,0,1,1.76-.43h.66a.48.48,0,0,1,.28.87l-2.91,2.14a1.781,1.781,0,0,0-.68,1.91c.08.29.16.58.26.86a8.01,8.01,0,0,0,6.1,5.14,20.78,20.78,0,0,1,6.01,2.18,21.034,21.034,0,0,1,6.7,5.81v.01c.1.12.19.27.29.41,0,.01.01.01.01.02a11.3217,11.3217,0,0,1,1.13,2.22,12.2135,12.2135,0,0,1,.75,4.8c-.01.14-.01.27-.01.39a20.3172,20.3172,0,0,0,.3,3.52,19.4651,19.4651,0,0,0,1.03,3.81,3.3983,3.3983,0,0,0,.13.34c.06.19.15.37.23.56l.01.02a5.546,5.546,0,0,0,1.04,1.87,5.3627,5.3627,0,0,0,2.65,1.76,36.5639,36.5639,0,0,0,4.51.81l18.51,2.78C64.9186,56.1607,64.9684,57.1107,64.6786,57.1107Z"/>
|
||||
</g>
|
||||
<g id="line">
|
||||
<line x1="34.0489" x2="34.0489" y1="32.7943" y2="32.8034" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/>
|
||||
<line x1="14.725" x2="14.3664" y1="35.105" y2="37.3322" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M22.4963,41.5007c-.34-.2-.68-.41-1-.62a18.3131,18.3131,0,0,1-3.97-3.18"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M12.2763,23.5207c0,.29,0,.58.02.86,0,.38.03.75.06,1.12.02.24.04.48.07.72a26.0614,26.0614,0,0,0,.69,4.09,26.7476,26.7476,0,0,0,1.61,4.61v.01c.09.2.18.41.28.61"/>
|
||||
<polyline fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" points="18.836 30.821 19.426 34.881 17.656 37.511 17.526 37.701 15.856 40.171 15.336 44.901 13.336 44.901 13.336 40.171 14.366 37.331 14.366 37.321 15.006 35.541 15.756 33.491 16.036 30.731"/>
|
||||
<polyline fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" points="13.359 40.081 11.259 39.991 11.519 33.951 13.119 30.311"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M26.6563,33.9107a9.0823,9.0823,0,0,0-4.16,7.59v.05a9.0307,9.0307,0,0,0,1.02,4.18,9.1243,9.1243,0,0,0,.6,1l.27,6.46.04.94a1.483,1.483,0,0,1-.27.94,1.57,1.57,0,0,1-1.21.73l-3.26.26-5.6.46a1.8069,1.8069,0,0,0-1.2.6.9034.9034,0,0,0,.68,1.5h13.06a2.6417,2.6417,0,0,0,.29-.02,1.9781,1.9781,0,0,0,1.53-1.18l.15-.37a2.0609,2.0609,0,0,0,.15-1.15l-.67-4.43a2.1282,2.1282,0,0,1,1.17-2.22,8.5511,8.5511,0,0,0,2.6-2.18c.01-.02.02-.03.03-.05a3.2588,3.2588,0,0,0,.72-2.07"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.2281,22.2784a1.5987,1.5987,0,0,1-1.08-1.4,1.4665,1.4665,0,0,1,.49-1.33l1.58-.9777v-.7a1.0654,1.0654,0,0,1,.38-.82l.66-.56-1.88-2.5a.3811.3811,0,0,1,.43-.59l2.25.76,2.31,1.46,2.5-1.31a3.7236,3.7236,0,0,1,1.76-.43h.66a.48.48,0,0,1,.28.87l-2.91,2.14a1.7813,1.7813,0,0,0-.68,1.91c.08.29.16.58.26.86a8.01,8.01,0,0,0,6.1,5.14,20.78,20.78,0,0,1,6.01,2.18,21.034,21.034,0,0,1,6.7,5.81v.01c.1.12.19.27.29.41,0,.01.01.01.01.02a11.3212,11.3212,0,0,1,1.13,2.22,12.21,12.21,0,0,1,.75,4.8c-.01.14-.01.27-.01.39a20.3073,20.3073,0,0,0,.3,3.52,19.4649,19.4649,0,0,0,1.03,3.81,3.4226,3.4226,0,0,0,.13.34,4.4066,4.4066,0,0,0,.23.56l.01.02a5.546,5.546,0,0,0,1.04,1.87,5.3634,5.3634,0,0,0,2.65,1.76,36.5639,36.5639,0,0,0,4.51.81l18.51,2.78"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M31.0585,48.0307c-.47.47.59,3.74.7,4.42l.76,3.43a2.0609,2.0609,0,0,1-.15,1.15l-.15.37a1.977,1.977,0,0,1-1.82,1.2h-3.48"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.1 KiB |
31
client/src/App.svelte
Normal file
@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { Router, Link, Route } from "svelte-routing";
|
||||
import Home from "./routes/Home.svelte";
|
||||
|
||||
export let url = "/";
|
||||
</script>
|
||||
|
||||
<Router {url}>
|
||||
<div class="main">
|
||||
<div class="header">
|
||||
<div class="nav">
|
||||
<h1>jumpstorm</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<Route path="/"><Home /></Route>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>
|
||||
built by
|
||||
<a href="https://github.com/simponic" target="_blank" class="tf">
|
||||
simponic
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
| inspired by
|
||||
<a href="https://www.youtube.com/watch?v=6qBcG31eiJY">carykh</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
28
client/src/components/GameCanvas.svelte
Normal file
@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Game } from "../../lib/Game";
|
||||
import { Render } from "../../lib/systems";
|
||||
import { Floor } from "../../lib/entities";
|
||||
import { loadAssets } from "../../lib/config";
|
||||
import { JumpStorm } from "../../lib/JumpStorm";
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
|
||||
export let width: number;
|
||||
export let height: number;
|
||||
|
||||
let jumpStorm: JumpStorm;
|
||||
|
||||
onMount(() => {
|
||||
ctx = canvas.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
loadAssets().then(() => {
|
||||
jumpStorm = new JumpStorm(ctx);
|
||||
jumpStorm.play();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} {width} {height} />
|
25
client/src/components/LeaderBoard.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { type LeaderBoardEntry } from "../../lib/interfaces";
|
||||
import LeaderBoardCard from "./LeaderBoardCard.svelte";
|
||||
|
||||
const MAX_ENTRIES = 8;
|
||||
|
||||
export let entries: LeaderBoardEntry[] = [];
|
||||
</script>
|
||||
|
||||
<div class="leaderboard">
|
||||
{#each entries
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_ENTRIES) as entry}
|
||||
<LeaderBoardCard {entry} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.leaderboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
20
client/src/components/LeaderBoardCard.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { type LeaderBoardEntry } from "../../lib/interfaces";
|
||||
|
||||
export let entry: LeaderBoardEntry = {
|
||||
name: "simponic",
|
||||
score: 100,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="entry">
|
||||
<span class="name">{entry.name}</span>
|
||||
<span class="score">{entry.score}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
7
client/src/main.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById("app"),
|
||||
});
|
||||
|
||||
export default app;
|
12
client/src/routes/Home.svelte
Normal file
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import GameCanvas from "../components/GameCanvas.svelte";
|
||||
import LeaderBoard from "../components/LeaderBoard.svelte";
|
||||
|
||||
let width: number = 600;
|
||||
let height: number = 800;
|
||||
</script>
|
||||
|
||||
<div class="centered-game">
|
||||
<GameCanvas {width} {height} />
|
||||
<LeaderBoard />
|
||||
</div>
|
2
client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
7
client/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
28
client/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"lib/**/*.d.ts",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.js",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.svelte"
|
||||
],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
9
client/tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
client/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
})
|