Skip to content

Commit

Permalink
Merge pull request #41 from shibisuriya/feat/ai-opponent
Browse files Browse the repository at this point in the history
Feat/ai opponent
  • Loading branch information
shibisuriya authored Dec 27, 2023
2 parents b217214 + c159f51 commit 2c98075
Show file tree
Hide file tree
Showing 10 changed files with 416 additions and 70 deletions.
17 changes: 12 additions & 5 deletions packages/game-client/src/Game.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ import { grid } from './Grid.js';
const Game = forwardRef((props, ref) => {
const { showCellId, gameState, updateSnakeList } = props;

const [cells, setCells] = useState(grid.getAllCells());
const [view, setView] = useState(grid.getViewData());
const [annotations, setAnnotations] = useState(grid.getAnnotationData());

const updateCells = (cells) => {
setCells(cells);
const viewUpdater = (cells) => {
setView(cells);
};

const annotationsUpdater = (cells) => {
setAnnotations(cells);
};

useEffect(() => {
// This callback is used to update data from the
// the object to the ui.
grid.updateCells = updateCells;
grid.viewUpdater = viewUpdater;
grid.annotationsUpdater = annotationsUpdater;

grid.updateSnakeList = updateSnakeList;
if (gameState) {
grid.startGame();
Expand All @@ -34,7 +41,7 @@ const Game = forwardRef((props, ref) => {
};
});

return <Grid cells={cells} showCellId={showCellId} />;
return <Grid view={view} annotations={annotations} showCellId={showCellId} />;
});

export default Game;
109 changes: 77 additions & 32 deletions packages/game-client/src/Grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class Grid {
}
}

getAllCells() {
return Object.values(this.snakes)
getViewData() {
const viewData = Object.values(this.snakes)
.reduce((cells, snake) => {
snake.keys.forEach((key, index) => {
const cell = snake.hash[key];
Expand All @@ -66,6 +66,7 @@ class Grid {
return cells;
}, []),
);
return viewData;
}

attachKeyboard() {
Expand All @@ -76,21 +77,25 @@ class Grid {
(event) => {
const key = event.key.toLowerCase();
if (['w', 'arrowup'].includes(key)) {
Object.values(this.snakes).forEach((snake) => {
snake.changeDirection(DIRECTIONS.UP);
});
this.snakes[4].changeDirection(DIRECTIONS.UP);
// Object.values(this.snakes).forEach((snake) => {
// snake.changeDirection(DIRECTIONS.UP);
// });
} else if (['s', 'arrowdown'].includes(key)) {
Object.values(this.snakes).forEach((snake) => {
snake.changeDirection(DIRECTIONS.DOWN);
});
this.snakes[4].changeDirection(DIRECTIONS.DOWN);
// Object.values(this.snakes).forEach((snake) => {
// snake.changeDirection(DIRECTIONS.DOWN);
// });
} else if (['a', 'arrowleft'].includes(key)) {
Object.values(this.snakes).forEach((snake) => {
snake.changeDirection(DIRECTIONS.LEFT);
});
this.snakes[4].changeDirection(DIRECTIONS.LEFT);
// Object.values(this.snakes).forEach((snake) => {
// snake.changeDirection(DIRECTIONS.LEFT);
// });
} else if (['d', 'arrowright'].includes(key)) {
Object.values(this.snakes).forEach((snake) => {
snake.changeDirection(DIRECTIONS.RIGHT);
});
this.snakes[4].changeDirection(DIRECTIONS.RIGHT);
// Object.values(this.snakes).forEach((snake) => {
// snake.changeDirection(DIRECTIONS.RIGHT);
// });
}
},
{ signal: this.keyboardAbortController.signal },
Expand Down Expand Up @@ -131,6 +136,7 @@ class Grid {

initializeSnakes() {
this.snakes = {};
this.bots = {}; // Keep a separate hashMap of snakes which are bots.
for (const [snakeId, initialSnakeState] of Object.entries(initialSnakesState)) {
const snake = new Snake(initialSnakeState);
snake.die = (causeOfDeath) => {
Expand Down Expand Up @@ -180,6 +186,13 @@ class Grid {
getCellsOccupiedBySnakes: this.getCellsOccupiedBySnakes.bind(this),
};

if (snake.isBot) {
// Every snake that is a bot should have access to the game's data like opponent's position,
// food's coordinates, etc.
snake.game = { getGameData: this.getGameData.bind(this) };
this.bots[snakeId] = snake;
}

this.snakes[snakeId] = snake;
const trackId = snake.defaultTick;
this.addSnakeToTrack(trackId, snakeId);
Expand All @@ -198,6 +211,10 @@ class Grid {
this.removeSnakeFromTrack({ snakeId });
delete this.snakes[snakeId];

if (snakeId in this.bots) {
delete this.bots[snakeId];
}

return cloneDeep(removedSnake);
}

Expand Down Expand Up @@ -324,9 +341,6 @@ class Grid {
Object.values(fedSnakesHash).forEach(({ snake, food }) => {
snake.consume(food);
});

// Grid data becomes consistent here, so update the UI.
this.updateState();
}

attachTickers() {
Expand All @@ -340,25 +354,36 @@ class Grid {
const { DURATION: duration } = tick;
const timer = setInterval(() => {
this.moveSnakes(Object.keys(this.tracks[tick.TYPE]));
this.updateView();
}, duration);
this.timers.push(timer);
}

for (const { DURATION: duration } of Object.values(FOOD_TICKS)) {
const timer = setInterval(() => {
this.spawnFood();
this.updateState();
this.updateView();
}, duration);
this.timers.push(timer);
}
}

updateState() {
// TODO: refactor this method.
if (this.updateCells) {
this.updateCells(this.getAllCells());
getGameData() {
return {
snakes: this.snakes,
food: this.food,
};
}

updateView() {
if (this.viewUpdater) {
this.viewUpdater(this.getViewData());
} else {
console.warn('Grid instance was not supplied a method to update the UI...');
console.warn('Grid instance was not supplied a method to update the view...');
}

if (Object.values(this.bots).length > 0) {
this.updateAnnotations();
}

if (this.updateSnakeList) {
Expand Down Expand Up @@ -403,16 +428,18 @@ class Grid {
getCellsOccupiedBySnakes() {
return Object.values(this.snakes).reduce((cells, snake) => {
// Make sure there is integrity in snake's data before invoking this
// method since it throws an error if two snakes occupy a single cell...
// method since it throws an error if two snakes occupy a single cell or food and snakes occupy the same cell...
const { hash } = snake;
// for (const [key, value] of Object.entries(hash)) {
// if (!(key in cells)) {
// Object.assign(cells, { [key]: value });
// } else {
// throw new Error('Two snakes are occupying a single cell!');
// }
// }
return Object.assign(cells, hash);
for (const [key, value] of Object.entries(hash)) {
if (!(key in cells) && !this.isFoodCell(value.x, value.y)) {
// hmmm, isFoodCell checks for isValidcell... So the edge case where
// we check wheather the snake has a valid cell or not is taken care of...
Object.assign(cells, { [key]: value });
} else {
throw new Error('Two snakes or food are occupying a single cell!');
}
}
return cells;
}, {});
}

Expand Down Expand Up @@ -448,6 +475,24 @@ class Grid {
this.timers = [];
}

updateAnnotations() {
if (this.annotationsUpdater) {
this.annotationsUpdater(this.getAnnotationData());
}
}

getAnnotationData() {
const annotationData = Object.values(this.bots).reduce((annotationData, bot) => {
const bodyColor = bot.bodyColor;
// TODO: reduce alpha of the color...
for (const cell of bot.getAnnotations()) {
annotationData.push({ color: bodyColor, ...cell });
}
return annotationData;
}, []);
return annotationData;
}

onDestroy() {
this.detachKeyboard();
this.detachTickers();
Expand Down
25 changes: 23 additions & 2 deletions packages/game-client/src/Grid.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,31 @@ import styles from './grid.module.css';
import { CELL_DIMENSION, GRID_HEIGHT, GRID_WIDTH, FOOD_TYPES } from './constants';
import animation from './animations.module.css';

function Grid({ cells, showCellId }) {
function Grid({ view, annotations, showCellId }) {
return (
<div className={styles.grid} style={{ width: `${GRID_WIDTH}px`, height: `${GRID_HEIGHT}px` }}>
{cells.map((cell) => {
{/* // I have separate <div />(s) for annotations and game, because these can overlap with each other. */}

{view.map((cell) => {
const { x, y, color, animationClass } = cell;
return (
<div
key={`${x}-${y}`}
className={`${styles.cell} ${animation[animationClass]}`}
style={{
left: `${x * CELL_DIMENSION}px`,
top: `${y * CELL_DIMENSION}px`,
height: `${CELL_DIMENSION}px`,
width: `${CELL_DIMENSION}px`,
backgroundColor: color,
}}
>
{showCellId && `${x}-${y}`}
</div>
);
})}

{annotations.map((cell) => {
const { x, y, color, animationClass } = cell;
return (
<div
Expand Down
70 changes: 70 additions & 0 deletions packages/game-client/src/Snake.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { generateKey, getOppositeDirection, isCellValid } from './helpers';
import { DIRECTIONS, FOOD_EFFECTS, FOOD_TYPES } from './constants';
import { SNAKE_COLLIDED_WITH_WALL, SNAKE_SUCIDE } from './errors';
import cloneDeep from 'lodash/cloneDeep';
import { BOTS } from './bots';

class Snake {
constructor(snake) {
Expand All @@ -25,6 +26,17 @@ class Snake {

this.direction = snake.direction;
this.buffs = {};

if (snake.isBot) {
this.loadBot(snake.botName);
}
}

loadBot(botName) {
this.isBot = true;
this.botName = botName;
this.annotations = [];
this.bot = BOTS[botName].bot;
}

addBuff(type, buff) {
Expand Down Expand Up @@ -150,10 +162,25 @@ class Snake {
}

changeDirection(direction) {
// Since the game is designed in a way that the player can change the direction
// n number of times before the next tick and the last changed direction will be the direction
// in which the snake will move, the player can trick the snake into colliding with his own neck...

const head = this.getHead();
const neck = this.getNeck();

if (direction === this.direction) {
console.warn(`Snake is already moving in the ${direction} direction.`);
} else if (getOppositeDirection(this.direction) === direction) {
console.warn(`The snake can't make a 180 degree turn.`);
} else if (direction === DIRECTIONS.LEFT && head.x - 1 === neck.x) {
console.warn(`You are trying to put the neck into a state where it will collide with its own head...`);
} else if (direction === DIRECTIONS.RIGHT && head.x + 1 === neck.x) {
console.warn(`You are trying to put the neck into a state where it will collide with its own head...`);
} else if (direction === DIRECTIONS.DOWN && head.y + 1 === neck.y) {
console.warn(`You are trying to put the neck into a state where it will collide with its own head...`);
} else if (direction === DIRECTIONS.UP && head.y - 1 === neck.y) {
console.warn(`You are trying to put the neck into a state where it will collide with its own head...`);
} else {
this.direction = direction;
}
Expand All @@ -179,7 +206,35 @@ class Snake {
delete this.hash[tailKey];
}

getAnnotations() {
if (this.isBot) {
return this.annotations;
} else {
throw new Error("This snake is not a bot, can't get any annotations data...");
}
}

updateAnnotations(annotations) {
if (this.isBot) {
this.annotations = annotations;
} else {
throw new Error('Trying to add annotations for a player that is not a bot?');
}
}

move() {
if (this.isBot) {
// The snake has been asked to move to the next cell...
// If this particular snake is a bot, implement the code for the bot logic here...
// The 'bot' can only do 1 out of 3 things move 'left', 'right' or 'forward', simple.

this.bot({
move: this.changeDirection.bind(this),
updateAnnotations: this.updateAnnotations.bind(this),
gameData: this.game.getGameData(),
});
}

switch (this.direction) {
case DIRECTIONS.DOWN:
this.moveDown();
Expand Down Expand Up @@ -257,11 +312,26 @@ class Snake {
};
}

getNeck() {
const [_, neckKey] = this.keys;
const neck = this.hash[neckKey];
return neck;
}

getHead() {
const [headKey] = this.keys;
const head = this.hash[headKey];
return head;
}

getBody() {
const body = [];
for (let i = 1; i < this.keys.length; i++) {
const key = this.keys[i];
body.push(this.hash[key]);
}
return body;
}
}

export default Snake;
3 changes: 3 additions & 0 deletions packages/game-client/src/bots/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SCRIPTED_BOTS } from './scripted-bots';

export const BOTS = { ...SCRIPTED_BOTS };
1 change: 1 addition & 0 deletions packages/game-client/src/bots/ml-bots/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Ml based bots will come here...
Loading

0 comments on commit 2c98075

Please sign in to comment.