Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]
### Added
- Added "Render lights" toggle to show how lights would look ingame

## [2.3.0] 2026-04-24
### Added
- Added "File > Export PNG" to export the current map as a pixel perfect PNG image.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

import { GlobalEventsService } from '../../../../services/global-events.service';
Expand All @@ -15,15 +15,15 @@ export interface HistoryState {
}

@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class StateHistoryService {
private readonly eventsService = inject(GlobalEventsService);

maxStates = 100;

states = new BehaviorSubject<HistoryState[]>([]);
selectedState = new BehaviorSubject<HistoryStateContainer>({state: undefined});
selectedState = new BehaviorSubject<HistoryStateContainer>({ state: undefined });

init(state: HistoryState) {
this.selectedState.value.state = state;
Expand All @@ -34,19 +34,19 @@ export class StateHistoryService {
icon: string;
name: string;
json?: string;
}, ignoreCheck = false) {
}, ignoreCheck = false): boolean {
if (!state.json) {
const newState = Globals.map.exportMap();
const stateJson = JSON.stringify(newState);
if (!ignoreCheck) {
const val = this.selectedState.getValue();
if (val.state && val.state.json === stateJson) {
return;
return false;
}
}
state.json = stateJson;
}

this.eventsService.hasUnsavedChanges.next(true);

const states = this.states.getValue();
Expand All @@ -59,6 +59,7 @@ export class StateHistoryService {
}
states.push(selected.state);
this.states.next(states);
return true;
}

undo() {
Expand All @@ -69,7 +70,7 @@ export class StateHistoryService {
return;
}
i--;
this.selectedState.next({state: states[i]});
this.selectedState.next({ state: states[i] });
}

redo() {
Expand All @@ -80,6 +81,6 @@ export class StateHistoryService {
return;
}
i++;
this.selectedState.next({state: states[i]});
this.selectedState.next({ state: states[i] });
}
}
11 changes: 11 additions & 0 deletions webapp/src/app/components/toolbar/toolbar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@
Ingame Preview
</mat-checkbox>
</div>

<div>
<mat-checkbox
[checked]="(events.renderLights | async) ?? false"
[disabled]="is3d"
color="primary"
(change)="events.renderLights.next($event.checked)"
>
Render lights
</mat-checkbox>
</div>
</div>
</div>

Expand Down
5 changes: 4 additions & 1 deletion webapp/src/app/services/global-events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type GridSettings } from '../components/toolbar/grid-menu/grid-menu.com
import { Globals } from './globals';

