Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Snake game ! #4

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/java/com/togetherjava/tjplays/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.togetherjava.tjplays.listeners.commands.PingCommand;
import com.togetherjava.tjplays.listeners.commands.SlashCommand;

import com.togetherjava.tjplays.listeners.commands.SnakeGameCommand;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
Expand All @@ -13,7 +14,7 @@ public final class App {
public static void main(String[] args) {
JDA jda = JDABuilder.createDefault(args[0]).build();

List<SlashCommand> commands = List.of(new PingCommand());
List<SlashCommand> commands = List.of(new PingCommand(), new SnakeGameCommand());
commands.forEach(command -> jda.addEventListener(command));

List<SlashCommandData> commandDatas = commands.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.togetherjava.tjplays.games.gamesnake;

import com.togetherjava.tjplays.utils.CardinalDirection;
import com.togetherjava.tjplays.utils.GifSequenceWriter;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class GameSnake {
private enum RunningState {
RUNNING,
LOST,
WON
}

private record Pos(int x, int y) {
}

private record State(List<Pos> snake, Pos apple, RunningState runningState) {
public State(List<Pos> snake, Pos apple) {
this(snake, apple, RunningState.RUNNING);
}
}

private static final int WIDTH = 25;
private static final int HEIGHT = 15;
private static final int TILE_SIZE = 10;
public static final String IMAGE_FORMAT = "gif";
private static final int TURN_PERIOD_MILLIS = 500;
private State state;
private CardinalDirection currentDirection = null;
private int randomAppleCacheId = -1;
private Pos randomAppleCache;
private long timeMillis;

public GameSnake(Instant eventTimeCreated) {
Pos apple = new Pos(ThreadLocalRandom.current().nextInt(WIDTH),
ThreadLocalRandom.current().nextInt(HEIGHT));
Pos head = new Pos(ThreadLocalRandom.current().nextInt(WIDTH / 4, WIDTH / 4 * 3),
ThreadLocalRandom.current().nextInt(HEIGHT / 4, HEIGHT / 4 * 3));
state = new State(List.of(head), apple);
currentDirection = CardinalDirection.values()[ThreadLocalRandom.current().nextInt(CardinalDirection.values().length)];
timeMillis = eventTimeCreated.toEpochMilli();
}

public void onNewDirectionAction(Instant actionTime, CardinalDirection newDirection) {
long now = actionTime.toEpochMilli();
long turns = (now - timeMillis) / TURN_PERIOD_MILLIS;
timeMillis = now;
state = playSeveralTurns(state, (int) turns).skip(turns - 1)
.findFirst()
.orElseGet(() -> playSeveralTurns(state, (int) turns)
.dropWhile(s -> s.runningState() == RunningState.RUNNING)
.findFirst()
.orElseThrow());
currentDirection = newDirection;
}

public byte[] generateCurrentAnimationBuffer() {
return generateAnimationBuffer(state);
}

private byte[] generateAnimationBuffer(State state) {
List<BufferedImage> images = renderSeveralTurns(state, Math.max(WIDTH, HEIGHT)).toList();
ByteArrayOutputStream baos = new ByteArrayOutputStream(32768);
try (var imageStream = ImageIO.createImageOutputStream(baos);
GifSequenceWriter writer = new GifSequenceWriter(imageStream,
images.get(0).getType(), TURN_PERIOD_MILLIS, false)) {
for (BufferedImage img : images) {
writer.writeToSequence(img);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return baos.toByteArray();
}

private State next(State state) {
List<Pos> snake = state.snake();
Pos head = snake.get(0);
Pos newPos = nextPos(head);
if (newPos.x() < 0 || newPos.x() >= WIDTH || newPos.y() < 0 || newPos.y() >= HEIGHT) {
return new State(state.snake(), state.apple(), RunningState.LOST);
}
if (snake.subList(1, snake.size()).contains(head)) {
return new State(state.snake(), state.apple(), RunningState.LOST);
}
if (newPos.equals(state.apple())) {
List<Pos> newSnake = Stream.concat(Stream.of(newPos), snake.stream()).toList();
if (newSnake.size() == WIDTH * HEIGHT) {
return new State(state.snake(), state.apple(), RunningState.WON);
}
if (newSnake.size() > WIDTH * HEIGHT)
throw new AssertionError();
return generateNewApple(new State(newSnake, state.apple));
} else {
List<Pos> newSnake =
Stream.concat(Stream.of(newPos), snake.subList(0, snake.size() - 1).stream())
.toList();
return new State(newSnake, state.apple());
}
}

private Pos nextPos(Pos pos) {
return switch (currentDirection) {
case LEFT -> new Pos(pos.x() - 1, pos.y());
case RIGHT -> new Pos(pos.x() + 1, pos.y());
case UP -> new Pos(pos.x(), pos.y() - 1);
case DOWN -> new Pos(pos.x(), pos.y() + 1);
};
}

private Stream<State> playSeveralTurns(State state, int turns) {
List<State> buffer = new ArrayList<>();
State current = state;
buffer.add(current);
for (int i = 0; i < turns && current.runningState() == RunningState.RUNNING; i++) {
current = next(current);
buffer.add(current);
}
return buffer.stream();
}

private State generateNewApple(State state) {
List<Pos> snake = state.snake();
if (randomAppleCacheId == snake.size()) {
return new State(snake, randomAppleCache);
}
List<Pos> pos = IntStream.range(0, HEIGHT)
.mapToObj(y -> IntStream.range(0, WIDTH).mapToObj(x -> new Pos(x, y)))
.flatMap(s -> s)
.filter(p -> !snake.contains(p))
.toList();
int i = ThreadLocalRandom.current().nextInt(pos.size());
randomAppleCacheId = snake.size();
randomAppleCache = pos.get(i);
return new State(snake, randomAppleCache);
}

private Stream<BufferedImage> renderSeveralTurns(State state, int turns) {
return playSeveralTurns(state, turns).map(this::render);
}

private BufferedImage render(State state) {
List<Pos> snake = state.snake();
BufferedImage img = new BufferedImage(WIDTH * TILE_SIZE, HEIGHT * TILE_SIZE,
BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();


for (int x = 0; x < WIDTH; x++) {
for (int y = 0; y < HEIGHT; y++) {
Pos current = new Pos(x, y);

Color color;
if (current.equals(state.apple())) {
color = Color.RED;
} else if (current.equals(snake.get(0))) {
color = Color.BLACK;
} else if (snake.contains(current)) {
color = Color.GRAY;
} else {
color = Color.WHITE;
}
g.setColor(color);

g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
if (state.runningState() == RunningState.WON) {
g.setColor(Color.GREEN);
drawCenteredString(g, "You won !", WIDTH * TILE_SIZE / 2F, HEIGHT * TILE_SIZE / 2F);
} else if (state.runningState() == RunningState.LOST) {
g.setColor(Color.RED);
drawCenteredString(g, "You lost...", WIDTH * TILE_SIZE / 2F, HEIGHT * TILE_SIZE / 2F);
}
return img;
}

private void drawCenteredString(Graphics2D g, String string, float x, float y) {
FontMetrics metrics = g.getFontMetrics();
x -= metrics.stringWidth(string) / 2F;
y += metrics.getHeight() / 2F + metrics.getAscent();
g.drawString(string, x, y);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.togetherjava.tjplays.listeners.commands;

import com.togetherjava.tjplays.games.gamesnake.GameSnake;
import com.togetherjava.tjplays.utils.CardinalDirection;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.utils.FileUpload;
import net.dv8tion.jda.api.utils.messages.MessageEditBuilder;
import net.dv8tion.jda.api.utils.messages.MessageEditData;

import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;

public class SnakeGameCommand extends SlashCommand {
private static final String SNAKE_BUTTONS_PREFIX = UUID.randomUUID().toString();
private static final String COMMAND_NAME = "snake";
private static final String NO_WIDTH_WHITESPACE = "\u200B";
private GameMessageId gameMessageId = null;
private GameSnake game;

public SnakeGameCommand() {
super(Commands.slash(COMMAND_NAME, "Play the famous snake game !"));
}

@Override
public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
if (!event.getName().equals(COMMAND_NAME)) return;
event.reply("Started a game").queue();
game = new GameSnake(event.getTimeCreated().toInstant());
byte[] gifData = game.generateCurrentAnimationBuffer();
FileUpload fileUpload = FileUpload.fromData(gifData, "game." + GameSnake.IMAGE_FORMAT);
event.getChannel()
.sendMessage("Snake game")
.setFiles(fileUpload)
.addActionRow(createButtons(NO_WIDTH_WHITESPACE, "⬆", NO_WIDTH_WHITESPACE))
.addActionRow(createButtons("⬅", NO_WIDTH_WHITESPACE, "➡"))
.addActionRow(createButtons(NO_WIDTH_WHITESPACE, "⬇", NO_WIDTH_WHITESPACE))
.queue(this::recordGameMessage);
}

private List<Button> createButtons(String... texts) {
return Stream.of(texts)
.map(text -> {
Button b = Button.of(ButtonStyle.SECONDARY, SNAKE_BUTTONS_PREFIX + " " + UUID.randomUUID() + " " + text, text);
return text.equals(NO_WIDTH_WHITESPACE) ? b.asDisabled() : b;
})
.toList();
}

private void recordGameMessage(Message gameMessage) {
gameMessageId = GameMessageId.fromMessage(gameMessage);
}

private record GameMessageId(long channelId, long messageId) {
static GameMessageId fromMessage(Message message) {
return new GameMessageId(message.getChannel().getIdLong(), message.getIdLong());
}
}

@Override
public void onButtonInteraction(ButtonInteractionEvent event) {
if(!event.getComponentId().startsWith(SNAKE_BUTTONS_PREFIX)) {
return;
}
String buttonPressed = event.getComponentId().split(" ")[2];
if(NO_WIDTH_WHITESPACE.equals(buttonPressed)) {
event.deferEdit().queue();
return;
}
CardinalDirection direction = switch (buttonPressed) {
case "⬆" -> CardinalDirection.UP;
case "⬅" -> CardinalDirection.LEFT;
case "➡" -> CardinalDirection.RIGHT;
case "⬇" -> CardinalDirection.DOWN;
default -> throw new AssertionError("unknown");
};
game.onNewDirectionAction(event.getTimeCreated().toInstant(), direction);
byte[] gifData = game.generateCurrentAnimationBuffer();
FileUpload fileUpload = FileUpload.fromData(gifData, "game." + GameSnake.IMAGE_FORMAT);
MessageEditData message = new MessageEditBuilder().setAttachments(fileUpload).build();
TextChannel channel = event.getJDA().getTextChannelById(gameMessageId.channelId);
Objects.requireNonNull(channel).editMessageById(gameMessageId.messageId, message).queue();
event.deferEdit().queue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.togetherjava.tjplays.utils;

public enum CardinalDirection {
LEFT,
RIGHT,
UP,
DOWN
}
Loading