1677 lines
38 KiB
Groovy
1677 lines
38 KiB
Groovy
import com.onresolve.scriptrunner.runner.rest.common.CustomEndpointDelegate
|
|
import com.atlassian.jira.component.ComponentAccessor
|
|
import com.atlassian.sal.api.user.UserManager
|
|
import com.atlassian.jira.user.ApplicationUser
|
|
import groovy.transform.BaseScript
|
|
|
|
import javax.ws.rs.core.MultivaluedMap
|
|
import javax.ws.rs.core.Response
|
|
import javax.servlet.http.HttpServletRequest
|
|
import org.apache.log4j.Logger
|
|
|
|
import groovy.json.JsonBuilder
|
|
import groovy.json.JsonSlurper
|
|
import java.util.concurrent.BlockingQueue
|
|
import java.util.concurrent.SynchronousQueue
|
|
import java.util.concurrent.ArrayBlockingQueue
|
|
import java.util.HashMap
|
|
import java.util.ArrayList
|
|
import java.util.stream.Stream
|
|
import java.util.Collections
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.concurrent.TimeoutException
|
|
|
|
@BaseScript CustomEndpointDelegate delegate
|
|
|
|
def gamerGroups = ["jira-software-users", "jira-administrators"]
|
|
|
|
class SnakeGame {
|
|
class Gamer {
|
|
public byte id;
|
|
public ArrayDeque<int> body = new ArrayDeque<int>();
|
|
public int length = 4;
|
|
public int direction = (int)Math.floor(Math.random()*4);
|
|
public ArrayDeque<int> nextDirs = new ArrayDeque<int>();
|
|
public ApplicationUser user;
|
|
public String pollToken;
|
|
public int pollCounter = 0;
|
|
public boolean sprint = false;
|
|
public int hunger = 0;
|
|
|
|
static class Poller {
|
|
public long timeStarted; // System.nanoTime()
|
|
public long timeOut;
|
|
public BlockingQueue<Response> que;
|
|
|
|
public void submit(Response r) {
|
|
if (que == null) return;
|
|
def nque = que;
|
|
que = null;
|
|
nque.put(r);
|
|
}
|
|
}
|
|
|
|
public void sequenceEvents() {
|
|
events.add([
|
|
type: 'seq',
|
|
i: pollCounter
|
|
]);
|
|
|
|
pollCounter = (pollCounter + 1) % 32;
|
|
}
|
|
|
|
public ArrayList<Map> events = new ArrayList<Map>();
|
|
public ArrayDeque<Poller> pollers = new ArrayDeque<Poller>();
|
|
|
|
public int nextHead(int direction) {
|
|
def (x,y) = board.idxToPos(body.first());
|
|
def (dx,dy) = DIRS[direction];
|
|
(x, y) = [x + dx, y + dy];
|
|
return board.posToIdx(x, y);
|
|
}
|
|
|
|
public int nextHead() {
|
|
return nextHead(direction);
|
|
}
|
|
|
|
public boolean isSprinting() {
|
|
return sprint && body.size() > 4;
|
|
}
|
|
|
|
public boolean shouldMove() {
|
|
if (body.isEmpty()) return false;
|
|
|
|
if (isSprinting()) {
|
|
return true;
|
|
}
|
|
|
|
return gametime % 3 == 0;
|
|
}
|
|
|
|
public boolean checkDir(int direction) {
|
|
if (direction < 0 || direction >= 4) return false;
|
|
int elem = body.stream()
|
|
.skip(1).limit(1).reduce(-1, (_,v) -> v);
|
|
if (elem == -1) return true;
|
|
if (nextHead(direction) != elem) return true;
|
|
return false;
|
|
}
|
|
|
|
public Map introduceTo(Gamer other) {
|
|
if (body.isEmpty())
|
|
return null;
|
|
|
|
def avatarService = ComponentAccessor.avatarService;
|
|
|
|
def v = [
|
|
type: "newplayer",
|
|
id: user.getKey(),
|
|
name: user.getUsername(),
|
|
body: body
|
|
];
|
|
|
|
try {
|
|
v.put("avatar", avatarService.getAvatarURL(other.user, user).toString());
|
|
} catch(Throwable e) {
|
|
L.log(errorToString(e));
|
|
}
|
|
|
|
if (other == this)
|
|
v.put("self", true);
|
|
|
|
return v;
|
|
}
|
|
|
|
public int discoTime = 0;
|
|
}
|
|
|
|
static enum BoardElem {
|
|
NULL(0), AIR(1), APPLE(2),
|
|
GAMER(64),
|
|
GAMER2(65),
|
|
GAMER3(66),
|
|
GAMER4(67);
|
|
|
|
private int id;
|
|
private static BoardElem[] idmap = new BoardElem[256];
|
|
static {
|
|
for (BoardElem e : BoardElem.values()) {
|
|
idmap[e.id] = e;
|
|
}
|
|
}
|
|
|
|
private BoardElem(int id) {
|
|
this.id = id & 0xFF;
|
|
}
|
|
|
|
public byte getId() {
|
|
return (byte)id;
|
|
}
|
|
|
|
public boolean isWall() {
|
|
switch (this) {
|
|
case NULL: return true;
|
|
//case GAMER: return true;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
public int gamerCount() {
|
|
return (id >= 64 && id <= 67) ? (id - 64 + 1) : 0
|
|
}
|
|
|
|
public int calories() {
|
|
return this == APPLE ? 1 : 0;
|
|
}
|
|
|
|
public boolean isApple() {
|
|
return this == APPLE;
|
|
}
|
|
|
|
public static BoardElem withGamers(int count) {
|
|
if (count == 0) return AIR;
|
|
if (count > 4 || count < 0)
|
|
throw new IndexOutOfBoundsException(count);
|
|
return getById((byte)(64 + count - 1));
|
|
}
|
|
|
|
public static BoardElem getById(byte id) {
|
|
return idmap[((int)id) & 0xFF];
|
|
}
|
|
}
|
|
|
|
static class Vec2 {
|
|
public final int x;
|
|
public final int y;
|
|
|
|
public Vec2(int x, int y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
|
|
public int getAt(int i) {
|
|
if (i == 0) return x;
|
|
else if (i == 1) return y;
|
|
else throw new IndexOutOfBoundsException(i);
|
|
}
|
|
}
|
|
|
|
static class Board {
|
|
public final int width;
|
|
public final int height;
|
|
public byte[] board;
|
|
public Board underlay = null;
|
|
|
|
public Board(int width, int height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.board = new byte[width*height];
|
|
|
|
for (int i=0; i<width*height; i++) {
|
|
this.board[i] = BoardElem.AIR.getId();
|
|
}
|
|
}
|
|
|
|
public Board(Board underlay) {
|
|
this.underlay = underlay;
|
|
this.width = underlay.width;
|
|
this.height = underlay.height;
|
|
this.board = new byte[this.width * this.height];
|
|
}
|
|
|
|
public int posToIdx(int x, int y) {
|
|
if (x < 0 || x >= width ) return -1;
|
|
if (y < 0 || y >= height) return -1;
|
|
return y*width+x;
|
|
}
|
|
|
|
public Vec2 idxToPos(int i) {
|
|
return new Vec2((int)(i%width), (int)(i/width));
|
|
}
|
|
|
|
public BoardElem get(int i) {
|
|
if (i < 0 || i >= width*height) return BoardElem.NULL;
|
|
|
|
if (underlay != null && board[i] == 0)
|
|
return underlay.get(i);
|
|
|
|
return BoardElem.getById(board[i]);
|
|
}
|
|
|
|
public BoardElem get(int x, int y) {
|
|
return get(posToIdx(x,y))
|
|
}
|
|
|
|
public void set(int i, BoardElem v) {
|
|
if (i < 0 || i >= width*height)
|
|
throw new IndexOutOfBoundsException();
|
|
|
|
board[i] = v.getId();
|
|
}
|
|
|
|
public void set(int x, int y, BoardElem v) {
|
|
set(posToIdx(x,y), v)
|
|
}
|
|
}
|
|
|
|
static class Event {}
|
|
static class TickEvent extends Event {}
|
|
static class PollEvent extends Event {
|
|
public Gamer.Poller poller;
|
|
public ApplicationUser user;
|
|
public String token;
|
|
public boolean joinGame;
|
|
}
|
|
static class UnpollEvent extends Event {
|
|
public Gamer.Poller poller;
|
|
public ApplicationUser user;
|
|
}
|
|
static class PostEvent extends Event {
|
|
public List actions;
|
|
public ApplicationUser user;
|
|
public String token;
|
|
public BlockingQueue<Response> res;
|
|
|
|
public void submit(Response r) {
|
|
if (res == null) return;
|
|
def nque = res;
|
|
res = null;
|
|
nque.put(r);
|
|
}
|
|
}
|
|
|
|
private static final double TICKRATE = 15.0;
|
|
private static final int NOGAMER_TIMEOUT = (int)(1.0 * TICKRATE);
|
|
private static final int DISCO_TIMEOUT = (int)(10.0 * TICKRATE);
|
|
private static final int DEAD_DISCO_TIMEOUT = (int)(2.0 * TICKRATE);
|
|
private static final long NANOS = 1000000000L;
|
|
private static final long POLLTIME_MIN = 33L*NANOS/10L;
|
|
private static final long POLLTIME_MAX = 67L*NANOS/10L;
|
|
|
|
private static final Vec2[] DIRS;
|
|
static {
|
|
DIRS = new Vec2[4];
|
|
def (int x, int y) = [1, 0];
|
|
|
|
for (int i=0; i<4; i++) {
|
|
DIRS[i] = new Vec2(x,y);
|
|
(x,y) = [-y,x];
|
|
}
|
|
}
|
|
|
|
private int targetAppleCount() {
|
|
//return 5 + gamers.size() * 2;
|
|
return (int)((board.width*board.height) * 0.01);
|
|
}
|
|
|
|
private void replenishApples(Closure f) {
|
|
if (board.width > 8 && board.height > 8) {
|
|
int strikes = 0;
|
|
while (apples < targetAppleCount()) {
|
|
int x, y;
|
|
x = (int)(2.0 + Math.random() * (board.width - 4));
|
|
y = (int)(2.0 + Math.random() * (board.height - 4));
|
|
if (board.get(x, y) == BoardElem.AIR) {
|
|
strikes = 0;
|
|
int p = board.posToIdx(x, y);
|
|
f(p, BoardElem.APPLE);
|
|
apples++;
|
|
} else {
|
|
strikes++;
|
|
if (strikes >= (gamers.size()+1)*8)
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (apples >= targetAppleCount())
|
|
return;
|
|
|
|
/*
|
|
List<int> freeSpace = Stream<int>
|
|
.iterate(0, v -> v + 1)
|
|
.limit(board.width * board.height)
|
|
.filter(i -> board.get(i) == BoardElem.AIR)
|
|
.collect(ArrayList<int>::new, ArrayList<int>::add, ArrayList<int>::addAll);
|
|
|
|
Collections.shuffle(freeSpace);
|
|
|
|
while (apples < targetAppleCount() && !freeSpace.isEmpty()) {
|
|
f((int)freeSpace.remove(freeSpace.size() - 1), BoardElem.APPLE);
|
|
apples++;
|
|
}
|
|
*/
|
|
}
|
|
|
|
public boolean isDead() {
|
|
return !alive;
|
|
}
|
|
|
|
private boolean createUser(ApplicationUser user, String token = null) {
|
|
Gamer gamer = new Gamer(
|
|
user: user,
|
|
pollToken: token
|
|
);
|
|
|
|
boolean success = false;
|
|
for (int i=0; i<(gamers.size()+1)*64; i++) {
|
|
int x, y;
|
|
x = (int)(Math.random() * board.width);
|
|
y = (int)(Math.random() * board.height);
|
|
|
|
if (board.get(x, y) != BoardElem.AIR) continue;
|
|
|
|
boolean strict = i < (gamers.size()+1)*16 && board.width > 16 && board.height > 16;
|
|
int bestdir = -1;
|
|
int dist = -1;
|
|
|
|
for (dir : ({ List<int> nl = [0,1,2,3]; Collections.shuffle(nl); nl })()) {
|
|
def (dx,dy) = DIRS[dir];
|
|
def (nx,ny) = [x+dx,y+dy];
|
|
for (int j=1; j<=16; j++) {
|
|
def v = board.get(nx, ny);
|
|
if (v.isWall() || v.gamerCount() > 0) break;
|
|
if (j > dist) {
|
|
dist = j;
|
|
bestdir = dir;
|
|
}
|
|
(nx, ny) = [nx+dx,ny+dy];
|
|
}
|
|
}
|
|
|
|
if (strict) {
|
|
if (dist < 8) continue;
|
|
dist = Integer.MAX_VALUE;
|
|
|
|
for (otherGamer : gamers.values()) {
|
|
if (otherGamer.body.isEmpty()) continue;
|
|
def (ox, oy) = board.idxToPos(otherGamer.body.first());
|
|
dist = Integer.min(Integer.max(Integer.max(ox-x, oy-y), Integer.max(x-ox, y-oy)), dist);
|
|
}
|
|
|
|
if (dist < 8) continue;
|
|
}
|
|
|
|
success = true;
|
|
if (bestdir != -1) {
|
|
gamer.direction = bestdir;
|
|
}
|
|
board.set(x, y, BoardElem.GAMER);
|
|
gamer.body.add(board.posToIdx(x, y));
|
|
break;
|
|
}
|
|
if (!success) return false;
|
|
|
|
for (otherGamer : gamers.values()) {
|
|
def intro = gamer.introduceTo(otherGamer)
|
|
if (intro == null) continue;
|
|
otherGamer.events.add(intro);
|
|
}
|
|
|
|
gamers.put(user, gamer);
|
|
|
|
return true;
|
|
}
|
|
|
|
private void onTick() {
|
|
List<Map> globalEvents = [];
|
|
Board vboard = new Board(board);
|
|
List<List<int>> boardDiff = [];
|
|
HashSet<Gamer> tailed = new HashSet<>();
|
|
|
|
def diffWrite = { int i, BoardElem v ->
|
|
def (was, will) = [board.get(i), v];
|
|
if (was == BoardElem.GAMER) was = BoardElem.AIR;
|
|
if (will == BoardElem.GAMER) will = BoardElem.AIR;
|
|
if (was != will)
|
|
boardDiff.add(Arrays.<int>asList(i, will.getId()));
|
|
board.set(i, v);
|
|
};
|
|
|
|
for (gamer : gamers.values()) {
|
|
if (!gamer.shouldMove()) continue;
|
|
|
|
if (!gamer.nextDirs.isEmpty()) {
|
|
int dir = gamer.nextDirs.remove();
|
|
|
|
if (gamer.checkDir(dir)) {
|
|
gamer.direction = dir;
|
|
}
|
|
}
|
|
}
|
|
|
|
// to ensure that the result of gamer movement does not depend on
|
|
// iteration order, a careful multi-step process is needed.
|
|
// we create an alternate copy of the board called "virtualspace",
|
|
// which is used as a scratchpad for logic without affecting the real
|
|
// board ("realspace"). the steps are as follows:
|
|
|
|
// shift back gamer tails in virtualspace
|
|
for (gamer : gamers.values()) {
|
|
if (!gamer.shouldMove()) continue;
|
|
|
|
if (gamer.length + board.get(gamer.nextHead()).calories() <= gamer.body.size()) {
|
|
vboard.set(gamer.body.last(), BoardElem.AIR);
|
|
tailed.add(gamer);
|
|
}
|
|
}
|
|
|
|
// push gamer heads forward in virtualspace.
|
|
// if there is already a gamer in destination, create a multi-gamer cell
|
|
for (gamer : gamers.values()) {
|
|
if (!gamer.shouldMove()) continue;
|
|
if (!vboard.get(gamer.nextHead()).isWall())
|
|
vboard.set(gamer.nextHead(),
|
|
BoardElem.withGamers(vboard.get(gamer.nextHead()).gamerCount() + 1))
|
|
}
|
|
|
|
// mark gamers whose head overlaps with another gamer (is a multi-gamer cell) for killing
|
|
// mark the rest of gamers for moving
|
|
List<Gamer> toKill = [];
|
|
List<Gamer> toMove = [];
|
|
for (gamer : gamers.values()) {
|
|
if (!gamer.shouldMove()) continue;
|
|
if (vboard.get(gamer.nextHead()).isWall() || vboard.get(gamer.nextHead()).gamerCount() > 1)
|
|
toKill.add(gamer);
|
|
else
|
|
toMove.add(gamer);
|
|
}
|
|
|
|
// erase dead gamers from virtualspace
|
|
for (gamer : toKill) {
|
|
Stream<int> body = gamer.body.stream();
|
|
if (tailed.contains(gamer))
|
|
body = body.limit(gamer.body.size() - 1)
|
|
if (vboard.get(gamer.nextHead()).gamerCount() > 0)
|
|
body = Stream<int>.concat([gamer.nextHead()].stream(), body);
|
|
for (idx : (Iterable<int>) () -> body.iterator())
|
|
vboard.set(idx, BoardElem.withGamers(vboard.get(idx).gamerCount() - 1));
|
|
}
|
|
|
|
// REALSPACE: move gamers (head and tail)
|
|
for (gamer : toMove) {
|
|
def el = board.get(gamer.nextHead())
|
|
gamer.length += el.calories();
|
|
gamer.body.addFirst(gamer.nextHead());
|
|
if (board.get(gamer.body.first()).isApple()) {
|
|
apples--;
|
|
}
|
|
if (tailed.contains(gamer)) {
|
|
diffWrite(gamer.body.last(), BoardElem.AIR);
|
|
gamer.body.removeLast();
|
|
}
|
|
def farted = false;
|
|
if (gamer.isSprinting()) {
|
|
gamer.hunger++;
|
|
if (gamer.hunger >= 10) {
|
|
gamer.hunger = 0;
|
|
def trep = Math.random() < 0.8 ? BoardElem.APPLE : BoardElem.AIR;
|
|
diffWrite(gamer.body.last(), trep);
|
|
if (trep == BoardElem.APPLE)
|
|
apples++;
|
|
gamer.body.removeLast();
|
|
gamer.length--;
|
|
farted = true;
|
|
}
|
|
}
|
|
diffWrite(gamer.body.first(), BoardElem.GAMER);
|
|
|
|
globalEvents.add([
|
|
type: 'moveplayer',
|
|
id: gamer.user.getKey(),
|
|
tail: (tailed.contains(gamer) ? 1 : 0) + (farted ? 1 : 0),
|
|
head: [gamer.body.first()]
|
|
]);
|
|
}
|
|
|
|
// REALSPACE: kill gamers, placing apples in their place
|
|
for (gamer : toKill) {
|
|
for (idx : gamer.body) {
|
|
if (vboard.get(idx) != BoardElem.AIR)
|
|
continue;
|
|
def el = Math.random() < 0.33 ? BoardElem.APPLE : BoardElem.AIR;
|
|
diffWrite(idx, el);
|
|
if (el == BoardElem.APPLE)
|
|
apples++;
|
|
}
|
|
gamer.body.clear();
|
|
gamer.length = 0;
|
|
|
|
globalEvents.add([
|
|
type: 'goneplayer',
|
|
id: gamer.user.getKey()
|
|
]);
|
|
}
|
|
|
|
replenishApples(diffWrite);
|
|
|
|
List<Gamer> timedOutGamers = [];
|
|
for (gamer : gamers.values()) {
|
|
List<Gamer.Poller> toRemove = [];
|
|
|
|
for (poller : gamer.pollers) {
|
|
if (System.nanoTime() - poller.timeStarted >= poller.timeOut)
|
|
toRemove.add(poller);
|
|
}
|
|
|
|
for (poller : toRemove) {
|
|
poller.submit(Response.ok('[]', 'application/json').build());
|
|
gamer.pollers.remove(poller);
|
|
}
|
|
|
|
if (gamer.pollers.isEmpty()) {
|
|
gamer.discoTime++;
|
|
} else {
|
|
gamer.discoTime = 0;
|
|
}
|
|
|
|
if ((gamer.body.isEmpty()) ? (gamer.discoTime >= DEAD_DISCO_TIMEOUT) : (gamer.discoTime >= DISCO_TIMEOUT)) {
|
|
timedOutGamers.add(gamer);
|
|
}
|
|
}
|
|
|
|
for (gamer : timedOutGamers) {
|
|
for (int idx : gamer.body) {
|
|
diffWrite(idx, BoardElem.AIR);
|
|
}
|
|
if (!gamer.body.isEmpty())
|
|
globalEvents.add([
|
|
type: 'goneplayer',
|
|
id: gamer.user.getKey()
|
|
]);
|
|
gamers.remove(gamer.user);
|
|
}
|
|
|
|
if (!boardDiff.isEmpty())
|
|
globalEvents.add([
|
|
type: 'deltaboard',
|
|
data: boardDiff
|
|
]);
|
|
|
|
if (!globalEvents.isEmpty()) {
|
|
//def serial = (new JsonBuilder(globalEvents)).toString();
|
|
|
|
for (gamer : gamers.values()) {
|
|
def poller = gamer.pollers.poll();
|
|
|
|
//if (poller && gamer.events.isEmpty()) {
|
|
// poller.submit(Response.ok(serial, 'application/json').build());
|
|
// continue;
|
|
//}
|
|
|
|
gamer.events.addAll(globalEvents);
|
|
|
|
if (poller == null)
|
|
continue;
|
|
|
|
gamer.sequenceEvents();
|
|
|
|
poller.submit(Response.ok((new JsonBuilder(gamer.events as List)).toString(), 'application/json').build());
|
|
gamer.events.clear();
|
|
}
|
|
}
|
|
|
|
if (gamers.isEmpty()) {
|
|
nogamerTime++;
|
|
} else {
|
|
nogamerTime = 0;
|
|
}
|
|
|
|
if (nogamerTime >= NOGAMER_TIMEOUT) {
|
|
alive = false;
|
|
L.log("No gamers! Exiting SnakeGame.");
|
|
}
|
|
}
|
|
|
|
private void onPoll(PollEvent ev) {
|
|
boolean newView = false;
|
|
|
|
if (!gamers.containsKey(ev.user)) {
|
|
if(!(ev.joinGame && createUser(ev.user, ev.token))) {
|
|
ev.poller.submit(Response.ok('[{"type":"nogamer"}]').build());
|
|
return;
|
|
} else {
|
|
newView = true;
|
|
}
|
|
}
|
|
|
|
Gamer gamer = gamers.get(ev.user);
|
|
if (gamer.pollToken != null && !gamer.pollToken.equals(ev.token)) {
|
|
gamer.events.clear();
|
|
for (Gamer.Poller p : gamer.pollers) {
|
|
p.submit(Response.ok('[{"type":"takeover"}]', 'application/json').build());
|
|
}
|
|
gamer.pollers.clear();
|
|
gamer.pollToken = ev.token;
|
|
newView = true;
|
|
} else if (gamer.pollToken == null) {
|
|
gamer.pollToken = ev.token;
|
|
}
|
|
|
|
if (newView) {
|
|
gamer.pollCounter = 0;
|
|
|
|
if (gamer.body.isEmpty()) {
|
|
gamers.remove(gamer.user);
|
|
onPoll(ev);
|
|
return;
|
|
}
|
|
|
|
gamer.events.add([
|
|
type: "board",
|
|
width: board.width,
|
|
height: board.height,
|
|
data: ({
|
|
List l = new ArrayList(board.width * board.height);
|
|
for (int i=0; i<board.width*board.height; i++) {
|
|
l.add(board.board[i] == BoardElem.GAMER.getId() ? BoardElem.AIR.getId() : board.board[i]);
|
|
}
|
|
return l;
|
|
}())
|
|
]);
|
|
|
|
for (otherGamer : gamers.values()) {
|
|
def intro = otherGamer.introduceTo(gamer)
|
|
if (intro == null) continue;
|
|
gamer.events.add(intro);
|
|
}
|
|
}
|
|
|
|
if (!gamer.events.isEmpty()) {
|
|
if (!newView)
|
|
gamer.sequenceEvents();
|
|
|
|
ev.poller.submit(Response.ok((new JsonBuilder(gamer.events as List)).toString(), 'application/json').build());
|
|
gamer.events.clear();
|
|
return;
|
|
}
|
|
|
|
gamer.pollers.add(ev.poller);
|
|
}
|
|
|
|
private void onUnpoll(UnpollEvent ev) {
|
|
if (!gamers.containsKey(ev.user)) {
|
|
return;
|
|
}
|
|
|
|
Gamer gamer = gamers.get(ev.user);
|
|
gamer.pollers.remove(ev.poller);
|
|
}
|
|
|
|
private void onPost(PostEvent ev) {
|
|
try {
|
|
if (!gamers.containsKey(ev.user)) {
|
|
ev.submit(Response.status(404)
|
|
.entity("You don't exist").type("text/plain").build());
|
|
return;
|
|
}
|
|
|
|
Gamer gamer = gamers.get(ev.user);
|
|
|
|
if (!gamer.pollToken.equals(ev.token)) {
|
|
ev.submit(Response.status(403)
|
|
.entity("Invalid token; poll with it first").type("text/plain").build());
|
|
return;
|
|
}
|
|
|
|
if (!(ev.actions.size() == 1
|
|
&& ev.actions[0] instanceof Map
|
|
&& ev.actions[0]["type"] instanceof String
|
|
&& ((
|
|
ev.actions[0]["type"] == "dir"
|
|
&& ev.actions[0]["dir"] instanceof Number
|
|
&& ev.actions[0]["dir"] >= 0
|
|
&& ev.actions[0]["dir"] < 4
|
|
) || (
|
|
ev.actions[0]["type"] == "sprint"
|
|
&& ev.actions[0]["sprint"] instanceof Boolean
|
|
))
|
|
)) {
|
|
ev.submit(Response.status(403)
|
|
.entity("Invalid argument").type("text/plain").build());
|
|
return;
|
|
}
|
|
|
|
if (ev.actions[0]["type"] == "dir") {
|
|
int dir = ev.actions[0]["dir"];
|
|
|
|
while (gamer.nextDirs.size() >= 2)
|
|
gamer.nextDirs.remove();
|
|
|
|
gamer.nextDirs.add(dir)
|
|
} else if (ev.actions[0]["type"] == "sprint") {
|
|
gamer.sprint = ev.actions[0]["sprint"];
|
|
}
|
|
|
|
ev.submit(Response.ok("ok", "text/plain").build());
|
|
} catch(Throwable e) {
|
|
ev.submit(Response.status(503)
|
|
.entity(errorToString(e)).type('text/plain').build());
|
|
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private boolean alive = true;
|
|
private long gametime = 0;
|
|
private final BlockingQueue<Event> events = new SynchronousQueue<Event>();
|
|
private final Map<ApplicationUser, Gamer> gamers = new HashMap<ApplicationUser, Gamer>();
|
|
private Board board = new Board(50, 50);
|
|
private int apples = 0;
|
|
private int nogamerTime = 0;
|
|
|
|
public static String lastError = null;
|
|
|
|
static String errorToString(Throwable e) {
|
|
def out = new java.io.ByteArrayOutputStream();
|
|
e.printStackTrace(new java.io.PrintStream(out));
|
|
return out.toString();
|
|
}
|
|
|
|
public SnakeGame(Closure onExit) {
|
|
L.log("Starting SnakeGame");
|
|
|
|
replenishApples({ int i, BoardElem v -> board.set(i, v); });
|
|
|
|
Thread.start {
|
|
while (alive) {
|
|
Thread.sleep((int)(1000.0/TICKRATE));
|
|
events.offer(new TickEvent(), 500L, TimeUnit.MILLISECONDS);
|
|
}
|
|
}
|
|
|
|
def lastTick = System.nanoTime();
|
|
|
|
def thr = Thread.start {
|
|
try {
|
|
try {
|
|
while (alive) {
|
|
def ev = events.take();
|
|
lastTick = System.nanoTime();
|
|
|
|
if (ev instanceof TickEvent) {
|
|
onTick();
|
|
gametime++;
|
|
} else if (ev instanceof PollEvent) {
|
|
onPoll(ev);
|
|
} else if (ev instanceof UnpollEvent) {
|
|
onUnpoll(ev);
|
|
} else if (ev instanceof PostEvent) {
|
|
onPost(ev);
|
|
}
|
|
}
|
|
} catch(Throwable e) {
|
|
for (gamer : gamers.values()) {
|
|
for (poller : gamer.pollers) {
|
|
poller.submit(Response.status(503)
|
|
.entity(errorToString(e)).type('text/plain').build());
|
|
}
|
|
}
|
|
L.log("SnakeGame error! " + errorToString(e));
|
|
lastError = errorToString(e);
|
|
} finally {
|
|
L.log("SnakeGame is over.");
|
|
alive = false;
|
|
onExit();
|
|
for (gamer : gamers.values()) {
|
|
for (poller : gamer.pollers) {
|
|
poller.submit(Response.status(503).build());
|
|
}
|
|
}
|
|
}
|
|
} catch(Throwable e) {
|
|
lastError = errorToString(e);
|
|
}
|
|
}
|
|
|
|
Thread.start {
|
|
while (alive) {
|
|
Thread.sleep(1000);
|
|
if (System.nanoTime() - lastTick > 3*NANOS) {
|
|
L.log("SnakeGame watchdog timeout!");
|
|
thr.interrupt();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public Response poll(ApplicationUser user, String token, boolean joinGame) {
|
|
def poller = new Gamer.Poller(
|
|
timeStarted: System.nanoTime(),
|
|
timeOut: POLLTIME_MIN + (long)(Math.random()*(POLLTIME_MAX - POLLTIME_MIN)),
|
|
que: new ArrayBlockingQueue<Response>(1)
|
|
);
|
|
def que = poller.que;
|
|
events.put(new PollEvent(
|
|
poller: poller,
|
|
user: user,
|
|
token: token,
|
|
joinGame: joinGame
|
|
));
|
|
try {
|
|
def val = que.poll(30L, TimeUnit.SECONDS);
|
|
if (val == null) {
|
|
throw new TimeoutException();
|
|
}
|
|
return val;
|
|
} catch(Throwable e) {
|
|
poller.que = null;
|
|
events.put(new UnpollEvent(
|
|
user: user,
|
|
poller: poller
|
|
));
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
public Response post(ApplicationUser user, String token, List actions) {
|
|
def que = new ArrayBlockingQueue<Response>(1);
|
|
events.put(new PostEvent(
|
|
actions: actions,
|
|
user: user,
|
|
token: token,
|
|
res: que
|
|
));
|
|
return que.take();
|
|
}
|
|
}
|
|
|
|
class L {
|
|
static public void log(String s) {
|
|
try {
|
|
def logger = SnakeAccessor.logger;
|
|
if (logger != null)
|
|
logger.warn(s);
|
|
} catch(Throwable e) {}
|
|
}
|
|
}
|
|
|
|
class SnakeAccessor {
|
|
static private SnakeGame game = null;
|
|
static public Logger logger = null;
|
|
|
|
static public synchronized SnakeGame getGame() {
|
|
if (game == null || game.isDead()) {
|
|
game = new SnakeGame({
|
|
game = null
|
|
})
|
|
}
|
|
|
|
return game
|
|
}
|
|
}
|
|
|
|
def webPage = '''\
|
|
<!DOCTYPE html>
|
|
<style>
|
|
html, body { margin: 0; padding: 0; }
|
|
body { position: relative; }
|
|
body, .content { width:100%; height: 100%; position: absolute; box-sizing: border-box; color: black; }
|
|
#welcomeScreen {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
gap: 3em;
|
|
background-color: white;
|
|
overflow: hidden;
|
|
}
|
|
#languageSelector {
|
|
position: absolute; top: 1em; right: 1em;
|
|
}
|
|
#welcomeMessage {
|
|
text-align: center;
|
|
}
|
|
#gameContent {
|
|
background-color: white;
|
|
padding: 1em;
|
|
display: flex;
|
|
flex-direction: row;
|
|
gap: 1em;
|
|
}
|
|
#gameLogo {
|
|
height: 4.5em;
|
|
}
|
|
#leaderboard {
|
|
width: 30ex;
|
|
overflow: hidden;
|
|
}
|
|
#leaderboard > h1 {
|
|
text-align: center;
|
|
font-size: inherit;
|
|
}
|
|
#leaderboardItems img {
|
|
vertical-align: center;
|
|
height: 1em;
|
|
width: 1em;
|
|
object-fit: middle;
|
|
border-radius: 0.5em;
|
|
overflow: hidden;
|
|
}
|
|
#leaderboardItems {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
align-items: stretch;
|
|
gap: 0.5em;
|
|
}
|
|
#leaderboardItems > div {
|
|
display: flex;
|
|
flex-direction: row;
|
|
}
|
|
#leaderboardItems .lbName {
|
|
flex-grow: 1;
|
|
text-wrap: nowrap;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
}
|
|
#leaderboardItems .lbName.lbSelfName {
|
|
text-decoration: underline;
|
|
}
|
|
#leaderboardItems .lbScore {
|
|
color: #202020;
|
|
}
|
|
button:hover { background-color: #eaeaea; }
|
|
button:active { background-color: #bebebe; }
|
|
button {
|
|
appearance: none;
|
|
outline: none;
|
|
border: 0.1em solid grey;
|
|
background-color: lightgray;
|
|
color: black;
|
|
font-size: 1.5em;
|
|
border-radius: 0.5em;
|
|
padding: 0.5em;
|
|
min-width: 8em;
|
|
}
|
|
canvas {
|
|
width:100%;height:100%;
|
|
flex-grow: 1;
|
|
object-fit: contain;
|
|
}
|
|
</style>
|
|
<div id=gameContent class="content" style="display: none">
|
|
<canvas id=gameCanvas></canvas>
|
|
<div id=leaderboard>
|
|
<h1>$snake.leaderboard.header</h1>
|
|
<div id=leaderboardItems>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id=welcomeScreen class="content" style="display: none">
|
|
<img id=gameLogo src="/s/eiryxg/10030008/1bqcfwe/_/images/logos/light/jira-software.png">
|
|
<div id=welcomeMessage style="display: none"></div>
|
|
<button id=joinBtn>$snake.welcome.joinBtn</button>
|
|
<select id=languageSelector></select>
|
|
</div>
|
|
<script>
|
|
|
|
let token = null;
|
|
let game = null;
|
|
let shouldRedraw = false;
|
|
let ctx = gameCanvas.getContext('2d');
|
|
let cancelGame = null;
|
|
let seqs = null;
|
|
let sprintKeyDown = false;
|
|
let unusedKeys = null;
|
|
|
|
let language = null;
|
|
let languageStorageKey = 'jiraworm_se4s9uns_language';
|
|
|
|
for (let el of document.querySelectorAll('*')) {
|
|
if (el.childElementCount > 0) continue;
|
|
let tx = el.textContent.trim().match(/^\\$(.*)$/);
|
|
if (!tx) continue;
|
|
console.log('Translatable element: '+tx[1]);
|
|
el.updateTranslation = function() {
|
|
this.textContent = translate(tx[1]) ?? this.textContent;
|
|
}
|
|
}
|
|
|
|
function setLanguage(code) {
|
|
if (languageSelector.value != code)
|
|
languageSelector.value = code;
|
|
|
|
console.log('Setting language to '+code);
|
|
|
|
language = code;
|
|
|
|
for (let el of document.querySelectorAll('*')) {
|
|
if (!el.updateTranslation) continue;
|
|
el.updateTranslation();
|
|
}
|
|
}
|
|
|
|
languageSelector.onchange = function() {
|
|
setLanguage(this.value);
|
|
localStorage[languageStorageKey] = this.value;
|
|
}
|
|
|
|
let strings = {
|
|
en: {
|
|
snake: {
|
|
welcome: {
|
|
joinBtn: 'Become a worm',
|
|
takeover: 'The worm is controlled by another tab.',
|
|
gameover: 'Wriggled too hard... Your length: ${{0}}',
|
|
hint: {
|
|
keys: {
|
|
base: 'HINT: Use the ${{0}}.',
|
|
walk: 'arrow keys to move',
|
|
sprint: 'spacebar to sprint',
|
|
}
|
|
},
|
|
},
|
|
leaderboard: {
|
|
header: 'TOP worms',
|
|
},
|
|
},
|
|
language: { name: 'English' }
|
|
},
|
|
ru: {
|
|
snake: {
|
|
welcome: {
|
|
joinBtn: 'Стать червяком',
|
|
takeover: 'Червяк управляется другой вкладкой.',
|
|
gameover: 'Доползлись... Ваша длина: ${{0}}',
|
|
hint: {
|
|
keys: {
|
|
base: 'ПОДСКАЗКА: Используйте ${{0}}.',
|
|
walk: 'клавиши стрелок чтобы перемещаться',
|
|
sprint: 'пробел чтобы ускориться'
|
|
}
|
|
},
|
|
},
|
|
leaderboard: {
|
|
header: 'ТОП червей'
|
|
}
|
|
},
|
|
language: { name: 'Русский' }
|
|
}
|
|
};
|
|
|
|
(() => {
|
|
let code = 'en';
|
|
let languages = ['en', 'ru'];
|
|
|
|
for (let lang of languages) {
|
|
languageSelector.add(new Option(strings[lang].language.name, lang))
|
|
}
|
|
|
|
try {
|
|
for (let c of [(e => e!=null ? [e] : [])(localStorage[languageStorageKey]), navigator.languages]) {
|
|
c+='';
|
|
let ok = false;
|
|
for (a of languages)
|
|
if (c == a || c.match('^'+a+'-')) {
|
|
code = a;
|
|
ok=true;
|
|
break;
|
|
}
|
|
if (ok) break;
|
|
}
|
|
} catch(e) {
|
|
console.error('Failed to get navigator language', e);
|
|
}
|
|
|
|
setLanguage(code);
|
|
})();
|
|
|
|
function translate(id, ...args) {
|
|
let o = strings[language];
|
|
if (!o) return null;
|
|
for (let i of id.split('.')) {
|
|
if (!(i in o)) {
|
|
o = null; break;
|
|
}
|
|
o = o[i];
|
|
}
|
|
if (typeof o != 'string')
|
|
return null;
|
|
|
|
return o.replace(/\\${{[0-9][0-9]*}}/g, e => { e = e.match(/\\d\\d*/)[0]-0; return args[e]; });
|
|
}
|
|
|
|
function L(id, ...args) {
|
|
return translate(id, ...args) ?? '$'+id+JSON.stringify(args);
|
|
}
|
|
|
|
let DIRS = Array(4);
|
|
{
|
|
let [x,y] = [1, 0];
|
|
for (let i=0; i<4; i++) {
|
|
DIRS[i] = [x,y];
|
|
[x,y] = [-y,x];
|
|
}
|
|
}
|
|
Object.assign(DIRS, {
|
|
dirToId(x, y) {
|
|
return DIRS.map(e => e+'').indexOf(`${x},${y}`);
|
|
}
|
|
});
|
|
|
|
function sendCommand(...args) {
|
|
let url = new URL("snakeIPC", location);
|
|
url.searchParams.append('token', token);
|
|
|
|
fetch(url, {
|
|
method: "POST",
|
|
body: JSON.stringify([...args]),
|
|
headers: {
|
|
'Content-Type': "application/json"
|
|
}
|
|
});
|
|
}
|
|
|
|
function html(t) {
|
|
let el = document.createElement('div');
|
|
el.innerHTML=t;
|
|
return el.querySelector('*');
|
|
}
|
|
|
|
function markKeyUse(key) {
|
|
if (!unusedKeys) return;
|
|
delete unusedKeys[key];
|
|
}
|
|
|
|
window.onkeydown = function(ev) {
|
|
if (ev.code == 'Enter') {
|
|
tryJoinGame();
|
|
return false;
|
|
} else if (ev.code == 'Escape') {
|
|
if (cancelGame)
|
|
cancelGame();
|
|
return false;
|
|
} else if (ev.code == 'Space') {
|
|
markKeyUse('sprint');
|
|
if (!sprintKeyDown)
|
|
sendCommand({type:'sprint',sprint:true})
|
|
sprintKeyDown = true;
|
|
}
|
|
|
|
let dir = ({
|
|
ArrowUp: [0,-1],
|
|
ArrowDown: [0,1],
|
|
ArrowLeft: [-1,0],
|
|
ArrowRight: [1,0]
|
|
})[ev.code];
|
|
|
|
if (dir == null) return;
|
|
|
|
ev.preventDefault();
|
|
|
|
markKeyUse('walk');
|
|
sendCommand({type:'dir',dir:DIRS.dirToId(...dir)});
|
|
}
|
|
|
|
window.onkeyup = function(ev) {
|
|
if (ev.code == 'Space') {
|
|
sendCommand({type:'sprint',sprint:false})
|
|
sprintKeyDown = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function tryJoinGame() {
|
|
if (cancelGame) return;
|
|
|
|
let evs = await pollOne({ join_game: 1 }).catch(e => { console.error(e); return [{type:"nogamer"}] });
|
|
|
|
if (evs.find(e => e.type=='nogamer')) {
|
|
alert('Unable to join game.');
|
|
return;
|
|
}
|
|
|
|
welcomeScreen.setVisible(false);
|
|
welcomeScreen.setMessage();
|
|
|
|
for (let ev of evs)
|
|
handleEvent(ev);
|
|
|
|
gameLoop();
|
|
}
|
|
|
|
joinBtn.onclick = tryJoinGame;
|
|
|
|
welcomeScreen.setVisible = function(b) {
|
|
this.style = b ? '' : 'display: none';
|
|
gameContent.style = b ? 'display: none' : '';
|
|
}
|
|
|
|
welcomeScreen.setMessage = function(msg, ...args) {
|
|
if (typeof msg == 'function') {
|
|
welcomeMessage.updateTranslation = function() {
|
|
this.innerHTML = msg(...args);
|
|
}
|
|
welcomeMessage.updateTranslation();
|
|
} else {
|
|
welcomeMessage.innerHTML = msg || '';
|
|
delete welcomeMessage.updateTranslation;
|
|
}
|
|
welcomeMessage.style = msg ? '' : 'display: none';
|
|
}
|
|
|
|
async function main() {
|
|
genToken();
|
|
let evs = await pollOne();
|
|
|
|
if (evs.find(e => e.type=='nogamer')) {
|
|
welcomeScreen.setVisible(true);
|
|
return;
|
|
}
|
|
|
|
welcomeScreen.setVisible(false);
|
|
|
|
for (let ev of evs)
|
|
handleEvent(ev);
|
|
|
|
gameLoop();
|
|
}
|
|
|
|
Game = {
|
|
NULL: 0,
|
|
AIR: 1,
|
|
APPLE: 2,
|
|
GAMER: 64,
|
|
|
|
posToIdx(x, y) {
|
|
if (x < 0 || x >= this.width ) return -1;
|
|
if (y < 0 || y >= this.height) return -1;
|
|
return y*this.width+x;
|
|
},
|
|
|
|
idxToPos(i) {
|
|
return [(i%this.width)|0, (i/this.width)|0];
|
|
},
|
|
|
|
toString() {
|
|
let lines = [];
|
|
for (let y=0; y<this.height; y++) {
|
|
let line = [];
|
|
for (let x=0; x<this.width; x++) {
|
|
line.push(({
|
|
[Game.AIR]: '. ',
|
|
[Game.APPLE]: '<>',
|
|
[Game.GAMER]: '[]'
|
|
})[this.data[this.posToIdx(x,y)]]??'? ')
|
|
}
|
|
lines.push(line.join(''));
|
|
}
|
|
return lines.join('\\n');
|
|
}
|
|
};
|
|
|
|
function genToken() {
|
|
token = [1,2].map(e=>(Math.random()*(2**31)|0).toString(16).padStart(8,0))
|
|
.join('').slice(0,12);
|
|
seqs = new SeqCounter(32);
|
|
}
|
|
|
|
async function pollOne(opts, ropts) {
|
|
let url = new URL("snakeIPC", location);
|
|
opts = { token, ...(opts ?? {}) };
|
|
for (let [k, v] of Object.entries(opts)) {
|
|
url.searchParams.append(k, v);
|
|
}
|
|
let res = await fetch(url, {
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
},
|
|
...(ropts ?? {})
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(await res.text());
|
|
let data = await res.json();
|
|
return data;
|
|
}
|
|
|
|
function DataGenerator() {
|
|
this._gcs = [];
|
|
this._cond = null;
|
|
}
|
|
|
|
Object.assign(DataGenerator.prototype, {
|
|
async *[Symbol.asyncIterator]() {
|
|
while (1) {
|
|
let gencond = this._gcs.shift();
|
|
if (gencond)
|
|
gencond();
|
|
let val = await new Promise((ok) => {
|
|
this._cond = ok;
|
|
});
|
|
if (val == null) break;
|
|
yield val;
|
|
}
|
|
},
|
|
|
|
async push(msg) {
|
|
if (!this._cond) {
|
|
let gencond;
|
|
let genprom = new Promise((ok) => {
|
|
gencond = ok;
|
|
});
|
|
this._gcs.push(gencond);
|
|
await genprom;
|
|
}
|
|
this._cond(msg);
|
|
this._cond = null;
|
|
},
|
|
});
|
|
|
|
function SeqCounter(m) {
|
|
this._seq = 0;
|
|
this._m = m;
|
|
this._w = Array(m);
|
|
}
|
|
|
|
Object.assign(SeqCounter.prototype, {
|
|
inc() {
|
|
this._seq = (this._seq + 1) % this._m;
|
|
if (this._w[this._seq])
|
|
this._w[this._seq]();
|
|
},
|
|
|
|
async poll(seq, signal) {
|
|
if (signal)
|
|
signal.throwIfAborted();
|
|
|
|
seq = (seq%this._m+this._m)%this._m;
|
|
if (this._seq == seq) {
|
|
this.inc();
|
|
return;
|
|
}
|
|
|
|
if (this._w[seq])
|
|
throw new Error('duplicate seq');
|
|
|
|
await ((new Promise((res, rej) => {
|
|
this._w[seq] = () => { res(); delete this._w[seq]; };
|
|
if (signal)
|
|
signal.addEventListener('abort', () => rej(signal.reason), { once: true });
|
|
})));
|
|
|
|
this.inc();
|
|
},
|
|
});
|
|
|
|
async function *poll(opts) {
|
|
let aborter = new AbortController();
|
|
let signal = aborter.signal;
|
|
let data = new DataGenerator();
|
|
let err = null;
|
|
|
|
if (opts && opts.signal) {
|
|
opts.signal.addEventListener('abort', () => {
|
|
console.log('aborting polls');
|
|
aborter.abort(opts.signal.reason)
|
|
}, { once: true });
|
|
}
|
|
|
|
for (let i=0; i<3; i++)
|
|
(async () => {
|
|
while (1) {
|
|
try {
|
|
let evs = await pollOne({}, { signal });
|
|
if (evs.find(e => e.type=='takeover')) {
|
|
console.warn("TAKEOVER");
|
|
aborter.abort();
|
|
data.push(evs);
|
|
throw aborter.reason;
|
|
}
|
|
let seq = null;
|
|
evs = evs.filter(e => (e.type == 'seq' ? (seq=e, 0) : (1)));
|
|
if (seq) {
|
|
await seqs.poll(seq.i, signal);
|
|
}
|
|
data.push(evs);
|
|
} catch(e) {
|
|
err = { err: e };
|
|
data.push(null);
|
|
return;
|
|
}
|
|
}
|
|
})()
|
|
|
|
try {
|
|
for await (let el of data) {
|
|
yield el;
|
|
}
|
|
if (err) {
|
|
throw err.err;
|
|
}
|
|
} finally {
|
|
aborter.abort();
|
|
}
|
|
}
|
|
|
|
function handleEvent(ev) {
|
|
if (ev.type == "board") {
|
|
game = Object.setPrototypeOf({
|
|
width: ev.width,
|
|
height: ev.height,
|
|
data: ev.data,
|
|
gamers: Object.create(null)
|
|
}, Game)
|
|
shouldRedraw = true;
|
|
} else if (ev.type == "deltaboard") {
|
|
for (let [i,v] of ev.data)
|
|
game.data[i] = v;
|
|
shouldRedraw = true;
|
|
} else if (ev.type == "newplayer") {
|
|
if (ev.avatar) {
|
|
let img = new Image(1,1);
|
|
img.src = ev.avatar;
|
|
ev.avatar = img;
|
|
}
|
|
game.gamers[ev.id] = ev;
|
|
if (ev.self)
|
|
game.gamer = ev;
|
|
shouldRedraw = true;
|
|
} else if (ev.type == "moveplayer") {
|
|
let gamer = game.gamers[ev.id];
|
|
gamer.body.splice(gamer.body.length - ev.tail);
|
|
gamer.body.unshift(...ev.head);
|
|
shouldRedraw = true;
|
|
} else if (ev.type == "goneplayer") {
|
|
delete game.gamers[ev.id];
|
|
if (game.gamer && game.gamer.id == ev.id) {
|
|
welcomeScreen.setMessage(((gamerLength, unusedKeys) => _ => L('snake.welcome.gameover', gamerLength)+
|
|
(e => e.length ? `<br><br>`+L('snake.welcome.hint.keys.base', e.join(', ')) : e)(Object.keys(unusedKeys??{})
|
|
.map(e => (L(`snake.welcome.hint.keys.${e}`)))))(game.gamer.body.length, {...unusedKeys}));
|
|
delete game.gamer;
|
|
let canceller = cancelGame;
|
|
setTimeout(canceller, 2000);
|
|
}
|
|
shouldRedraw = true;
|
|
}
|
|
}
|
|
|
|
async function gameLoop() {
|
|
unusedKeys = { walk: true, sprint: true };
|
|
let aborter = new AbortController();
|
|
let signal = aborter.signal;
|
|
|
|
function draw() {
|
|
if (signal.aborted)
|
|
return;
|
|
|
|
{
|
|
let { width: w, height: h } = gameCanvas.getBoundingClientRect();
|
|
if (h < w)
|
|
w = Math.round(h * (game.width / game.height));
|
|
else
|
|
h = Math.round(w * (game.height / game.width));
|
|
if (gameCanvas.width != w || gameCanvas.height != h) {
|
|
[gameCanvas.width, gameCanvas.height] = [w, h];
|
|
shouldRedraw = true;
|
|
}
|
|
}
|
|
|
|
if (shouldRedraw) {
|
|
shouldRedraw = false;
|
|
|
|
ctx.reset();
|
|
ctx.scale(
|
|
gameCanvas.width / game.width,
|
|
gameCanvas.height / game.height
|
|
);
|
|
|
|
for (let y=0; y<game.height; y++)
|
|
for (let x=0; x<game.width; x++) {
|
|
let c = game.data[game.posToIdx(x,y)];
|
|
if (c == Game.AIR) {
|
|
ctx.fillStyle = '#aeaeae';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x+0.5, y+0.5, 0.1, 0.1, 0, 0, 2*Math.PI);
|
|
ctx.fill();
|
|
} else if (c == Game.APPLE) {
|
|
ctx.fillStyle = '#fa0000';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x+0.5, y+0.5, 0.45, 0.45, 0, 0, 2*Math.PI);
|
|
ctx.fill();
|
|
} else if (c == Game.GAMER) {
|
|
/*
|
|
ctx.fillStyle = '#00a000';
|
|
ctx.beginPath();
|
|
ctx.ellipse(x+0.5, y+0.5, 0.5, 0.5, 0, 0, 2*Math.PI);
|
|
ctx.fill();
|
|
*/
|
|
}
|
|
}
|
|
|
|
leaderboardItems.textContent='';
|
|
|
|
for (let gamer of Object.values(game.gamers)) {
|
|
if (gamer.body.length > 0) {
|
|
ctx.fillStyle = '#00a000';
|
|
ctx.beginPath();
|
|
for (let i=gamer.body.length-1; i>0; i--) {
|
|
let [x1,y1] = game.idxToPos(gamer.body[i])
|
|
let [x2,y2] = game.idxToPos(gamer.body[i-1]);
|
|
[x1,x2] = x2 > x1 ? [x1,x2] : [x2,x1];
|
|
[y1,y2] = y2 > y1 ? [y1,y2] : [y2,y1];
|
|
let r = 0.4;
|
|
ctx.roundRect(x1+0.5-r, y1+0.5-r, x2-x1+r*2, y2-y1+r*2, r);
|
|
}
|
|
ctx.fill();
|
|
|
|
let [x,y] = game.idxToPos(gamer.body[0]);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.ellipse(x+0.5, y+0.5, 0.5, 0.5, 0, 0, 2*Math.PI);
|
|
ctx.clip();
|
|
ctx.drawImage(gamer.avatar, x, y, 1, 1);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
for (let gamer of Object.values(game.gamers).sort((a,b) => b.body.length - a.body.length)) {
|
|
let item = html(`
|
|
<div class=lbItem>
|
|
<div class="lbName${gamer.self ? ' lbSelfName' : ''}">
|
|
<img src="">
|
|
name
|
|
</div>
|
|
<div class=lbScore>1</div>
|
|
</div>
|
|
`);
|
|
|
|
let pfp = item.querySelector('img');
|
|
if (gamer.avatar)
|
|
pfp.src = gamer.avatar.src;
|
|
pfp.nextSibling.data = ' '+gamer.name;
|
|
item.querySelector('.lbScore').textContent = gamer.body.length;
|
|
|
|
leaderboardItems.append(item);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(draw);
|
|
}
|
|
|
|
requestAnimationFrame(draw);
|
|
|
|
cancelGame = () => {
|
|
console.log('Cancelling game');
|
|
aborter.abort();
|
|
}
|
|
|
|
try {
|
|
while (1) {
|
|
try {
|
|
for await (let evs of poll({ signal })) {
|
|
for (let ev of evs) {
|
|
if (ev.type == "takeover")
|
|
welcomeScreen.setMessage(L, 'snake.welcome.takeover');
|
|
if (ev.type == "nogamer" || ev.type == "takeover")
|
|
return;
|
|
try {
|
|
handleEvent(ev);
|
|
} catch(e) {
|
|
console.error('GAME CRASHED', e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {
|
|
console.error('POLL ERROR', e);
|
|
if (signal.aborted)
|
|
return;
|
|
genToken();
|
|
}
|
|
}
|
|
} finally {
|
|
cancelGame = null;
|
|
game = null;
|
|
shouldRedraw = true;
|
|
welcomeScreen.setVisible(true);
|
|
genToken();
|
|
aborter.abort();
|
|
}
|
|
}
|
|
|
|
main();
|
|
</script>
|
|
'''
|
|
|
|
snake(
|
|
httpMethod: "GET", groups: gamerGroups
|
|
) { MultivaluedMap queryParams, HttpServletRequest req ->
|
|
return Response.ok(webPage, 'text/html').build()
|
|
}
|
|
|
|
snakeIPC(
|
|
httpMethod: "GET", groups: gamerGroups
|
|
) { MultivaluedMap queryParams, HttpServletRequest req ->
|
|
String token = queryParams.getFirst("token");
|
|
if (token == null)
|
|
return Response.status(400).entity("Missing token").type('text/plain').build();
|
|
boolean joinGame = queryParams.getFirst("join_game") != null;
|
|
SnakeAccessor.logger = log;
|
|
def game = SnakeAccessor.getGame();
|
|
def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager);
|
|
def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue());
|
|
return game.poll(user, token, joinGame);
|
|
}
|
|
|
|
snakeError(
|
|
httpMethod: "GET", groups: gamerGroups
|
|
) { MultivaluedMap queryParams, HttpServletRequest req ->
|
|
return Response.ok(SnakeGame.lastError.toString(),'text/plain').build()
|
|
}
|
|
|
|
snakeIPC(
|
|
httpMethod: "POST", groups: gamerGroups
|
|
) { MultivaluedMap queryParams, String body, HttpServletRequest req ->
|
|
String token = queryParams.getFirst("token");
|
|
if (token == null)
|
|
return Response.status(400).entity("Missing token").type('text/plain').build();
|
|
SnakeAccessor.logger = log;
|
|
def game = SnakeAccessor.getGame();
|
|
def userManager = ComponentAccessor.getOSGiComponentInstanceOfType(UserManager);
|
|
def user = ComponentAccessor.userManager.getUserByKey(userManager.getRemoteUserKey(req).getStringValue());
|
|
List action = (new JsonSlurper()).parseText(body);
|
|
return game.post(user, token, action);
|
|
}
|
|
|