Compare commits

...

4 commits
v0.4.0 ... main

Author SHA1 Message Date
forgebot
109cee0052 chore: update version to 0.5.0 [skip ci] 2026-01-07 06:29:14 +00:00
2858898ec2 Merge pull request 'Feature/Camera and Large Map' (#7) from Feature/Camera-and-Large-Map into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 9s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #7
2026-01-07 01:29:09 -05:00
c859e20ffc feat: implement Camera system and component for improved viewport management and player tracking
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 14s
2026-01-07 01:25:56 -05:00
62e58f77ae chore: add eslint-config-prettier to ESLint configuration for improved formatting compatibility 2026-01-07 01:25:53 -05:00
18 changed files with 618 additions and 77 deletions

View file

@ -1 +1 @@
0.4.0 0.5.0

View file

@ -1,10 +1,12 @@
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [ export default [
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
prettier,
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
@ -27,7 +29,6 @@ export default [
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'off', 'no-console': 'off',
indent: ['error', 2],
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn',
}, },

19
package-lock.json generated
View file

@ -7,12 +7,12 @@
"": { "": {
"name": "slime-genesis-poc", "name": "slime-genesis-poc",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
@ -1753,6 +1753,22 @@
} }
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -3120,7 +3136,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View file

@ -18,6 +18,7 @@
"@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",

View file

@ -2,7 +2,7 @@ name: slime
services: services:
app: app:
image: git.jusemon.com/jusemon/slime:0.4.0 image: git.jusemon.com/jusemon/slime:0.5.0
restart: unless-stopped restart: unless-stopped
networks: networks:

58
src/components/Camera.ts Normal file
View file

@ -0,0 +1,58 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for camera/viewport management.
*/
export class Camera extends Component {
x: number;
y: number;
targetX: number;
targetY: number;
smoothness: number;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
viewportWidth: number;
viewportHeight: number;
constructor(viewportWidth: number, viewportHeight: number, smoothness = 0.15) {
super(ComponentType.CAMERA);
this.x = 0;
this.y = 0;
this.targetX = 0;
this.targetY = 0;
this.smoothness = smoothness;
this.viewportWidth = viewportWidth;
this.viewportHeight = viewportHeight;
this.bounds = {
minX: 0,
maxX: 0,
minY: 0,
maxY: 0,
};
}
/**
* Set camera bounds based on map size.
* @param mapWidth - Total map width in pixels
* @param mapHeight - Total map height in pixels
*/
setBounds(mapWidth: number, mapHeight: number): void {
this.bounds.minX = this.viewportWidth / 2;
this.bounds.maxX = mapWidth - this.viewportWidth / 2;
this.bounds.minY = this.viewportHeight / 2;
this.bounds.maxY = mapHeight - this.viewportHeight / 2;
}
/**
* Clamp camera position to bounds.
*/
clampToBounds(): void {
this.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, this.x));
this.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, this.y));
}
}

View file

