initial commit

This commit is contained in:
Elizabeth Hunt 2023-07-19 20:38:24 -07:00
commit 0fd9fb0975
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
78 changed files with 4471 additions and 0 deletions

11
client/.eslintrc.js Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

47
client/README.md Normal file
View 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
View 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
View 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
View 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;
}
}

View 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];
}
}

View File

@ -0,0 +1,7 @@
import { Component, ComponentNames } from ".";
export class Collide extends Component {
constructor() {
super(ComponentNames.Collide);
}
}

View File

@ -0,0 +1,7 @@
export abstract class Component {
public readonly name: string;
constructor(name: string) {
this.name = name;
}
}

View File

@ -0,0 +1,7 @@
import { Component, ComponentNames } from ".";
export class Control extends Component {
constructor() {
super(ComponentNames.Control);
}
}

View 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;
}
}

View 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 ?? [];
}
}

View 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;
}
}

View File

@ -0,0 +1,10 @@
import { Component, ComponentNames } from ".";
export class Jump extends Component {
public canJump: boolean;
constructor() {
super(ComponentNames.Jump);
this.canJump = false;
}
}

View 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;
}
}

View 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;
}
}

View 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,
];
}
}

View File

@ -0,0 +1,7 @@
import { Component, ComponentNames } from ".";
export class TopCollidable extends Component {
constructor() {
super(ComponentNames.TopCollidable);
}
}

View 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;
}
}

View File

@ -0,0 +1,7 @@
import { Component, ComponentNames } from ".";
export class WallBounded extends Component {
constructor() {
super(ComponentNames.WallBounded);
}
}

View 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";

View 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";
}

View 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
]);

View 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;
}

View File

@ -0,0 +1,3 @@
export * from "./constants";
export * from "./assets.ts";
export * from "./sprites.ts";

View 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",
},
},
});

View 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);
}
}

View 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());
}
}

View 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
}
}

View File

@ -0,0 +1,3 @@
export * from "./Entity";
export * from "./Floor";
export * from "./Player";

View File

@ -0,0 +1,5 @@
export enum Action {
MOVE_LEFT,
MOVE_RIGHT,
JUMP,
}

View File

@ -0,0 +1,6 @@
export enum Direction {
UP = "UP",
DOWN = "DOWN",
LEFT = "LEFT",
RIGHT = "RIGHT",
}

View File

@ -0,0 +1,9 @@
import type { Coord2D, Dimension2D } from "./";
export interface DrawArgs {
center: Coord2D;
dimension: Dimension2D;
tint?: string;
opacity?: number;
rotation?: number;
}

View File

@ -0,0 +1,5 @@
export interface LeaderBoardEntry {
name: string;
score: number;
avatar: string;
}

View 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;
}

View File

@ -0,0 +1,5 @@
export * from "./LeaderBoardEntry";
export * from "./Vec2";
export * from "./Draw";
export * from "./Direction";
export * from "./Action";

View 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;
}
}

View File

@ -0,0 +1 @@
export * from "./QuadTree";

View 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)
);
}
}

View 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);
}
});
}
}

View 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 },
});
}
}
});
}
}

View 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;
});
}
}

View 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);
});
}
}

View 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;
}

View 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)
);
});
}
}

View 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";

View 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";
}

View 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;

View File

@ -0,0 +1,3 @@
export * from "./rotateVector";
export * from "./normalizeVector";
export * from "./dotProduct";

View 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 };
};

View 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

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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);
}

View 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
View 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;
}

View 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);
}

Binary file not shown.

Binary file not shown.

View 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
View 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>

View 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} />

View 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>

View 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
View File

@ -0,0 +1,7 @@
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

View 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
View File

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

7
client/svelte.config.js Normal file
View 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
View 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" }]
}

View 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
View 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()],
})