/* GDevelop - Top-down movement Behavior Extension Copyright (c) 2010-2016 Florian Rival (Florian.Rival@gmail.com) */ namespace gdjs { interface TopDownMovementNetworkSyncDataType { a: float; xv: float; yv: float; as: float; lk: boolean; rk: boolean; uk: boolean; dk: boolean; wsu: boolean; sa: float; sf: float; } /** @category Behaviors > Top-down movement */ export interface TopDownMovementNetworkSyncData extends BehaviorNetworkSyncData { props: TopDownMovementNetworkSyncDataType; } /** * Allows an object to move in 4 or 8 directions, with customizable speed, accelerations * and rotation. * @category Behaviors > Top-down movement */ export class TopDownMovementRuntimeBehavior extends gdjs.RuntimeBehavior { //Behavior configuration: private _allowDiagonals: boolean; private _acceleration: float; private _deceleration: float; private _maxSpeed: float; private _angularMaxSpeed: float; private _rotateObject: boolean; private _angleOffset: float; private _ignoreDefaultControls: boolean; private _movementAngleOffset: float; private _useLegacyTurnBack: boolean; /** The latest angle of movement, in degrees. */ private _angle: float = 0; //Attributes used when moving private _xVelocity: float = 0; private _yVelocity: float = 0; private _angularSpeed: float = 0; // Inputs private _leftKey: boolean = false; private _rightKey: boolean = false; private _upKey: boolean = false; private _downKey: boolean = false; private _leftKeyPressedDuration: float = 0; private _rightKeyPressedDuration: float = 0; private _upKeyPressedDuration: float = 0; private _downKeyPressedDuration: float = 0; private _wasStickUsed: boolean = false; private _stickAngle: float = 0; private _stickForce: float = 0; // This is useful when the object is synchronized by an external source // like in a multiplayer game, and we want to be able to predict the // movement of the object, even if the inputs are not updated every frame. private _clearInputsBetweenFrames: boolean = true; // This is useful when the object is synchronized over the network, // object is controlled by the network and we want to ensure the current player // cannot control it. _ignoreDefaultControlsAsSyncedByNetwork: boolean = false; // This is useful for extensions that need to know // which keys were pressed and doesn't know the mapping // done by the scene events. private _wasLeftKeyPressed: boolean = false; private _wasRightKeyPressed: boolean = false; private _wasUpKeyPressed: boolean = false; private _wasDownKeyPressed: boolean = false; // @ts-ignore The setter "setViewpoint" is not detected as an affectation. private _basisTransformation: gdjs.TopDownMovementRuntimeBehavior.BasisTransformation | null; private _temporaryPointForTransformations: FloatPoint = [0, 0]; private _topDownMovementHooks: Array = []; constructor( instanceContainer: gdjs.RuntimeInstanceContainer, behaviorData, owner: gdjs.RuntimeObject ) { super(instanceContainer, behaviorData, owner); this._allowDiagonals = behaviorData.allowDiagonals; this._acceleration = behaviorData.acceleration; this._deceleration = behaviorData.deceleration; this._maxSpeed = behaviorData.maxSpeed; this._angularMaxSpeed = behaviorData.angularMaxSpeed; this._rotateObject = behaviorData.rotateObject; this._angleOffset = behaviorData.angleOffset; this._ignoreDefaultControls = behaviorData.ignoreDefaultControls; this.setViewpoint( behaviorData.viewpoint, behaviorData.customIsometryAngle ); this._movementAngleOffset = behaviorData.movementAngleOffset || 0; this._useLegacyTurnBack = behaviorData.useLegacyTurnBack === undefined ? true : behaviorData.useLegacyTurnBack; } getNetworkSyncData( options: GetNetworkSyncDataOptions ): TopDownMovementNetworkSyncData { // This method is called, so we are synchronizing this object. // Let's clear the inputs between frames as we control it. this._clearInputsBetweenFrames = true; this._ignoreDefaultControlsAsSyncedByNetwork = false; return { ...super.getNetworkSyncData(options), props: { a: this._angle, xv: this._xVelocity, yv: this._yVelocity, as: this._angularSpeed, lk: this._wasLeftKeyPressed, rk: this._wasRightKeyPressed, uk: this._wasUpKeyPressed, dk: this._wasDownKeyPressed, wsu: this._wasStickUsed, sa: this._stickAngle, sf: this._stickForce, }, }; } updateFromNetworkSyncData( networkSyncData: TopDownMovementNetworkSyncData, options: UpdateFromNetworkSyncDataOptions ): void { super.updateFromNetworkSyncData(networkSyncData, options); const behaviorSpecificProps = networkSyncData.props; if (behaviorSpecificProps.a !== undefined) { this._angle = behaviorSpecificProps.a; } if (behaviorSpecificProps.xv !== undefined) { this._xVelocity = behaviorSpecificProps.xv; } if (behaviorSpecificProps.yv !== undefined) { this._yVelocity = behaviorSpecificProps.yv; } if (behaviorSpecificProps.as !== undefined) { this._angularSpeed = behaviorSpecificProps.as; } if (behaviorSpecificProps.lk !== undefined) { this._leftKey = behaviorSpecificProps.lk; } if (behaviorSpecificProps.rk !== undefined) { this._rightKey = behaviorSpecificProps.rk; } if (behaviorSpecificProps.uk !== undefined) { this._upKey = behaviorSpecificProps.uk; } if (behaviorSpecificProps.dk !== undefined) { this._downKey = behaviorSpecificProps.dk; } if (behaviorSpecificProps.wsu !== undefined) { this._wasStickUsed = behaviorSpecificProps.wsu; } if (behaviorSpecificProps.sa !== undefined) { this._stickAngle = behaviorSpecificProps.sa; } if (behaviorSpecificProps.sf !== undefined) { this._stickForce = behaviorSpecificProps.sf; } // Clear user inputs between frames only if requested. this._clearInputsBetweenFrames = !!options.clearInputs; // And ignore default controls if not asked otherwise. this._ignoreDefaultControlsAsSyncedByNetwork = !options.keepControl; } override applyBehaviorOverriding(behaviorData): boolean { if (behaviorData.allowDiagonals !== undefined) { this._allowDiagonals = behaviorData.allowDiagonals; } if (behaviorData.acceleration !== undefined) { this._acceleration = behaviorData.acceleration; } if (behaviorData.deceleration !== undefined) { this._deceleration = behaviorData.deceleration; } if (behaviorData.maxSpeed !== undefined) { this._maxSpeed = behaviorData.maxSpeed; } if (behaviorData.angularMaxSpeed !== undefined) { this._angularMaxSpeed = behaviorData.angularMaxSpeed; } if (behaviorData.rotateObject !== undefined) { this._rotateObject = behaviorData.rotateObject; } if (behaviorData.angleOffset !== undefined) { this._angleOffset = behaviorData.angleOffset; } if (behaviorData.ignoreDefaultControls !== undefined) { this._ignoreDefaultControls = behaviorData.ignoreDefaultControls; } if ( behaviorData.platformType !== undefined || behaviorData.customIsometryAngle !== undefined ) { this.setViewpoint( behaviorData.platformType, behaviorData.customIsometryAngle ); } if (behaviorData.movementAngleOffset !== undefined) { this._movementAngleOffset = behaviorData.movementAngleOffset; } return true; } setViewpoint(viewpoint: string, customIsometryAngle: float): void { if (viewpoint === 'PixelIsometry') { this._basisTransformation = new gdjs.TopDownMovementRuntimeBehavior.IsometryTransformation( Math.atan(0.5) ); } else if (viewpoint === 'TrueIsometry') { this._basisTransformation = new gdjs.TopDownMovementRuntimeBehavior.IsometryTransformation( Math.PI / 6 ); } else if (viewpoint === 'CustomIsometry') { this._basisTransformation = new gdjs.TopDownMovementRuntimeBehavior.IsometryTransformation( (customIsometryAngle * Math.PI) / 180 ); } else { this._basisTransformation = null; } } setAcceleration(acceleration: float): void { this._acceleration = acceleration; } getAcceleration() { return this._acceleration; } setDeceleration(deceleration: float): void { this._deceleration = deceleration; } getDeceleration() { return this._deceleration; } setMaxSpeed(maxSpeed: float): void { this._maxSpeed = maxSpeed; } getMaxSpeed() { return this._maxSpeed; } setAngularMaxSpeed(angularMaxSpeed: float): void { this._angularMaxSpeed = angularMaxSpeed; } getAngularMaxSpeed() { return this._angularMaxSpeed; } setAngleOffset(angleOffset: float): void { this._angleOffset = angleOffset; } getAngleOffset() { return this._angleOffset; } allowDiagonals(allow: boolean) { this._allowDiagonals = allow; } diagonalsAllowed() { return this._allowDiagonals; } setRotateObject(allow: boolean): void { this._rotateObject = allow; } isObjectRotated(): boolean { return this._rotateObject; } isMoving(): boolean { return this._xVelocity !== 0 || this._yVelocity !== 0; } getSpeed(): float { return Math.hypot(this._xVelocity, this._yVelocity); } getXVelocity(): float { return this._xVelocity; } setXVelocity(velocityX: float): void { this._xVelocity = velocityX; } getYVelocity(): float { return this._yVelocity; } setYVelocity(velocityY: float): void { this._yVelocity = velocityY; } getAngle(): float { return this._angle; } isMovementAngleAround(degreeAngle: float, tolerance: float) { return ( Math.abs( gdjs.evtTools.common.angleDifference(this._angle, degreeAngle) ) <= tolerance ); } setMovementAngleOffset(movementAngleOffset: float): void { this._movementAngleOffset = movementAngleOffset; } getMovementAngleOffset() { return this._movementAngleOffset; } doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) { const LEFTKEY = 37; const UPKEY = 38; const RIGHTKEY = 39; const DOWNKEY = 40; //Get the player input: this._leftKey || (this._leftKey = !this.shouldIgnoreDefaultControls() && instanceContainer.getGame().getInputManager().isKeyPressed(LEFTKEY)); this._rightKey || (this._rightKey = !this.shouldIgnoreDefaultControls() && instanceContainer.getGame().getInputManager().isKeyPressed(RIGHTKEY)); this._downKey || (this._downKey = !this.shouldIgnoreDefaultControls() && instanceContainer.getGame().getInputManager().isKeyPressed(DOWNKEY)); this._upKey || (this._upKey = !this.shouldIgnoreDefaultControls() && instanceContainer.getGame().getInputManager().isKeyPressed(UPKEY)); const elapsedTime = this.owner.getElapsedTime(); if (!this._leftKey) { this._leftKeyPressedDuration = 0; } else { this._leftKeyPressedDuration += elapsedTime; } if (!this._rightKey) { this._rightKeyPressedDuration = 0; } else { this._rightKeyPressedDuration += elapsedTime; } if (!this._downKey) { this._downKeyPressedDuration = 0; } else { this._downKeyPressedDuration += elapsedTime; } if (!this._upKey) { this._upKeyPressedDuration = 0; } else { this._upKeyPressedDuration += elapsedTime; } let direction = -1; if (!this._allowDiagonals) { if (this._upKey && !this._downKey) { direction = 6; } else if (!this._upKey && this._downKey) { direction = 2; } // when 2 keys are pressed for diagonals the most recently pressed win if ( this._leftKey && !this._rightKey && (this._upKey === this._downKey || (this._upKey && this._leftKeyPressedDuration < this._upKeyPressedDuration) || (this._downKey && this._leftKeyPressedDuration < this._downKeyPressedDuration)) ) { direction = 4; } else if ( this._rightKey && !this._leftKey && (this._upKey === this._downKey || (this._upKey && this._rightKeyPressedDuration < this._upKeyPressedDuration) || (this._downKey && this._rightKeyPressedDuration < this._downKeyPressedDuration)) ) { direction = 0; } } else { if (this._upKey && !this._downKey) { if (this._leftKey && !this._rightKey) { direction = 5; } else if (!this._leftKey && this._rightKey) { direction = 7; } else { direction = 6; } } else if (!this._upKey && this._downKey) { if (this._leftKey && !this._rightKey) { direction = 3; } else if (!this._leftKey && this._rightKey) { direction = 1; } else { direction = 2; } } else { if (this._leftKey && !this._rightKey) { direction = 4; } else if (!this._leftKey && this._rightKey) { direction = 0; } } } const hookContext = gdjs.TopDownMovementRuntimeBehavior._topDownMovementHookContext; for (const topDownMovementHook of this._topDownMovementHooks) { hookContext._setDirection(direction); direction = topDownMovementHook.overrideDirection(hookContext); } hookContext._setDirection(direction); for (const topDownMovementHook of this._topDownMovementHooks) { topDownMovementHook.beforeSpeedUpdate(hookContext); } const object = this.owner; const timeDelta = this.owner.getElapsedTime() / 1000; const previousVelocityX = this._xVelocity; const previousVelocityY = this._yVelocity; this._wasStickUsed = false; // These 4 values are not actually used. // JavaScript doesn't allow to declare // variables without assigning them a value. let directionInRad = 0; let directionInDeg = 0; let isMoving = false; let targetedSpeed = 0; // Update the speed of the object: if (direction !== -1) { isMoving = true; directionInRad = ((direction + this._movementAngleOffset / 45) * Math.PI) / 4.0; directionInDeg = direction * 45 + this._movementAngleOffset; targetedSpeed = this._maxSpeed; } else if (this._stickForce !== 0) { isMoving = true; if (!this._allowDiagonals) { this._stickAngle = 90 * Math.floor((this._stickAngle + 45) / 90); } directionInDeg = this._stickAngle + this._movementAngleOffset; directionInRad = (directionInDeg * Math.PI) / 180; targetedSpeed = this._maxSpeed * this._stickForce; this._wasStickUsed = true; } else if (this._yVelocity !== 0 || this._xVelocity !== 0) { isMoving = true; directionInRad = Math.atan2(this._yVelocity, this._xVelocity); directionInDeg = (directionInRad * 180.0) / Math.PI; } if (isMoving) { // This makes the trigo resilient to rounding errors on directionInRad. let cos = Math.cos(directionInRad); let sin = Math.sin(directionInRad); if (cos === -1 || cos === 1) { sin = 0; } if (sin === -1 || sin === 1) { cos = 0; } let currentSpeed = Math.hypot(this._xVelocity, this._yVelocity); const dotProduct = this._xVelocity * cos + this._yVelocity * sin; if (dotProduct < 0) { // The object is turning back. // Keep the negative velocity projected on the new direction. currentSpeed = dotProduct; } const speed = TopDownMovementRuntimeBehavior.getAcceleratedSpeed( currentSpeed, targetedSpeed, this._maxSpeed, this._acceleration, this._deceleration, timeDelta, this._useLegacyTurnBack ); this._xVelocity = speed * cos; this._yVelocity = speed * sin; } const squaredSpeed = this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity; if (squaredSpeed > this._maxSpeed * this._maxSpeed) { const ratio = this._maxSpeed / Math.sqrt(squaredSpeed); this._xVelocity *= ratio; this._yVelocity *= ratio; } // No acceleration for angular speed for now. this._angularSpeed = this._angularMaxSpeed; for (const topDownMovementHook of this._topDownMovementHooks) { topDownMovementHook.beforePositionUpdate(hookContext); } // Position object. // This is a Verlet integration considering the acceleration as constant. // If you expand deltaX or deltaY, it gives, thanks to the usage of both // the old and the new velocity: // "velocity * timeDelta + acceleration * timeDelta^2 / 2". // // The acceleration is not actually always constant, particularly with a gamepad, // but the error is multiplied by timDelta^3. So, it shouldn't matter much. const deltaX = ((previousVelocityX + this._xVelocity) / 2) * timeDelta; const deltaY = ((previousVelocityY + this._yVelocity) / 2) * timeDelta; if (this._basisTransformation === null) { // Top-down viewpoint object.setX(object.getX() + deltaX); object.setY(object.getY() + deltaY); } else { // Isometry viewpoint const point = this._temporaryPointForTransformations; point[0] = deltaX; point[1] = deltaY; this._basisTransformation.toScreen(point, point); object.setX(object.getX() + point[0]); object.setY(object.getY() + point[1]); } // Also update angle if needed. if (this._xVelocity !== 0 || this._yVelocity !== 0) { this._angle = directionInDeg; if (this._rotateObject) { object.rotateTowardAngle( directionInDeg + this._angleOffset, this._angularSpeed ); } } this._wasLeftKeyPressed = this._leftKey; this._wasRightKeyPressed = this._rightKey; this._wasUpKeyPressed = this._upKey; this._wasDownKeyPressed = this._downKey; if (this._clearInputsBetweenFrames) { this._leftKey = false; this._rightKey = false; this._upKey = false; this._downKey = false; this._stickForce = 0; } } private static getAcceleratedSpeed( currentSpeed: float, targetedSpeed: float, speedMax: float, acceleration: float, deceleration: float, timeDelta: float, useLegacyTurnBack: boolean = false ): float { let newSpeed = currentSpeed; const turningBackAcceleration = useLegacyTurnBack ? acceleration : Math.max(acceleration, deceleration); if (targetedSpeed < 0) { if (currentSpeed <= targetedSpeed) { // Reduce the speed to match the stick force. newSpeed = Math.min( targetedSpeed, currentSpeed + turningBackAcceleration * timeDelta ); } else if (currentSpeed <= 0) { // Accelerate newSpeed -= Math.max(-speedMax, acceleration * timeDelta); } else { // Turn back at least as fast as it would stop. newSpeed = Math.max( targetedSpeed, currentSpeed - turningBackAcceleration * timeDelta ); } } else if (targetedSpeed > 0) { if (currentSpeed >= targetedSpeed) { // Reduce the speed to match the stick force. newSpeed = Math.max( targetedSpeed, currentSpeed - turningBackAcceleration * timeDelta ); } else if (currentSpeed >= 0) { // Accelerate newSpeed = Math.min( speedMax, currentSpeed + acceleration * timeDelta ); } else { // Turn back at least as fast as it would stop. newSpeed = Math.min( targetedSpeed, currentSpeed + turningBackAcceleration * timeDelta ); } } else { // Decelerate and stop. if (currentSpeed < 0) { newSpeed = Math.min(currentSpeed + deceleration * timeDelta, 0); } if (currentSpeed > 0) { newSpeed = Math.max(currentSpeed - deceleration * timeDelta, 0); } } return newSpeed; } simulateControl(input: string) { if (input === 'Left') { this._leftKey = true; } else if (input === 'Right') { this._rightKey = true; } else if (input === 'Up') { this._upKey = true; } else if (input === 'Down') { this._downKey = true; } } ignoreDefaultControls(ignore: boolean) { this._ignoreDefaultControls = ignore; } shouldIgnoreDefaultControls() { return ( this._ignoreDefaultControls || this._ignoreDefaultControlsAsSyncedByNetwork ); } simulateLeftKey() { this._leftKey = true; } simulateRightKey() { this._rightKey = true; } simulateUpKey() { this._upKey = true; } simulateDownKey() { this._downKey = true; } simulateStick(stickAngle: float, stickForce: float) { this._stickAngle = stickAngle % 360; this._stickForce = Math.max(0, Math.min(1, stickForce)); } /**. * @param input The control to be tested [Left,Right,Up,Down,Stick]. * @returns true if the key was used since the last `doStepPreEvents` call. */ isUsingControl(input: string): boolean { if (input === 'Left') { return this._leftKeyPressedDuration > 0; } if (input === 'Right') { return this._rightKeyPressedDuration > 0; } if (input === 'Up') { return this._upKeyPressedDuration > 0; } if (input === 'Down') { return this._downKeyPressedDuration > 0; } if (input === 'Stick') { return this._wasStickUsed; } return false; } getLastStickInputAngle() { return this._stickAngle; } /** * A hook must typically be registered by a behavior that requires this one * in its onCreate function. * The hook must stay forever to avoid side effects like a hooks order * change. To handle deactivated behavior, the hook can check that its * behavior is activated before doing anything. */ registerHook( hook: gdjs.TopDownMovementRuntimeBehavior.TopDownMovementHook ) { this._topDownMovementHooks.push(hook); } } /** @category Behaviors > Top-down movement */ export namespace TopDownMovementRuntimeBehavior { export class TopDownMovementHookContext { private direction: integer = -1; /** * @returns The movement direction from 0 for left to 7 for up-left and * -1 for no direction. */ getDirection(): integer { return this.direction; } /** * This method won't change the direction used by the top-down movement * behavior. */ _setDirection(direction: integer): void { this.direction = direction; } } // This should be a static attribute but it's not possible because of // declaration order. export const _topDownMovementHookContext = new gdjs.TopDownMovementRuntimeBehavior.TopDownMovementHookContext(); /** * Allow extensions relying on the top-down movement to customize its * behavior a bit. */ export interface TopDownMovementHook { /** * Return the direction to use instead of the direction given in * parameter. */ overrideDirection(hookContext: TopDownMovementHookContext): integer; /** * Called before the acceleration and new direction is applied to the * velocity. */ beforeSpeedUpdate(hookContext: TopDownMovementHookContext): void; /** * Called before the velocity is applied to the object position and * angle. */ beforePositionUpdate(hookContext: TopDownMovementHookContext): void; } export interface BasisTransformation { toScreen(worldPoint: FloatPoint, screenPoint: FloatPoint): void; } /** @category Behaviors > Top-down movement */ export class IsometryTransformation implements gdjs.TopDownMovementRuntimeBehavior.BasisTransformation { private _screen: float[][]; /** * @param angle between the x axis and the projected isometric x axis. * @throws if the angle is not in ]0; pi/4[. Note that 0 is a front viewpoint and pi/4 a top-down viewpoint. */ constructor(angle: float) { if (angle <= 0 || angle >= Math.PI / 4) throw new RangeError( 'An isometry angle must be in ]0; pi/4] but was: ' + angle ); const alpha = Math.asin(Math.tan(angle)); const sinA = Math.sin(alpha); const cosB = Math.cos(Math.PI / 4); const sinB = cosB; // https://en.wikipedia.org/wiki/Isometric_projection // // / 1 0 0 \ / cosB 0 -sinB \ / 1 0 0 \ // | 0 cosA sinA | | 0 1 0 | | 0 0 -1 | // \ 0 -sinA cosA / \ sinB 0 cosB / \ 0 1 0 / this._screen = [ [cosB, -sinB], [sinA * sinB, sinA * cosB], ]; } toScreen(worldPoint: FloatPoint, screenPoint: FloatPoint): void { const x = this._screen[0][0] * worldPoint[0] + this._screen[0][1] * worldPoint[1]; const y = this._screen[1][0] * worldPoint[0] + this._screen[1][1] * worldPoint[1]; screenPoint[0] = x; screenPoint[1] = y; } } } gdjs.registerBehavior( 'TopDownMovementBehavior::TopDownMovementBehavior', gdjs.TopDownMovementRuntimeBehavior ); }