@ -10,6 +10,10 @@ export class Stealth extends Component {
isStealthed: boolean; isStealthed: boolean;
stealthLevel: number; stealthLevel: number;
detectionRadius: number; detectionRadius: number;
camouflageColor: string | null;
baseColor: string | null;
sizeMultiplier: number;
formAppearance: string | null;
constructor() { constructor() {
super(ComponentType.STEALTH); super(ComponentType.STEALTH);
@ -18,16 +22,29 @@ export class Stealth extends Component {
this.isStealthed = false; this.isStealthed = false;
this.stealthLevel = 0; this.stealthLevel = 0;
this.detectionRadius = 100; this.detectionRadius = 100;
this.camouflageColor = null;
this.baseColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
} }
/** /**
* Enter stealth mode. * Enter stealth mode.
* @param type - The type of stealth (e.g., 'slime', 'human') * @param type - The type of stealth (e.g., 'slime', 'human')
* @param baseColor - Original entity color to restore later
*/ */
enterStealth(type: string): void { enterStealth(type: string, baseColor?: string): void {
this.stealthType = type; this.stealthType = type;
this.isStealthed = true; this.isStealthed = true;
this.visibility = 0.3; this.visibility = 0.3;
if (baseColor) {
this.baseColor = baseColor;
}
if (type === 'slime') {
this.sizeMultiplier = 0.6;
} else {
this.sizeMultiplier = 1.0;
}
} }
/** /**
@ -36,6 +53,9 @@ export class Stealth extends Component {
exitStealth(): void { exitStealth(): void {
this.isStealthed = false; this.isStealthed = false;
this.visibility = 1.0; this.visibility = 1.0;
this.camouflageColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
} }
/** /**

102
src/core/ColorSampler.ts Normal file
View file

@ -0,0 +1,102 @@
import type { TileMap } from './TileMap.ts';
import { Palette } from './Palette.ts';
/**
* Utility for sampling colors from the background and tile map.
*/
export class ColorSampler {
private static cache: Map<string, string> = new Map();
private static cacheFrame: number = 0;
/**
* Sample the dominant color from a region around a position based on tile map and background.
* @param tileMap - The tile map to sample from
* @param x - Center X coordinate in world space
* @param y - Center Y coordinate in world space
* @param radius - Sampling radius in pixels
* @returns Dominant color as hex string (e.g., '#1a1a2e')
*/
static sampleDominantColor(
tileMap: TileMap | null,
x: number,
y: number,
radius: number
): string {
const cacheKey = `${Math.floor(x / 20)}_${Math.floor(y / 20)}`;
const currentFrame = Math.floor(Date.now() / 200);
if (currentFrame !== this.cacheFrame) {
this.cache.clear();
this.cacheFrame = currentFrame;
}
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) || Palette.VOID;
}
if (!tileMap) {
return Palette.VOID;
}
const tileSize = tileMap.tileSize;
const startCol = Math.max(0, Math.floor((x - radius) / tileSize));
const endCol = Math.min(tileMap.cols, Math.ceil((x + radius) / tileSize));
const startRow = Math.max(0, Math.floor((y - radius) / tileSize));
const endRow = Math.min(tileMap.rows, Math.ceil((y + radius) / tileSize));
const colorCounts: Map<string, number> = new Map();
let totalTiles = 0;
for (let r = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
const tileType = tileMap.getTile(c, r);
let color: string;
if (tileType === 1) {
color = Palette.DARK_BLUE;
} else {
const distFromCenter = Math.sqrt(
Math.pow(c * tileSize - x, 2) + Math.pow(r * tileSize - y, 2)
);
if (distFromCenter < radius) {
const noise = Math.sin(c * 0.1 + r * 0.1) * 0.1;
if (Math.random() < 0.3 + noise) {
color = Palette.DARKER_BLUE;
} else {
color = Palette.VOID;
}
} else {
continue;
}
}
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
totalTiles++;
}
}
if (totalTiles === 0) {
return Palette.VOID;
}
let dominantColor = Palette.VOID;
let maxCount = 0;
colorCounts.forEach((count, color) => {
if (count > maxCount) {
maxCount = count;
dominantColor = color;
}
});
this.cache.set(cacheKey, dominantColor);
return dominantColor;
}
/**
* Clear the color sampling cache.
*/
static clearCache(): void {
this.cache.clear();
}
}

View file

