feat: add initial implementation of ECS architecture and systems
- Introduced a minimal Entity-Component-System (ECS) framework in `ecs.ts` with basic entity management and component handling. - Created `World` class to manage entities and components, including methods for creating, adding, retrieving, and removing components. - Implemented unit tests for the `World` class in `ecs.test.ts` to ensure functionality. - Developed input handling with `InputSystem` to capture keyboard events. - Added `PhysicsSystem` for movement and collision detection. - Created `RenderSystem` to handle drawing entities on the canvas. - Set up a main game loop in `main.ts` to integrate systems and manage game state. - Added SVG icons and images for UI elements. - Included CSS styles for layout and theming. - Configured TypeScript settings in `tsconfig.json` for project compilation. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { World } from "./ecs";
|
||||
|
||||
describe("World", () => {
|
||||
let world: World;
|
||||
|
||||
beforeEach(() => {
|
||||
world = new World();
|
||||
});
|
||||
|
||||
it("creates unique entities", () => {
|
||||
const a = world.createEntity();
|
||||
const b = world.createEntity();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("adds and retrieves components", () => {
|
||||
const e = world.createEntity();
|
||||
const pos = { x: 10, y: 20 };
|
||||
world.addComponent(e, "Position", pos);
|
||||
expect(world.getComponent(e, "Position")).toEqual(pos);
|
||||
});
|
||||
|
||||
it("checks component presence", () => {
|
||||
const e = world.createEntity();
|
||||
world.addComponent(e, "Sprite", { src: "zombie.png" });
|
||||
expect(world.hasComponent(e, "Sprite")).toBe(true);
|
||||
expect(world.hasComponent(e, "Position")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes components", () => {
|
||||
const e = world.createEntity();
|
||||
world.addComponent(e, "Velocity", { dx: 1, dy: 0 });
|
||||
world.removeComponent(e, "Velocity");
|
||||
expect(world.hasComponent(e, "Velocity")).toBe(false);
|
||||
});
|
||||
|
||||
it("queries entities by component set", () => {
|
||||
const e1 = world.createEntity();
|
||||
const e2 = world.createEntity();
|
||||
world.addComponent(e1, "Position", { x: 0, y: 0 });
|
||||
world.addComponent(e1, "Sprite", { src: "p1.png" });
|
||||
world.addComponent(e2, "Position", { x: 5, y: 5 });
|
||||
|
||||
const withPos = world.query("Position");
|
||||
const withBoth = world.query("Position", "Sprite");
|
||||
|
||||
expect(withPos).toContain(e1);
|
||||
expect(withPos).toContain(e2);
|
||||
expect(withBoth).toContain(e1);
|
||||
expect(withBoth).not.toContain(e2);
|
||||
});
|
||||
|
||||
it("destroys entity and cleans components", () => {
|
||||
const e = world.createEntity();
|
||||
world.addComponent(e, "Position", { x: 0, y: 0 });
|
||||
world.destroyEntity(e);
|
||||
expect(world.hasComponent(e, "Position")).toBe(false);
|
||||
expect(world.query("Position")).not.toContain(e);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Minimal ECS core for ZAMNY browser edition.
|
||||
* Entities are numbers. Components are plain objects.
|
||||
* Systems iterate over entities that have required components.
|
||||
*/
|
||||
|
||||
export type Entity = number;
|
||||
|
||||
export interface Component {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class World {
|
||||
private nextId = 1;
|
||||
private entities: Map<Entity, Set<string>> = new Map();
|
||||
private components: Map<string, Map<Entity, Component>> = new Map();
|
||||
|
||||
createEntity(): Entity {
|
||||
const id = this.nextId++;
|
||||
this.entities.set(id, new Set());
|
||||
return id;
|
||||
}
|
||||
|
||||
addComponent<T extends Component>(entity: Entity, name: string, data: T): T {
|
||||
if (!this.components.has(name)) {
|
||||
this.components.set(name, new Map());
|
||||
}
|
||||
this.components.get(name)!.set(entity, data);
|
||||
this.entities.get(entity)!.add(name);
|
||||
return data;
|
||||
}
|
||||
|
||||
getComponent<T extends Component>(
|
||||
entity: Entity,
|
||||
name: string,
|
||||
): T | undefined {
|
||||
return this.components.get(name)?.get(entity) as T | undefined;
|
||||
}
|
||||
|
||||
hasComponent(entity: Entity, name: string): boolean {
|
||||
return this.entities.get(entity)?.has(name) ?? false;
|
||||
}
|
||||
|
||||
removeComponent(entity: Entity, name: string): void {
|
||||
this.components.get(name)?.delete(entity);
|
||||
this.entities.get(entity)?.delete(name);
|
||||
}
|
||||
|
||||
query(...componentNames: string[]): Entity[] {
|
||||
const result: Entity[] = [];
|
||||
for (const [entity, comps] of this.entities) {
|
||||
if (componentNames.every((c) => comps.has(c))) {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
destroyEntity(entity: Entity): void {
|
||||
const comps = this.entities.get(entity);
|
||||
if (!comps) return;
|
||||
for (const name of comps) {
|
||||
this.components.get(name)?.delete(entity);
|
||||
}
|
||||
this.entities.delete(entity);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class System {
|
||||
abstract update(world: World, dt: number): void;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { World } from "./engine/ecs";
|
||||
import { InputSystem } from "./systems/InputSystem";
|
||||
import { RenderSystem } from "./systems/RenderSystem";
|
||||
import { PhysicsSystem } from "./systems/PhysicsSystem";
|
||||
|
||||
/**
|
||||
* ZAMNY Browser Edition - main entry.
|
||||
* 60 FPS game loop. ECS + systems.
|
||||
* TODO: add nickname UI, network client, level loader.
|
||||
*/
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
const world = new World();
|
||||
const input = new InputSystem();
|
||||
const render = new RenderSystem(canvas);
|
||||
const physics = new PhysicsSystem();
|
||||
|
||||
const systems = [input, physics, render];
|
||||
|
||||
let last = performance.now();
|
||||
|
||||
function loop(now: number) {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
|
||||
for (const sys of systems) {
|
||||
sys.update(world, dt);
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
console.log("[ZAMNY] Game loop started. ECS ready.");
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { World, System } from "../engine/ecs";
|
||||
|
||||
/**
|
||||
* InputSystem - captures keyboard + touch input.
|
||||
* TODO: map to actions (move, shoot, use item).
|
||||
*/
|
||||
export class InputSystem extends System {
|
||||
private keys = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("keydown", (e) => this.keys.add(e.key));
|
||||
window.addEventListener("keyup", (e) => this.keys.delete(e.key));
|
||||
}
|
||||
|
||||
isPressed(key: string): boolean {
|
||||
return this.keys.has(key);
|
||||
}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
// TODO: translate key state into movement / action components
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { World, System } from "../engine/ecs";
|
||||
|
||||
/**
|
||||
* PhysicsSystem - movement + collision.
|
||||
* TODO: tile collision, enemy pushback, weapon hitboxes.
|
||||
*/
|
||||
export class PhysicsSystem extends System {
|
||||
update(world: World, dt: number): void {
|
||||
// TODO: integrate velocity into position
|
||||
// TODO: resolve collisions against level geometry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { World, System } from "../engine/ecs";
|
||||
|
||||
/**
|
||||
* RenderSystem - draws entities with Position + Sprite to canvas.
|
||||
* TODO: implement camera, sprite batching, neighbor name labels.
|
||||
*/
|
||||
export class RenderSystem extends System {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private canvas: HTMLCanvasElement;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
super();
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d")!;
|
||||
}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
this.ctx.fillStyle = "#0a0a0a";
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// TODO: query entities with Position + Sprite, draw them
|
||||
// TODO: draw nickname labels above co-op player
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user