Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add undo-stack on a per map basis #22

Closed
wants to merge 6 commits into from
Closed
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
6 changes: 4 additions & 2 deletions docs/content/examples/map-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export default function MapEditorExample() {

const handleMapUpdate = useCallback(
(variables: MapCreateVariables | MapUpdateVariables) => {
const slug = toSlug(variables.mapName);

setMapObject({
campaigns: {
edges: [],
Expand All @@ -90,9 +92,9 @@ export default function MapEditorExample() {
username: viewer.username,
},
effects: JSON.stringify(encodeEffects(variables.effects)),
id: 'id' in variables ? variables.id : '',
id: 'id' in variables ? variables.id : slug,
name: variables.mapName,
slug: toSlug(variables.mapName),
slug,
state: JSON.stringify(variables.map.toJSON()),
tags: variables.tags,
});
Expand Down
174 changes: 152 additions & 22 deletions hera/editor/MapEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import validateMap from '@deities/athena/lib/validateMap.tsx';
import withModifiers from '@deities/athena/lib/withModifiers.tsx';
import { Biome, Biomes } from '@deities/athena/map/Biome.tsx';
import { DoubleSize, TileSize } from '@deities/athena/map/Configuration.tsx';
import { PlainMap } from '@deities/athena/map/PlainMap.tsx';
import { Bot, HumanPlayer, PlayerID } from '@deities/athena/map/Player.tsx';
import { toTeamArray } from '@deities/athena/map/Team.tsx';
import MapData, { SizeVector } from '@deities/athena/MapData.tsx';
import getFirstOrThrow from '@deities/hephaestus/getFirstOrThrow.tsx';
import isPresent from '@deities/hephaestus/isPresent.tsx';
import random from '@deities/hephaestus/random.tsx';
import toSlug from '@deities/hephaestus/toSlug.tsx';
import { ClientGame } from '@deities/hermes/game/toClientGame.tsx';
import { isIOS } from '@deities/ui/Browser.tsx';
import isControlElement from '@deities/ui/controls/isControlElement.tsx';
Expand Down Expand Up @@ -89,7 +91,10 @@ import useZoom from './hooks/useZoom.tsx';
import BiomeIcon from './lib/BiomeIcon.tsx';
import canFillTile from './lib/canFillTile.tsx';
import getMapValidationErrorText from './lib/getMapValidationErrorText.tsx';
import updateUndoStack from './lib/updateUndoStack.tsx';
import updateUndoStack, {
getUndoStackIndexKeyFor,
getUndoStackKeyFor,
} from './lib/updateUndoStack.tsx';
import ZoomButton from './lib/ZoomButton.tsx';
import MapEditorControlPanel from './panels/MapEditorControlPanel.tsx';
import ResizeHandle from './ResizeHandle.tsx';
Expand All @@ -103,15 +108,39 @@ import {
PreviousMapEditorState,
SaveMapFunction,
SetMapFunction,
UndoStack,
} from './Types.tsx';

const generateName = nameGenerator();

export function decodeUndoStack(
encodedUndoStack: string | null,
): UndoStack | null {
if (!encodedUndoStack) {
return null;
}

try {
return JSON.parse(encodedUndoStack).map(
([key, value]: [string, PlainMap]) => [key, MapData.fromObject(value)],
);
} catch {
return null;
}
}

const getUndoStack = (id?: string) =>
decodeUndoStack(Storage.getItem(getUndoStackKeyFor(id)));

const getUndoStackIndex = (id?: string) => {
const index = Storage.getItem(getUndoStackIndexKeyFor(id));
return index ? Number.parseInt(index, 10) : undefined;
};

const startAction = {
type: 'Start',
} as const;

const MAP_KEY = 'map-editor-previous-map';
const EFFECTS_KEY = 'map-editor-previous-effects';

const getDefaultScenario = (effects: Effects) =>
Expand All @@ -125,6 +154,8 @@ const getEditorBaseState = (
mapObject: Pick<MapObject, 'effects'> | null = null,
mode: EditorMode = 'design',
scenario?: Scenario,
undoStack?: UndoStack | null,
undoStackIndex?: number | null,
): EditorState => {
const startScenario = new Set([{ actions: [] }]);
let effects: Effects = mapObject?.effects
Expand All @@ -133,6 +164,7 @@ const getEditorBaseState = (
if (!effects.has('Start')) {
effects = new Map([...effects, ['Start', startScenario]]);
}

return {
effects,
isDrawing: false,
Expand All @@ -143,8 +175,8 @@ const getEditorBaseState = (
selected: {
tile: Plain.id,
},
undoStack: [['initial', map]],
undoStackIndex: null,
undoStack: undoStack ?? [['initial', map]],
undoStackIndex: undoStackIndex ?? null,
};
};

Expand All @@ -169,6 +201,23 @@ export type BaseMapEditorProps = Readonly<{
setHasChanges: (hasChanges: boolean) => void;
}>;

const getInitialMapFromUndoStack = (id?: string) => {
const undoStack = getUndoStack(id);

if (!undoStack) {
return null;
}

const undoStackIndex = getUndoStackIndex(id) ?? undoStack.length - 1;
const map = undoStack.at(undoStackIndex)?.[1];

if (!map) {
return null;
}

return { map, undoStack, undoStackIndex };
};

export default function MapEditor({
animationSpeed,
campaignLock,
Expand Down Expand Up @@ -212,7 +261,8 @@ export default function MapEditor({
(size = new SizeVector(random(10, 15), random(10, 15))): MapData => {
return withHumanPlayer(
mapObject
? MapData.fromJSON(mapObject.state)!
? getInitialMapFromUndoStack(mapObject?.id)?.map ??
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this restores the state from the correct position in the undostack when loading the map from the url

MapData.fromJSON(mapObject.state)!
: withModifiers(
generateSea(
generateBuildings(
Expand Down Expand Up @@ -241,7 +291,14 @@ export default function MapEditor({
usePlayMusic(map.config.biome);

const [editor, _setEditorState] = useState<EditorState>(() =>
getEditorBaseState(map, mapObject, mode, scenario),
getEditorBaseState(
map,
mapObject,
mode,
scenario,
getUndoStack(mapObject?.id),
getUndoStackIndex(mapObject?.id),
),
);
const [previousEffects, setPreviousEffects] = useState(editor.effects);

Expand Down Expand Up @@ -319,40 +376,55 @@ export default function MapEditor({
const [previousState, setPreviousState] =
useState<PreviousMapEditorState | null>(() => {
try {
const effects = Storage.getItem(EFFECTS_KEY) || '';
const stateFromStorage = getInitialMapFromUndoStack(mapObject?.id);

if (!stateFromStorage) {
return null;
}

return {
effects: Storage.getItem(EFFECTS_KEY) || '',
map: MapData.fromJSON(Storage.getItem(MAP_KEY) || ''),
effects,
map: stateFromStorage.map,
undoStack: stateFromStorage.undoStack,
undoStackIndex: stateFromStorage.undoStackIndex,
};
// eslint-disable-next-line no-empty
} catch {}
return null;
});

const setMap: SetMapFunction = useCallback(
(type, map) => {
(type, map, undoStack) => {
_setMap(map);
if (type !== 'cleanup') {
updateUndoStack({ setEditorState }, editor, [
type === 'resize'
? `resize-${map.size.height}-${map.size.width}`
: type === 'biome'
? `biome-${map.config.biome}`
: 'checkpoint',
map,
]);
updateUndoStack(
{ setEditorState },
undoStack ? { ...editor, undoStack } : editor,
[
type === 'resize'
? `resize-${map.size.height}-${map.size.width}`
: type === 'biome'
? `biome-${map.config.biome}`
: 'checkpoint',
map,
],
);
}
setInternalMapID(internalMapID + 1);
},
[editor, internalMapID, setEditorState],
);

const updatePreviousMap = useCallback(
(map?: MapData, editorEffects?: Effects) => {
(map?: MapData, editorEffects?: Effects, editorUndoStack?: UndoStack) => {
const effects = JSON.stringify(
editorEffects ? encodeEffects(editorEffects) : '',
);
Storage.setItem(MAP_KEY, JSON.stringify(map));
setPreviousState(map ? { effects, map } : null);

setPreviousState(
map ? { effects, map, undoStack: editorUndoStack } : null,
);
},
[],
);
Expand Down Expand Up @@ -457,6 +529,12 @@ export default function MapEditor({

const isNew = type === 'New' || !mapObject?.id;
setSaveState(null);

const stack = editor.undoStack.map(([key, value]) => [
key,
value.toJSON(),
]);

if (isNew) {
createMap(
{
Expand All @@ -469,6 +547,18 @@ export default function MapEditor({
},
setSaveState,
);
Storage.removeItem(getUndoStackKeyFor(''));
Storage.removeItem(getUndoStackIndexKeyFor(''));
Storage.setItem(
getUndoStackKeyFor(toSlug(mapName)),
JSON.stringify(stack),
);
if (editor.undoStackIndex !== null) {
Storage.setItem(
getUndoStackIndexKeyFor(toSlug(mapName)),
`${editor?.undoStackIndex}`,
);
}
} else {
updateMap(
{
Expand All @@ -481,14 +571,26 @@ export default function MapEditor({
type,
setSaveState,
);
Storage.setItem(
getUndoStackKeyFor(mapObject?.id),
JSON.stringify(stack),
);
if (editor.undoStackIndex !== null) {
Storage.setItem(
getUndoStackIndexKeyFor(mapObject?.id),
`${editor?.undoStackIndex}`,
);
}
}
},
[
createMap,
editor.effects,
editor.undoStackIndex,
mapName,
mapObject?.id,
setMap,
editor.undoStack,
tags,
updateMap,
withHumanPlayer,
Expand Down Expand Up @@ -574,6 +676,12 @@ export default function MapEditor({
undoStack.length,
),
);
if (Storage.getItem(getUndoStackKeyFor(mapObject?.id)) !== null) {
Storage.setItem(
getUndoStackIndexKeyFor(mapObject?.id),
`${index}`,
);
}
if (index > -1 && index < undoStack.length) {
const [, newMap] = undoStack.at(index) || [];
if (newMap) {
Expand Down Expand Up @@ -659,7 +767,15 @@ export default function MapEditor({
document.removeEventListener('keydown', keydownListener);
document.removeEventListener('keyup', keyupListener);
};
}, [editor, saveMap, setEditorState, setMap, tilted, toggleDeleteEntity]);
}, [
editor,
saveMap,
setEditorState,
setMap,
tilted,
toggleDeleteEntity,
mapObject?.id,
]);

useInput(
'select',
Expand All @@ -684,6 +800,8 @@ export default function MapEditor({
setMap('reset', newMap);
_setEditorState(getEditorBaseState(newMap, mapObject, mode, scenario));
updatePreviousMap();
Storage.removeItem(getUndoStackKeyFor(''));
Storage.removeItem(getUndoStackIndexKeyFor(''));
}, [getInitialMap, mapObject, mode, scenario, setMap, updatePreviousMap]);

const fillMap = useCallback(() => {
Expand Down Expand Up @@ -712,7 +830,7 @@ export default function MapEditor({

const restorePreviousState = useCallback(() => {
if (previousState) {
setMap('reset', previousState.map);
setMap('reset', previousState.map, previousState.undoStack);
setPreviousState(null);
_setEditorState(
getEditorBaseState(
Expand All @@ -722,6 +840,8 @@ export default function MapEditor({
},
editor.mode,
editor.scenario,
previousState.undoStack,
previousState.undoStackIndex,
),
);
}
Expand Down Expand Up @@ -754,6 +874,16 @@ export default function MapEditor({
}
}, [saveState]);

useEffect(() => {
if (editor.undoStack) {
const stack = editor.undoStack.map(([key, value]) => [
key,
value.toJSON(),
]);
Storage.setItem(getUndoStackKeyFor(mapObject?.id), JSON.stringify(stack));
}
}, [editor.undoStack, mapObject?.id]);

// Disabling the context menu is handled globally in production,
// so this is only needed for the Map Editor in development.
if (process.env.NODE_ENV === 'development') {
Expand Down
3 changes: 3 additions & 0 deletions hera/editor/Types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type SetEditorStateFunction = (editor: Partial<EditorState>) => void;
export type SetMapFunction = (
type: 'biome' | 'cleanup' | 'heal' | 'reset' | 'resize' | 'teams',
map: MapData,
undoStack?: UndoStack,
) => void;

type MapSaveType = 'New' | 'Update' | 'Disk' | 'Export';
Expand Down Expand Up @@ -110,6 +111,8 @@ export type MapObject = Readonly<{
export type PreviousMapEditorState = Readonly<{
effects: string;
map: MapData;
undoStack?: UndoStack;
undoStackIndex?: number;
}>;

export type MapEditorSaveMessageId =
Expand Down
6 changes: 6 additions & 0 deletions hera/editor/lib/updateUndoStack.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { EditorState, SetEditorStateFunction, UndoEntry } from '../Types.tsx';

export const getUndoStackKeyFor = (id: string | undefined) =>
`map-editor-undo-stack-${id ? `${id}` : 'fallback'}`;

export const getUndoStackIndexKeyFor = (id: string | undefined) =>
`map-editor-undo-stack-index-${id ? `${id}` : 'fallback'}`;

export default function updateUndoStack(
{ setEditorState }: { setEditorState: SetEditorStateFunction },
{ undoStack, undoStackIndex }: EditorState,
Expand Down
Loading