@ -32,6 +32,7 @@ export enum ComponentType {
INVENTORY = 'Inventory', INVENTORY = 'Inventory',
MUSIC = 'Music', MUSIC = 'Music',
SOUND_EFFECTS = 'SoundEffects', SOUND_EFFECTS = 'SoundEffects',
CAMERA = 'Camera',
} }
/** /**
@ -83,4 +84,5 @@ export enum SystemName {
HEALTH_REGEN = 'HealthRegenerationSystem', HEALTH_REGEN = 'HealthRegenerationSystem',
MUSIC = 'MusicSystem', MUSIC = 'MusicSystem',
SOUND_EFFECTS = 'SoundEffectsSystem', SOUND_EFFECTS = 'SoundEffectsSystem',
CAMERA = 'CameraSystem',
} }

View file

@ -44,7 +44,7 @@ export class Engine {
this.ctx.imageSmoothingEnabled = false; this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0; this.deltaTime = 0;
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16); this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16);
} }
/** /**

View file

@ -27,4 +27,99 @@ export class LevelLoader {
} }
return map; return map;
} }
/**
* Generates a larger designed map with rooms, corridors, and interesting layout.
* @param cols - Map width in tiles (default 200)
* @param rows - Map height in tiles (default 150)
* @param tileSize - Tile size in pixels (default 16)
* @returns The generated tile map
*/
static loadDesignedLevel(cols = 200, rows = 150, tileSize = 16): TileMap {
const map = new TileMap(cols, rows, tileSize);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) {
map.setTile(c, r, 1);
} else {
map.setTile(c, r, 0);
}
}
}
const roomCount = 15;
const rooms: Array<{ x: number; y: number; w: number; h: number }> = [];
for (let i = 0; i < roomCount; i++) {
const roomW = 8 + Math.floor(Math.random() * 12);
const roomH = 8 + Math.floor(Math.random() * 12);
const roomX = 2 + Math.floor(Math.random() * (cols - roomW - 4));
const roomY = 2 + Math.floor(Math.random() * (rows - roomH - 4));
let overlaps = false;
for (const existingRoom of rooms) {
if (
roomX < existingRoom.x + existingRoom.w + 2 &&
roomX + roomW + 2 > existingRoom.x &&
roomY < existingRoom.y + existingRoom.h + 2 &&
roomY + roomH + 2 > existingRoom.y
) {
overlaps = true;
break;
}
}
if (!overlaps) {
rooms.push({ x: roomX, y: roomY, w: roomW, h: roomH });
for (let ry = roomY; ry < roomY + roomH; ry++) {
for (let rx = roomX; rx < roomX + roomW; rx++) {
if (rx > 0 && rx < cols - 1 && ry > 0 && ry < rows - 1) {
map.setTile(rx, ry, 0);
}
}
}
}
}
for (let i = 1; i < rooms.length; i++) {
const prevRoom = rooms[i - 1];
const currRoom = rooms[i];
const startX = Math.floor(prevRoom.x + prevRoom.w / 2);
const startY = Math.floor(prevRoom.y + prevRoom.h / 2);
const endX = Math.floor(currRoom.x + currRoom.w / 2);
const endY = Math.floor(currRoom.y + currRoom.h / 2);
let x = startX;
let y = startY;
while (x !== endX || y !== endY) {
if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) {
map.setTile(x, y, 0);
}
if (x < endX) x++;
else if (x > endX) x--;
if (y < endY) y++;
else if (y > endY) y--;
}
if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) {
map.setTile(x, y, 0);
}
}
for (let r = 1; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) {
if (map.getTile(c, r) === 0 && Math.random() < 0.03) {
map.setTile(c, r, 1);
}
}
}
return map;
}
} }

View file

