1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-06-13 13:04:03 +00:00

undo / redo - bulk operations

This commit is contained in:
akupiec 2021-05-17 21:51:14 +02:00
parent 4231231f0e
commit 593773f44b
4 changed files with 226 additions and 89 deletions

View File

@ -5,6 +5,7 @@ import { Vector } from "../core/vector";
import { Entity } from "./entity"; import { Entity } from "./entity";
import { ACHIEVEMENTS } from "../platform/achievement_provider"; import { ACHIEVEMENTS } from "../platform/achievement_provider";
import { GameRoot } from "./root"; import { GameRoot } from "./root";
import { ActionBuilder } from "./history_manager";
export class Blueprint { export class Blueprint {
/** /**
@ -149,6 +150,7 @@ export class Blueprint {
*/ */
tryPlace(root, tile) { tryPlace(root, tile) {
return root.logic.performBulkOperation(() => { return root.logic.performBulkOperation(() => {
const actionBuilder = new ActionBuilder(root);
let count = 0; let count = 0;
for (let i = 0; i < this.entities.length; ++i) { for (let i = 0; i < this.entities.length; ++i) {
const entity = this.entities[i]; const entity = this.entities[i];
@ -158,11 +160,13 @@ export class Blueprint {
const clone = entity.clone(); const clone = entity.clone();
clone.components.StaticMapEntity.origin.addInplace(tile); clone.components.StaticMapEntity.origin.addInplace(tile);
root.logic.freeEntityAreaBeforeBuild(clone); const removed = root.logic.freeEntityAreaBeforeBuild(clone);
root.map.placeStaticEntity(clone); root.map.placeStaticEntity(clone);
root.entityMgr.registerEntity(clone); root.entityMgr.registerEntity(clone);
actionBuilder.placeBuilding(clone, removed);
count++; count++;
} }
root.historyMgr.addAction(actionBuilder.build());
root.signals.bulkAchievementCheck.dispatch( root.signals.bulkAchievementCheck.dispatch(
ACHIEVEMENTS.placeBlueprint, ACHIEVEMENTS.placeBlueprint,

View File

@ -1,6 +1,167 @@
import { Entity } from "./entity"; import { Entity } from "./entity";
import { SOUNDS } from "../platform/sound"; import { SOUNDS } from "../platform/sound";
import { KEYMAPPINGS } from "./key_action_mapper"; import { KEYMAPPINGS } from "./key_action_mapper";
import { createLogger } from "../core/logging";
const logger = createLogger("ingame/history");
class UserAction {
constructor(size = 1) {
this.size = size;
}
get canUndo() {
assert(false, "NOT IMPLEMENTED !!!");
return false;
}
undo() {
assert(false, "NOT IMPLEMENTED !!!");
}
postUndo() {
assert(false, "NOT IMPLEMENTED !!!");
}
/**
* @return {UserAction}
*/
redoAction() {
assert(false, "NOT IMPLEMENTED !!!");
return this;
}
}
class ActionBuildOne extends UserAction {
constructor(root, entity) {
super();
this.root = root;
this.entity = entity;
}
undo() {
this.root.map.removeStaticEntity(this.entity);
this.root.entityMgr.destroyEntity(this.entity);
}
postUndo() {
this.root.entityMgr.processDestroyList();
this.root.soundProxy.playUi(SOUNDS.destroyBuilding);
}
redoAction() {
return new ActionRemoveOne(this.root, this.entity);
}
get canUndo() {
return this.root.logic.canDeleteBuilding(this.entity);
}
}
class ActionRemoveOne extends UserAction {
constructor(root, entity) {
super();
this.root = root;
this.entity = entity;
}
undo() {
const entity = this.entity;
entity.destroyed = false;
entity.queuedForDestroy = false;
this.root.logic.freeEntityAreaBeforeBuild(entity);
this.root.map.placeStaticEntity(entity);
this.root.entityMgr.registerEntity(entity);
}
postUndo() {
this.root.soundProxy.playUi(SOUNDS.placeBuilding);
}
get canUndo() {
return this.root.logic.checkCanPlaceEntity(this.entity);
}
redoAction() {
return new ActionBuildOne(this.root, this.entity);
}
}
class ComplexAction extends UserAction {
constructor(root, actionArray) {
super();
this.root = root;
this.actionArray = [...actionArray].reverse();
}
get canUndo() {
return this.actionArray.every(a => a.canUndo);
}
undo() {
this.actionArray.forEach(a => a.undo());
}
postUndo() {
const haveRemove = this.actionArray.some(e => e instanceof ActionRemoveOne);
const haveBuild = this.actionArray.some(e => e instanceof ActionBuildOne);
if (haveRemove) {
this.root.soundProxy.playUi(SOUNDS.placeBuilding);
}
if (haveBuild) {
this.root.entityMgr.processDestroyList();
this.root.soundProxy.playUi(SOUNDS.destroyBuilding);
}
}
redoAction() {
const redoActions = this.actionArray.map(a => a.redoAction());
return new ComplexAction(this.root, redoActions);
}
}
export class ActionBuilder {
constructor(root) {
this.root = root;
this.actionArray = [];
}
/**
* @param {Entity} entity - newly build entity
* @param {Entity[]} removed - entities in the way
*/
placeBuilding(entity, removed = []) {
removed.forEach(e => this.removeBuilding(e));
this.actionArray.push(new ActionBuildOne(this.root, entity));
return this;
}
/**
* @param {Entity} entity
*/
removeBuilding(entity) {
this.actionArray.push(new ActionRemoveOne(this.root, entity));
return this;
}
/**
* @param {Entity[]} entities
*/
bulkRemoveBuildings(entities) {
entities.forEach(e => this.actionArray.push(new ActionRemoveOne(this.root, e)));
return this;
}
build() {
if (this.actionArray.length > 1) {
return new ComplexAction(this.root, this.actionArray);
}
if (this.actionArray.length === 1) {
return this.actionArray.pop();
}
}
}
class LiFoQueue { class LiFoQueue {
constructor(size = 20) { constructor(size = 20) {
@ -28,11 +189,6 @@ class LiFoQueue {
} }
} }
const ActionType = {
add: 0,
remove: 1,
};
export class HistoryManager { export class HistoryManager {
constructor(root) { constructor(root) {
this.root = root; this.root = root;
@ -55,59 +211,34 @@ export class HistoryManager {
return !this._forRedo.isEmpty(); return !this._forRedo.isEmpty();
} }
/** addAction(action) {
* @param {Entity} entity
*/
addAction(entity) {
this._forRedo.clear(); this._forRedo.clear();
this._forUndo.enqueue({ type: ActionType.add, entity }); this._forUndo.enqueue(action);
}
removeAction(entity) {
this._forRedo.clear();
this._forUndo.enqueue({ type: ActionType.remove, entity });
} }
_undo() { _undo() {
const { type, entity } = this._forUndo.dequeue() || {}; const action = this._forUndo.dequeue();
if (!entity) { if (!action) {
return; return;
} }
if (type === ActionType.add && this.root.logic.canDeleteBuilding(entity)) { if (!action.canUndo) {
this._forRedo.enqueue({ type: ActionType.remove, entity: entity.clone() }); return;
this._removeEntity(entity);
}
if (type === ActionType.remove && this.root.logic.checkCanPlaceEntity(entity)) {
this._forRedo.enqueue({ type: ActionType.add, entity: entity });
this._placeEntity(entity);
} }
action.undo();
action.postUndo();
this._forRedo.enqueue(action.redoAction());
} }
_redo() { _redo() {
const { type, entity } = this._forRedo.dequeue() || {}; const action = this._forRedo.dequeue();
if (!entity) { if (!action) {
return; return;
} }
if (type === ActionType.remove && this.root.logic.checkCanPlaceEntity(entity)) { if (!action.canUndo) {
this._placeEntity(entity); return;
this._forUndo.enqueue({ type: ActionType.add, entity });
} }
if (type === ActionType.add && this.root.logic.canDeleteBuilding(entity)) { action.undo();
this._forUndo.enqueue({ type: ActionType.remove, entity: entity.clone() }); action.postUndo();
this._removeEntity(entity); this._forUndo.enqueue(action.redoAction());
}
}
_removeEntity(entity) {
this.root.map.removeStaticEntity(entity);
this.root.entityMgr.destroyEntity(entity);
this.root.entityMgr.processDestroyList();
this.root.soundProxy.playUi(SOUNDS.destroyBuilding);
}
_placeEntity(entity) {
this.root.logic.freeEntityAreaBeforeBuild(entity);
this.root.map.placeStaticEntity(entity);
this.root.entityMgr.registerEntity(entity);
} }
} }

View File

@ -94,34 +94,8 @@ export class HUDMassSelector extends BaseHUDPart {
doDelete() { doDelete() {
const entityUids = Array.from(this.selectedUids); const entityUids = Array.from(this.selectedUids);
const count = this.root.logic.tryBulkDelete(entityUids);
// Build mapping from uid to entity this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.destroy1000, count);
/**
* @type {Map<number, Entity>}
*/
const mapUidToEntity = this.root.entityMgr.getFrozenUidSearchMap();
let count = 0;
this.root.logic.performBulkOperation(() => {
for (let i = 0; i < entityUids.length; ++i) {
const uid = entityUids[i];
const entity = mapUidToEntity.get(uid);
if (!entity) {
logger.error("Entity not found by uid:", uid);
continue;
}
if (!this.root.logic.tryDeleteBuilding(entity)) {
logger.error("Error in mass delete, could not remove building");
} else {
count++;
}
}
this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.destroy1000, count);
});
// Clear uids later
this.selectedUids = new Set(); this.selectedUids = new Set();
} }
@ -174,14 +148,8 @@ export class HUDMassSelector extends BaseHUDPart {
// copy code relies on entities still existing, so must copy before deleting. // copy code relies on entities still existing, so must copy before deleting.
this.root.hud.signals.buildingsSelectedForCopy.dispatch(entityUids); this.root.hud.signals.buildingsSelectedForCopy.dispatch(entityUids);
for (let i = 0; i < entityUids.length; ++i) { this.root.logic.tryBulkDelete(entityUids);
const uid = entityUids[i]; this.selectedUids = new Set();
const entity = this.root.entityMgr.findByUid(uid);
if (!this.root.logic.tryDeleteBuilding(entity)) {
logger.error("Error in mass cut, could not remove building");
this.selectedUids.delete(uid);
}
}
}; };
const blueprint = Blueprint.fromUids(this.root, entityUids); const blueprint = Blueprint.fromUids(this.root, entityUids);
@ -195,7 +163,6 @@ export class HUDMassSelector extends BaseHUDPart {
); );
ok.add(cutAction); ok.add(cutAction);
} }
this.root.soundProxy.playUiClick(); this.root.soundProxy.playUiClick();
} else { } else {
this.root.soundProxy.playUiError(); this.root.soundProxy.playUiError();

View File

@ -10,6 +10,7 @@ import { CHUNK_OVERLAY_RES } from "./map_chunk_view";
import { MetaBuilding } from "./meta_building"; import { MetaBuilding } from "./meta_building";
import { GameRoot } from "./root"; import { GameRoot } from "./root";
import { WireNetwork } from "./systems/wire"; import { WireNetwork } from "./systems/wire";
import { ActionBuilder } from "./history_manager";
const logger = createLogger("ingame/logic"); const logger = createLogger("ingame/logic");
@ -107,10 +108,11 @@ export class GameLogic {
variant, variant,
}); });
if (this.checkCanPlaceEntity(entity)) { if (this.checkCanPlaceEntity(entity)) {
this.freeEntityAreaBeforeBuild(entity); const removed = this.freeEntityAreaBeforeBuild(entity);
this.root.map.placeStaticEntity(entity); this.root.map.placeStaticEntity(entity);
this.root.entityMgr.registerEntity(entity); this.root.entityMgr.registerEntity(entity);
this.root.historyMgr.addAction(entity); const action = new ActionBuilder(this.root).placeBuilding(entity, removed).build();
this.root.historyMgr.addAction(action);
return entity; return entity;
} }
return null; return null;
@ -120,10 +122,12 @@ export class GameLogic {
* Removes all entities with a RemovableMapEntityComponent which need to get * Removes all entities with a RemovableMapEntityComponent which need to get
* removed before placing this entity * removed before placing this entity
* @param {Entity} entity * @param {Entity} entity
* @return {Entity[]} removedEntities
*/ */
freeEntityAreaBeforeBuild(entity) { freeEntityAreaBeforeBuild(entity) {
const staticComp = entity.components.StaticMapEntity; const staticComp = entity.components.StaticMapEntity;
const rect = staticComp.getTileSpaceBounds(); const rect = staticComp.getTileSpaceBounds();
const toRemove = [];
// Remove any removeable colliding entities on the same layer // Remove any removeable colliding entities on the same layer
for (let x = rect.x; x < rect.x + rect.w; ++x) { for (let x = rect.x; x < rect.x + rect.w; ++x) {
for (let y = rect.y; y < rect.y + rect.h; ++y) { for (let y = rect.y; y < rect.y + rect.h; ++y) {
@ -133,15 +137,19 @@ export class GameLogic {
contents.components.StaticMapEntity.getMetaBuilding().getIsReplaceable(), contents.components.StaticMapEntity.getMetaBuilding().getIsReplaceable(),
"Tried to replace non-repleaceable entity" "Tried to replace non-repleaceable entity"
); );
if (!this.tryDeleteBuilding(contents)) { if (!this.canDeleteBuilding(contents)) {
assertAlways(false, "Tried to replace non-repleaceable entity #2"); assertAlways(false, "Tried to replace non-repleaceable entity #2");
} }
toRemove.push(contents);
} }
} }
} }
toRemove.forEach(e => this.unsafeDeleteBuilding(e));
// Perform other callbacks // Perform other callbacks
this.root.signals.freeEntityAreaBeforeBuild.dispatch(entity); this.root.signals.freeEntityAreaBeforeBuild.dispatch(entity);
return toRemove;
} }
/** /**
@ -179,11 +187,38 @@ export class GameLogic {
if (!this.canDeleteBuilding(building)) { if (!this.canDeleteBuilding(building)) {
return false; return false;
} }
this.root.historyMgr.removeAction(building.clone()); const action = new ActionBuilder(this.root).removeBuilding(building.clone()).build();
this.root.historyMgr.addAction(action);
this.unsafeDeleteBuilding(building);
return true;
}
unsafeDeleteBuilding(building) {
this.root.map.removeStaticEntity(building); this.root.map.removeStaticEntity(building);
this.root.entityMgr.destroyEntity(building); this.root.entityMgr.destroyEntity(building);
this.root.entityMgr.processDestroyList(); this.root.entityMgr.processDestroyList();
return true; }
/**
* @param {number[]} entityUids
* @return {number} number of removed entities
*/
tryBulkDelete(entityUids) {
return this.performBulkOperation(() => {
const mapUidToEntity = this.root.entityMgr.getFrozenUidSearchMap();
const entitiesToDelete = entityUids
.map(uid => mapUidToEntity.get(uid))
.filter(entity => !!entity)
.filter(entity => this.canDeleteBuilding(entity));
const action = new ActionBuilder(this.root).bulkRemoveBuildings(entitiesToDelete).build();
this.root.historyMgr.addAction(action);
entitiesToDelete.forEach(entity => {
this.root.map.removeStaticEntity(entity);
this.root.entityMgr.destroyEntity(entity);
});
this.root.entityMgr.processDestroyList();
return entitiesToDelete.length;
});
} }
/** /**