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; | |
} | |