diff --git a/CHANGELOG.md b/CHANGELOG.md index 7658959f..db7bbdc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/webapp/src/app/components/dialogs/floating-window/history/state-history.service.ts b/webapp/src/app/components/dialogs/floating-window/history/state-history.service.ts index 5bf01328..48c477ab 100644 --- a/webapp/src/app/components/dialogs/floating-window/history/state-history.service.ts +++ b/webapp/src/app/components/dialogs/floating-window/history/state-history.service.ts @@ -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'; @@ -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([]); - selectedState = new BehaviorSubject({state: undefined}); + selectedState = new BehaviorSubject({ state: undefined }); init(state: HistoryState) { this.selectedState.value.state = state; @@ -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(); @@ -59,6 +59,7 @@ export class StateHistoryService { } states.push(selected.state); this.states.next(states); + return true; } undo() { @@ -69,7 +70,7 @@ export class StateHistoryService { return; } i--; - this.selectedState.next({state: states[i]}); + this.selectedState.next({ state: states[i] }); } redo() { @@ -80,6 +81,6 @@ export class StateHistoryService { return; } i++; - this.selectedState.next({state: states[i]}); + this.selectedState.next({ state: states[i] }); } } diff --git a/webapp/src/app/components/toolbar/toolbar.component.html b/webapp/src/app/components/toolbar/toolbar.component.html index 15e48977..57303cfe 100644 --- a/webapp/src/app/components/toolbar/toolbar.component.html +++ b/webapp/src/app/components/toolbar/toolbar.component.html @@ -55,6 +55,17 @@ Ingame Preview + +
+ + Render lights + +
diff --git a/webapp/src/app/services/global-events.service.ts b/webapp/src/app/services/global-events.service.ts index 22fa3e27..3e97222c 100644 --- a/webapp/src/app/services/global-events.service.ts +++ b/webapp/src/app/services/global-events.service.ts @@ -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(undefined); @@ -34,5 +34,8 @@ export class GlobalEventsService { babylonLoading = new BehaviorSubject(false); is3D = new BehaviorSubject(false); + renderLights = new BehaviorSubject(false); + lightsChanged = new Subject(); + constructor() {} } diff --git a/webapp/src/app/services/phaser/lightmap.ts b/webapp/src/app/services/phaser/lightmap.ts new file mode 100644 index 00000000..f1c2aefa --- /dev/null +++ b/webapp/src/app/services/phaser/lightmap.ts @@ -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 | 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; + + + 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('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()); + } + +} diff --git a/webapp/src/app/services/phaser/main-scene.ts b/webapp/src/app/services/phaser/main-scene.ts index a703ac2b..24f05c2f 100644 --- a/webapp/src/app/services/phaser/main-scene.ts +++ b/webapp/src/app/services/phaser/main-scene.ts @@ -10,13 +10,14 @@ 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() { @@ -24,6 +25,7 @@ export class MainScene extends Phaser.Scene { 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)); @@ -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); diff --git a/webapp/src/app/services/phaser/tilemap/tile-drawer.ts b/webapp/src/app/services/phaser/tilemap/tile-drawer.ts index f53e5b60..b120d635 100644 --- a/webapp/src/app/services/phaser/tilemap/tile-drawer.ts +++ b/webapp/src/app/services/phaser/tilemap/tile-drawer.ts @@ -26,7 +26,7 @@ export class TileDrawer extends BaseObject { private visibilityKey!: Phaser.Input.Keyboard.Key; private shiftKey!: Phaser.Input.Keyboard.Key; private fillKey!: Phaser.Input.Keyboard.Key; - private lastDraw: Point = {x: -1, y: -1}; + private lastDraw: Point = { x: -1, y: -1 }; private dirty = false; @@ -75,8 +75,8 @@ export class TileDrawer extends BaseObject { // draw tiles // trigger only when mouse is over canvas element (the renderer), avoids triggering when interacting with ui if (pointer.leftButtonDown() && pointer.downElement?.nodeName === 'CANVAS' && this.layer) { - const finalPos = {x: 0, y: 0}; - const startPos = {x: 0, y: 0}; + const finalPos = { x: 0, y: 0 }; + const startPos = { x: 0, y: 0 }; // skip drawing if last frame was the same if (this.lastDraw.x === p.x && this.lastDraw.y === p.y) { @@ -133,7 +133,7 @@ export class TileDrawer extends BaseObject { this.fill(); } }; - this.addKeybinding({event: 'up', fun: fill, emitter: this.fillKey}); + this.addKeybinding({ event: 'up', fun: fill, emitter: this.fillKey }); const leftUp = (pointer: Phaser.Input.Pointer) => { @@ -152,13 +152,10 @@ export class TileDrawer extends BaseObject { } this.dirty = false; - Globals.stateHistoryService.saveState({ - name: 'Tile Drawer', - icon: 'create', - }); + this.updateState('tile-drawer', 'create'); }; - this.addKeybinding({event: 'pointerup', fun: leftUp, emitter: this.scene.input}); - this.addKeybinding({event: 'pointerupoutside', fun: leftUp, emitter: this.scene.input}); + this.addKeybinding({ event: 'pointerup', fun: leftUp, emitter: this.scene.input }); + this.addKeybinding({ event: 'pointerupoutside', fun: leftUp, emitter: this.scene.input }); const transparent = () => { if (!Helper.isInputFocused()) { @@ -166,14 +163,14 @@ export class TileDrawer extends BaseObject { this.setLayerAlpha(); } }; - this.addKeybinding({event: 'up', fun: transparent, emitter: this.transparentKey}); + this.addKeybinding({ event: 'up', fun: transparent, emitter: this.transparentKey }); const visible = () => { if (!Helper.isInputFocused()) { Globals.globalEventsService.toggleVisibility.next(); } }; - this.addKeybinding({event: 'up', fun: visible, emitter: this.visibilityKey}); + this.addKeybinding({ event: 'up', fun: visible, emitter: this.visibilityKey }); } private setLayerAlpha() { @@ -198,11 +195,18 @@ export class TileDrawer extends BaseObject { if (this.selectedTiles.length > 0) { Filler.fill(this.layer, this.selectedTiles[0].id, p); - Globals.stateHistoryService.saveState({ - name: 'fill', - icon: 'format_color_fill' - }); + this.updateState('fill', 'format_color_fill'); } } + + private updateState(name: string, icon: string) { + const stateAdded = Globals.stateHistoryService.saveState({ + name: name, + icon: icon, + }); + if (stateAdded && this.layer?.details.type === 'Light') { + Globals.globalEventsService.lightsChanged.next(); + } + } } diff --git a/webapp/src/assets/weather-types.json b/webapp/src/assets/weather-types.json new file mode 100644 index 00000000..a7bec3b0 --- /dev/null +++ b/webapp/src/assets/weather-types.json @@ -0,0 +1,293 @@ +{ + "NONE": { + "lightMapDarkness": 0.6, + "glowColor": "#000000" + }, + "DUSTY": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "ROOKIE_HARBOR_INNER": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "EVO_VILLAGE_INNER": { + "lightMapDarkness": 0.6, + "glowColor": "#1b1c19" + }, + "EXPO_SPACE": { + "lightMapDarkness": 0.2 + }, + "OLD_HIDEOUT_OUTSIDE": { + "lightMapDarkness": 0.2, + "glowColor": "#000b2c" + }, + "OLD_HIDEOUT_OUTSIDE_ALT": { + "lightMapDarkness": 0.6, + "glowColor": "#331251" + }, + "OLD_HIDEOUT_INNER": { + "lightMapDarkness": 0.8, + "glowColor": "#302313" + }, + "OLD_HIDEOUT_OFFICE": { + "lightMapDarkness": 0.8, + "glowColor": "#302313" + }, + "RHOMBUS_DUNGEON": { + "glowColor": "#101112" + }, + "CAVE": { + "lightMapDarkness": 0.8 + }, + "CAVE_BERGEN": { + "lightMapDarkness": 0.8 + }, + "BERGEN_INNER": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "COLD_DUNGEON": { + "lightMapDarkness": 0.5, + "glowColor": "#021116" + }, + "COLD_DUNGEON_DARK": { + "lightMapDarkness": 0.7, + "glowColor": "#021116" + }, + "COLD_DUNGEON_POST_BOSS": { + "lightMapDarkness": 0.9, + "glowColor": "#021116" + }, + "HEAT_VILLAGE_INNER": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "HEAT_VILLAGE_INNER_DUSTY": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "HEAT_DUNGEON": { + "lightMapDarkness": 0.5, + "glowColor": "#140e08" + }, + "HEAT_DUNGEON_MIDBOSS": { + "lightMapDarkness": 0.5, + "glowColor": "#140e08" + }, + "HEAT_DUNGEON_COAL": { + "lightMapDarkness": 0.5, + "glowColor": "#140e08" + }, + "HEAT_DUNGEON_BOSS": { + "lightMapDarkness": 0.5, + "glowColor": "#140e08" + }, + "UNKNOWN_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#4c1221" + }, + "OFFICE": { + "lightMapDarkness": 0.6, + "glowColor": "#0f0f0f" + }, + "LOBBY": { + "lightMapDarkness": 0.8, + "glowColor": "#1e3036" + }, + "LOBBY_DARK": { + "lightMapDarkness": 1, + "glowColor": "#1e3036" + }, + "FLAT": { + "lightMapDarkness": 0.8, + "glowColor": "#43392c" + }, + "FLAT_DARK": { + "lightMapDarkness": 1, + "glowColor": "#43392c" + }, + "JUNGLE_RAINY_LIGHT": { + "glowColor": "#332a17" + }, + "JUNGLE_RAINY": { + "glowColor": "#332a17" + }, + "JUNGLE_INFESTED_PRE": { + "lightMapDarkness": 0.5, + "glowColor": "#32133b" + }, + "JUNGLE_INFESTED": { + "lightMapDarkness": 0.5, + "glowColor": "#32133b" + }, + "CAVE_INFESTED": { + "lightMapDarkness": 0.6, + "glowColor": "#32133b" + }, + "JUNGLE_CITY_PRE": { + "lightMapDarkness": 0.2, + "glowColor": "#332a17" + }, + "JUNGLE_CITY": { + "glowColor": "#332a17" + }, + "JUNGLE_CITY_INNER": { + "lightMapDarkness": 0.6, + "glowColor": "#2e271a" + }, + "JUNGLE_WAVE_TEMPLE": { + "glowColor": "#173133" + }, + "WAVE_DNG_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#0a2f29" + }, + "WAVE_DNG_INNER_FISH": { + "lightMapDarkness": 0.8, + "glowColor": "#0a2f29" + }, + "SHOCK_DNG_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#220b28" + }, + "TREE_DNG_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#0a2f29" + }, + "TREE_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#0a2f29" + }, + "TREE_INNER_INFESTED": { + "lightMapDarkness": 0.5, + "glowColor": "#0a2f29" + }, + "TREE_DNG_INNER_WAVE": { + "lightMapDarkness": 0.5, + "glowColor": "#0a2f29" + }, + "TREE_DNG_INNER_SHOCK": { + "lightMapDarkness": 0.5, + "glowColor": "#220b28" + }, + "SPOOKY_INNER": { + "lightMapDarkness": 0.7, + "glowColor": "#211f1b" + }, + "CAVE_FOREST": { + "lightMapDarkness": 0.8, + "glowColor": "#20134c" + }, + "CAVE_ARID": { + "lightMapDarkness": 0.8, + "glowColor": "#4c121d" + }, + "CAVE_ARID_CLOSER": { + "lightMapDarkness": 0.8, + "glowColor": "#371008" + }, + "ARID_OUTSIDE": { + "lightMapDarkness": 0.5, + "glowColor": "#4c121d" + }, + "ARID_INSIDE": { + "lightMapDarkness": 0.5, + "glowColor": "#4c1221" + }, + "ARID_ELEVATOR_UP": { + "lightMapDarkness": 0.8, + "glowColor": "#4c1221" + }, + "ARID_ELEVATOR_DOWN": { + "lightMapDarkness": 0.8, + "glowColor": "#4c1221" + }, + "ARID_BOSS": { + "lightMapDarkness": 0.8, + "glowColor": "#4c1221" + }, + "ARID_END_SCENE": { + "lightMapDarkness": 0.8, + "glowColor": "#4c1221" + }, + "ARID_BETWEEN": { + "lightMapDarkness": 0.5, + "glowColor": "#4c1221" + }, + "ARID_DNG_OUTSIDE": { + "lightMapDarkness": 0.5, + "glowColor": "#4d4032" + }, + "SAPPHIRE_RIDGE": { + "lightMapDarkness": 0.6, + "glowColor": "#331251" + }, + "SAPPHIRE_RIDGE_BUILDING": { + "lightMapDarkness": 0.6, + "glowColor": "#4c3127" + }, + "SAPPHIRE_RIDGE_INNER": { + "lightMapDarkness": 0.65, + "glowColor": "#302313" + }, + "FLASHBACK_OFFICE": { + "lightMapDarkness": 1, + "glowColor": "#0f0f0f" + }, + "FLASHBACK_HIDEOUT": { + "lightMapDarkness": 1, + "glowColor": "#411e61" + }, + "FLASHBACK_HIDEOUT_INNER": { + "lightMapDarkness": 1, + "glowColor": "#411e61" + }, + "FLASHBACK_ARID": { + "lightMapDarkness": 1, + "glowColor": "#4c121d" + }, + "FLASHBACK_DIAGRAM": { + "lightMapDarkness": 1, + "glowColor": "#4c121d" + }, + "TREE_SPACE": { + "lightMapDarkness": 0.5, + "glowColor": "#4c1221" + }, + "RHOMBUS_SQUARE_INNER": { + "lightMapDarkness": 0.8, + "glowColor": "#161c24" + }, + "GAUTHAM_ROOM": { + "lightMapDarkness": 0.7, + "glowColor": "#360e0f" + }, + "LAB": { + "lightMapDarkness": 0.5, + "glowColor": "#623212" + }, + "FINAL_DNG_OUTER": { + "lightMapDarkness": 0.5, + "glowColor": "#294058" + }, + "FINAL_DNG_OUTER_BATTLE": { + "lightMapDarkness": 0.5, + "glowColor": "#294058" + }, + "FINAL_DNG_INNER": { + "lightMapDarkness": 0.5, + "glowColor": "#373632" + }, + "FINAL_DNG_INNER_TELEPORT": { + "lightMapDarkness": 0.5, + "glowColor": "#2c3737" + }, + "FINAL_DNG_INNER_BATTLE": { + "lightMapDarkness": 0.5, + "glowColor": "#373632" + }, + "BEACH_DEFAULT": { + "glowColor": "#e9d8a9" + } +}