@ -17,6 +17,7 @@ import { UISystem } from './systems/UISystem.ts';
import { VFXSystem } from './systems/VFXSystem.ts'; import { VFXSystem } from './systems/VFXSystem.ts';
import { MusicSystem } from './systems/MusicSystem.ts'; import { MusicSystem } from './systems/MusicSystem.ts';
import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts'; import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts';
import { CameraSystem } from './systems/CameraSystem.ts';
import { Position } from './components/Position.ts'; import { Position } from './components/Position.ts';
import { Velocity } from './components/Velocity.ts'; import { Velocity } from './components/Velocity.ts';
@ -34,6 +35,7 @@ import { SkillProgress } from './components/SkillProgress.ts';
import { Intent } from './components/Intent.ts'; import { Intent } from './components/Intent.ts';
import { Music } from './components/Music.ts'; import { Music } from './components/Music.ts';
import { SoundEffects } from './components/SoundEffects.ts'; import { SoundEffects } from './components/SoundEffects.ts';
import { Camera } from './components/Camera.ts';
import { EntityType, ComponentType } from './core/Constants.ts'; import { EntityType, ComponentType } from './core/Constants.ts';
import type { Entity } from './core/Entity.ts'; import type { Entity } from './core/Entity.ts';
@ -50,6 +52,7 @@ if (!canvas) {
engine.addSystem(new InputSystem()); engine.addSystem(new InputSystem());
engine.addSystem(new MusicSystem()); engine.addSystem(new MusicSystem());
engine.addSystem(new SoundEffectsSystem()); engine.addSystem(new SoundEffectsSystem());
engine.addSystem(new CameraSystem());
engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem()); engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem()); engine.addSystem(new AISystem());
@ -66,7 +69,9 @@ if (!canvas) {
engine.addSystem(new UISystem(engine)); engine.addSystem(new UISystem(engine));
const player = engine.createEntity(); const player = engine.createEntity();
player.addComponent(new Position(160, 120)); const startX = engine.tileMap ? (engine.tileMap.cols * engine.tileMap.tileSize) / 2 : 160;
const startY = engine.tileMap ? (engine.tileMap.rows * engine.tileMap.tileSize) / 2 : 120;
player.addComponent(new Position(startX, startY));
player.addComponent(new Velocity(0, 0)); player.addComponent(new Velocity(0, 0));
player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME)); player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME));
player.addComponent(new Health(100)); player.addComponent(new Health(100));
@ -83,6 +88,17 @@ if (!canvas) {
player.addComponent(new SkillProgress()); player.addComponent(new SkillProgress());
player.addComponent(new Intent()); player.addComponent(new Intent());
const cameraEntity = engine.createEntity();
const camera = new Camera(canvas.width, canvas.height, 0.15);
if (engine.tileMap) {
const mapWidth = engine.tileMap.cols * engine.tileMap.tileSize;
const mapHeight = engine.tileMap.rows * engine.tileMap.tileSize;
camera.setBounds(mapWidth, mapHeight);
camera.x = startX;
camera.y = startY;
}
cameraEntity.addComponent(camera);
function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity { function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity {
const creature = engine.createEntity(); const creature = engine.createEntity();
creature.addComponent(new Position(x, y)); creature.addComponent(new Position(x, y));
@ -129,12 +145,36 @@ if (!canvas) {
return creature; return creature;
} }
for (let i = 0; i < 8; i++) { const mapWidth = engine.tileMap ? engine.tileMap.cols * engine.tileMap.tileSize : 320;
const x = 20 + Math.random() * 280; const mapHeight = engine.tileMap ? engine.tileMap.rows * engine.tileMap.tileSize : 240;
const y = 20 + Math.random() * 200;
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; function spawnEnemyNearPlayer(): void {
const type = types[Math.floor(Math.random() * types.length)]; const playerPos = player.getComponent<Position>(ComponentType.POSITION);
createCreature(engine, x, y, type); if (!playerPos) return;
const spawnRadius = 150;
const minDistance = 80;
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const angle = Math.random() * Math.PI * 2;
const distance = minDistance + Math.random() * (spawnRadius - minDistance);
const x = playerPos.x + Math.cos(angle) * distance;
const y = playerPos.y + Math.sin(angle) * distance;
if (x >= 50 && x <= mapWidth - 50 && y >= 50 && y <= mapHeight - 50) {
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
return;
}
}
}
const numberOfEnemies = 20;
for (let i = 0; i < numberOfEnemies / 2; i++) {
spawnEnemyNearPlayer();
} }
setInterval(() => { setInterval(() => {
@ -142,12 +182,8 @@ if (!canvas) {
.getEntities() .getEntities()
.filter((e) => e.hasComponent(ComponentType.AI) && e !== player); .filter((e) => e.hasComponent(ComponentType.AI) && e !== player);
if (existingCreatures.length < 10) { if (existingCreatures.length < numberOfEnemies) {
const x = 20 + Math.random() * 280; spawnEnemyNearPlayer();
const y = 20 + Math.random() * 200;
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
} }
}, 5000); }, 5000);

View file

@ -0,0 +1,50 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Camera } from '../components/Camera.ts';
import type { Position } from '../components/Position.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
/**
* System responsible for camera movement and following the player.
*/
export class CameraSystem extends System {
constructor() {
super(SystemName.CAMERA);
this.requiredComponents = [ComponentType.CAMERA];
this.priority = 0;
}
/**
* Update camera position to smoothly follow the player.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Filtered entities with Camera component
*/
process(deltaTime: number, entities: Entity[]): void {
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
const playerPos = player.getComponent<Position>(ComponentType.POSITION);
if (!playerPos) return;
entities.forEach((entity) => {
const camera = entity.getComponent<Camera>(ComponentType.CAMERA);
if (!camera) return;
camera.targetX = playerPos.x;
camera.targetY = playerPos.y;
const dx = camera.targetX - camera.x;
const dy = camera.targetY - camera.y;
camera.x += dx * camera.smoothness;
camera.y += dy * camera.smoothness;
camera.clampToBounds();
});
}
}

View file

@ -1,7 +1,8 @@
import { System } from '../core/System.ts'; import { System } from '../core/System.ts';
import { SystemName } from '../core/Constants.ts'; import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Engine } from '../core/Engine.ts'; import type { Engine } from '../core/Engine.ts';
import type { Entity } from '../core/Entity.ts'; import type { Entity } from '../core/Entity.ts';
import type { Camera } from '../components/Camera.ts';
interface MouseState { interface MouseState {
x: number; x: number;
@ -155,10 +156,26 @@ export class InputSystem extends System {
/** /**
* Get the current mouse position in world coordinates. * Get the current mouse position in world coordinates.
* @returns The mouse coordinates * @returns The mouse coordinates in world space
*/ */
getMousePosition(): { x: number; y: number } { getMousePosition(): { x: number; y: number } {
return { x: this.mouse.x, y: this.mouse.y }; if (!this.engine) {
return { x: this.mouse.x, y: this.mouse.y };
}
const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA));
if (!cameraEntity) {
return { x: this.mouse.x, y: this.mouse.y };
}
const camera = cameraEntity.getComponent<Camera>(ComponentType.CAMERA);
if (!camera) {
return { x: this.mouse.x, y: this.mouse.y };
}
const worldX = this.mouse.x + camera.x - camera.viewportWidth / 2;
const worldY = this.mouse.y + camera.y - camera.viewportHeight / 2;
return { x: worldX, y: worldY };
} }
/** /**

View file

@ -77,21 +77,42 @@ export class MovementSystem extends System {
velocity.vy *= Math.pow(friction, deltaTime * 60); velocity.vy *= Math.pow(friction, deltaTime * 60);
} }
const canvas = this.engine.canvas; if (tileMap) {
if (position.x < 0) { const mapWidth = tileMap.cols * tileMap.tileSize;
position.x = 0; const mapHeight = tileMap.rows * tileMap.tileSize;
velocity.vx = 0;
} else if (position.x > canvas.width) {
position.x = canvas.width;
velocity.vx = 0;
}
if (position.y < 0) { if (position.x < 0) {
position.y = 0; position.x = 0;
velocity.vy = 0; velocity.vx = 0;
} else if (position.y > canvas.height) { } else if (position.x > mapWidth) {
position.y = canvas.height; position.x = mapWidth;
velocity.vy = 0; velocity.vx = 0;
}
if (position.y < 0) {
position.y = 0;
velocity.vy = 0;
} else if (position.y > mapHeight) {
position.y = mapHeight;
velocity.vy = 0;
}
} else {
const canvas = this.engine.canvas;
if (position.x < 0) {
position.x = 0;
velocity.vx = 0;
} else if (position.x > canvas.width) {
position.x = canvas.width;
velocity.vx = 0;
}
if (position.y < 0) {
position.y = 0;
velocity.vy = 0;
} else if (position.y > canvas.height) {
position.y = canvas.height;
velocity.vy = 0;
}
} }
}); });
} }

View file

@ -99,14 +99,23 @@ export class ProjectileSystem extends System {
} }
}); });
const canvas = this.engine.canvas; const tileMap = this.engine.tileMap;
if ( if (tileMap) {
position.x < 0 || const mapWidth = tileMap.cols * tileMap.tileSize;
position.x > canvas.width || const mapHeight = tileMap.rows * tileMap.tileSize;
position.y < 0 || if (position.x < 0 || position.x > mapWidth || position.y < 0 || position.y > mapHeight) {
position.y > canvas.height this.engine.removeEntity(entity);
) { }
this.engine.removeEntity(entity); } else {
const canvas = this.engine.canvas;
if (
position.x < 0 ||
position.x > canvas.width ||
position.y < 0 ||
position.y > canvas.height
) {
this.engine.removeEntity(entity);
}
} }
}); });
} }

View file

@ -18,6 +18,7 @@ import type { Combat } from '../components/Combat.ts';
import type { Stealth } from '../components/Stealth.ts'; import type { Stealth } from '../components/Stealth.ts';
import type { Evolution } from '../components/Evolution.ts'; import type { Evolution } from '../components/Evolution.ts';
import type { Absorbable } from '../components/Absorbable.ts'; import type { Absorbable } from '../components/Absorbable.ts';
import type { Camera } from '../components/Camera.ts';
import type { VFXSystem } from './VFXSystem.ts'; import type { VFXSystem } from './VFXSystem.ts';
import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts'; import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
@ -26,6 +27,7 @@ import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
*/ */
export class RenderSystem extends System { export class RenderSystem extends System {
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
private camera: Camera | null;
/** /**
* @param engine - The game engine instance * @param engine - The game engine instance
@ -36,6 +38,37 @@ export class RenderSystem extends System {
this.priority = 100; this.priority = 100;
this.engine = engine; this.engine = engine;
this.ctx = engine.ctx; this.ctx = engine.ctx;
this.camera = null;
}
/**
* Get the active camera from the engine.
*/
private getCamera(): Camera | null {
if (this.camera) return this.camera;
const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA));
if (cameraEntity) {
this.camera = cameraEntity.getComponent<Camera>(ComponentType.CAMERA);
}
return this.camera;
}
/**
* Transform world coordinates to screen coordinates using camera.
* @param worldX - World X coordinate
* @param worldY - World Y coordinate
* @returns Screen coordinates {x, y}
*/
private worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
const camera = this.getCamera();
if (!camera) {
return { x: worldX, y: worldY };
}
const screenX = worldX - camera.x + camera.viewportWidth / 2;
const screenY = worldY - camera.y + camera.viewportHeight / 2;
return { x: screenX, y: screenY };
} }
/** /**
@ -90,11 +123,14 @@ export class RenderSystem extends System {
ctx.fillStyle = Palette.DARKER_BLUE; ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
const x = Math.floor((i * 70 + Math.sin(i) * 30) % width); const worldX = (i * 70 + Math.sin(i) * 30) % 2000;
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height); const worldY = (i * 50 + Math.cos(i) * 40) % 1500;
const screen = this.worldToScreen(worldX, worldY);
const size = Math.floor(25 + (i % 4) * 15); const size = Math.floor(25 + (i % 4) * 15);
ctx.fillRect(x, y, size, size); if (screen.x + size > 0 && screen.x < width && screen.y + size > 0 && screen.y < height) {
ctx.fillRect(screen.x, screen.y, size, size);
}
} }
} }
@ -105,18 +141,35 @@ export class RenderSystem extends System {
const tileMap = this.engine.tileMap; const tileMap = this.engine.tileMap;
if (!tileMap) return; if (!tileMap) return;
const camera = this.getCamera();
const ctx = this.ctx; const ctx = this.ctx;
const tileSize = tileMap.tileSize; const tileSize = tileMap.tileSize;
const viewportLeft = camera ? camera.x - camera.viewportWidth / 2 : 0;
const viewportRight = camera ? camera.x + camera.viewportWidth / 2 : this.engine.canvas.width;
const viewportTop = camera ? camera.y - camera.viewportHeight / 2 : 0;
const viewportBottom = camera
? camera.y + camera.viewportHeight / 2
: this.engine.canvas.height;
const startCol = Math.max(0, Math.floor(viewportLeft / tileSize) - 1);
const endCol = Math.min(tileMap.cols, Math.ceil(viewportRight / tileSize) + 1);
const startRow = Math.max(0, Math.floor(viewportTop / tileSize) - 1);
const endRow = Math.min(tileMap.rows, Math.ceil(viewportBottom / tileSize) + 1);
ctx.fillStyle = Palette.DARK_BLUE; ctx.fillStyle = Palette.DARK_BLUE;
for (let r = 0; r < tileMap.rows; r++) { for (let r = startRow; r < endRow; r++) {
for (let c = 0; c < tileMap.cols; c++) { for (let c = startCol; c < endCol; c++) {
if (tileMap.getTile(c, r) === 1) { if (tileMap.getTile(c, r) === 1) {
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); const worldX = c * tileSize;
const worldY = r * tileSize;
const screen = this.worldToScreen(worldX, worldY);
ctx.fillRect(screen.x, screen.y, tileSize, tileSize);
ctx.fillStyle = Palette.ROYAL_BLUE; ctx.fillStyle = Palette.ROYAL_BLUE;
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2); ctx.fillRect(screen.x, screen.y, tileSize, 2);
ctx.fillStyle = Palette.DARK_BLUE; ctx.fillStyle = Palette.DARK_BLUE;
} }
} }
@ -138,8 +191,9 @@ export class RenderSystem extends System {
this.ctx.save(); this.ctx.save();
const drawX = Math.floor(position.x); const screen = this.worldToScreen(position.x, position.y);
const drawY = Math.floor(position.y); const drawX = Math.floor(screen.x);
const drawY = Math.floor(screen.y);
let alpha = sprite.alpha; let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) { if (isDeadFade && health && health.isDead()) {
@ -155,13 +209,21 @@ export class RenderSystem extends System {
this.ctx.translate(drawX, drawY + (sprite.yOffset || 0)); this.ctx.translate(drawX, drawY + (sprite.yOffset || 0));
this.ctx.scale(sprite.scale, sprite.scale); this.ctx.scale(sprite.scale, sprite.scale);
if (sprite.shape === EntityType.SLIME) { const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
sprite.animationTime += deltaTime; sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
} }
let drawColor = sprite.color; let drawColor = sprite.color;
if (sprite.shape === EntityType.SLIME) drawColor = Palette.CYAN; if (effectiveShape === EntityType.SLIME && (!stealth || !stealth.isStealthed)) {
drawColor = Palette.CYAN;
}
this.ctx.fillStyle = drawColor; this.ctx.fillStyle = drawColor;
@ -171,7 +233,7 @@ export class RenderSystem extends System {
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE; sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE;
} }
let spriteData = SpriteLibrary[sprite.shape as string]; let spriteData = SpriteLibrary[effectiveShape as string];
if (!spriteData) { if (!spriteData) {
spriteData = SpriteLibrary[EntityType.SLIME]; spriteData = SpriteLibrary[EntityType.SLIME];
} }
@ -245,7 +307,6 @@ export class RenderSystem extends System {
this.ctx.restore(); this.ctx.restore();
} }
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
if (stealth && stealth.isStealthed) { if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite); this.drawStealthIndicator(stealth, sprite);
} }
@ -284,8 +345,9 @@ export class RenderSystem extends System {
ctx.fillStyle = p.color; ctx.fillStyle = p.color;
ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8; ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8;
const x = Math.floor(p.x); const screen = this.worldToScreen(p.x, p.y);
const y = Math.floor(p.y); const x = Math.floor(screen.x);
const y = Math.floor(screen.y);
const size = Math.floor(p.size); const size = Math.floor(p.size);
ctx.fillRect(x - size / 2, y - size / 2, size, size); ctx.fillRect(x - size / 2, y - size / 2, size, size);
@ -337,7 +399,13 @@ export class RenderSystem extends System {
ctx.save(); ctx.save();
if (sprite.shape === EntityType.SLIME) { const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
ctx.strokeStyle = Palette.CYAN; ctx.strokeStyle = Palette.CYAN;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
@ -353,7 +421,7 @@ export class RenderSystem extends System {
ctx.beginPath(); ctx.beginPath();
ctx.arc(length, 0, 2, 0, Math.PI * 2); ctx.arc(length, 0, 2, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} else if (sprite.shape === EntityType.BEAST) { } else if (effectiveShape === EntityType.BEAST) {
ctx.strokeStyle = Palette.WHITE; ctx.strokeStyle = Palette.WHITE;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
@ -365,7 +433,7 @@ export class RenderSystem extends System {
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, radius, start - 0.5, start + 0.5); ctx.arc(0, 0, radius, start - 0.5, start + 0.5);
ctx.stroke(); ctx.stroke();
} else if (sprite.shape === EntityType.HUMANOID) { } else if (effectiveShape === EntityType.HUMANOID) {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.lineWidth = 4; ctx.lineWidth = 4;
@ -481,7 +549,8 @@ export class RenderSystem extends System {
const progress = Math.min(1.0, effect.time / effect.lifetime); const progress = Math.min(1.0, effect.time / effect.lifetime);
const alpha = Math.max(0, 1.0 - progress); const alpha = Math.max(0, 1.0 - progress);
ctx.translate(effect.x, effect.y); const screen = this.worldToScreen(effect.x, effect.y);
ctx.translate(screen.x, screen.y);
ctx.rotate(effect.angle); ctx.rotate(effect.angle);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range); const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
@ -531,10 +600,13 @@ export class RenderSystem extends System {
currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time; currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time;
} }
const startScreen = this.worldToScreen(effect.startX, effect.startY);
const currentScreen = this.worldToScreen(currentX, currentY);
ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress)); ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress));
ctx.fillStyle = Palette.VOID; ctx.fillStyle = Palette.VOID;
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(effect.startX, effect.startY, 10, 5, 0, 0, Math.PI * 2); ctx.ellipse(startScreen.x, startScreen.y, 10, 5, 0, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
const alpha = Math.max(0, 0.8 * (1.0 - progress)); const alpha = Math.max(0, 0.8 * (1.0 - progress));
@ -542,15 +614,15 @@ export class RenderSystem extends System {
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(effect.startX, effect.startY); ctx.moveTo(startScreen.x, startScreen.y);
ctx.lineTo(currentX, currentY); ctx.lineTo(currentScreen.x, currentScreen.y);
ctx.stroke(); ctx.stroke();
const ringSize = progress * 40; const ringSize = progress * 40;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`; ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.arc(effect.startX, effect.startY, ringSize, 0, Math.PI * 2); ctx.arc(startScreen.x, startScreen.y, ringSize, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
} }
@ -563,18 +635,20 @@ export class RenderSystem extends System {
const alpha = Math.max(0, 1.0 - progress); const alpha = Math.max(0, 1.0 - progress);
const size = Math.max(0, 30 * (1 - progress)); const size = Math.max(0, 30 * (1 - progress));
const screen = this.worldToScreen(effect.x, effect.y);
if (size > 0 && alpha > 0) { if (size > 0 && alpha > 0) {
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.beginPath(); ctx.beginPath();
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2); ctx.arc(screen.x, screen.y, size, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2; const angle = (i / 8) * Math.PI * 2;
const dist = size * 0.7; const dist = size * 0.7;
const x = effect.x + Math.cos(angle) * dist; const x = screen.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist; const y = screen.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath(); ctx.beginPath();

View file

@ -1,10 +1,13 @@
import { System } from '../core/System.ts'; import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts'; import { SystemName, ComponentType, EntityType } from '../core/Constants.ts';
import { ColorSampler } from '../core/ColorSampler.ts';
import type { Entity } from '../core/Entity.ts'; import type { Entity } from '../core/Entity.ts';
import type { Stealth } from '../components/Stealth.ts'; import type { Stealth } from '../components/Stealth.ts';
import type { Velocity } from '../components/Velocity.ts'; import type { Velocity } from '../components/Velocity.ts';
import type { Combat } from '../components/Combat.ts'; import type { Combat } from '../components/Combat.ts';
import type { Evolution } from '../components/Evolution.ts'; import type { Evolution } from '../components/Evolution.ts';
import type { Sprite } from '../components/Sprite.ts';
import type { Position } from '../components/Position.ts';
import type { InputSystem } from './InputSystem.ts'; import type { InputSystem } from './InputSystem.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
@ -45,13 +48,23 @@ export class StealthSystem extends System {
stealth.stealthType = form; stealth.stealthType = form;
} }
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
const position = entity.getComponent<Position>(ComponentType.POSITION);
if (entity === player && inputSystem) { if (entity === player && inputSystem) {
const shiftPress = inputSystem.isKeyJustPressed('shift'); const shiftPress = inputSystem.isKeyJustPressed('shift');
if (shiftPress) { if (shiftPress) {
if (stealth.isStealthed) { if (stealth.isStealthed) {
stealth.exitStealth(); stealth.exitStealth();
if (sprite && stealth.baseColor) {
sprite.color = stealth.baseColor;
}
} else { } else {
stealth.enterStealth(stealth.stealthType); if (sprite) {
stealth.enterStealth(stealth.stealthType, sprite.color);
} else {
stealth.enterStealth(stealth.stealthType);
}
} }
} }
} }
@ -61,24 +74,51 @@ export class StealthSystem extends System {
stealth.updateStealth(isMoving || false, isInCombat || false); stealth.updateStealth(isMoving || false, isInCombat || false);
if (stealth.isStealthed) { if (stealth.isStealthed && sprite && position) {
switch (stealth.stealthType) { switch (stealth.stealthType) {
case 'slime': case 'slime': {
if (!isMoving) { if (!isMoving) {
stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2);
} }
const sampledColor = ColorSampler.sampleDominantColor(
this.engine.tileMap,
position.x,
position.y,
30
);
if (stealth.camouflageColor !== sampledColor) {
stealth.camouflageColor = sampledColor;
sprite.color = sampledColor;
}
sprite.scale = stealth.sizeMultiplier;
break; break;
case 'beast': }
case 'beast': {
if (isMoving && velocity) { if (isMoving && velocity) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed < 50) { if (speed < 50) {
stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1); stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1);
} }
} }
stealth.formAppearance = EntityType.BEAST;
sprite.scale = 1.0;
break; break;
case 'human': }
case 'human': {
stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05);
stealth.formAppearance = EntityType.HUMANOID;
sprite.scale = 1.0;
break; break;
}
}
} else if (!stealth.isStealthed && sprite) {
sprite.scale = 1.0;
if (stealth.baseColor) {
sprite.color = stealth.baseColor;
} }
} }
}); });