First please excuse me for this long post. I tried to make it the smaller I can but everything come together so I cannot make it smaller than that.
The need
I want to create a Top Down RPG online (like Pokemon), and I like React so I did choose it as Framework.
I tried one time, didn’t work.. So I tried another, and something like 6 different solution but impossible..
So I did a vanilla JS version of it
A working JS vanilla version
// ----- Const -----
const DIRECTION = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
};
const FPS = 5;
const MOVE_TIME = 1000/FPS; //
const MOVE_DISTANCE = 64/FPS; // 64px/sec
// ----- Variable -----
let isWalking = false;
let direction = DIRECTION.DOWN;
let position = {x:64, y:32};
let isDirectionPressed = {}; // [DIRECTION]: bool
let timerId = null;
let delayId = null;
let lastMoveTime = 0;
// ----- Refresh HTML -----
const updatePlayer = () => {
player.style.top = `${position.y}px`;
player.style.left = `${position.x}px`;
player.className = `${direction} ${isWalking ? 'walk' : ''}`;
}
// --------------- Engine ---------------
// Init
updatePlayer();
// Start moving in a direction
const go = (dir) => {
// If we already know that, avoid
if (!dir || isDirectionPressed[dir] && isWalking) {
return;
}
isDirectionPressed[dir] = true;
direction = dir;
isWalking = true;
clearTimers();
// Do we start now ? Or wait (because we already move/stop during the delay - Low FPS)
let now = new Date().getTime();
let delay = Math.max(0, (lastMoveTime + MOVE_TIME) - now);
delayId = setTimeout(() => {
if (isWalking) { // Are we still walking after delay ?
move();
timerId = setInterval(() => {
move();
}, MOVE_TIME);
}
}, delay);
updatePlayer();
}
// Do the move here
const move = () => {
lastMoveTime = new Date().getTime();
if (direction == DIRECTION.UP) {
position.y -= MOVE_DISTANCE;
}
if (direction == DIRECTION.DOWN) {
position.y += MOVE_DISTANCE;
}
if (direction == DIRECTION.LEFT) {
position.x -= MOVE_DISTANCE;
}
if (direction == DIRECTION.RIGHT) {
position.x += MOVE_DISTANCE;
}
updatePlayer();
}
// Clear
const clearTimers = () => {
clearInterval(timerId);
timerId = null;
clearTimeout(delayId);
delayId = null;
}
// --------------- Events ---------------
const onKeyDown = (e) => {
go(keyCodeToDirection(e.keyCode));
};
const onKeyUp = (e) => {
const dir = keyCodeToDirection(e.keyCode);
if (!dir) {
return;
}
isDirectionPressed[dir] = false;
// Stop here (or go in another pressed direction)
if (dir === direction) {
if (isDirectionPressed[DIRECTION.UP]) direction = DIRECTION.UP;
else if (isDirectionPressed[DIRECTION.DOWN]) direction = DIRECTION.DOWN;
else if (isDirectionPressed[DIRECTION.LEFT]) direction = DIRECTION.LEFT;
else if (isDirectionPressed[DIRECTION.RIGHT]) direction = DIRECTION.RIGHT;
else {
isWalking = false; // Stop
clearTimers();
updatePlayer();
}
}
};
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
const keyCodeToDirection = (keyCode) => {
if (keyCode === 37) { return DIRECTION.LEFT }
else if (keyCode === 38) { return DIRECTION.UP }
else if (keyCode === 39) { return DIRECTION.RIGHT }
else if (keyCode === 40) { return DIRECTION.DOWN }
}
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
display: flex;
}
#viewport {
width: 600px;
height: 150px;
margin: auto;
border: 1px solid black;
position: relative;
}
#player {
position: absolute;
background: grey;
width: 32px;
height: 32px;
}
#player.up {
border-top: 3px solid black;
}
#player.down {
border-bottom: 3px solid black;
}
#player.left {
border-left: 3px solid black;
}
#player.right {
border-right: 3px solid black;
}
#player.walk {
background: orange;
}
<div id="viewport">
<div id="player" style="top:32px; left:32px;"></div>
</div>
N-th times try with React
Now I’m sure I have a working version of this code, and I want to make a useControllable hook who can do the same and return {position, direction, isWalking}
state.
Player Component
import React from 'react';
import Character from './character';
import useControllable from '../hooks/useControllable';
const Player = () => {
const { position, direction, isWalking } = useControllable();
return <Character position={position} skin={1} direction={direction} isWalking={isWalking} />
};
export default Player;
useControllable Hook
import { useEffect, useState } from "react";
const DIRECTION = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
};
const FPS = 20;
const MOVE_TIME = 1000/FPS; //
const MOVE_DISTANCE = 64/FPS; // 64px/sec
export default function useControllable (_position={x:0,y:0}, _direction=DIRECTION.DOWN) {
// ----- Define States -----
let [position, setPosition] = useState(_position);
let [direction, setDirection] = useState(_direction);
let [isWalking, setWalking] = useState(false);
// ----- Intern vars -----
let isDirectionPressed = {}; // [DIRECTION]: bool
let timerId = null;
let delayId = null;
let lastMoveTime = 0;
// ----- Engine -----
const go = (dir) => {
// If we already know that, avoid
if (!dir || isDirectionPressed[dir] && isWalking) {
return;
}
isDirectionPressed[dir] = true;
clearTimers();
setDirection(dir); // direction = dir;
setWalking(true); // isWalking=true;
// Do we start now ? Or wait (because we already move/stop during the delay - Low FPS)
let now = new Date().getTime();
let delay = Math.max(0, (lastMoveTime + MOVE_TIME) - now);
// console.log("Start Walking. Delay: " + delay);
delayId = setTimeout(() => {
if (isWalking) {
move();
timerId = setInterval(() => {
console.log("Tic");
move();
}, MOVE_TIME);
}
}, delay);
}
const move = () => {
lastMoveTime = new Date().getTime();
if (direction === DIRECTION.UP) {
setPosition({x: position.x, y: position.y - MOVE_DISTANCE});
// position = {x: position.x, y: position.y - MOVE_DISTANCE};
}
else if (direction === DIRECTION.DOWN) {
setPosition({x: position.x, y: position.y + MOVE_DISTANCE});
// position = {x: position.x, y: position.y + MOVE_DISTANCE};
}
else if (direction === DIRECTION.LEFT) {
setPosition({x: position.x - MOVE_DISTANCE, y: position.y});
// position = {x: position.x - MOVE_DISTANCE, y: position.y};
}
else if (direction === DIRECTION.RIGHT) {
setPosition({x: position.x + MOVE_DISTANCE, y: position.y});
// position = {x: position.x + MOVE_DISTANCE, y: position.y};
}
}
const clearTimers = () => {
clearInterval(timerId);
timerId = null;
clearTimeout(delayId);
delayId = null;
}
// ----- Event Management -----
const onKeyDown = (e) => {
go(keyCodeToDirection(e.keyCode));
};
const onKeyUp = (e) => {
const dir = keyCodeToDirection(e.keyCode);
if (!dir) {
return;
}
isDirectionPressed[dir] = false;
// Stop here
if (dir === direction) {
if (isDirectionPressed[DIRECTION.UP]) setDirection(DIRECTION.UP); // direction = DIRECTION.UP
else if (isDirectionPressed[DIRECTION.DOWN]) setDirection(DIRECTION.DOWN); // direction = DIRECTION.DOWN
else if (isDirectionPressed[DIRECTION.LEFT]) setDirection(DIRECTION.LEFT); // direction = DIRECTION.LEFT
else if (isDirectionPressed[DIRECTION.RIGHT]) setDirection(DIRECTION.RIGHT); // direction = DIRECTION.RIGHT
else {
setWalking(false); // isWalking=false;
clearTimers();
}
}
};
const keyCodeToDirection = (keyCode) => {
if (keyCode === 37) { return DIRECTION.LEFT }
else if (keyCode === 38) { return DIRECTION.UP }
else if (keyCode === 39) { return DIRECTION.RIGHT }
else if (keyCode === 40) { return DIRECTION.DOWN }
}
useEffect(() => {
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('keydown', onKeyDown);
};
}, []);
return { position, direction, isWalking };
}
But I never works.. When I update a state (like setDirection
) the variable direction isn’t updated into the hook, so function always have it as “down” (initial value).
So I tried to update them in the same time (see comment after setter) setDirection(dir); // direction = dir;
Also Timeout and Interval are f*cked here, they fire to much, or not at all.. I tried some version using useEffect() to simulate an Interval (see code under) but it’s always buggy (and the Tic function have to stay on all the time, I cannot stop it. And I’ll have NPC too who can also walk, don’t want one by Character)
// Tic for animation...
useEffect(() => {
const id = setTimeout(() => {
console.log("Tic " + playerDir, `[${playerPos.x};${playerPos.y}]`);
setLastMoveTime(new Date().getTime()); // Make it recall later
}, MOVING_TIME);
return () => {
clearTimeout(id);
}
}, [lastMoveTime]);
I probably miss something.. Thank all for taking time to read everything.