@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class GlobalEventsService {
currentView = new BehaviorSubject<EditorView | undefined>(undefined);
Expand All @@ -34,5 +34,8 @@ export class GlobalEventsService {
babylonLoading = new BehaviorSubject<boolean>(false);
is3D = new BehaviorSubject<boolean>(false);

renderLights = new BehaviorSubject(false);
lightsChanged = new Subject<void>();

constructor() {}
}
206 changes: 206 additions & 0 deletions webapp/src/app/services/phaser/lightmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { Globals } from '../globals';
import { Scene } from 'phaser';
import { Subscription } from 'rxjs';
import { Helper } from './helper';

export interface WeatherTypes {
NONE: WeatherType;

[key: string]: Partial<WeatherType> | undefined;
}

export interface WeatherType {
lightMapDarkness: number;
glowColor: string;
}

const LIGHTMAP_KEY = 'media/map/lightmap.png';

// from CrossCode source
const LIGHT_METRIC = [
null,
{ x: 0, y: 0, w: 384, h: 384 }, // 1 XXXXL
{ x: 384, y: 0, w: 256, h: 256 }, // 2 XXXL
{ x: 448, y: 256, w: 192, h: 192 }, // 3 XXL
{ x: 0, y: 384, w: 128, h: 128 }, // 4 XL
{ x: 128, y: 448, w: 64, h: 64 }, // 5 L
{ x: 192, y: 464, w: 48, h: 48 }, // 6 M
{ x: 240, y: 480, w: 32, h: 32 }, // 7 S
{ x: 272, y: 480, w: 32, h: 32 }, // 8 XS
] as const;

const OFFSETS: [number, number][] = [
[0, 0], [0, -8], [8, 0], [0, 8], [-8, 0], [8, -8],
];

const FRAME_KEY = 'light_';
const TEXTURE_Z = 999999;

export class Lightmap extends Phaser.GameObjects.GameObject {

private subs: Subscription[] = [];
private renderTexture?: Phaser.GameObjects.RenderTexture;
private glowRenderTexture?: Phaser.GameObjects.RenderTexture;
private readonly imagePool: Phaser.GameObjects.Image[] = [];
private readonly ready: Promise<void>;


constructor(scene: Scene) {
super(scene, 'Lightmap');
this.ready = this.init();
this.subs.push(Globals.mapLoaderService.tileMap.subscribe((map) => {
if (map) {
void this.updateSize();
}
}));
this.subs.push(Globals.globalEventsService.resizeMap.subscribe(() => this.updateSize()));
this.subs.push(Globals.globalEventsService.renderLights.subscribe(() => this.renderLights()));
this.subs.push(Globals.globalEventsService.lightsChanged.subscribe(() => this.renderLights()));
}

private async init() {
await Helper.loadTexture(LIGHTMAP_KEY, this.scene);

const tex = this.scene.textures.get(LIGHTMAP_KEY);
for (let i = 1; i < LIGHT_METRIC.length; i++) {
const m = LIGHT_METRIC[i]!;
const frameKey = `${FRAME_KEY}${i}`;
if (!tex.has(frameKey)) {
tex.add(frameKey, 0, m.x, m.y, m.w, m.h);
}
}
}

private poolImage(index: number): Phaser.GameObjects.Image {
let img = this.imagePool[index];
if (!img) {
img = this.scene.make.image({ key: LIGHTMAP_KEY, add: false })
.setOrigin(0.5, 0.5)
.setBlendMode(Phaser.BlendModes.ADD);
this.imagePool[index] = img;
}
return img;
}

private async renderLights() {

if (!this.renderTexture || !this.glowRenderTexture) {
return;
}

this.renderTexture.visible = false;
this.glowRenderTexture.visible = false;

if (!Globals.globalEventsService.renderLights.getValue()) {
return;
}

const map = Globals.map;
const lightLayer = map.layers.find(l => l.details.type === 'Light');

if (!lightLayer) {
return;
}

await this.ready;

this.renderTexture.visible = true;
this.glowRenderTexture.visible = true;

this.renderTexture.fill(0x000000, 1);
this.glowRenderTexture.fill(0x000000, 1);

const weatherTypes = await Globals.jsonLoader.loadJsonMerged<WeatherTypes>('weather-types.json');
const weatherType = {
...weatherTypes.NONE,
...weatherTypes[map.attributes.weather],
};
this.renderTexture.alpha = 1 - weatherType.lightMapDarkness;

const glowColor = Phaser.Display.Color.HexStringToColor(weatherType.glowColor).color;
const tileSize = Globals.TILE_SIZE;
const data = lightLayer.getPhaserLayer().layer.data;

const eraseEntries: Phaser.GameObjects.Image[] = [];
const glowEntries: Phaser.GameObjects.Image[] = [];
let poolIndex = 0;

for (let row = 0; row < data.length; row++) {
for (let col = 0; col < data[row].length; col++) {
const tile = data[row][col].index;
if (tile <= 0) {
continue;
}

const n = tile - 1;
const lightSize = (n % 32) % 5 + 1;
const glowType = Math.floor((n % 32) / 5);
const offsetType = Math.floor(n / 32);

const [offX, offY] = OFFSETS[offsetType] ?? [0, 0];
const cx = col * tileSize + tileSize / 2 + offX;
const cy = row * tileSize + tileSize / 2 + offY;

if (glowType !== 4) {
const img = this.poolImage(poolIndex++);
img.setFrame(`${FRAME_KEY}${lightSize}`).setPosition(cx, cy);
eraseEntries.push(img);
}

if (glowType > 0) {
const glowSize = glowType === 4 ? lightSize : lightSize + glowType - 1;
if (glowSize >= 1 && glowSize < LIGHT_METRIC.length) {
const img = this.poolImage(poolIndex++);
img.setFrame(`${FRAME_KEY}${glowSize}`).setPosition(cx, cy).setTint(glowColor);
glowEntries.push(img);
}
}
}
}

this.renderTexture!.erase(eraseEntries);

// glow can't be batched because of blend mode ADD
for (const img of glowEntries) {
this.glowRenderTexture!.draw(img);
}
}

private createRenderTexture(width: number, height: number, depth: number, blendMode?: Phaser.BlendModes) {
const texture = this.scene.add.renderTexture(0, 0, width, height);
texture.setOrigin(0, 0);
texture.depth = depth;
if (blendMode !== undefined) {
texture.setBlendMode(blendMode);
}
return texture;
}

private async updateSize() {
const width = Globals.map.mapWidth * Globals.TILE_SIZE;
const height = Globals.map.mapHeight * Globals.TILE_SIZE;

if (this.renderTexture) {
this.renderTexture.resize(width, height);
} else {
this.renderTexture = this.createRenderTexture(width, height, TEXTURE_Z);
}

if (this.glowRenderTexture) {
this.glowRenderTexture.resize(width, height);
} else {
this.glowRenderTexture = this.createRenderTexture(width, height, TEXTURE_Z - 1, Phaser.BlendModes.ADD);
}

await this.renderLights();
}

override destroy(fromScene?: boolean) {
super.destroy(fromScene);
this.renderTexture?.destroy();
this.glowRenderTexture?.destroy();
this.imagePool.forEach(img => img.destroy());
this.subs.forEach(s => s.unsubscribe());
}

}
7 changes: 6 additions & 1 deletion webapp/src/app/services/phaser/main-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ import { TileDrawer } from './tilemap/tile-drawer';
import { LayerParallax } from './layer-parallax';
import { IngamePreview } from './ingame-preview';
import { EntityGrid } from './entity-grid';
import { Lightmap } from './lightmap';

export class MainScene extends Phaser.Scene {

private sub?: Subscription;

constructor() {
super({key: 'main'});
super({ key: 'main' });
}

preload() {

this.load.image('pixel', 'assets/pixel.png');
this.load.image('ingame', 'assets/ingame.png');


this.load.crossOrigin = 'anonymous';

// this.load.on('progress', (val: number) => console.log(val));
Expand Down Expand Up @@ -80,6 +82,9 @@ export class MainScene extends Phaser.Scene {
const grid = new EntityGrid(this);
this.add.existing(grid);

const lightmap = new Lightmap(this);
this.add.existing(lightmap);

Globals.globalEventsService.currentView.subscribe(view => {
tileDrawer.setActive(false);
entityManager.setActive(false);
Expand Down
Loading
Loading