1
0
forked from noxious/client

Best undo/redo function across all map editor elements

This commit is contained in:
Andrei 2025-02-10 16:02:38 -06:00
parent ca307d4de3
commit 9459639497
6 changed files with 147 additions and 166 deletions

View File

@ -1,6 +1,6 @@
<template>
<MapTiles ref="mapTiles" @createCommand="addCommand" v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
<PlacedMapObjects ref="mapObjects" @createCommand="addCommand" v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
<PlacedMapObjects ref="mapObjects" @update="updateMapObjects" @updateAndCommit="updateAndCommit" v-if="tileMap && tileMapLayer" :tileMap :tileMapLayer />
<MapEventTiles ref="eventTiles" @createCommand="addCommand" v-if="tileMap" :tileMap />
</template>
@ -10,12 +10,13 @@ import MapEventTiles from '@/components/gameMaster/mapEditor/mapPartials/MapEven
import MapTiles from '@/components/gameMaster/mapEditor/mapPartials/MapTiles.vue'
import PlacedMapObjects from '@/components/gameMaster/mapEditor/mapPartials/PlacedMapObjects.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { cloneArray, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
import { cloneArray, createTileArray, createTileLayer, createTileMap, placeTiles } from '@/services/mapService'
import { TileStorage } from '@/storage/storages'
import { useScene } from 'phavuer'
import { onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
import type { MapEventTile, PlacedMapObject as PlacedMapObjectT } from '@/application/types'
import type { PlacedMapObject as PlacedMapObjectT, Map as MapT, MapEventTile } from '@/application/types'
import { useManualRefHistory, useRefHistory } from '@vueuse/core'
const tileMap = shallowRef<Phaser.Tilemaps.Tilemap>()
const tileMapLayer = shallowRef<Phaser.Tilemaps.TilemapLayer>()
@ -28,18 +29,20 @@ const mapObjects = useTemplateRef('mapObjects')
const eventTiles = useTemplateRef('eventTiles')
//Record of commands
let commandStack: EditorCommand[] = []
let commandStack: (EditorCommand | number) [] = []
let commandIndex = ref(0)
let originTiles: string[][] = []
let originEventTiles: MapEventTile[] = []
let originObjects: PlacedMapObjectT[] = []
let originObjects = ref<PlacedMapObjectT[]>(mapEditor.currentMap.value.placedMapObjects)
const {undo, redo, commit, reset, history, canUndo, canRedo} = useRefHistory(originObjects, {clone:true, deep:true, capacity:9})
//Command Pattern basic interface, extended to store what elements have been changed by each edit
export interface EditorCommand {
apply: (elements: any[]) => any[]
type: 'tile' | 'map_object' | 'event_tile'
operation: 'draw' | 'erase' | 'place' | 'move' | 'delete' | 'rotate'
operation: 'draw' | 'erase' | 'clear'
}
function applyCommands(tiles: any[], ...commands: EditorCommand[]): any[] {
@ -50,57 +53,95 @@ function applyCommands(tiles: any[], ...commands: EditorCommand[]): any[] {
return tileVersion
}
watch(() => commandIndex.value!, (val) => {
if (val !== undefined) {
update(commandStack.slice(0, val))
watch(
() => mapEditor.shouldClearTiles.value,
(shouldClear) => {
if (shouldClear && mapEditor.currentMap.value) {
mapTiles.value!.clearTiles()
eventTiles.value!.clearTiles()
mapEditor.currentMap.value.placedMapObjects = []
updateAndCommit(mapEditor.currentMap.value)
mapEditor.resetClearTilesFlag()
}
}
})
)
function update(commands: EditorCommand[]) {
function update(commands: (EditorCommand | number)[]) {
if (!mapEditor.currentMap.value) return
const tileCommands = commands.filter((command) => command.type === 'tile')
const eventTileCommands = commands.filter((command) => command.type === 'event_tile')
const objectCommands = commands.filter((command) => command.type === 'map_object')
if (commandStack.length >= 9) {
if (typeof commandStack[0] !== 'number') {
const base = commandStack.shift() as EditorCommand
if (base.operation !== 'clear') {
switch (base.type) {
case 'tile':
originTiles = base.apply(originTiles) as string[][]
break
case 'event_tile':
originEventTiles = base.apply(originEventTiles) as MapEventTile[]
break
}
}
else {
commandStack.shift()
}
}
else if (typeof commandStack[0] === 'number') {
commandStack.shift()
}
}
let tileCommands = commands.filter((item) => typeof item !== 'number' && item.type === 'tile') as EditorCommand[]
let eventTileCommands = commands.filter((item) => typeof item !== 'number' && item.type === 'event_tile') as EditorCommand[]
let modifiedTiles = applyCommands(originTiles, ...tileCommands)
placeTiles(tileMap.value!, tileMapLayer.value!, modifiedTiles)
let eventTiles = applyCommands(originEventTiles, ...eventTileCommands)
mapEditor.currentMap.value.tiles = modifiedTiles
mapEditor.currentMap.value.mapEventTiles = applyCommands(originEventTiles, ...eventTileCommands)
mapEditor.currentMap.value.placedMapObjects = applyCommands(originObjects, ...objectCommands)
mapEditor.currentMap.value.mapEventTiles = eventTiles
}
function updateMapObjects(map: MapT) {
originObjects.value = map.placedMapObjects
}
function updateAndCommit(map?: MapT) {
commandStack = commandStack.slice(0, commandIndex.value)
if (map) updateMapObjects(map)
commit()
commandStack.push(0)
commandIndex.value = commandStack.length
console.log(history.value)
console.log(commandStack)
console.log(commandIndex.value)
}
function addCommand(command: EditorCommand) {
commandStack = commandStack.slice(0, commandIndex.value)
commandStack.push(command)
if (commandStack.length >= 9) {
switch (commandStack[0].type) {
case 'tile':
originTiles = commandStack.shift()?.apply(originTiles) as string[][]
break
case 'map_object':
originObjects = commandStack.shift()?.apply(originObjects) as PlacedMapObjectT[]
break
case 'event_tile':
originEventTiles = commandStack.shift()?.apply(originEventTiles) as MapEventTile[]
break
}
}
commandIndex.value = commandStack.length
}
function undo() {
function undoEdit() {
if (commandIndex.value > 0) {
commandIndex.value--
if (typeof(commandStack[--commandIndex.value]) === 'number' && canUndo) {
undo()
mapEditor.currentMap.value.placedMapObjects = originObjects.value
}
update(commandStack.slice(0, commandIndex.value))
}
}
function redo() {
if (commandIndex.value <= 9 && commandIndex.value <= commandStack.length) {
commandIndex.value++
function redoEdit() {
if (commandIndex.value <= 9 && commandIndex.value < commandStack.length) {
if (typeof(commandStack[commandIndex.value++]) === 'number' && canRedo) {
redo()
mapEditor.currentMap.value.placedMapObjects = originObjects.value
}
update(commandStack.slice(0, commandIndex.value))
}
}
@ -133,12 +174,12 @@ function handlePointerDown(pointer: Phaser.Input.Pointer) {
function handleKeyDown(event: KeyboardEvent) {
//CTRL+Y
if (event.key === 'y' && event.ctrlKey) {
redo()
redoEdit()
}
//CTRL+Z
if (event.key === 'z' && event.ctrlKey) {
undo()
undoEdit()
}
}
@ -154,7 +195,7 @@ function handlePointerUp(pointer: Phaser.Input.Pointer) {
mapTiles.value!.finalizeCommand()
break
case 'map_object':
mapObjects.value!.finalizeCommand()
updateAndCommit()
break
case 'teleport':
eventTiles.value!.finalizeCommand()
@ -171,9 +212,10 @@ onMounted(async () => {
//Clone
originTiles = cloneArray(mapValue.tiles)
originObjects = cloneArray(mapValue.placedMapObjects)
originEventTiles = cloneArray(mapValue.mapEventTiles)
commit()
const tileStorage = new TileStorage()
const allTiles = await tileStorage.getAll()
const allTileIds = allTiles.map((tile) => tile.id)

View File

@ -13,7 +13,7 @@ import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue'
const mapEditor = useMapEditorComposable()
defineExpose({ handlePointer, finalizeCommand })
defineExpose({ handlePointer, finalizeCommand, clearTiles})
const emit = defineEmits(['createCommand'])
@ -27,7 +27,7 @@ const props = defineProps<{
let currentCommand: EventTileCommand | null = null
class EventTileCommand implements EditorCommand {
public operation: 'draw' | 'erase' = 'draw'
public operation: 'draw' | 'erase' | 'clear' = 'draw'
public type: 'event_tile' = 'event_tile'
public affectedTiles: MapEventTile[] = []
@ -39,15 +39,18 @@ class EventTileCommand implements EditorCommand {
else if (this.operation === 'erase') {
tileVersion = tileVersion.filter((v) => !this.affectedTiles.includes(v))
}
else if (this.operation === 'clear') {
tileVersion = []
}
return tileVersion
}
constructor(operation: 'draw' | 'erase') {
constructor(operation: 'draw' | 'erase' | 'clear') {
this.operation = operation
}
}
function createCommandUpdate(tile: MapEventTile, operation: 'draw' | 'erase') {
function createCommandUpdate(tile?: MapEventTile, operation: 'draw' | 'erase' | 'clear') {
if (!currentCommand) {
currentCommand = new EventTileCommand(operation)
}
@ -149,4 +152,11 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
break
}
}
function clearTiles() {
if (mapEditor.currentMap.value.mapEventTiles.length === 0) return
createCommandUpdate(null, 'clear')
finalizeCommand()
}
</script>

View File

@ -11,7 +11,7 @@ import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue'
const mapEditor = useMapEditorComposable()
defineExpose({ handlePointer, finalizeCommand })
defineExpose({ handlePointer, finalizeCommand, clearTiles })
const emit = defineEmits(['createCommand'])
@ -20,32 +20,37 @@ const props = defineProps<{
tileMapLayer: Phaser.Tilemaps.TilemapLayer
}>()
// *** COMMAND STATE ***
let currentCommand: TileCommand | null = null
class TileCommand implements EditorCommand {
public operation: 'draw' | 'erase' = 'draw'
public operation: 'draw' | 'erase' | 'clear' = 'draw'
public type: 'tile' = 'tile'
public tileName: string = 'blank_tile'
public affectedTiles: number[][] = []
apply(elements: string[][]) {
let tileVersion = cloneArray(elements) as string[][]
for (const position of this.affectedTiles) {
tileVersion[position[1]][position[0]] = this.tileName
let tileVersion
if (this.operation === 'clear') {
tileVersion = createTileArray(props.tileMapLayer.width, props.tileMapLayer.height, 'blank_tile')
}
else {
tileVersion = cloneArray(elements) as string[][]
for (const position of this.affectedTiles) {
tileVersion[position[1]][position[0]] = this.tileName
}
}
return tileVersion
}
constructor(operation: 'draw' | 'erase', tileName: string) {
constructor(operation: 'draw' | 'erase' | 'clear', tileName: string) {
this.operation = operation
this.tileName = tileName
}
}
function createCommandUpdate(x: number, y: number, tileName: string, operation: 'draw' | 'erase') {
function createCommandUpdate(x: number, y: number, tileName: string, operation: 'draw' | 'erase' | 'clear') {
if (!currentCommand) {
currentCommand = new TileCommand(operation, tileName)
}
@ -137,21 +142,14 @@ function handlePointer(pointer: Phaser.Input.Pointer) {
}
}
// *** LIFECYCLE ***
watch(
() => mapEditor.shouldClearTiles.value,
(shouldClear) => {
if (shouldClear && mapEditor.currentMap.value) {
const blankTiles = createTileArray(props.tileMapLayer.width, props.tileMapLayer.height, 'blank_tile')
placeTiles(props.tileMap, props.tileMapLayer, blankTiles)
mapEditor.currentMap.value.tiles = blankTiles
mapEditor.resetClearTilesFlag()
}
}
)
function clearTiles() {
const tileArray = createTileArray(props.tileMap.width, props.tileMap.height, 'blank_tile')
placeTiles(props.tileMap, props.tileMapLayer, tileArray)
createCommandUpdate(0,0,"blank_tile",'clear')
finalizeCommand()
}
onMounted(async () => {
if (!mapEditor.currentMap.value) return

View File

@ -1,5 +1,5 @@
<template>
<SelectedPlacedMapObjectComponent v-if="mapEditor.selectedPlacedObject.value" :map :placedMapObject="mapEditor.selectedPlacedObject.value" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
<SelectedPlacedMapObjectComponent v-if="mapEditor.selectedPlacedObject.value" :map="mapEditor.currentMap.value!" :placedMapObject="mapEditor.selectedPlacedObject.value" @move="moveMapObject" @rotate="rotatePlacedMapObject" @delete="deletePlacedMapObject" />
<PlacedMapObject v-for="placedMapObject in mapEditor.currentMap.value?.placedMapObjects" :tileMap :tileMapLayer :placedMapObject @pointerdown="clickPlacedMapObject(placedMapObject)" />
</template>
@ -7,101 +7,38 @@
import type {
MapObject,
Map as MapT,
PlacedMapObject as PlacedMapObjectT,
UUID,
MapEventTile
PlacedMapObject as PlacedMapObjectT
} from '@/application/types'
import { uuidv4 } from '@/application/utilities'
import PlacedMapObject from '@/components/game/map/partials/PlacedMapObject.vue'
import SelectedPlacedMapObjectComponent from '@/components/gameMaster/mapEditor/partials/SelectedPlacedMapObject.vue'
import { useMapEditorComposable } from '@/composables/useMapEditorComposable'
import { cloneArray, getTile } from '@/services/mapService'
import { getTile } from '@/services/mapService'
import { useScene } from 'phavuer'
import Tilemap = Phaser.Tilemaps.Tilemap
import TilemapLayer = Phaser.Tilemaps.TilemapLayer
import { computed } from 'vue'
import { type EditorCommand } from '@/components/gameMaster/mapEditor/Map.vue'
import Vector2 = Phaser.Math.Vector2
import Tile = Phaser.Tilemaps.Tile
const scene = useScene()
const mapEditor = useMapEditorComposable()
const map = computed(() => mapEditor.currentMap.value!)
defineExpose({ handlePointer, finalizeCommand })
const emit = defineEmits<{(e: 'update', map: MapT): void, (e: 'updateAndCommit', map: MapT): void}>()
const emit = defineEmits(['createCommand'])
defineExpose({ handlePointer })
const props = defineProps<{
tileMap: Tilemap
tileMapLayer: TilemapLayer
}>()
// *** COMMAND STATE ***
let currentCommand: MapObjectCommand | null = null
class MapObjectCommand implements EditorCommand {
public operation: 'place' | 'move' | 'delete' | 'rotate' = 'place'
public type: 'map_object' = 'map_object'
public affectedTiles: PlacedMapObjectT[] = []
public targetPosition?: Phaser.Math.Vector2
apply(elements: PlacedMapObjectT[]) {
let tileVersion = cloneArray(elements) as PlacedMapObjectT[]
if (this.operation === 'place') {
tileVersion = tileVersion.concat(this.affectedTiles)
}
else if (this.operation === 'delete') {
tileVersion = tileVersion.filter((v) => !this.affectedTiles.includes(v))
}
else if (this.operation === 'move') {
const targetObject = tileVersion.find((v) => this.affectedTiles[0].id === v.id)
if (targetObject) {
targetObject.positionX = this.targetPosition!.x
targetObject.positionY = this.targetPosition!.y
}
}
return tileVersion
}
constructor(operation: 'place' | 'move' | 'delete' | 'rotate') {
this.operation = operation
}
}
function createCommandUpdate(object: PlacedMapObjectT, operation: 'place' | 'move' | 'delete' | 'rotate', targetPosition?: Phaser.Math.Vector2) {
if (!currentCommand) {
currentCommand = new MapObjectCommand(operation)
}
else {
if (targetPosition) {
currentCommand.targetPosition = targetPosition
}
}
currentCommand.affectedTiles.push(object)
}
function finalizeCommand() {
if (!currentCommand) return
emit('createCommand', currentCommand)
currentCommand = null
}
// *** HANDLERS ***
function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return
// Check if object already exists on position
const existingPlacedMapObject = findObjectByPointer(pointer, map)
const existingPlacedMapObject = findObjectByPointer(pointer, mapEditor.currentMap.value!)
if (existingPlacedMapObject) return
if (!mapEditor.selectedMapObject.value) return
@ -115,11 +52,10 @@ function pencil(pointer: Phaser.Input.Pointer, map: MapT) {
}
// Add new object to mapObjects
mapEditor.selectedPlacedObject.value = newPlacedMapObject
map.placedMapObjects.push(newPlacedMapObject)
createCommandUpdate(newPlacedMapObject, 'place')
mapEditor.selectedPlacedObject.value = newPlacedMapObject
emit('update', map)
}
function eraser(pointer: Phaser.Input.Pointer, map: MapT) {
@ -127,10 +63,10 @@ function eraser(pointer: Phaser.Input.Pointer, map: MapT) {
const existingPlacedMapObject = findObjectByPointer(pointer, map)
if (!existingPlacedMapObject) return
createCommandUpdate(existingPlacedMapObject, 'delete')
// Remove existing object
map.placedMapObjects = map.placedMapObjects.filter((placedMapObject) => placedMapObject.id !== existingPlacedMapObject.id)
emit('update', map)
}
function findObjectByPointer(pointer: Phaser.Input.Pointer, map: MapT): PlacedMapObjectT | undefined {
@ -152,48 +88,51 @@ function objectPicker(pointer: Phaser.Input.Pointer, map: MapT) {
function moveMapObject(id: string, map: MapT) {
mapEditor.movingPlacedObject.value = map.placedMapObjects.find((object) => object.id === id) as PlacedMapObjectT
let t: Tile
function handlePointerMove(pointer: Phaser.Input.Pointer) {
if (!mapEditor.movingPlacedObject.value) return
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return
t = tile
mapEditor.movingPlacedObject.value.positionX = tile.x
mapEditor.movingPlacedObject.value.positionY = tile.y
}
scene.input.on(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
function handlePointerUp() {
function handlePointerUp(pointer: Phaser.Input.Pointer) {
scene.input.off(Phaser.Input.Events.POINTER_MOVE, handlePointerMove)
mapEditor.movingPlacedObject.value = null
createCommandUpdate(mapEditor.movingPlacedObject.value!, 'move', new Vector2(t.x, t.y))
finalizeCommand()
const tile = getTile(props.tileMap, pointer.worldX, pointer.worldY)
if (!tile) return
map.placedMapObjects.map((placed) => {
if (placed.id === id) {
placed.positionX = tile.x
placed.positionY = tile.y
}})
mapEditor.movingPlacedObject.value = null
}
emit('updateAndCommit', map)
scene.input.on(Phaser.Input.Events.POINTER_UP, handlePointerUp)
}
function rotatePlacedMapObject(id: string, map: MapT) {
const matchingObject = map.placedMapObjects.find((placedMapObject) => placedMapObject.id === id)
matchingObject!.isRotated = !matchingObject!.isRotated
map.placedMapObjects.map((placed) => {
if (placed.id === id) {
console.log(placed.id)
placed.isRotated = !placed.isRotated
}})
emit('updateAndCommit', map)
}
function deletePlacedMapObject(id: string, map: MapT) {
let mapE = mapEditor.currentMap.value!
const foundObject = mapE.placedMapObjects.find((obj) => obj.id === id)
if (!foundObject) return
createCommandUpdate(foundObject, 'delete')
finalizeCommand()
mapE.placedMapObjects = map.placedMapObjects.filter((object) => object.id !== id)
map.placedMapObjects = map.placedMapObjects.filter((object) => object.id !== id)
mapEditor.selectedPlacedObject.value = null
emit('updateAndCommit', map)
}
function clickPlacedMapObject(placedMapObject: PlacedMapObjectT) {

View File

@ -113,7 +113,6 @@ function clear() {
if (!mapEditor.currentMap.value) return
// Clear placed objects, event tiles and tiles
mapEditor.clearMap()
mapEditor.triggerClearTiles()
}
</script>

View File

@ -36,12 +36,6 @@ export function useMapEditorComposable() {
}
}
const clearMap = () => {
if (!currentMap.value) return
currentMap.value.placedMapObjects = []
currentMap.value.mapEventTiles = []
}
const toggleActive = () => {
if (active.value) reset()
active.value = !active.value
@ -105,7 +99,6 @@ export function useMapEditorComposable() {
// Methods
loadMap,
updateProperty,
clearMap,
toggleActive,
setTool,
setDrawMode,