diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e7d8c9b1..0185bc50 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -47,6 +47,7 @@ module.exports = { ], plugins: ['@deities'], rules: { + '@typescript-eslint/array-type': [2, { default: 'generic' }], '@typescript-eslint/no-restricted-imports': [ 2, { diff --git a/apollo/lib/gameHasEnded.tsx b/apollo/lib/gameHasEnded.tsx index 46d6caa7..aabc31ff 100644 --- a/apollo/lib/gameHasEnded.tsx +++ b/apollo/lib/gameHasEnded.tsx @@ -1,7 +1,7 @@ import { ActionResponse } from '../ActionResponse.tsx'; export default function gameHasEnded( - gameState: ReadonlyArray | null, + gameState: ReadonlyArray]> | null, ) { return !!( gameState?.length && diff --git a/art/Sprites.tsx b/art/Sprites.tsx index 3aca82f1..1437671b 100644 --- a/art/Sprites.tsx +++ b/art/Sprites.tsx @@ -12,7 +12,10 @@ type Resource = Readonly<[name: string, url: string]>; type Resources = ReadonlyArray; type PaletteSwapFn = typeof paletteSwap; type PaletteSwapParameters = Parameters; -type DropFirstInTuple = T extends [unknown, ...infer Rest] +type DropFirstInTuple> = T extends [ + unknown, + ...infer Rest, +] ? Rest : never; type MaybePaletteSwapParameters = [ diff --git a/hera/GameMap.tsx b/hera/GameMap.tsx index 68642b73..8534856a 100644 --- a/hera/GameMap.tsx +++ b/hera/GameMap.tsx @@ -62,6 +62,7 @@ import { resetBehavior, setBaseClass } from './behavior/Behavior.tsx'; import MenuBehavior from './behavior/Menu.tsx'; import NullBehavior from './behavior/NullBehavior.tsx'; import Cursor from './Cursor.tsx'; +import MapEditorExtraCursors from './editor/MapEditorMirrorCursors.tsx'; import { EditorState } from './editor/Types.tsx'; import addEndTurnAnimations from './lib/addEndTurnAnimations.tsx'; import animateSupply from './lib/animateSupply.tsx'; @@ -1721,12 +1722,25 @@ export default class GameMap extends Component { {(propsShowCursor || propsShowCursor == null) && showCursor && !replayState.isReplaying && ( - + <> + + {editor?.mode === 'design' && ( + + )} + )} + , 'position'>) { + if (!orign || !drawingMode || drawingMode === 'regular') { + return null; + } + + const vectors = getSymmetricPositions(orign, drawingMode, mapSize); + return vectors.size + ? [...vectors].map((vector) => ( + + )) + : null; +} diff --git a/hera/editor/Types.tsx b/hera/editor/Types.tsx index 161b3f55..1e3dc5ca 100644 --- a/hera/editor/Types.tsx +++ b/hera/editor/Types.tsx @@ -38,9 +38,16 @@ type UndoKey = export type UndoEntry = readonly [UndoKey, MapData]; export type UndoStack = ReadonlyArray; +export type DrawingMode = + | 'regular' + | 'horizontal' + | 'vertical' + | 'horizontal-vertical' + | 'diagonal'; export type EditorState = Readonly<{ condition?: readonly [WinConditionsWithVectors, number]; + drawingMode: DrawingMode; effects: Effects; isDrawing: boolean; isErasing: boolean; diff --git a/hera/editor/behavior/DesignBehavior.tsx b/hera/editor/behavior/DesignBehavior.tsx index 1263f131..8dc198db 100644 --- a/hera/editor/behavior/DesignBehavior.tsx +++ b/hera/editor/behavior/DesignBehavior.tsx @@ -23,6 +23,7 @@ import verifyTiles from '@deities/athena/lib/verifyTiles.tsx'; import Building from '@deities/athena/map/Building.tsx'; import { getDecoratorLimit } from '@deities/athena/map/Configuration.tsx'; import Entity from '@deities/athena/map/Entity.tsx'; +import { PlayerID, PlayerIDs } from '@deities/athena/map/Player.tsx'; import Unit from '@deities/athena/map/Unit.tsx'; import Vector from '@deities/athena/map/Vector.tsx'; import MapData from '@deities/athena/MapData.tsx'; @@ -48,6 +49,7 @@ import { } from '../../Types.tsx'; import FlashFlyout from '../../ui/FlashFlyout.tsx'; import { FlyoutItem } from '../../ui/Flyout.tsx'; +import getSymmetricPositions from '../lib/getSymmetricPositions.ts'; import updateUndoStack from '../lib/updateUndoStack.tsx'; import { EditorState } from '../Types.tsx'; @@ -162,7 +164,11 @@ export default class DesignBehavior { subVector?: Vector, ): StateLike | null { if (editor?.isDrawing && editor.selected) { - return this.put(vector, state, actions, editor); + const vectors = [ + vector, + ...getSymmetricPositions(vector, editor.drawingMode, state.map.size), + ]; + return this.draw(vectors, state, actions, editor); } const { animations, map } = state; @@ -228,11 +234,46 @@ export default class DesignBehavior { return null; } + private draw( + vectors: Array, + state: State, + actions: Actions, + editor: EditorState, + ): StateLike | null { + let newState: StateLike | null = null; + const players = Array.from( + new Set([...state.map.active, ...PlayerIDs.filter((id) => id !== 0)]), + ).slice(0, vectors.length); + vectors.forEach((vector, index) => { + const currentPlayerIndex = players.indexOf( + state.map.getCurrentPlayer().id, + ); + const playerId = + players[ + ((currentPlayerIndex >= 0 ? currentPlayerIndex : 0) + index) % + players.length + ]; + + newState = { + ...newState, + ...this.put( + vector, + { ...state, ...newState }, + actions, + editor, + playerId, + ), + }; + }); + return newState; + } + private put( vector: Vector, state: State, actions: Actions, editor: EditorState, + playerId: PlayerID, ): StateLike | null { if (shouldPlaceDecorator(editor)) { return null; @@ -299,7 +340,14 @@ export default class DesignBehavior { ); } else if (selected.unit) { this.previous = null; - return this.putUnit(selected.unit, vector, state, actions, editor); + return this.putUnit( + selected.unit, + vector, + state, + actions, + editor, + playerId, + ); } else if (selected.building) { this.previous = null; return this.putBuilding( @@ -308,6 +356,7 @@ export default class DesignBehavior { state, actions, editor, + playerId, ); } return null; @@ -503,6 +552,7 @@ export default class DesignBehavior { state: State, actions: Actions, editor: EditorState, + playerId: PlayerID, ): StateLike | null { const { map } = state; const { units } = map; @@ -521,12 +571,7 @@ export default class DesignBehavior { ...spawn( actions, state, - [ - [ - vector, - unit.removeLeader().setPlayer(map.getCurrentPlayer().id), - ], - ], + [[vector, unit.removeLeader().setPlayer(playerId)]], null, ({ map }) => { updateUndoStack(actions, editor, [ @@ -551,6 +596,7 @@ export default class DesignBehavior { state: State, actions: Actions, editor: EditorState, + playerId: PlayerID, ): StateLike | null { const { animations, map } = state; const { buildings, units } = map; @@ -568,14 +614,13 @@ export default class DesignBehavior { const config = map.config.copy({ blocklistedBuildings: new Set(), }); - const player = map.getCurrentPlayer(); const isAlwaysNeutral = building.info.isStructure(); const tryToPlaceBuilding = (state: State): StateLike | null => { let { map } = state; map = map.copy({ active: getActivePlayers(map), - buildings: map.buildings.set(vector, building.setPlayer(player.id)), + buildings: map.buildings.set(vector, building.setPlayer(playerId)), }); const { editorPlaceOn, placeOn } = building.info.configuration; @@ -618,7 +663,7 @@ export default class DesignBehavior { return canBuild( getTemporaryMapForBuilding(temporaryMap, vector, building), building.info, - isAlwaysNeutral ? 0 : player, + isAlwaysNeutral ? 0 : playerId, vector, true, ) && !(building.info.isHQ() && map.currentPlayer === 0) @@ -636,7 +681,7 @@ export default class DesignBehavior { return newState; }, type: 'createBuilding', - variant: isAlwaysNeutral ? 0 : player.id, + variant: isAlwaysNeutral ? 0 : playerId, }), map: state.map.copy({ buildings: buildings.delete(vector) }), } diff --git a/hera/editor/lib/__tests__/getSymmetricPositions.test.ts b/hera/editor/lib/__tests__/getSymmetricPositions.test.ts new file mode 100644 index 00000000..f015fe6d --- /dev/null +++ b/hera/editor/lib/__tests__/getSymmetricPositions.test.ts @@ -0,0 +1,139 @@ +import vec from '@deities/athena/map/vec.tsx'; +import { SizeVector } from '@deities/athena/MapData.tsx'; +import { expect, test } from 'vitest'; +import getSymmetricPositions from '../getSymmetricPositions.ts'; + +test('`getSymmetricPositions` regular', () => { + expect( + getSymmetricPositions(vec(4, 4), 'regular', new SizeVector(10, 10)), + ).toMatchInlineSnapshot(`Set {}`); +}); + +test('`getSymmetricPositions` horizontal', () => { + expect(getSymmetricPositions(vec(4, 4), 'horizontal', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 7, + 4, + ], + } + `); + expect(getSymmetricPositions(vec(8, 8), 'horizontal', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 3, + 8, + ], + } + `); +}); + +test('`getSymmetricPositions` vertical', () => { + expect(getSymmetricPositions(vec(4, 4), 'vertical', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 4, + 7, + ], + } + `); + expect(getSymmetricPositions(vec(8, 8), 'vertical', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 8, + 3, + ], + } + `); +}); + +test('`getSymmetricPositions` diagonal', () => { + expect(getSymmetricPositions(vec(4, 4), 'diagonal', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 7, + 7, + ], + } + `); + expect(getSymmetricPositions(vec(8, 8), 'diagonal', new SizeVector(10, 10))) + .toMatchInlineSnapshot(` + Set { + [ + 3, + 3, + ], + } + `); +}); + +test('`getSymmetricPositions` horizontal-vertical', () => { + expect( + getSymmetricPositions( + vec(4, 4), + 'horizontal-vertical', + new SizeVector(10, 10), + ), + ).toMatchInlineSnapshot(` + Set { + [ + 7, + 4, + ], + [ + 4, + 7, + ], + [ + 7, + 7, + ], + } + `); + expect( + getSymmetricPositions( + vec(8, 8), + 'horizontal-vertical', + new SizeVector(10, 10), + ), + ).toMatchInlineSnapshot(` + Set { + [ + 3, + 8, + ], + [ + 8, + 3, + ], + [ + 3, + 3, + ], + } + `); +}); + +test('`getSymmetricPositions` does not include vector itself', () => { + expect( + getSymmetricPositions(vec(3, 2), 'horizontal', new SizeVector(5, 5)), + ).toMatchInlineSnapshot(`Set {}`); + expect( + getSymmetricPositions(vec(2, 3), 'vertical', new SizeVector(5, 5)), + ).toMatchInlineSnapshot(`Set {}`); + expect( + getSymmetricPositions(vec(3, 3), 'diagonal', new SizeVector(5, 5)), + ).toMatchInlineSnapshot(`Set {}`); + expect( + getSymmetricPositions( + vec(3, 3), + 'horizontal-vertical', + new SizeVector(5, 5), + ), + ).toMatchInlineSnapshot(`Set {}`); +}); diff --git a/hera/editor/lib/getSymmetricPositions.ts b/hera/editor/lib/getSymmetricPositions.ts new file mode 100644 index 00000000..4cfa8022 --- /dev/null +++ b/hera/editor/lib/getSymmetricPositions.ts @@ -0,0 +1,31 @@ +import Vector from '@deities/athena/map/Vector.tsx'; +import { SizeVector } from '@deities/athena/MapData.tsx'; +import { DrawingMode } from '../Types.tsx'; +import { mirrorVector } from './mirrorVector.ts'; + +export default function getSymmetricPositions( + origin: Vector, + drawingMode: DrawingMode, + mapSize: SizeVector, +) { + const vectors = new Set(); + + if (drawingMode !== 'regular') { + if (drawingMode === 'horizontal-vertical') { + vectors.add(mirrorVector(origin, mapSize, 'horizontal')); + vectors.add(mirrorVector(origin, mapSize, 'vertical')); + vectors.add( + mirrorVector( + mirrorVector(origin, mapSize, 'horizontal'), + mapSize, + 'vertical', + ), + ); + } else { + vectors.add(mirrorVector(origin, mapSize, drawingMode)); + } + } + + vectors.delete(origin); + return vectors; +} diff --git a/hera/editor/lib/mirrorVector.ts b/hera/editor/lib/mirrorVector.ts new file mode 100644 index 00000000..63e15cde --- /dev/null +++ b/hera/editor/lib/mirrorVector.ts @@ -0,0 +1,22 @@ +import vec from '@deities/athena/map/vec.tsx'; +import Vector from '@deities/athena/map/Vector.tsx'; +import { SizeVector } from '@deities/athena/MapData.tsx'; +import { DrawingMode } from '../Types.tsx'; + +export function mirrorVector( + vector: Vector, + { height, width }: SizeVector, + mirrorType: Extract, +) { + const { x, y } = vector; + if (mirrorType === 'horizontal') { + return vec(width - x + 1, y); + } + if (mirrorType === 'vertical') { + return vec(x, height - y + 1); + } + if (mirrorType === 'diagonal') { + return vec(width - x + 1, height - y + 1); + } + return vector; +} diff --git a/hera/editor/panels/DesignPanel.tsx b/hera/editor/panels/DesignPanel.tsx index 43bdea82..61d83caf 100644 --- a/hera/editor/panels/DesignPanel.tsx +++ b/hera/editor/panels/DesignPanel.tsx @@ -25,6 +25,13 @@ import Box from '@deities/ui/Box.tsx'; import ellipsis from '@deities/ui/ellipsis.tsx'; import useAlert from '@deities/ui/hooks/useAlert.tsx'; import Icon from '@deities/ui/Icon.tsx'; +import { + DiagonalDrawingMode, + HorizontalDrawingMode, + HorizontalVerticalDrawingMode, + RegularDrawingMode, + VerticalDrawingMode, +} from '@deities/ui/icons/DrawingMode.tsx'; import InlineLink from '@deities/ui/InlineLink.tsx'; import Stack from '@deities/ui/Stack.tsx'; import { css, cx } from '@emotion/css'; @@ -254,100 +261,159 @@ export default function DesignPanel({ return ( - - - selectTile(tile)} - selected={ - tile - ? tiles.findIndex((currentTile) => currentTile.id === tile.id) - : undefined - } - tiles={tiles} - > - setEditorState({ selected: { eraseTiles: true } })} - scale={2} - tileSize={TileSize} - /> - - alert({ - onAccept: fillMap, - text: fbt( - `Fill the map with tile "${fbt.param( - 'tile name', - (canFillTile(tile) ? tile : Plain).name, - )}"?`, - 'Confirmation dialog to fill the map in the editor', - ), - }) + + + + selectTile(tile)} + selected={ + tile + ? tiles.findIndex((currentTile) => currentTile.id === tile.id) + : undefined } + tiles={tiles} > - - - - - - selectBuilding(building)} - selected={ - building - ? buildings.findIndex((entity) => entity.id === building.id) - : undefined - } - size="tall" - tiles={buildings.map((building) => - getTileInfo(getAnyBuildingTileField(building.info)), - )} - > - - setEditorState({ selected: { eraseBuildings: true } }) + + setEditorState({ selected: { eraseTiles: true } }) + } + scale={2} + tileSize={TileSize} + /> + + alert({ + onAccept: fillMap, + text: fbt( + `Fill the map with tile "${fbt.param( + 'tile name', + (canFillTile(tile) ? tile : Plain).name, + )}"?`, + 'Confirmation dialog to fill the map in the editor', + ), + }) + } + > + + + + + + selectBuilding(building)} + selected={ + building + ? buildings.findIndex((entity) => entity.id === building.id) + : undefined } - scale={2} - tall - tileSize={TileSize} - /> - - + size="tall" + tiles={buildings.map((building) => + getTileInfo(getAnyBuildingTileField(building.info)), + )} + > + + setEditorState({ selected: { eraseBuildings: true } }) + } + scale={2} + tall + tileSize={TileSize} + /> + + - - selectUnit(unit)} - selected={ - unit - ? units.findIndex((entity) => entity.id === unit.id) - : undefined - } - tiles={units.map((unit) => getAnyUnitTile(unit.info) || Plain)} - units={units} - > - setEditorState({ selected: { eraseUnits: true } })} - scale={2} - tileSize={TileSize} + + selectUnit(unit)} + selected={ + unit + ? units.findIndex((entity) => entity.id === unit.id) + : undefined + } + tiles={units.map((unit) => getAnyUnitTile(unit.info) || Plain)} + units={units} + > + + setEditorState({ selected: { eraseUnits: true } }) + } + scale={2} + tileSize={TileSize} + /> + + + + - - - - + + + + + { + setEditorState({ drawingMode: 'regular' }); + }} + selected={editor.drawingMode === 'regular'} + > + + + { + setEditorState({ drawingMode: 'horizontal' }); + }} + selected={editor.drawingMode === 'horizontal'} + > + + + { + setEditorState({ drawingMode: 'vertical' }); + }} + selected={editor.drawingMode === 'vertical'} + > + + + { + setEditorState({ drawingMode: 'diagonal' }); + }} + selected={editor.drawingMode === 'diagonal'} + > + + {' '} + { + setEditorState({ drawingMode: 'horizontal-vertical' }); + }} + selected={editor.drawingMode === 'horizontal-vertical'} + > + + + @@ -362,3 +428,7 @@ const fillStyle = css` margin: 2px; width: ${DoubleSize - 4}px; `; + +const drawingModeContainerStyle = css` + margin-top: ${TileSize}px; +`; diff --git a/ui/icons/DrawingMode.tsx b/ui/icons/DrawingMode.tsx new file mode 100644 index 00000000..1563fdd2 --- /dev/null +++ b/ui/icons/DrawingMode.tsx @@ -0,0 +1,29 @@ +export const RegularDrawingMode = { + body: ``, + height: 24, + width: 24, +}; + +export const HorizontalDrawingMode = { + body: ``, + height: 24, + width: 24, +}; + +export const VerticalDrawingMode = { + body: ``, + height: 24, + width: 24, +}; + +export const DiagonalDrawingMode = { + body: ``, + height: 24, + width: 24, +}; + +export const HorizontalVerticalDrawingMode = { + body: ``, + height: 24, + width: 24, +};