diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a6889bb..811909da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ 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] -## [1.4.0] 2024-02-23 +### Added +- Added UI for Entity Grid (hotkey G) with additional settings [#292](https://github.com/CCDirectLink/crosscode-map-editor/issues/292) +## [1.4.0] 2024-02-23 ### Added - Added proper support for the event steps `SHOW_MODAL_CHOICE` and `SET_MSG_EXPRESSION`. - Added rendering of text colors in relevant events (such as `SHOW_MSG`). diff --git a/webapp/package.json b/webapp/package.json index 3a0903f1..c1198254 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -71,8 +71,8 @@ "@angular/platform-browser": "^17.1.2", "@angular/platform-browser-dynamic": "^17.1.2", "@angular/router": "^17.1.2", - "@babylonjs/core": "^5.50.1", - "@babylonjs/gui": "^5.50.1", + "@babylonjs/core": "5.50.1", + "@babylonjs/gui": "5.50.1", "@types/earcut": "^2.1.1", "@types/jasmine": "~4.3.0", "@types/jsoneditor": "^9.9.0", diff --git a/webapp/src/app/app.module.ts b/webapp/src/app/app.module.ts index 6dd38bad..05d624c8 100644 --- a/webapp/src/app/app.module.ts +++ b/webapp/src/app/app.module.ts @@ -68,6 +68,8 @@ import { ResizedDirective } from './directives/resized.directive'; import { MaterialModule } from './external-modules/material.module'; import { CombinedTooltipPipe } from './pipes/combined-tooltip.pipe'; import { KeepHtmlPipe } from './pipes/keep-html.pipe'; +import { ToolbarDividerComponent } from './components/toolbar/toolbar-divider/toolbar-divider.component'; +import { GridMenuComponent } from './components/toolbar/grid-menu/grid-menu.component'; const WIDGETS = [ StringWidgetComponent, @@ -147,6 +149,8 @@ const WIDGETS = [ AutofocusDirective, CombinedTooltipPipe, InputWithButtonComponent, + ToolbarDividerComponent, + GridMenuComponent, ], bootstrap: [AppComponent], }) diff --git a/webapp/src/app/components/dialogs/map-settings/map-settings.component.ts b/webapp/src/app/components/dialogs/map-settings/map-settings.component.ts index e13749c2..1e669b64 100644 --- a/webapp/src/app/components/dialogs/map-settings/map-settings.component.ts +++ b/webapp/src/app/components/dialogs/map-settings/map-settings.component.ts @@ -4,6 +4,7 @@ import { CrossCodeMap } from '../../../models/cross-code-map'; import { MapLoaderService } from '../../../services/map-loader.service'; import { CCMap } from '../../../services/phaser/tilemap/cc-map'; import { OverlayRefControl } from '../overlay/overlay-ref-control'; +import { GlobalEventsService } from '../../../services/global-events.service'; @Component({ selector: 'app-map-settings', @@ -20,7 +21,8 @@ export class MapSettingsComponent { constructor( loader: MapLoaderService, - public ref: OverlayRefControl + public ref: OverlayRefControl, + private events: GlobalEventsService ) { const tileMap = loader.tileMap.getValue(); @@ -56,7 +58,10 @@ export class MapSettingsComponent { tileMap.masterLevel = settings.masterLevel; tileMap.attributes = settings.attributes; - tileMap.resize(settings.mapWidth, settings.mapHeight); + this.events.resizeMap.next({ + x: settings.mapWidth, + y: settings.mapHeight + }); this.ref.close(); } diff --git a/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.html b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.html new file mode 100644 index 00000000..c27e28db --- /dev/null +++ b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.html @@ -0,0 +1,61 @@ +
+ + + Entity Grid + +
+ +
+
+ + Size + + + + + Offset + + + + + Visible + +
+
+
diff --git a/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.scss b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.scss new file mode 100644 index 00000000..76474a64 --- /dev/null +++ b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.scss @@ -0,0 +1,15 @@ +:host { + display: flex; + align-items: center; + height: 100%; +} + +.point-form-field { + width: 170px; +} + +.extended-menu { + overflow-x: hidden; + min-width: 0; + height: 100%; +} diff --git a/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.ts b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.ts new file mode 100644 index 00000000..17243a26 --- /dev/null +++ b/webapp/src/app/components/toolbar/grid-menu/grid-menu.component.ts @@ -0,0 +1,96 @@ +import { Component, effect, OnInit } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { FormsModule } from '@angular/forms'; +import { Globals } from '../../../services/globals'; +import { NgIf } from '@angular/common'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { PointInputComponent } from '../vec-input/point-input.component'; +import { Helper } from '../../../services/phaser/helper'; +import { Point } from '../../../models/cross-code-map'; +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { GlobalEventsService } from '../../../services/global-events.service'; + +export interface GridSettings { + size: Point; + offset: Point; + color: string; + enableGrid: boolean; + showSettings?: boolean; + visible?: boolean; +} + +const gridSettingsKey = 'gridSettingsKey'; + +@Component({ + selector: 'app-grid-menu', + standalone: true, + animations: [ + trigger('openClose', [ + state('void', style({ + width: '0', + margin: '0' + })), + transition('* <=> *', [ + animate('100ms ease'), + ]), + ]) + ], + imports: [ + MatCheckbox, + MatIconButton, + MatIcon, + FormsModule, + NgIf, + MatFormField, + MatInput, + MatLabel, + PointInputComponent + ], + templateUrl: './grid-menu.component.html', + styleUrl: './grid-menu.component.scss' +}) +export class GridMenuComponent implements OnInit { + + gridSettings = Globals.gridSettings; + + constructor( + events: GlobalEventsService + ) { + effect(() => { + const settings = this.gridSettings(); + localStorage.setItem(gridSettingsKey, JSON.stringify(settings)); + events.gridSettings.next(settings); + }); + } + + ngOnInit() { + try { + const settings = JSON.parse(localStorage.getItem(gridSettingsKey)!) as Partial; + Globals.gridSettings.update(old => ({ + ...old, + ...settings + })); + } catch (e) { + } + } + + update(newSettings: Partial) { + Globals.gridSettings.update(old => { + const cpy = Helper.copy(old); + Object.assign(cpy, newSettings); + return cpy; + }); + } + + protected readonly MatCheckbox = MatCheckbox; + + toggleSettings() { + Globals.gridSettings.update(old => ({ + ...old, + showSettings: !old.showSettings + })); + } +} diff --git a/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.scss b/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.scss new file mode 100644 index 00000000..d56c938f --- /dev/null +++ b/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.scss @@ -0,0 +1,6 @@ +:host { + width: 1px; + height: 100%; + background: rgba(255, 255, 255, 0.24); + margin: 0 28px; +} diff --git a/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.ts b/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.ts new file mode 100644 index 00000000..ec4395cb --- /dev/null +++ b/webapp/src/app/components/toolbar/toolbar-divider/toolbar-divider.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-toolbar-divider', + standalone: true, + imports: [], + template: '', + styleUrl: './toolbar-divider.component.scss' +}) +export class ToolbarDividerComponent { +} diff --git a/webapp/src/app/components/toolbar/toolbar.component.html b/webapp/src/app/components/toolbar/toolbar.component.html index 9baa59cf..5cc37ea2 100644 --- a/webapp/src/app/components/toolbar/toolbar.component.html +++ b/webapp/src/app/components/toolbar/toolbar.component.html @@ -1,7 +1,7 @@ -
-
+
+
@@ -54,14 +54,19 @@
-
- {{map?.name}} + + + + +
+ {{ map?.name }}
-
+ +
- {{error}} + {{ error }}
diff --git a/webapp/src/app/components/toolbar/vec-input/point-input.component.html b/webapp/src/app/components/toolbar/vec-input/point-input.component.html new file mode 100644 index 00000000..34bb244d --- /dev/null +++ b/webapp/src/app/components/toolbar/vec-input/point-input.component.html @@ -0,0 +1,19 @@ +
+ X: + + Y: + +
diff --git a/webapp/src/app/components/toolbar/vec-input/point-input.component.scss b/webapp/src/app/components/toolbar/vec-input/point-input.component.scss new file mode 100644 index 00000000..e27c82fb --- /dev/null +++ b/webapp/src/app/components/toolbar/vec-input/point-input.component.scss @@ -0,0 +1,21 @@ +input { + border: none; + background: none; + padding: 0 0 0 4px; + outline: none; + font: inherit; + color: currentcolor; + min-width: 0; + flex: 1; + background: rgba(255, 255, 255, 0.1); + border-radius: 1px; +} + +.input-label { + opacity: 0; + transition: opacity 200ms; +} + +:host.floating .input-label { + opacity: 1; +} diff --git a/webapp/src/app/components/toolbar/vec-input/point-input.component.ts b/webapp/src/app/components/toolbar/vec-input/point-input.component.ts new file mode 100644 index 00000000..f9349c65 --- /dev/null +++ b/webapp/src/app/components/toolbar/vec-input/point-input.component.ts @@ -0,0 +1,147 @@ +import { Component, ElementRef, HostBinding, Input, OnDestroy, Optional, Self } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, FormsModule, NgControl, ReactiveFormsModule } from '@angular/forms'; +import { Point } from '../../../models/cross-code-map'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; +import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'app-point-input', + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule + ], + providers: [{provide: MatFormFieldControl, useExisting: PointInputComponent}], + templateUrl: './point-input.component.html', + styleUrl: './point-input.component.scss' +}) +export class PointInputComponent implements MatFormFieldControl, OnDestroy, ControlValueAccessor { + parts: FormGroup; + stateChanges = new Subject(); + + @Input() + min = 1; + + onChange = (_: any) => { + }; + + @Input() + get value(): Point | null { + return this.parts.value; + } + + set value(point: Point | null) { + point = point || {x: 1, y: 1}; + this.parts.setValue({...point}); + this.stateChanges.next(); + } + + static nextId = 0; + @HostBinding() id = `point-input-${PointInputComponent.nextId++}`; + + readonly placeholder = ''; + + focused = false; + touched = false; + onTouched = () => { + }; + + onFocusIn(event: FocusEvent) { + if (!this.focused) { + this.focused = true; + this.stateChanges.next(); + } + } + + onFocusOut(event: FocusEvent) { + if (!this.elementRef.nativeElement.contains(event.relatedTarget as Element)) { + this.touched = true; + this.focused = false; + this.onTouched(); + this.stateChanges.next(); + } + } + + get empty() { + const p = this.parts.value as Point; + return isNaN(p.x) && isNaN(p.y); + } + + @HostBinding('class.floating') + get shouldLabelFloat() { + return this.focused || !this.empty; + } + + required = false; + + private _disabled = false; + + @Input() + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: BooleanInput) { + this._disabled = coerceBooleanProperty(value); + if (this._disabled) { + this.parts.disable(); + } else { + this.parts.enable(); + } + this.stateChanges.next(); + } + + get errorState(): boolean { + return this.parts.invalid && this.touched; + } + + controlType = 'point-input'; + + setDescribedByIds(ids: string[]): void { + } + + onContainerClick(event: MouseEvent): void { + if ((event.target as Element).tagName.toLowerCase() !== 'input') { + this.elementRef.nativeElement.querySelector('input')?.focus(); + } + } + + constructor( + fb: FormBuilder, + private elementRef: ElementRef, + @Optional() @Self() public ngControl: NgControl, + ) { + this.parts = fb.group({ + x: 0, + y: 0 + }); + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + } + + writeValue(p: Point | null): void { + this.value = p; + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + update() { + this.onChange(this.value); + } +} diff --git a/webapp/src/app/services/global-events.service.ts b/webapp/src/app/services/global-events.service.ts index edc62133..4c9814ac 100644 --- a/webapp/src/app/services/global-events.service.ts +++ b/webapp/src/app/services/global-events.service.ts @@ -3,6 +3,8 @@ import { BehaviorSubject, Subject } from 'rxjs'; import { MapEntity, Point, Point3 } from '../models/cross-code-map'; import { EditorView } from '../models/editor-view'; import { CCEntity } from './phaser/entities/cc-entity'; +import { GridSettings } from '../components/toolbar/grid-menu/grid-menu.component'; +import { Globals } from './globals'; @Injectable({ providedIn: 'root' @@ -16,17 +18,19 @@ export class GlobalEventsService { loadComplete = new Subject(); generateHeights = new Subject(); offsetMap = new Subject(); + resizeMap = new Subject(); offsetEntities = new Subject(); toggleVisibility = new Subject(); showAddEntityMenu = new Subject(); - + updateCoords = new Subject(); updateTileSelectionSize = new Subject(); showIngamePreview = new BehaviorSubject(false); hasUnsavedChanges = new BehaviorSubject(false); - + gridSettings = new BehaviorSubject(Globals.gridSettings()); + babylonLoading = new BehaviorSubject(false); is3D = new BehaviorSubject(false); - + constructor() {} } diff --git a/webapp/src/app/services/globals.ts b/webapp/src/app/services/globals.ts index c0a20497..73393a92 100644 --- a/webapp/src/app/services/globals.ts +++ b/webapp/src/app/services/globals.ts @@ -8,6 +8,8 @@ import { PhaserEventsService } from './phaser/phaser-events.service'; import { CCMap } from './phaser/tilemap/cc-map'; import { MatSnackBar } from '@angular/material/snack-bar'; import { SettingsService } from './settings.service'; +import { signal } from '@angular/core'; +import { GridSettings } from '../components/toolbar/grid-menu/grid-menu.component'; export class Globals { static isElectron = false; @@ -17,10 +19,12 @@ export class Globals { static map: CCMap; static TILE_SIZE = 16; static URL = 'http://localhost:8080/'; - static entitySettings = { - gridSize: 8, + static gridSettings = signal({ + size: {x: 8, y: 8}, + offset: {x: 0, y: 0}, + color: '#222222', enableGrid: false - }; + }); static disablePhaserInput = new Set(); // TODO: remove them from global state diff --git a/webapp/src/app/services/phaser/entities/cc-entity.ts b/webapp/src/app/services/phaser/entities/cc-entity.ts index 6b268ff2..65d2f187 100644 --- a/webapp/src/app/services/phaser/entities/cc-entity.ts +++ b/webapp/src/app/services/phaser/entities/cc-entity.ts @@ -193,20 +193,20 @@ export abstract class CCEntity extends BaseObject { container.x = Math.round(p.worldX - this.startOffset.x); container.y = Math.round(p.worldY - this.startOffset.y); - const settings = Globals.entitySettings; + const settings = Globals.gridSettings(); if (settings.enableGrid) { - const diffX = container.x % settings.gridSize; - if (diffX * 2 < settings.gridSize) { + const diffX = (container.x - settings.offset.x) % settings.size.x; + if (diffX * 2 < settings.size.x) { container.x -= diffX; } else { - container.x += settings.gridSize - diffX; + container.x += settings.size.x - diffX; } - const diffY = container.y % settings.gridSize; - if (diffY * 2 < settings.gridSize) { + const diffY = (container.y - settings.offset.y) % settings.size.y; + if (diffY * 2 < settings.size.y) { container.y -= diffY; } else { - container.y += settings.gridSize - diffY; + container.y += settings.size.y - diffY; } } this.updateZIndex(); diff --git a/webapp/src/app/services/phaser/entities/entity-manager.ts b/webapp/src/app/services/phaser/entities/entity-manager.ts index 3d0d4af1..9b578a56 100644 --- a/webapp/src/app/services/phaser/entities/entity-manager.ts +++ b/webapp/src/app/services/phaser/entities/entity-manager.ts @@ -246,7 +246,10 @@ export class EntityManager extends BaseObject { if (Helper.isInputFocused()) { return; } - Globals.entitySettings.enableGrid = !Globals.entitySettings.enableGrid; + Globals.gridSettings.update(settings => ({ + ...settings, + enableGrid: !settings.enableGrid + })); } }); diff --git a/webapp/src/app/services/phaser/entity-grid.ts b/webapp/src/app/services/phaser/entity-grid.ts new file mode 100644 index 00000000..0aaf8907 --- /dev/null +++ b/webapp/src/app/services/phaser/entity-grid.ts @@ -0,0 +1,72 @@ +import { Scene } from 'phaser'; +import { Globals } from '../globals'; +import { debounceTime, merge, Subscription } from 'rxjs'; +import { Point } from '../../models/cross-code-map'; + +export class EntityGrid extends Phaser.GameObjects.GameObject { + private sub: Subscription; + private grid?: Phaser.GameObjects.Grid; + + constructor(scene: Scene) { + super(scene, 'EntityGrid'); + + this.sub = merge( + Globals.globalEventsService.gridSettings, + Globals.mapLoaderService.map, + Globals.globalEventsService.resizeMap + ).pipe(debounceTime(1)).subscribe(() => this.updateGrid()); + } + + updateGrid() { + const settings = Globals.gridSettings(); + const scale = 4; + this.grid?.destroy(); + if (!settings.enableGrid || !settings.visible) { + return; + } + const pos: Point = { + x: settings.offset.x % settings.size.x, + y: settings.offset.y % settings.size.y + }; + + if (pos.x > 0) { + pos.x -= settings.size.x; + } + if (pos.y > 0) { + pos.y -= settings.size.y; + } + + const width = Globals.map.mapWidth * Globals.TILE_SIZE - pos.x; + const height = Globals.map.mapHeight * Globals.TILE_SIZE - pos.y; + + let color = parseInt(settings.color.substring(1), 16); + if (isNaN(color)) { + color = 0x222222; + } + + this.grid = new Phaser.GameObjects.Grid( + this.scene, + pos.x, + pos.y, + width * scale, + height * scale, + settings.size.x * scale, + settings.size.y * scale, + undefined, + 0, + color, + 0.6 + ); + this.grid.depth = 500; + this.grid.setOrigin(0, 0); + this.grid.setScale(1 / scale, 1 / scale); + + this.scene.add.existing(this.grid); + } + + override destroy(fromScene?: boolean) { + super.destroy(fromScene); + this.grid?.destroy(); + this.sub.unsubscribe(); + } +} diff --git a/webapp/src/app/services/phaser/main-scene.ts b/webapp/src/app/services/phaser/main-scene.ts index b853df94..106db6ca 100644 --- a/webapp/src/app/services/phaser/main-scene.ts +++ b/webapp/src/app/services/phaser/main-scene.ts @@ -9,6 +9,7 @@ import { CCMap } from './tilemap/cc-map'; import { TileDrawer } from './tilemap/tile-drawer'; import { LayerParallax } from './layer-parallax'; import { IngamePreview } from './ingame-preview'; +import { EntityGrid } from './entity-grid'; export class MainScene extends Phaser.Scene { @@ -74,20 +75,23 @@ export class MainScene extends Phaser.Scene { const preview = new IngamePreview(this); this.add.existing(preview); this.add.existing(new LayerParallax(this, preview)); - + const coordsReporter = new CoordsReporter(this); this.add.existing(coordsReporter); + const grid = new EntityGrid(this); + this.add.existing(grid); + Globals.globalEventsService.currentView.subscribe(view => { tileDrawer.setActive(false); entityManager.setActive(false); switch (view) { - case EditorView.Layers: - tileDrawer.setActive(true); - break; - case EditorView.Entities: - entityManager.setActive(true); - break; + case EditorView.Layers: + tileDrawer.setActive(true); + break; + case EditorView.Entities: + entityManager.setActive(true); + break; } }); diff --git a/webapp/src/app/services/phaser/tilemap/cc-map.ts b/webapp/src/app/services/phaser/tilemap/cc-map.ts index 026d9ef4..c6b3bbfe 100644 --- a/webapp/src/app/services/phaser/tilemap/cc-map.ts +++ b/webapp/src/app/services/phaser/tilemap/cc-map.ts @@ -26,9 +26,7 @@ export class CCMap { private lastMapId = 1; private tileMap?: Phaser.Tilemaps.Tilemap; - private historySub: Subscription; - private offsetSub: Subscription; - private offsetSub2: Subscription; + private subs: Subscription[] = []; filename = ''; path?: string; @@ -41,7 +39,7 @@ export class CCMap { public entityManager: EntityManager ) { const stateHistory = Globals.stateHistoryService; - this.historySub = stateHistory.selectedState.subscribe(async container => { + this.subs.push(stateHistory.selectedState.subscribe(async container => { if (!container || !container.state) { return; } @@ -70,16 +68,18 @@ export class CCMap { selectedLayer.next(layer); } } - }); + })); - this.offsetSub = Globals.globalEventsService.offsetMap.subscribe(offset => this.offsetMap(offset)); - this.offsetSub2 = Globals.globalEventsService.offsetEntities.subscribe(offset => this.offsetEntities(offset)); + this.subs.push(Globals.globalEventsService.offsetMap.subscribe(offset => this.offsetMap(offset))); + this.subs.push(Globals.globalEventsService.offsetEntities.subscribe(offset => this.offsetEntities(offset))); + this.subs.push(Globals.globalEventsService.resizeMap.subscribe(size => this.resize(size.x, size.y))); } destroy() { - this.historySub.unsubscribe(); - this.offsetSub.unsubscribe(); - this.offsetSub2.unsubscribe(); + for (const sub of this.subs) { + sub.unsubscribe(); + } + this.subs = []; } async loadMap(map: CrossCodeMap, skipInit = false) { @@ -143,7 +143,7 @@ export class CCMap { Globals.mapLoaderService.selectedLayer.next(this.layers[0]); } - resize(width: number, height: number) { + private resize(width: number, height: number) { this.mapWidth = width; this.mapHeight = height; diff --git a/webapp/src/styles.scss b/webapp/src/styles.scss index 368d5a27..94b9a875 100644 --- a/webapp/src/styles.scss +++ b/webapp/src/styles.scss @@ -16,6 +16,14 @@ $config: mat.define-typography-config(); $my-typography: mat.define-typography-config(); @include mat.typography-hierarchy($my-typography); + +// tailwind base makes angular material icon buttons off-center. This fixes it +@layer base { + button { + line-height: normal; + } +} + html * { font-family: Roboto, "Helvetica Neue", sans-serif; }