From 1fa3f59a771ca1a55a4de36f473684a007cc4e24 Mon Sep 17 00:00:00 2001 From: AlexandreS <32449369+AlexandreSi@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:37:20 +0200 Subject: [PATCH] Add possibility to paint tilemap with a rectangle selection from the tileset (#6977) --- .../CompactInstancePropertiesEditor/index.js | 13 +- .../InstancesEditor/TileMapPaintingPreview.js | 406 +++++++-------- .../src/InstancesEditor/TileSetVisualizer.js | 316 ++++++------ newIDE/app/src/InstancesEditor/index.js | 197 ++++---- .../Editors/SimpleTileMapEditor.js | 13 +- newIDE/app/src/UI/ScrollView.js | 15 +- newIDE/app/src/Utils/TileMap.js | 463 ++++++++++++++++++ newIDE/app/src/Utils/TileMap.spec.js | 244 +++++++++ newIDE/app/src/Utils/UseLongTouch.js | 6 +- 9 files changed, 1208 insertions(+), 465 deletions(-) create mode 100644 newIDE/app/src/Utils/TileMap.js create mode 100644 newIDE/app/src/Utils/TileMap.spec.js diff --git a/newIDE/app/src/InstancesEditor/CompactInstancePropertiesEditor/index.js b/newIDE/app/src/InstancesEditor/CompactInstancePropertiesEditor/index.js index 142eacc0c4bf..e040aac36b31 100644 --- a/newIDE/app/src/InstancesEditor/CompactInstancePropertiesEditor/index.js +++ b/newIDE/app/src/InstancesEditor/CompactInstancePropertiesEditor/index.js @@ -15,7 +15,7 @@ import IconButton from '../../UI/IconButton'; import { Line, Column, Spacer, marginsSize } from '../../UI/Grid'; import Text from '../../UI/Text'; import { type UnsavedChanges } from '../../MainFrame/UnsavedChangesContext'; -import ScrollView from '../../UI/ScrollView'; +import ScrollView, { type ScrollViewInterface } from '../../UI/ScrollView'; import EventsRootVariablesFinder from '../../Utils/EventsRootVariablesFinder'; import VariablesList, { type HistoryHandler, @@ -95,7 +95,7 @@ const CompactInstancePropertiesEditor = ({ onSelectTileMapTile, }: Props) => { const forceUpdate = useForceUpdate(); - + const scrollViewRef = React.useRef(null); const instance = instances[0]; /** * TODO: multiple instances support for variables list. Expected behavior should be: @@ -105,6 +105,12 @@ const CompactInstancePropertiesEditor = ({ */ const shouldDisplayVariablesList = instances.length === 1; + const onScrollY = React.useCallback(deltaY => { + if (scrollViewRef.current) { + scrollViewRef.current.scrollBy(deltaY); + } + }, []); + const { object, instanceSchema } = React.useMemo<{| object?: gdObject, instanceSchema?: Schema, @@ -220,6 +226,7 @@ const CompactInstancePropertiesEditor = ({ scope="scene-editor-instance-properties" > diff --git a/newIDE/app/src/InstancesEditor/TileMapPaintingPreview.js b/newIDE/app/src/InstancesEditor/TileMapPaintingPreview.js index 56b530349870..a8e4edd3820c 100644 --- a/newIDE/app/src/InstancesEditor/TileMapPaintingPreview.js +++ b/newIDE/app/src/InstancesEditor/TileMapPaintingPreview.js @@ -9,6 +9,12 @@ import RenderedInstance from '../ObjectsRendering/Renderers/RenderedInstance'; import Rendered3DInstance from '../ObjectsRendering/Renderers/Rendered3DInstance'; import { type TileMapTileSelection } from './TileSetVisualizer'; import { AffineTransformation } from '../Utils/AffineTransformation'; +import { + getTileSet, + getTilesGridCoordinatesFromPointerSceneCoordinates, + isTileSetBadlyConfigured, + type TileSet, +} from '../Utils/TileMap'; export const updateSceneToTileMapTransformation = ( instance: gdInitialInstance, @@ -62,136 +68,6 @@ export const updateSceneToTileMapTransformation = ( return { scaleX, scaleY }; }; -export const getTileSet = (object: gdObject) => { - const objectConfigurationProperties = object - .getConfiguration() - .getProperties(); - const columnCount = parseFloat( - objectConfigurationProperties.get('columnCount').getValue() - ); - const rowCount = parseFloat( - objectConfigurationProperties.get('rowCount').getValue() - ); - const tileSize = parseFloat( - objectConfigurationProperties.get('tileSize').getValue() - ); - const atlasImage = objectConfigurationProperties.get('atlasImage').getValue(); - return { rowCount, columnCount, tileSize, atlasImage }; -}; - -export const isTileSetBadlyConfigured = ({ - rowCount, - columnCount, - tileSize, - atlasImage, -}: {| - rowCount: number, - columnCount: number, - tileSize: number, - atlasImage: string, -|}) => { - return ( - !Number.isInteger(columnCount) || - columnCount <= 0 || - !Number.isInteger(rowCount) || - rowCount <= 0 - ); -}; - -/** - * Returns the list of tiles corresponding to the user selection. - * If only one coordinate is present, only one tile is placed at the slot the - * pointer points to. - * If two coordinates are present, tiles are displayed to form a rectangle with the - * two coordinates being the top left and bottom right corner of the rectangle. - */ -export const getTilesGridCoordinatesFromPointerSceneCoordinates = ({ - coordinates, - tileSize, - sceneToTileMapTransformation, -}: {| - coordinates: Array<{| x: number, y: number |}>, - tileSize: number, - sceneToTileMapTransformation: AffineTransformation, -|}): Array<{| x: number, y: number |}> => { - if (coordinates.length === 0) return []; - - const tilesCoordinatesInTileMapGrid = []; - - if (coordinates.length === 1) { - const coordinatesInTileMapGrid = [0, 0]; - sceneToTileMapTransformation.transform( - [coordinates[0].x, coordinates[0].y], - coordinatesInTileMapGrid - ); - coordinatesInTileMapGrid[0] = Math.floor( - coordinatesInTileMapGrid[0] / tileSize - ); - coordinatesInTileMapGrid[1] = Math.floor( - coordinatesInTileMapGrid[1] / tileSize - ); - tilesCoordinatesInTileMapGrid.push({ - x: coordinatesInTileMapGrid[0], - y: coordinatesInTileMapGrid[1], - }); - } - if (coordinates.length === 2) { - const firstPointCoordinatesInTileMap = [0, 0]; - sceneToTileMapTransformation.transform( - [coordinates[0].x, coordinates[0].y], - firstPointCoordinatesInTileMap - ); - const secondPointCoordinatesInTileMap = [0, 0]; - sceneToTileMapTransformation.transform( - [coordinates[1].x, coordinates[1].y], - secondPointCoordinatesInTileMap - ); - const topLeftCornerCoordinatesInTileMap = [ - Math.min( - firstPointCoordinatesInTileMap[0], - secondPointCoordinatesInTileMap[0] - ), - Math.min( - firstPointCoordinatesInTileMap[1], - secondPointCoordinatesInTileMap[1] - ), - ]; - const bottomRightCornerCoordinatesInTileMap = [ - Math.max( - firstPointCoordinatesInTileMap[0], - secondPointCoordinatesInTileMap[0] - ), - Math.max( - firstPointCoordinatesInTileMap[1], - secondPointCoordinatesInTileMap[1] - ), - ]; - const topLeftCornerCoordinatesInTileMapGrid = [ - Math.floor(topLeftCornerCoordinatesInTileMap[0] / tileSize), - Math.floor(topLeftCornerCoordinatesInTileMap[1] / tileSize), - ]; - const bottomRightCornerCoordinatesInTileMapGrid = [ - Math.floor(bottomRightCornerCoordinatesInTileMap[0] / tileSize), - Math.floor(bottomRightCornerCoordinatesInTileMap[1] / tileSize), - ]; - - for ( - let columnIndex = topLeftCornerCoordinatesInTileMapGrid[0]; - columnIndex <= bottomRightCornerCoordinatesInTileMapGrid[0]; - columnIndex++ - ) { - for ( - let rowIndex = topLeftCornerCoordinatesInTileMapGrid[1]; - rowIndex <= bottomRightCornerCoordinatesInTileMapGrid[1]; - rowIndex++ - ) { - tilesCoordinatesInTileMapGrid.push({ x: columnIndex, y: rowIndex }); - } - } - } - return tilesCoordinatesInTileMapGrid; -}; - type Props = {| project: gdProject, layout: gdLayout | null, @@ -249,72 +125,100 @@ class TileMapPaintingPreview { return this.preview; } - render() { - this.preview.removeChildren(0); - const tileMapTileSelection = this.getTileMapTileSelection(); - if (!tileMapTileSelection) { - return; - } - const selection = this.instancesSelection.getSelectedInstances(); - if (selection.length !== 1) return; - const instance = selection[0]; - const associatedObjectName = instance.getObjectName(); - const object = getObjectByName( - this.project.getObjects(), - this.layout ? this.layout.getObjects() : null, - associatedObjectName + _getTextureInAtlas({ + tileSet, + x, + y, + }: { + tileSet: TileSet, + x: number, + y: number, + }): ?PIXI.Texture { + const { atlasImage, tileSize } = tileSet; + if (!atlasImage) return; + const cacheKey = `${atlasImage}-${tileSize}-${x}-${y}`; + const cachedTexture = this.cache.get(cacheKey); + if (cachedTexture) return cachedTexture; + + const atlasTexture = PixiResourcesLoader.getPIXITexture( + this.project, + atlasImage ); - if (!object || object.getType() !== 'TileMap::SimpleTileMap') return; - const tileSet = getTileSet(object); - const isBadlyConfigured = isTileSetBadlyConfigured(tileSet); - const { tileSize } = tileSet; - let texture; - if (isBadlyConfigured) { - texture = PixiResourcesLoader.getInvalidPIXITexture(); - } else { - if (tileMapTileSelection.kind === 'single') { - const atlasResourceName = object - .getConfiguration() - .getProperties() - .get('atlasImage') - .getValue(); - if (!atlasResourceName) return; - const cacheKey = `${atlasResourceName}-${tileSize}-${ - tileMapTileSelection.coordinates.x - }-${tileMapTileSelection.coordinates.y}`; - texture = this.cache.get(cacheKey); - if (!texture) { - const atlasTexture = PixiResourcesLoader.getPIXITexture( - this.project, - atlasResourceName - ); - const rect = new PIXI.Rectangle( - tileMapTileSelection.coordinates.x * tileSize, - tileMapTileSelection.coordinates.y * tileSize, - tileSize, - tileSize - ); + const rect = new PIXI.Rectangle( + x * tileSize, + y * tileSize, + tileSize, + tileSize + ); - try { - texture = new PIXI.Texture(atlasTexture, rect); - } catch (error) { - console.error( - `Tile could not be extracted from atlas texture:`, - error - ); - texture = PixiResourcesLoader.getInvalidPIXITexture(); - } - this.cache.set(cacheKey, texture); - } - } else if (tileMapTileSelection.kind === 'erase') { - texture = PIXI.Texture.from( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAARSURBVHgBY7h58+Z/BhgAcQA/VAcVLiw46wAAAABJRU5ErkJggg==' - ); - texture.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; - } + try { + const texture = new PIXI.Texture(atlasTexture, rect); + this.cache.set(cacheKey, texture); + } catch (error) { + console.error(`Tile could not be extracted from atlas texture:`, error); + return PixiResourcesLoader.getInvalidPIXITexture(); } + } + + _getTilingSpriteForRectangle({ + bottomRightCorner, + topLeftCorner, + texture, + scaleX, + scaleY, + flipHorizontally, + flipVertically, + tileSize, + angle, + }: {| + bottomRightCorner: {| x: number, y: number |}, + topLeftCorner: {| x: number, y: number |}, + scaleX: number, + scaleY: number, + tileSize: number, + flipHorizontally: boolean, + flipVertically: boolean, + angle: number, + texture: PIXI.Texture, + |}) { + const sprite = new PIXI.TilingSprite(texture); + const workingPoint = [0, 0]; + sprite.tileScale.x = + (flipHorizontally ? -1 : +1) * this.viewPosition.toCanvasScale(scaleX); + sprite.tileScale.y = + (flipVertically ? -1 : +1) * this.viewPosition.toCanvasScale(scaleY); + + this.tileMapToSceneTransformation.transform( + [topLeftCorner.x * tileSize, topLeftCorner.y * tileSize], + workingPoint + ); + const tileSizeInCanvas = this.viewPosition.toCanvasScale(tileSize); + + sprite.x = this.viewPosition.toCanvasScale(workingPoint[0]); + sprite.y = this.viewPosition.toCanvasScale(workingPoint[1]); + sprite.width = + (bottomRightCorner.x - topLeftCorner.x + 1) * tileSizeInCanvas * scaleX; + sprite.height = + (bottomRightCorner.y - topLeftCorner.y + 1) * tileSizeInCanvas * scaleY; + + sprite.angle = angle; + + return sprite; + } + + _getPreviewSprites({ + instance, + tileSet, + isBadlyConfigured, + tileMapTileSelection, + }: { + instance: gdInitialInstance, + tileSet: TileSet, + isBadlyConfigured: boolean, + tileMapTileSelection: TileMapTileSelection, + }): ?PIXI.Container { const renderedInstance = this.getRendererOfInstance(instance); if ( !renderedInstance || @@ -324,7 +228,7 @@ class TileMapPaintingPreview { console.error( `Instance of ${instance.getObjectName()} seems to not be a RenderedSimpleTileMapInstance (method getEditableTileMap does not exist).` ); - return; + return null; } const scales = updateSceneToTileMapTransformation( @@ -334,65 +238,97 @@ class TileMapPaintingPreview { this.sceneToTileMapTransformation, this.tileMapToSceneTransformation ); - if (!scales) return; + if (!scales) return null; const { scaleX, scaleY } = scales; const coordinates = this.getCoordinatesToRender(); - if (coordinates.length === 0) return; - const tileSizeInCanvas = this.viewPosition.toCanvasScale(tileSize); - const spriteWidth = tileSizeInCanvas * scaleX; - const spriteHeight = tileSizeInCanvas * scaleY; + if (coordinates.length === 0) return null; + const { tileSize } = tileSet; - const spritesCoordinatesInTileMapGrid = getTilesGridCoordinatesFromPointerSceneCoordinates( + const tilesCoordinatesInTileMapGrid = getTilesGridCoordinatesFromPointerSceneCoordinates( { + tileMapTileSelection, coordinates, tileSize, sceneToTileMapTransformation: this.sceneToTileMapTransformation, } ); - if (spritesCoordinatesInTileMapGrid.length === 0) { + if (tilesCoordinatesInTileMapGrid.length === 0) { console.warn("Could't get coordinates to render in tile map grid."); - return; + return null; } + const container = new PIXI.Container(); + tilesCoordinatesInTileMapGrid.forEach(tilesCoordinates => { + const { + bottomRightCorner, + topLeftCorner, + tileCoordinates, + } = tilesCoordinates; + let texture; + if (isBadlyConfigured) { + texture = PixiResourcesLoader.getInvalidPIXITexture(); + } else { + if (tileMapTileSelection.kind === 'rectangle' && tileCoordinates) { + texture = this._getTextureInAtlas({ + tileSet, + ...tileCoordinates, + }); + if (!texture) return null; + } else if (tileMapTileSelection.kind === 'erase') { + texture = PIXI.Texture.from( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAARSURBVHgBY7h58+Z/BhgAcQA/VAcVLiw46wAAAABJRU5ErkJggg==' + ); + texture.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; + } + } + const sprite = this._getTilingSpriteForRectangle({ + bottomRightCorner, + topLeftCorner, + texture, + scaleX, + scaleY, + flipHorizontally: tileMapTileSelection.flipHorizontally || false, + flipVertically: tileMapTileSelection.flipVertically || false, + tileSize, + angle: instance.getAngle(), + }); + container.addChild(sprite); + }); - const workingPoint = [0, 0]; - - const sprite = new PIXI.TilingSprite(texture); - - sprite.tileScale.x = - (tileMapTileSelection.flipHorizontally ? -1 : +1) * - this.viewPosition.toCanvasScale(scaleX); - sprite.tileScale.y = - (tileMapTileSelection.flipVertically ? -1 : +1) * - this.viewPosition.toCanvasScale(scaleY); - sprite.width = spriteWidth; - sprite.height = spriteHeight; + return container; + } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const { x, y } of spritesCoordinatesInTileMapGrid) { - if (x < minX) minX = x; - if (y < minY) minY = y; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; + render() { + this.preview.removeChildren(0); + const tileMapTileSelection = this.getTileMapTileSelection(); + if (!tileMapTileSelection) { + return; } - - this.tileMapToSceneTransformation.transform( - [minX * tileSize, minY * tileSize], - workingPoint + const selection = this.instancesSelection.getSelectedInstances(); + if (selection.length !== 1) return; + const instance = selection[0]; + const associatedObjectName = instance.getObjectName(); + const object = getObjectByName( + this.project.getObjects(), + this.layout ? this.layout.getObjects() : null, + associatedObjectName ); + if (!object || object.getType() !== 'TileMap::SimpleTileMap') return; + const tileSet = getTileSet(object); + const isBadlyConfigured = isTileSetBadlyConfigured(tileSet); - sprite.x = this.viewPosition.toCanvasScale(workingPoint[0]); - sprite.y = this.viewPosition.toCanvasScale(workingPoint[1]); - sprite.width = - (maxX - minX + 1) * this.viewPosition.toCanvasScale(tileSize) * scaleX; - sprite.height = - (maxY - minY + 1) * this.viewPosition.toCanvasScale(tileSize) * scaleY; - - sprite.angle = instance.getAngle(); - - this.preview.addChild(sprite); + if ( + isBadlyConfigured || + tileMapTileSelection.kind === 'rectangle' || + tileMapTileSelection.kind === 'erase' + ) { + const container = this._getPreviewSprites({ + instance, + tileSet, + tileMapTileSelection, + isBadlyConfigured, + }); + if (container) this.preview.addChild(container); + } const canvasCoordinates = this.viewPosition.toCanvasCoordinates(0, 0); this.preview.position.x = canvasCoordinates[0]; diff --git a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js index ee98eb1913a1..1da3f02bcfb0 100644 --- a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js +++ b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js @@ -16,7 +16,7 @@ import useForceUpdate from '../Utils/UseForceUpdate'; import { useLongTouch, type ClientCoordinates } from '../Utils/UseLongTouch'; import Text from '../UI/Text'; import EmptyMessage from '../UI/EmptyMessage'; -import { isTileSetBadlyConfigured } from './TileMapPaintingPreview'; +import { isTileSetBadlyConfigured } from '../Utils/TileMap'; const styles = { tilesetAndTooltipsContainer: { @@ -30,6 +30,7 @@ const styles = { position: 'relative', display: 'flex', overflow: 'auto', + touchAction: 'none', }, atlasImage: { flex: 1, imageRendering: 'pixelated' }, icon: { fontSize: 18 }, @@ -64,12 +65,12 @@ type TileMapCoordinates = {| x: number, y: number |}; /** * Returns the tile id in a tile set. * This id corresponds to the index of the tile if the tile set - * is flattened so that each column is put right after the previous one. + * is flattened so that each row is put right after the previous one. * Example: - * 1 | 4 | 7 - * 2 | 5 | 8 - * 3 | 6 | 9 - * @param argument Object that contains x the horizontal position of the tile, y the vertical position and rowCount the number of rows in the tile set. + * 0 | 1 | 2 + * 3 | 4 | 5 + * 6 | 7 | 8 + * @param argument Object that contains x the horizontal position of the tile, y the vertical position and columnCount the number of columns in the tile set. * @returns the id of the tile. */ export const getTileIdFromGridCoordinates = ({ @@ -85,12 +86,12 @@ export const getTileIdFromGridCoordinates = ({ /** * Returns the coordinates of a tile in a tile set given its id. * This id corresponds to the index of the tile if the tile set - * is flattened so that each column is put right after the previous one. + * is flattened so that each row is put right after the previous one. * Example: - * 1 | 4 | 7 - * 2 | 5 | 8 - * 3 | 6 | 9 - * @param argument Object that contains id the id of the tile and rowCount the number of rows in the tile set. + * 0 | 1 | 2 + * 3 | 4 | 5 + * 6 | 7 | 8 + * @param argument Object that contains the id of the tile and columnCount the number of columns in the tile set. * @returns the id of the tile. */ export const getGridCoordinatesFromTileId = ({ @@ -191,14 +192,14 @@ const Tile = ({ export type TileMapTileSelection = | {| - kind: 'single', - coordinates: TileMapCoordinates, - flipHorizontally: boolean, - flipVertically: boolean, + kind: 'multiple', + coordinates: TileMapCoordinates[], |} | {| - kind: 'multiple', + kind: 'rectangle', coordinates: TileMapCoordinates[], + flipHorizontally: boolean, + flipVertically: boolean, |} | {| kind: 'erase', @@ -210,12 +211,18 @@ type Props = {| tileMapTileSelection: ?TileMapTileSelection, onSelectTileMapTile: (?TileMapTileSelection) => void, allowMultipleSelection: boolean, + allowRectangleSelection: boolean, showPaintingToolbar: boolean, interactive: boolean, onAtlasImageLoaded?: ( e: SyntheticEvent, atlasResourceName: string ) => void, + /** + * Needed to enable scrolling on touch devices when the user is not using + * a long touch to make a tile selection on the tile set. + */ + onScrollY: number => void, |}; const TileSetVisualizer = ({ @@ -224,9 +231,11 @@ const TileSetVisualizer = ({ tileMapTileSelection, onSelectTileMapTile, allowMultipleSelection, + allowRectangleSelection, showPaintingToolbar, interactive, onAtlasImageLoaded, + onScrollY, }: Props) => { const forceUpdate = useForceUpdate(); const atlasResourceName = objectConfiguration @@ -242,9 +251,9 @@ const TileSetVisualizer = ({ setShouldFlipHorizontally, ] = React.useState(false); const [ - lastSelectedTile, - setLastSelectedTile, - ] = React.useState(null); + lastSelection, + setLastSelection, + ] = React.useState(null); const tilesetContainerRef = React.useRef(null); const tilesetAndTooltipContainerRef = React.useRef(null); const [tooltipContent, setTooltipContent] = React.useState(null); - const [touchStartCoordinates, setTouchStartCoordinates] = React.useState(null); - const [shouldCancelClick, setShouldCancelClick] = React.useState( - false - ); + const isLongTouchRef = React.useRef(false); const tooltipDisplayTimeoutId = React.useRef(null); const [ rectangularSelectionTilePreview, @@ -314,7 +317,6 @@ const TileSetVisualizer = ({ const displayTileIdTooltip = React.useCallback( (e: ClientCoordinates) => { - setShouldCancelClick(true); if (!displayedTileSize || isBadlyConfigured) return; const imageCoordinates = getImageCoordinatesFromPointerEvent(e); @@ -339,7 +341,17 @@ const TileSetVisualizer = ({ [displayedTileSize, columnCount, rowCount, isBadlyConfigured] ); - const longTouchProps = useLongTouch(displayTileIdTooltip); + const handleLongTouch = React.useCallback( + (e: ClientCoordinates) => { + isLongTouchRef.current = true; + displayTileIdTooltip(e); + }, + [displayTileIdTooltip] + ); + + const longTouchProps = useLongTouch(handleLongTouch, { + doNotCancelOnScroll: true, + }); React.useEffect( () => { @@ -353,15 +365,12 @@ const TileSetVisualizer = ({ const onPointerDown = React.useCallback( (event: PointerEvent) => { if (isBadlyConfigured) return; - if (event.pointerType === 'touch') { - setTouchStartCoordinates({ x: event.pageX, y: event.pageY }); - } - const imageCoordinates = getImageCoordinatesFromPointerEvent(event); - if (!imageCoordinates) return; - setClickStartCoordinates({ - x: imageCoordinates.mouseX, - y: imageCoordinates.mouseY, - }); + const coordinates = getImageCoordinatesFromPointerEvent(event); + if (!coordinates) return; + startCoordinatesRef.current = { + x: coordinates.mouseX, + y: coordinates.mouseY, + }; }, [isBadlyConfigured] ); @@ -370,13 +379,33 @@ const TileSetVisualizer = ({ (event: PointerEvent) => { if ( isBadlyConfigured || - !clickStartCoordinates || + !startCoordinatesRef || !displayedTileSize || - !allowMultipleSelection || - event.pointerType === 'touch' + (!allowMultipleSelection && !allowRectangleSelection) ) { return; } + + const startCoordinates = startCoordinatesRef.current; + if (!startCoordinates) return; + + const isTouchDevice = event.pointerType === 'touch'; + + if (isTouchDevice) { + // Distinguish between a long touch (to multi select tiles) and a scroll. + if (!isLongTouchRef.current) { + const coordinates = getImageCoordinatesFromPointerEvent(event); + if (!coordinates) return; + if (tilesetContainerRef.current) { + const deltaY = -event.movementY; + const deltaX = + startCoordinates.x - coordinates.mouseXWithoutScrollLeft; + tilesetContainerRef.current.scrollLeft = deltaX; + onScrollY(deltaY); + } + return; + } + } const imageCoordinates = getImageCoordinatesFromPointerEvent(event); if (!imageCoordinates) return; @@ -387,10 +416,11 @@ const TileSetVisualizer = ({ rowCount, displayedTileSize, }); + const { x: startX, y: startY } = getGridCoordinatesFromPointerCoordinates( { - pointerX: clickStartCoordinates.x, - pointerY: clickStartCoordinates.y, + pointerX: startCoordinates.x, + pointerY: startCoordinates.y, columnCount, rowCount, displayedTileSize, @@ -412,7 +442,8 @@ const TileSetVisualizer = ({ columnCount, rowCount, allowMultipleSelection, - clickStartCoordinates, + allowRectangleSelection, + onScrollY, ] ); @@ -420,22 +451,13 @@ const TileSetVisualizer = ({ (event: PointerEvent) => { try { if (!displayedTileSize || isBadlyConfigured) return; - if (shouldCancelClick) { - setShouldCancelClick(false); - return; - } - let isTouchDevice = false; + const isTouchDevice = event.pointerType === 'touch'; + const startCoordinates = startCoordinatesRef.current; + if (!startCoordinates) return; - if (event.pointerType === 'touch') { - isTouchDevice = true; - if ( - !touchStartCoordinates || - Math.abs(event.pageX - touchStartCoordinates.x) > 30 || - Math.abs(event.pageY - touchStartCoordinates.y) > 30 - ) { - return; - } + if (isTouchDevice && !isLongTouchRef.current) { + return; } const imageCoordinates = getImageCoordinatesFromPointerEvent(event); @@ -448,80 +470,89 @@ const TileSetVisualizer = ({ rowCount, displayedTileSize, }); - if (!allowMultipleSelection) { - if ( - tileMapTileSelection && - tileMapTileSelection.kind === 'single' && - tileMapTileSelection.coordinates.x === x && - tileMapTileSelection.coordinates.y === y - ) { - onSelectTileMapTile(null); - } else { - onSelectTileMapTile({ - kind: 'single', - coordinates: { x, y }, - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - } - return; - } - if (!clickStartCoordinates) return; + if (!startCoordinates) return; const { x: startX, y: startY, } = getGridCoordinatesFromPointerCoordinates({ - pointerX: clickStartCoordinates.x, - pointerY: clickStartCoordinates.y, + pointerX: startCoordinates.x, + pointerY: startCoordinates.y, columnCount, rowCount, displayedTileSize, }); - const newSelection = - tileMapTileSelection && tileMapTileSelection.kind === 'multiple' - ? { ...tileMapTileSelection } - : { kind: 'multiple', coordinates: [] }; - // Click on a tile. - if ( - (startX === x && startY === y) || - // Do not allow rectangular select on touch device as it conflicts with basic scrolling gestures. - isTouchDevice - ) { - if ( - tileMapTileSelection && - tileMapTileSelection.kind === 'multiple' - ) { - addOrRemoveCoordinatesInArray(newSelection.coordinates, { - x, - y, - }); - } - } else { - for ( - let columnIndex = Math.min(startX, x); - columnIndex <= Math.max(startX, x); - columnIndex++ - ) { + if (allowMultipleSelection) { + const newSelection = + tileMapTileSelection && tileMapTileSelection.kind === 'multiple' + ? { ...tileMapTileSelection } + : { kind: 'multiple', coordinates: [] }; + if (startX === x && startY === y) { + if ( + tileMapTileSelection && + tileMapTileSelection.kind === 'multiple' + ) { + addOrRemoveCoordinatesInArray(newSelection.coordinates, { + x, + y, + }); + } + } else { for ( - let rowIndex = Math.min(startY, y); - rowIndex <= Math.max(startY, y); - rowIndex++ + let columnIndex = Math.min(startX, x); + columnIndex <= Math.max(startX, x); + columnIndex++ ) { - if (newSelection && newSelection.kind === 'multiple') { - addOrRemoveCoordinatesInArray(newSelection.coordinates, { - x: columnIndex, - y: rowIndex, - }); + for ( + let rowIndex = Math.min(startY, y); + rowIndex <= Math.max(startY, y); + rowIndex++ + ) { + if (newSelection && newSelection.kind === 'multiple') { + addOrRemoveCoordinatesInArray(newSelection.coordinates, { + x: columnIndex, + y: rowIndex, + }); + } } } } + onSelectTileMapTile(newSelection); + } else if (allowRectangleSelection) { + const shouldRemoveSelection = + tileMapTileSelection && + tileMapTileSelection.kind === 'rectangle' && + startX === x && + startY === y && + x <= tileMapTileSelection.coordinates[1].x && + x >= tileMapTileSelection.coordinates[0].x && + y <= tileMapTileSelection.coordinates[1].y && + y >= tileMapTileSelection.coordinates[0].y; + if (shouldRemoveSelection) { + // Remove selection when user selects a single tile in the current tile selection. + onSelectTileMapTile(null); + } else { + const topLeftCorner = { + x: Math.min(startX, x), + y: Math.min(startY, y), + }; + const bottomRightCorner = { + x: Math.max(startX, x), + y: Math.max(startY, y), + }; + const newSelection = { + kind: 'rectangle', + coordinates: [topLeftCorner, bottomRightCorner], + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + }; + onSelectTileMapTile(newSelection); + } } - onSelectTileMapTile(newSelection); } finally { - setClickStartCoordinates(null); + startCoordinatesRef.current = null; setRectangularSelectionTilePreview(null); - setTouchStartCoordinates(null); + isLongTouchRef.current = false; } }, [ @@ -534,19 +565,14 @@ const TileSetVisualizer = ({ shouldFlipHorizontally, shouldFlipVertically, allowMultipleSelection, - clickStartCoordinates, - shouldCancelClick, - touchStartCoordinates, + allowRectangleSelection, ] ); React.useEffect( () => { - if (tileMapTileSelection && tileMapTileSelection.kind === 'single') { - setLastSelectedTile({ - x: tileMapTileSelection.coordinates.x, - y: tileMapTileSelection.coordinates.y, - }); + if (tileMapTileSelection && tileMapTileSelection.kind === 'rectangle') { + setLastSelection(tileMapTileSelection); } }, [tileMapTileSelection] @@ -651,21 +677,23 @@ const TileSetVisualizer = ({ tooltip={t`Paint`} selected={ !!tileMapTileSelection && - tileMapTileSelection.kind === 'single' + tileMapTileSelection.kind === 'rectangle' } onClick={e => { if ( !!tileMapTileSelection && - tileMapTileSelection.kind === 'single' + tileMapTileSelection.kind === 'rectangle' ) onSelectTileMapTile(null); else - onSelectTileMapTile({ - kind: 'single', - coordinates: lastSelectedTile || { x: 0, y: 0 }, - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); + onSelectTileMapTile( + lastSelection || { + kind: 'rectangle', + coordinates: [{ x: 0, y: 0 }, { x: 0, y: 0 }], + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + } + ); }} disabled={!isAtlasImageSet} > @@ -676,15 +704,14 @@ const TileSetVisualizer = ({ tooltip={t`Horizontal flip`} selected={shouldFlipHorizontally} disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind !== 'single' + !tileMapTileSelection || tileMapTileSelection.kind === 'erase' } onClick={e => { const newShouldFlipHorizontally = !shouldFlipHorizontally; setShouldFlipHorizontally(newShouldFlipHorizontally); if ( !!tileMapTileSelection && - tileMapTileSelection.kind === 'single' + tileMapTileSelection.kind === 'rectangle' ) { onSelectTileMapTile({ ...tileMapTileSelection, @@ -700,15 +727,14 @@ const TileSetVisualizer = ({ tooltip={t`Vertical flip`} selected={shouldFlipVertically} disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind !== 'single' + !tileMapTileSelection || tileMapTileSelection.kind === 'erase' } onClick={e => { const newShouldFlipVertically = !shouldFlipVertically; setShouldFlipVertically(newShouldFlipVertically); if ( !!tileMapTileSelection && - tileMapTileSelection.kind === 'single' + tileMapTileSelection.kind === 'rectangle' ) { onSelectTileMapTile({ ...tileMapTileSelection, @@ -776,14 +802,24 @@ const TileSetVisualizer = ({ /> )} {tileMapTileSelection && - tileMapTileSelection.kind === 'single' && + tileMapTileSelection.kind === 'rectangle' && displayedTileSize && ( )} {tileMapTileSelection && diff --git a/newIDE/app/src/InstancesEditor/index.js b/newIDE/app/src/InstancesEditor/index.js index 6ee6d2f99e2b..82ff5a7f1c3d 100644 --- a/newIDE/app/src/InstancesEditor/index.js +++ b/newIDE/app/src/InstancesEditor/index.js @@ -43,9 +43,6 @@ import { } from '../Utils/ZoomUtils'; import Background from './Background'; import TileMapPaintingPreview, { - getTileSet, - getTilesGridCoordinatesFromPointerSceneCoordinates, - isTileSetBadlyConfigured, updateSceneToTileMapTransformation, } from './TileMapPaintingPreview'; import { @@ -58,6 +55,11 @@ import { AffineTransformation } from '../Utils/AffineTransformation'; import { ErrorFallbackComponent } from '../UI/ErrorBoundary'; import { Trans } from '@lingui/macro'; import { generateUUID } from 'three/src/math/MathUtils'; +import { + getTilesGridCoordinatesFromPointerSceneCoordinates, + getTileSet, + isTileSetBadlyConfigured, +} from '../Utils/TileMap'; const gd: libGDevelop = global.gd; @@ -839,6 +841,7 @@ export default class InstancesEditor extends Component { } const tileMapGridCoordinates = getTilesGridCoordinatesFromPointerSceneCoordinates( { + tileMapTileSelection, coordinates: sceneCoordinates, tileSize: tileSet.tileSize, sceneToTileMapTransformation, @@ -847,96 +850,126 @@ export default class InstancesEditor extends Component { let shouldTrimAfterOperations = false; - if (tileMapTileSelection.kind === 'single') { + if (tileMapTileSelection.kind === 'rectangle') { shouldTrimAfterOperations = editableTileMap.isEmpty(); // TODO: Optimize list execution to make sure the most important size changing operations are done first. let cumulatedUnshiftedRows = 0, cumulatedUnshiftedColumns = 0; - const tileId = getTileIdFromGridCoordinates({ - columnCount: tileSet.columnCount, - ...tileMapTileSelection.coordinates, - }); - - const tileDefinition = editableTileMap.getTileDefinition(tileId); - if (!tileDefinition) return; const layer = editableTileMap.getTileLayer(0); if (!layer) return; - tileMapGridCoordinates.forEach(({ x: gridX, y: gridY }) => { - // If rows or columns have been unshifted in the previous tile setting operations, - // we have to take them into account for the current coordinates. - const x = gridX + cumulatedUnshiftedColumns; - const y = gridY + cumulatedUnshiftedRows; - const rowsToAppend = Math.max( - 0, - y - (editableTileMap.getDimensionY() - 1) - ); - const columnsToAppend = Math.max( - 0, - x - (editableTileMap.getDimensionX() - 1) - ); - const rowsToUnshift = Math.abs(Math.min(0, y)); - const columnsToUnshift = Math.abs(Math.min(0, x)); - if ( - rowsToAppend > 0 || - columnsToAppend > 0 || - rowsToUnshift > 0 || - columnsToUnshift > 0 + tileMapGridCoordinates.forEach( + ({ bottomRightCorner, topLeftCorner, tileCoordinates }) => { + if (!tileCoordinates) return; + const tileId = getTileIdFromGridCoordinates({ + columnCount: tileSet.columnCount, + ...tileCoordinates, + }); + + const tileDefinition = editableTileMap.getTileDefinition(tileId); + if (!tileDefinition) return; + + for ( + let gridX = topLeftCorner.x; + gridX <= bottomRightCorner.x; + gridX++ + ) { + for ( + let gridY = topLeftCorner.y; + gridY <= bottomRightCorner.y; + gridY++ + ) { + // If rows or columns have been unshifted in the previous tile setting operations, + // we have to take them into account for the current coordinates. + const x = gridX + cumulatedUnshiftedColumns; + const y = gridY + cumulatedUnshiftedRows; + const rowsToAppend = Math.max( + 0, + y - (editableTileMap.getDimensionY() - 1) + ); + const columnsToAppend = Math.max( + 0, + x - (editableTileMap.getDimensionX() - 1) + ); + const rowsToUnshift = Math.abs(Math.min(0, y)); + const columnsToUnshift = Math.abs(Math.min(0, x)); + if ( + rowsToAppend > 0 || + columnsToAppend > 0 || + rowsToUnshift > 0 || + columnsToUnshift > 0 + ) { + editableTileMap.increaseDimensions( + columnsToAppend, + columnsToUnshift, + rowsToAppend, + rowsToUnshift + ); + } + const newX = x + columnsToUnshift; + const newY = y + rowsToUnshift; + + editableTileMap.setTile(newX, newY, 0, tileId); + editableTileMap.flipTileOnX( + newX, + newY, + 0, + tileMapTileSelection.flipHorizontally + ); + editableTileMap.flipTileOnY( + newX, + newY, + 0, + tileMapTileSelection.flipVertically + ); + + cumulatedUnshiftedRows += rowsToUnshift; + cumulatedUnshiftedColumns += columnsToUnshift; + // The instance angle is not considered when moving the instance after + // rows/columns were added/removed because the instance position does not + // include the rotation transformation. Otherwise, we could have used + // tileMapToSceneTransformation to get the new position. + selectedInstance.setX( + selectedInstance.getX() - + columnsToUnshift * (tileSet.tileSize * scaleX) + ); + selectedInstance.setY( + selectedInstance.getY() - + rowsToUnshift * (tileSet.tileSize * scaleY) + ); + if (selectedInstance.hasCustomSize()) { + selectedInstance.setCustomWidth( + selectedInstance.getCustomWidth() + + tileSet.tileSize * + scaleX * + (columnsToAppend + columnsToUnshift) + ); + selectedInstance.setCustomHeight( + selectedInstance.getCustomHeight() + + tileSet.tileSize * scaleY * (rowsToAppend + rowsToUnshift) + ); + } + } + } + } + ); + } else if (tileMapTileSelection.kind === 'erase') { + const { bottomRightCorner, topLeftCorner } = tileMapGridCoordinates[0]; + for ( + let gridX = topLeftCorner.x; + gridX <= bottomRightCorner.x; + gridX++ + ) { + for ( + let gridY = topLeftCorner.y; + gridY <= bottomRightCorner.y; + gridY++ ) { - editableTileMap.increaseDimensions( - columnsToAppend, - columnsToUnshift, - rowsToAppend, - rowsToUnshift - ); + editableTileMap.removeTile(gridX, gridY, 0); } - const newX = x + columnsToUnshift; - const newY = y + rowsToUnshift; - - editableTileMap.setTile(newX, newY, 0, tileId); - editableTileMap.flipTileOnX( - newX, - newY, - 0, - tileMapTileSelection.flipHorizontally - ); - editableTileMap.flipTileOnY( - newX, - newY, - 0, - tileMapTileSelection.flipVertically - ); + } - cumulatedUnshiftedRows += rowsToUnshift; - cumulatedUnshiftedColumns += columnsToUnshift; - // The instance angle is not considered when moving the instance after - // rows/columns were added/removed because the instance position does not - // include the rotation transformation. Otherwise, we could have used - // tileMapToSceneTransformation to get the new position. - selectedInstance.setX( - selectedInstance.getX() - - columnsToUnshift * (tileSet.tileSize * scaleX) - ); - selectedInstance.setY( - selectedInstance.getY() - - rowsToUnshift * (tileSet.tileSize * scaleY) - ); - if (selectedInstance.hasCustomSize()) { - selectedInstance.setCustomWidth( - selectedInstance.getCustomWidth() + - tileSet.tileSize * scaleX * (columnsToAppend + columnsToUnshift) - ); - selectedInstance.setCustomHeight( - selectedInstance.getCustomHeight() + - tileSet.tileSize * scaleY * (rowsToAppend + rowsToUnshift) - ); - } - }); - } else if (tileMapTileSelection.kind === 'erase') { - tileMapGridCoordinates.forEach(({ x: gridX, y: gridY }) => { - editableTileMap.removeTile(gridX, gridY, 0); - }); shouldTrimAfterOperations = true; } else { return; diff --git a/newIDE/app/src/ObjectEditor/Editors/SimpleTileMapEditor.js b/newIDE/app/src/ObjectEditor/Editors/SimpleTileMapEditor.js index dc5e6a4354a6..51a8665bddeb 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SimpleTileMapEditor.js +++ b/newIDE/app/src/ObjectEditor/Editors/SimpleTileMapEditor.js @@ -2,7 +2,7 @@ import * as React from 'react'; import type { EditorProps } from './EditorProps.flow'; -import ScrollView from '../../UI/ScrollView'; +import ScrollView, { type ScrollViewInterface } from '../../UI/ScrollView'; import { ColumnStackLayout } from '../../UI/Layout'; import SemiControlledTextField from '../../UI/SemiControlledTextField'; import { Trans } from '@lingui/macro'; @@ -27,6 +27,7 @@ const SimpleTileMapEditor = ({ resourceManagementProps, renderObjectNameField, }: EditorProps) => { + const scrollViewRef = React.useRef(null); const forceUpdate = useForceUpdate(); const objectProperties = objectConfiguration.getProperties(); const tileSize = parseFloat(objectProperties.get('tileSize').getValue()); @@ -128,6 +129,12 @@ const SimpleTileMapEditor = ({ [columnCount, objectConfiguration, forceUpdate, onObjectUpdated] ); + const onScrollY = React.useCallback(deltaY => { + if (scrollViewRef.current) { + scrollViewRef.current.scrollBy(deltaY); + } + }, []); + const onChangeAtlasImage = React.useCallback( () => { if (onObjectUpdated) onObjectUpdated(); @@ -168,7 +175,7 @@ const SimpleTileMapEditor = ({ ); return ( - + {!!renderObjectNameField && renderObjectNameField()} {/* TODO: Should this be a Select field with all possible values given the atlas image size? */} @@ -208,8 +215,10 @@ const SimpleTileMapEditor = ({ onSelectTileMapTile={onChangeTilesWithHitBox} showPaintingToolbar={false} allowMultipleSelection + allowRectangleSelection={false} onAtlasImageLoaded={onAtlasImageLoaded} interactive={true} + onScrollY={onScrollY} /> )} diff --git a/newIDE/app/src/UI/ScrollView.js b/newIDE/app/src/UI/ScrollView.js index 8f396f354d2b..e94cd064cf32 100644 --- a/newIDE/app/src/UI/ScrollView.js +++ b/newIDE/app/src/UI/ScrollView.js @@ -27,6 +27,7 @@ export type ScrollViewInterface = {| target: ?React$Component | ?React.ElementRef ) => void, scrollToPosition: (number: number) => void, + scrollBy: (deltaY: number) => void, scrollToBottom: () => void, |}; @@ -66,15 +67,23 @@ export default React.forwardRef( } }, /** - * Scroll the view to the target position. + * Scroll the view to the target component. + */ + scrollBy: (deltaY: number) => { + const scrollViewElement = scrollView.current; + if (!scrollViewElement) return; + scrollViewElement.scrollBy(0, deltaY); + }, + /** + * Scroll the view vertically by the offset passed as argument. */ - scrollToPosition: (y: number) => { + scrollToPosition: (deltaY: number) => { const scrollViewElement = scrollView.current; if (!scrollViewElement) return; const scrollViewYPosition = scrollViewElement.getBoundingClientRect() .top; - scrollViewElement.scrollTop = y - scrollViewYPosition; + scrollViewElement.scrollTop = deltaY - scrollViewYPosition; }, /** * Scroll the view to the bottom. diff --git a/newIDE/app/src/Utils/TileMap.js b/newIDE/app/src/Utils/TileMap.js new file mode 100644 index 000000000000..b27dea1ff5dc --- /dev/null +++ b/newIDE/app/src/Utils/TileMap.js @@ -0,0 +1,463 @@ +// @flow +import { AffineTransformation } from './AffineTransformation'; +import { type TileMapTileSelection } from '../InstancesEditor/TileSetVisualizer'; + +export type TileMapTilePatch = {| + tileCoordinates?: {| x: number, y: number |}, + erase?: boolean, + topLeftCorner: {| x: number, y: number |}, + bottomRightCorner: {| x: number, y: number |}, +|}; + +export type TileSet = {| + rowCount: number, + columnCount: number, + tileSize: number, + atlasImage: string, +|}; + +const areSameCoordinates = ( + tileA: {| x: number, y: number |}, + tileB: {| x: number, y: number |} +): boolean => tileA.x === tileB.x && tileA.y === tileB.y; + +/** + * This method gathers tiles into a big rectangle when possible. + * Hypothesis taken on the input list: + * - The list contains only tiles of size 1 + * - The list is a flattened view of the grid in order to have the following grid: + * A, C, E, G + * B, D, F, H + * given as: + * [A, B, C, D, E, F, G, H] + * + * Note: This method won't handle perfectly nested rectangles. For instance, this layout: + * A D D D D D D D D D D D E + * B J J J J J J J J J J J G + * B J J J K K K K K J J J G + * B J J J K K K K K J J J G + * B J J J K K K K K J J J G + * B J J J J J J J J J J J G + * C F F F F F F F F F F F H + * might result in something like: + * A ╾ ─ ─ ─ ─ D ─ ─ ─ ─ ╼ E + * ╿ J ╾ ─ ─ ─ J ─ ─ ─ ─ ╼ ╿ + * │ ┌ ─ ┐ ┌ ─ ─ ─ ┐ ┌ ─ ┐ │ + * B │ J │ │ K │ │ J │ G + * │ │ │ └ ─ ─ ─ ┘ └ ─ ┘ │ + * ╽ └ ─ ┘ ╾ ─ ─ J ─ ─ ─ ╼ ╽ + * C ╾ ─ ─ ─ ─ F ─ ─ ─ ─ ╼ H + */ +export const optimizeTilesGridCoordinates = ({ + tileMapTilePatches, + minX, + minY, + maxX, + maxY, +}: {| + tileMapTilePatches: TileMapTilePatch[], + minX: number, + maxX: number, + minY: number, + maxY: number, +|}): TileMapTilePatch[] => { + const newTileMapTilePatches = []; + + while (tileMapTilePatches[0]) { + const referencePatch = tileMapTilePatches[0]; + if (!referencePatch || !referencePatch.tileCoordinates) break; + const referencePatchTileCoordinates = referencePatch.tileCoordinates; + if (!referencePatchTileCoordinates) break; + const patchesWithSameTile = tileMapTilePatches + .slice(1) + .filter( + patch => + patch.tileCoordinates && + areSameCoordinates( + referencePatchTileCoordinates, + patch.tileCoordinates + ) + ); + let expandRight = 0; + let expandBottom = 0; + const patchesOnRight = patchesWithSameTile.filter( + patch => + patch.topLeftCorner.x > referencePatch.topLeftCorner.x && + patch.topLeftCorner.y === referencePatch.topLeftCorner.y && + areSameCoordinates(patch.topLeftCorner, patch.bottomRightCorner) + ); + const patchesOnRightX = patchesOnRight.map(patch => patch.topLeftCorner.x); + for ( + let deltaX = 1; + deltaX <= maxX - referencePatch.topLeftCorner.x; + deltaX++ + ) { + if (patchesOnRightX.includes(deltaX + referencePatch.topLeftCorner.x)) + expandRight = deltaX; + else break; + } + const patchesOnBottom = patchesWithSameTile.filter( + patch => + patch.topLeftCorner.x === referencePatch.topLeftCorner.x && + patch.topLeftCorner.y > referencePatch.topLeftCorner.y && + areSameCoordinates(patch.topLeftCorner, patch.bottomRightCorner) + ); + const patchesOnBottomY = patchesOnBottom.map( + patch => patch.topLeftCorner.y + ); + for ( + let deltaY = 1; + deltaY <= maxY - referencePatch.topLeftCorner.y; + deltaY++ + ) { + if (patchesOnBottomY.includes(deltaY + referencePatch.topLeftCorner.y)) + expandBottom = deltaY; + else break; + } + if (expandRight === 0 && expandBottom === 0) { + newTileMapTilePatches.push(tileMapTilePatches.shift()); + continue; + } + + let isWholeRectangleOfSameTile = true; + const patchIndices = []; + for (let deltaX = 0; deltaX <= expandRight; deltaX++) { + for (let deltaY = 0; deltaY <= expandBottom; deltaY++) { + if (deltaX === 0 && deltaY === 0) { + patchIndices.push(0); + continue; + } + + const patchIndex = tileMapTilePatches.findIndex( + patch => + patch.topLeftCorner.x === deltaX + referencePatch.topLeftCorner.x && + patch.topLeftCorner.y === deltaY + referencePatch.topLeftCorner.y && + patch.tileCoordinates && + areSameCoordinates( + referencePatchTileCoordinates, + patch.tileCoordinates + ) + ); + if (patchIndex === -1) { + isWholeRectangleOfSameTile = false; + break; + } else { + patchIndices.push(patchIndex); + } + } + if (!isWholeRectangleOfSameTile) break; + } + if (!isWholeRectangleOfSameTile) { + newTileMapTilePatches.push(tileMapTilePatches.shift()); + } else { + newTileMapTilePatches.push({ + tileCoordinates: referencePatchTileCoordinates, + topLeftCorner: referencePatch.topLeftCorner, + bottomRightCorner: { + x: referencePatch.topLeftCorner.x + expandRight, + y: referencePatch.topLeftCorner.y + expandBottom, + }, + }); + patchIndices.sort((a, b) => (a > b ? -1 : 1)); + patchIndices.forEach(index => tileMapTilePatches.splice(index, 1)); + } + } + return newTileMapTilePatches; +}; + +export const isSelectionASingleTileRectangle = ( + tileMapTileSelection: TileMapTileSelection +): boolean => { + return ( + tileMapTileSelection.kind === 'rectangle' && + tileMapTileSelection.coordinates.length === 2 && + tileMapTileSelection.coordinates[0].x === + tileMapTileSelection.coordinates[1].x && + tileMapTileSelection.coordinates[0].y === + tileMapTileSelection.coordinates[1].y + ); +}; + +/** + * When flipping is activated, the tile texture should be flipped but + * the tiles should be flipped as well in the selection + * (the left tiles should be replaced by the right tiles if the horizontal flip + * is activated). + */ +const getTileCorrespondingToFlippingInstructions = ({ + tileMapTileSelection, + tileCoordinates, +}: {| + tileMapTileSelection: TileMapTileSelection, + tileCoordinates: {| x: number, y: number |}, +|}): {| x: number, y: number |} => { + if (tileMapTileSelection.kind === 'rectangle') { + const selectionTopLeftCorner = tileMapTileSelection.coordinates[0]; + const selectionBottomRightCorner = tileMapTileSelection.coordinates[1]; + const selectionWidth = + selectionBottomRightCorner.x - selectionTopLeftCorner.x + 1; + const selectionHeight = + selectionBottomRightCorner.y - selectionTopLeftCorner.y + 1; + const deltaX = tileCoordinates.x - selectionTopLeftCorner.x; + const deltaY = tileCoordinates.y - selectionTopLeftCorner.y; + const newX = + selectionTopLeftCorner.x + + (tileMapTileSelection.flipHorizontally + ? selectionWidth - deltaX - 1 + : deltaX); + const newY = + selectionTopLeftCorner.y + + (tileMapTileSelection.flipVertically + ? selectionHeight - deltaY - 1 + : deltaY); + return { x: newX, y: newY }; + } + return tileCoordinates; +}; + +/** + * Returns the list of tiles corresponding to the user selection. + * This method maps tiles from the tileset selection to a grid position on the + * tilemap corresponding to the user selection. This operation is done coordinate + * coordinate (on the tilemap) and is then optimized to gather rectangles of same tile + * to speed up consequential operations. + */ +export const getTilesGridCoordinatesFromPointerSceneCoordinates = ({ + tileMapTileSelection, + coordinates, + tileSize, + sceneToTileMapTransformation, +}: {| + tileMapTileSelection: TileMapTileSelection, + coordinates: Array<{| x: number, y: number |}>, + tileSize: number, + sceneToTileMapTransformation: AffineTransformation, +|}): TileMapTilePatch[] => { + if (coordinates.length === 0) return []; + + if (coordinates.length === 1) { + // One coordinate corresponds to the pointer over the canvas. + const coordinatesInTileMapGrid = [0, 0]; + sceneToTileMapTransformation.transform( + [coordinates[0].x, coordinates[0].y], + coordinatesInTileMapGrid + ); + const x = Math.floor(coordinatesInTileMapGrid[0] / tileSize); + const y = Math.floor(coordinatesInTileMapGrid[1] / tileSize); + let tileCoordinates; + if (tileMapTileSelection.kind === 'rectangle') { + const topLeftCorner = tileMapTileSelection.coordinates[0]; + tileCoordinates = getTileCorrespondingToFlippingInstructions({ + tileMapTileSelection, + tileCoordinates: topLeftCorner, + }); + } + return [ + { + erase: tileMapTileSelection.kind === 'erase', + tileCoordinates, + topLeftCorner: { x, y }, + bottomRightCorner: { x, y }, + }, + ]; + } + + const tilesCoordinatesInTileMapGrid: TileMapTilePatch[] = []; + + if (coordinates.length === 2) { + const firstPointCoordinatesInTileMap = [0, 0]; + sceneToTileMapTransformation.transform( + [coordinates[0].x, coordinates[0].y], + firstPointCoordinatesInTileMap + ); + const secondPointCoordinatesInTileMap = [0, 0]; + sceneToTileMapTransformation.transform( + [coordinates[1].x, coordinates[1].y], + secondPointCoordinatesInTileMap + ); + const topLeftCornerCoordinatesInTileMap = [ + Math.min( + firstPointCoordinatesInTileMap[0], + secondPointCoordinatesInTileMap[0] + ), + Math.min( + firstPointCoordinatesInTileMap[1], + secondPointCoordinatesInTileMap[1] + ), + ]; + const bottomRightCornerCoordinatesInTileMap = [ + Math.max( + firstPointCoordinatesInTileMap[0], + secondPointCoordinatesInTileMap[0] + ), + Math.max( + firstPointCoordinatesInTileMap[1], + secondPointCoordinatesInTileMap[1] + ), + ]; + const topLeftCornerCoordinatesInTileMapGrid = [ + Math.floor(topLeftCornerCoordinatesInTileMap[0] / tileSize), + Math.floor(topLeftCornerCoordinatesInTileMap[1] / tileSize), + ]; + const bottomRightCornerCoordinatesInTileMapGrid = [ + Math.floor(bottomRightCornerCoordinatesInTileMap[0] / tileSize), + Math.floor(bottomRightCornerCoordinatesInTileMap[1] / tileSize), + ]; + if (tileMapTileSelection.kind === 'erase') { + tilesCoordinatesInTileMapGrid.push({ + erase: true, + topLeftCorner: { + x: topLeftCornerCoordinatesInTileMapGrid[0], + y: topLeftCornerCoordinatesInTileMapGrid[1], + }, + bottomRightCorner: { + x: bottomRightCornerCoordinatesInTileMapGrid[0], + y: bottomRightCornerCoordinatesInTileMapGrid[1], + }, + }); + return tilesCoordinatesInTileMapGrid; + } + if (tileMapTileSelection.kind === 'rectangle') { + const selectionTopLeftCorner = tileMapTileSelection.coordinates[0]; + const selectionBottomRightCorner = tileMapTileSelection.coordinates[1]; + const selectionWidth = + selectionBottomRightCorner.x - selectionTopLeftCorner.x + 1; + const selectionHeight = + selectionBottomRightCorner.y - selectionTopLeftCorner.y + 1; + + if (isSelectionASingleTileRectangle(tileMapTileSelection)) { + tilesCoordinatesInTileMapGrid.push({ + tileCoordinates: getTileCorrespondingToFlippingInstructions({ + tileMapTileSelection, + tileCoordinates: selectionTopLeftCorner, + }), + topLeftCorner: { + x: topLeftCornerCoordinatesInTileMapGrid[0], + y: topLeftCornerCoordinatesInTileMapGrid[1], + }, + bottomRightCorner: { + x: bottomRightCornerCoordinatesInTileMapGrid[0], + y: bottomRightCornerCoordinatesInTileMapGrid[1], + }, + }); + return tilesCoordinatesInTileMapGrid; + } + + for ( + let x = topLeftCornerCoordinatesInTileMapGrid[0]; + x <= bottomRightCornerCoordinatesInTileMapGrid[0]; + x++ + ) { + for ( + let y = topLeftCornerCoordinatesInTileMapGrid[1]; + y <= bottomRightCornerCoordinatesInTileMapGrid[1]; + y++ + ) { + const deltaX = x - topLeftCornerCoordinatesInTileMapGrid[0]; + const deltaY = y - topLeftCornerCoordinatesInTileMapGrid[1]; + const invertedDeltaX = + bottomRightCornerCoordinatesInTileMapGrid[0] - x; + const invertedDeltaY = + bottomRightCornerCoordinatesInTileMapGrid[1] - y; + if (deltaX === 0 && deltaY === 0) { + tilesCoordinatesInTileMapGrid.push({ + tileCoordinates: getTileCorrespondingToFlippingInstructions({ + tileMapTileSelection, + tileCoordinates: selectionTopLeftCorner, + }), + topLeftCorner: { x, y }, + bottomRightCorner: { x, y }, + }); + continue; + } + if (invertedDeltaX === 0 && invertedDeltaY === 0) { + tilesCoordinatesInTileMapGrid.push({ + tileCoordinates: getTileCorrespondingToFlippingInstructions({ + tileMapTileSelection, + tileCoordinates: selectionBottomRightCorner, + }), + topLeftCorner: { x, y }, + bottomRightCorner: { x, y }, + }); + continue; + } + + let tileX, tileY; + if (deltaX === 0 || selectionWidth === 1) { + tileX = selectionTopLeftCorner.x; + } else if (invertedDeltaX === 0 || selectionWidth === 2) { + tileX = selectionBottomRightCorner.x; + } else { + tileX = + ((deltaX - 1) % (selectionWidth - 2)) + + 1 + + selectionTopLeftCorner.x; + } + if (deltaY === 0 || selectionHeight === 1) { + tileY = selectionTopLeftCorner.y; + } else if (invertedDeltaY === 0 || selectionHeight === 2) { + tileY = selectionBottomRightCorner.y; + } else { + tileY = + ((deltaY - 1) % (selectionHeight - 2)) + + 1 + + selectionTopLeftCorner.y; + } + + tilesCoordinatesInTileMapGrid.push({ + tileCoordinates: getTileCorrespondingToFlippingInstructions({ + tileMapTileSelection, + tileCoordinates: { x: tileX, y: tileY }, + }), + topLeftCorner: { x, y }, + bottomRightCorner: { x, y }, + }); + } + } + if (selectionWidth >= 4 && selectionHeight >= 4) { + // In this case, each cell in the grid will contain a tile that is different + // from all the adjacent ones, so there is no need to optimize the list. + return tilesCoordinatesInTileMapGrid; + } + return optimizeTilesGridCoordinates({ + tileMapTilePatches: tilesCoordinatesInTileMapGrid, + minX: topLeftCornerCoordinatesInTileMapGrid[0], + minY: topLeftCornerCoordinatesInTileMapGrid[1], + maxX: bottomRightCornerCoordinatesInTileMapGrid[0], + maxY: bottomRightCornerCoordinatesInTileMapGrid[1], + }); + } + } + return []; +}; + +export const getTileSet = (object: gdObject): TileSet => { + const objectConfigurationProperties = object + .getConfiguration() + .getProperties(); + const columnCount = parseFloat( + objectConfigurationProperties.get('columnCount').getValue() + ); + const rowCount = parseFloat( + objectConfigurationProperties.get('rowCount').getValue() + ); + const tileSize = parseFloat( + objectConfigurationProperties.get('tileSize').getValue() + ); + const atlasImage = objectConfigurationProperties.get('atlasImage').getValue(); + return { rowCount, columnCount, tileSize, atlasImage }; +}; + +export const isTileSetBadlyConfigured = ({ + rowCount, + columnCount, + tileSize, + atlasImage, +}: TileSet) => { + return ( + !Number.isInteger(columnCount) || + columnCount <= 0 || + !Number.isInteger(rowCount) || + rowCount <= 0 + ); +}; diff --git a/newIDE/app/src/Utils/TileMap.spec.js b/newIDE/app/src/Utils/TileMap.spec.js new file mode 100644 index 000000000000..19e3f75f6f32 --- /dev/null +++ b/newIDE/app/src/Utils/TileMap.spec.js @@ -0,0 +1,244 @@ +// @flow + +import { optimizeTilesGridCoordinates } from './TileMap'; + +describe('optimizeTilesGridCoordinates', () => { + test('Selection of 2x1 is expanded on the right', () => { + const minX = 4; + const maxX = 7; + const minY = 12; + const maxY = 12; + const result = optimizeTilesGridCoordinates({ + minX, + maxX, + minY, + maxY, + tileMapTilePatches: [ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + ...[1, 2, 3].map(deltaX => ({ + tileCoordinates: { x: 4, y: 1 }, + topLeftCorner: { x: minX + deltaX, y: minY }, + bottomRightCorner: { x: minX + deltaX, y: minY }, + })), + ], + }); + expect(result).toEqual([ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + { + tileCoordinates: { x: 4, y: 1 }, + topLeftCorner: { x: minX + 1, y: minY }, + bottomRightCorner: { x: maxX, y: minY }, + }, + ]); + }); + test('Selection of 1x1 is expanded on the right', () => { + const minX = 4; + const maxX = 7; + const minY = 12; + const maxY = 12; + const result = optimizeTilesGridCoordinates({ + minX, + maxX, + minY, + maxY, + tileMapTilePatches: [ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + ...[1, 2, 3].map(deltaX => ({ + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX + deltaX, y: minY }, + bottomRightCorner: { x: minX + deltaX, y: minY }, + })), + ], + }); + expect(result).toEqual([ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: maxX, y: maxY }, + }, + ]); + }); + test('Selection of 1x1 is expanded on the bottom', () => { + const minX = 4; + const maxX = 4; + const minY = 12; + const maxY = 16; + const result = optimizeTilesGridCoordinates({ + minX, + maxX, + minY, + maxY, + tileMapTilePatches: [ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + ...[1, 2, 3, 4].map(deltaY => ({ + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY + deltaY }, + bottomRightCorner: { x: minX, y: minY + deltaY }, + })), + ], + }); + expect(result).toEqual([ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: maxX, y: maxY }, + }, + ]); + }); + test('Selection of 1x1 is expanded on the right and on the bottom', () => { + const minX = 4; + const maxX = 7; + const minY = 12; + const maxY = 15; + const result = optimizeTilesGridCoordinates({ + minX, + maxX, + minY, + maxY, + tileMapTilePatches: [ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + ...[0, 1, 2, 3] + .map(deltaX => { + return [0, 1, 2, 3].map(deltaY => + deltaX === 0 && deltaY === 0 + ? null + : { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX + deltaX, y: minY + deltaY }, + bottomRightCorner: { x: minX + deltaX, y: minY + deltaY }, + } + ); + }) + .flat() + .filter(Boolean), + ], + }); + expect(result).toEqual([ + { + tileCoordinates: { x: 4, y: 4 }, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: maxX, y: maxY }, + }, + ]); + }); + test('Selection of 3x2 is expanded on the right and on the bottom', () => { + const minX = 4; + const maxX = 7; + const minY = 12; + const maxY = 13; + const topLeftCorner = { x: 4, y: 4 }; + const topMiddleCorner = { x: 5, y: 4 }; + const topRightCorner = { x: 6, y: 4 }; + const bottomLeftCorner = { x: 4, y: 5 }; + const bottomMiddleCorner = { x: 5, y: 5 }; + const bottomRightCorner = { x: 6, y: 5 }; + const result = optimizeTilesGridCoordinates({ + minX, + maxX, + minY, + maxY, + tileMapTilePatches: [ + // First column + { + tileCoordinates: topLeftCorner, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + { + tileCoordinates: bottomLeftCorner, + topLeftCorner: { x: minX, y: minY + 1 }, + bottomRightCorner: { x: minX, y: minY + 1 }, + }, + // Second column + { + tileCoordinates: topMiddleCorner, + topLeftCorner: { x: minX + 1, y: minY }, + bottomRightCorner: { x: minX + 1, y: minY }, + }, + { + tileCoordinates: bottomMiddleCorner, + topLeftCorner: { x: minX + 1, y: minY + 1 }, + bottomRightCorner: { x: minX + 1, y: minY + 1 }, + }, + // Third column + { + tileCoordinates: topMiddleCorner, + topLeftCorner: { x: minX + 2, y: minY }, + bottomRightCorner: { x: minX + 2, y: minY }, + }, + { + tileCoordinates: bottomMiddleCorner, + topLeftCorner: { x: minX + 2, y: minY + 1 }, + bottomRightCorner: { x: minX + 2, y: minY + 1 }, + }, + // Fourth column + { + tileCoordinates: topRightCorner, + topLeftCorner: { x: minX + 3, y: minY }, + bottomRightCorner: { x: minX + 3, y: minY }, + }, + { + tileCoordinates: bottomRightCorner, + topLeftCorner: { x: minX + 3, y: minY + 1 }, + bottomRightCorner: { x: minX + 3, y: minY + 1 }, + }, + ], + }); + expect(result).toEqual([ + // First column + { + tileCoordinates: topLeftCorner, + topLeftCorner: { x: minX, y: minY }, + bottomRightCorner: { x: minX, y: minY }, + }, + { + tileCoordinates: bottomLeftCorner, + topLeftCorner: { x: minX, y: minY + 1 }, + bottomRightCorner: { x: minX, y: minY + 1 }, + }, + // Second and third column first line + { + tileCoordinates: topMiddleCorner, + topLeftCorner: { x: minX + 1, y: minY }, + bottomRightCorner: { x: minX + 2, y: minY }, + }, + // Second and third column second line + { + tileCoordinates: bottomMiddleCorner, + topLeftCorner: { x: minX + 1, y: minY + 1 }, + bottomRightCorner: { x: minX + 2, y: minY + 1 }, + }, + // Fourth column + { + tileCoordinates: topRightCorner, + topLeftCorner: { x: minX + 3, y: minY }, + bottomRightCorner: { x: minX + 3, y: minY }, + }, + { + tileCoordinates: bottomRightCorner, + topLeftCorner: { x: minX + 3, y: minY + 1 }, + bottomRightCorner: { x: minX + 3, y: minY + 1 }, + }, + ]); + }); +}); diff --git a/newIDE/app/src/Utils/UseLongTouch.js b/newIDE/app/src/Utils/UseLongTouch.js index b292332e578b..6224baea0631 100644 --- a/newIDE/app/src/Utils/UseLongTouch.js +++ b/newIDE/app/src/Utils/UseLongTouch.js @@ -47,6 +47,7 @@ export const useLongTouch = ( */ context?: string, delay?: number, + doNotCancelOnScroll?: boolean, } ) => { const timeout = React.useRef(null); @@ -61,8 +62,11 @@ export const useLongTouch = ( [context] ); + const cancelOnScroll = !options || !options.doNotCancelOnScroll; + React.useEffect( () => { + if (!cancelOnScroll) return; // Cancel the long touch if scrolling (otherwise we can get a long touch // being activated while scroll and maintaining the touch on an element, // which is weird for the user that just want to scroll). @@ -84,7 +88,7 @@ export const useLongTouch = ( document.removeEventListener('scroll', clear, { capture: true }); }; }, - [clear] + [clear, cancelOnScroll] ); const start = React.useCallback(