Spaces:
Sleeping
Sleeping
| import { movementSpeed } from '../../data/characters'; | |
| import { COLLISION_THRESHOLD } from '../constants'; | |
| import { compressPath, distance, manhattanDistance, pointsEqual } from '../util/geometry'; | |
| import { MinHeap } from '../util/minheap'; | |
| import { Point, Vector } from '../util/types'; | |
| import { Game } from './game'; | |
| import { GameId } from './ids'; | |
| import { Player } from './player'; | |
| import { WorldMap } from './worldMap'; | |
| type PathCandidate = { | |
| position: Point; | |
| facing?: Vector; | |
| t: number; | |
| length: number; | |
| cost: number; | |
| prev?: PathCandidate; | |
| }; | |
| export function stopPlayer(player: Player) { | |
| delete player.pathfinding; | |
| player.speed = 0; | |
| } | |
| export function movePlayer( | |
| game: Game, | |
| now: number, | |
| player: Player, | |
| destination: Point, | |
| allowInConversation?: boolean, | |
| ) { | |
| if (Math.floor(destination.x) !== destination.x || Math.floor(destination.y) !== destination.y) { | |
| throw new Error(`Non-integral destination: ${JSON.stringify(destination)}`); | |
| } | |
| const { position } = player; | |
| // Close enough to current position or destination => no-op. | |
| if (pointsEqual(position, destination)) { | |
| return; | |
| } | |
| // Disallow movement in the corresponding cycle state | |
| const { cycleState } = game.world.gameCycle; | |
| if (cycleState === 'Night' || cycleState === 'PlayerKillVoting') { | |
| // 'villager' cannot move | |
| if (player.playerType(game) === 'villager') { | |
| return; | |
| } | |
| } | |
| // Don't allow players in a conversation to move. | |
| const inConversation = [...game.world.conversations.values()].some( | |
| (c) => c.participants.get(player.id)?.status.kind === 'participating', | |
| ); | |
| if (inConversation && !allowInConversation) { | |
| throw new Error(`Can't move when in a conversation. Leave the conversation first!`); | |
| } | |
| player.pathfinding = { | |
| destination: destination, | |
| started: now, | |
| state: { | |
| kind: 'needsPath', | |
| }, | |
| }; | |
| return; | |
| } | |
| export function findRoute(game: Game, now: number, player: Player, destination: Point) { | |
| const minDistances: PathCandidate[][] = []; | |
| const explore = (current: PathCandidate): Array<PathCandidate> => { | |
| const { x, y } = current.position; | |
| const neighbors = []; | |
| // If we're not on a grid point, first try to move horizontally | |
| // or vertically to a grid point. Note that this can create very small | |
| // deltas between the current position and the nearest grid point so | |
| // be careful to preserve the `facing` vectors rather than trying to | |
| // derive them anew. | |
| if (x !== Math.floor(x)) { | |
| neighbors.push( | |
| { position: { x: Math.floor(x), y }, facing: { dx: -1, dy: 0 } }, | |
| { position: { x: Math.floor(x) + 1, y }, facing: { dx: 1, dy: 0 } }, | |
| ); | |
| } | |
| if (y !== Math.floor(y)) { | |
| neighbors.push( | |
| { position: { x, y: Math.floor(y) }, facing: { dx: 0, dy: -1 } }, | |
| { position: { x, y: Math.floor(y) + 1 }, facing: { dx: 0, dy: 1 } }, | |
| ); | |
| } | |
| // Otherwise, just move to adjacent grid points. | |
| if (x == Math.floor(x) && y == Math.floor(y)) { | |
| neighbors.push( | |
| { position: { x: x + 1, y }, facing: { dx: 1, dy: 0 } }, | |
| { position: { x: x - 1, y }, facing: { dx: -1, dy: 0 } }, | |
| { position: { x, y: y + 1 }, facing: { dx: 0, dy: 1 } }, | |
| { position: { x, y: y - 1 }, facing: { dx: 0, dy: -1 } }, | |
| ); | |
| } | |
| const next = []; | |
| for (const { position, facing } of neighbors) { | |
| const segmentLength = distance(current.position, position); | |
| const length = current.length + segmentLength; | |
| if (blocked(game, now, position, player.id)) { | |
| continue; | |
| } | |
| const remaining = manhattanDistance(position, destination); | |
| const path = { | |
| position, | |
| facing, | |
| // Movement speed is in tiles per second. | |
| t: current.t + (segmentLength / movementSpeed) * 1000, | |
| length, | |
| cost: length + remaining, | |
| prev: current, | |
| }; | |
| const existingMin = minDistances[position.y]?.[position.x]; | |
| if (existingMin && existingMin.cost <= path.cost) { | |
| continue; | |
| } | |
| minDistances[position.y] ??= []; | |
| minDistances[position.y][position.x] = path; | |
| next.push(path); | |
| } | |
| return next; | |
| }; | |
| const startingLocation = player.position; | |
| const startingPosition = { x: startingLocation.x, y: startingLocation.y }; | |
| let current: PathCandidate | undefined = { | |
| position: startingPosition, | |
| facing: player.facing, | |
| t: now, | |
| length: 0, | |
| cost: manhattanDistance(startingPosition, destination), | |
| prev: undefined, | |
| }; | |
| let bestCandidate = current; | |
| const minheap = MinHeap<PathCandidate>((p0, p1) => p0.cost > p1.cost); | |
| while (current) { | |
| if (pointsEqual(current.position, destination)) { | |
| break; | |
| } | |
| if ( | |
| manhattanDistance(current.position, destination) < | |
| manhattanDistance(bestCandidate.position, destination) | |
| ) { | |
| bestCandidate = current; | |
| } | |
| for (const candidate of explore(current)) { | |
| minheap.push(candidate); | |
| } | |
| current = minheap.pop(); | |
| } | |
| let newDestination = null; | |
| if (!current) { | |
| if (bestCandidate.length === 0) { | |
| return null; | |
| } | |
| current = bestCandidate; | |
| newDestination = current.position; | |
| } | |
| const densePath = []; | |
| let facing = current.facing!; | |
| while (current) { | |
| densePath.push({ position: current.position, t: current.t, facing }); | |
| facing = current.facing!; | |
| current = current.prev; | |
| } | |
| densePath.reverse(); | |
| return { path: compressPath(densePath), newDestination }; | |
| } | |
| export function blocked(game: Game, now: number, pos: Point, playerId?: GameId<'players'>) { | |
| const otherPositions = [...game.world.players.values()] | |
| .filter((p) => p.id !== playerId) | |
| .map((p) => p.position); | |
| return blockedWithPositions(pos, otherPositions, game.worldMap); | |
| } | |
| export function blockedWithPositions(position: Point, otherPositions: Point[], map: WorldMap) { | |
| if (isNaN(position.x) || isNaN(position.y)) { | |
| throw new Error(`NaN position in ${JSON.stringify(position)}`); | |
| } | |
| if (position.x < 0 || position.y < 0 || position.x >= map.width || position.y >= map.height) { | |
| return 'out of bounds'; | |
| } | |
| for (const layer of map.objectTiles) { | |
| if (layer[Math.floor(position.x)][Math.floor(position.y)] !== -1) { | |
| return 'world blocked'; | |
| } | |
| } | |
| for (const otherPosition of otherPositions) { | |
| if (distance(otherPosition, position) < COLLISION_THRESHOLD) { | |
| return 'player'; | |
| } | |
| } | |
| return null; | |
| } | |