init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.vscode
|
||||
0
Dockerfile
Normal file
0
Dockerfile
Normal file
19
back/__init__.py
Normal file
19
back/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter
|
||||
from socketio import AsyncServer
|
||||
|
||||
from .socket import Socket
|
||||
from .game import game_router
|
||||
from .create_room import create_room_router
|
||||
|
||||
|
||||
def get_backend() -> tuple[APIRouter, AsyncServer]:
|
||||
router = APIRouter(prefix="/api")
|
||||
sio = AsyncServer(async_mode="asgi")
|
||||
server = Socket(sio)
|
||||
|
||||
@sio.on("connect") # type: ignore
|
||||
async def sio_connect(sid, _):
|
||||
socket = server.to(sid)
|
||||
await create_room_router(server, socket)
|
||||
|
||||
return router, sio
|
||||
108
back/create_room.py
Normal file
108
back/create_room.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from random import randint
|
||||
|
||||
from .socket import Socket
|
||||
from .game import game_router
|
||||
|
||||
|
||||
async def create_room_router(server: Socket, socket: Socket):
|
||||
async def check_nickname(nickname: str) -> str | None:
|
||||
nickname = nickname.strip()
|
||||
if len(nickname) < 4:
|
||||
await socket.emit("room-error", "Никнейм должен быть больше 4 букв!")
|
||||
return
|
||||
|
||||
return nickname
|
||||
|
||||
async def check_width(width: str) -> int | None:
|
||||
width = width.strip()
|
||||
if not width.isnumeric():
|
||||
await socket.emit("room-error", "Ширина должна быть числом!")
|
||||
return
|
||||
|
||||
width_i = int(width)
|
||||
|
||||
if width_i < 5 or width_i > 25:
|
||||
await socket.emit("room-error", "Ширина может быть числом от 5 до 25!")
|
||||
return
|
||||
|
||||
return width_i
|
||||
|
||||
async def check_height(height: str) -> int | None:
|
||||
height = height.strip()
|
||||
if not height.isnumeric():
|
||||
await socket.emit("room-error", "Высота должна быть числом!")
|
||||
return
|
||||
|
||||
height_i = int(height)
|
||||
|
||||
if height_i < 5 or height_i > 15:
|
||||
await socket.emit("room-error", "Высота может быть числом от 5 до 15!")
|
||||
return
|
||||
|
||||
return height_i
|
||||
|
||||
async def check_bombs(bombs: str, width: int, height: int) -> int | None:
|
||||
bombs = bombs.strip()
|
||||
if not bombs.isnumeric():
|
||||
await socket.emit("room-error", "Кол-во бомб должно быть числом!")
|
||||
return
|
||||
|
||||
bombs_i = int(bombs)
|
||||
bombs_max = (width * height * 2) // 5
|
||||
|
||||
if bombs_i < 5 or bombs_i > bombs_max:
|
||||
await socket.emit(
|
||||
"room-error", f"Кол-во бомб может быть от 5 до {bombs_max}!"
|
||||
)
|
||||
return
|
||||
|
||||
return bombs_i
|
||||
|
||||
async def check_room(room_id: str) -> str | None:
|
||||
room_id = room_id.strip()
|
||||
|
||||
if not room_id.isnumeric() or len(room_id) != 6:
|
||||
await socket.emit("room-error", "Код должен быть из 6 цифр!")
|
||||
return
|
||||
|
||||
if not server.room_exists(room_id):
|
||||
await socket.emit("room-error", "Такой комнаты не существует!")
|
||||
return
|
||||
|
||||
return room_id
|
||||
|
||||
@socket.on("create-room")
|
||||
async def create_room(_nickname: str, _width: str, _height: str, _bombs: str):
|
||||
nickname = await check_nickname(_nickname)
|
||||
if nickname is None:
|
||||
return
|
||||
|
||||
width = await check_width(_width)
|
||||
if width is None:
|
||||
return
|
||||
|
||||
height = await check_height(_height)
|
||||
if height is None:
|
||||
return
|
||||
|
||||
bombs = await check_bombs(_bombs, width, height)
|
||||
if bombs is None:
|
||||
return
|
||||
|
||||
room_id = str(randint(100000, 999999))
|
||||
|
||||
room = await socket.join(room_id)
|
||||
await game_router(server, socket, room, nickname, width, height, bombs)
|
||||
|
||||
@socket.on("join-room")
|
||||
async def join_room(_nickname: str, _room_id: str):
|
||||
nickname = await check_nickname(_nickname)
|
||||
if nickname is None:
|
||||
return
|
||||
|
||||
room_id = await check_room(_room_id)
|
||||
if room_id is None:
|
||||
return
|
||||
|
||||
room = await socket.join(room_id)
|
||||
await game_router(server, socket, room, nickname)
|
||||
231
back/game.py
Normal file
231
back/game.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from random import randint
|
||||
|
||||
from .socket import Socket
|
||||
|
||||
|
||||
class CellState:
|
||||
OPEN = 0
|
||||
CLOSED = 1
|
||||
FLAGGED = 2
|
||||
MARKED = 3
|
||||
NOT_A_FIELD = 4
|
||||
|
||||
|
||||
STR_CLOSED = ""
|
||||
STR_BOMB = "💣"
|
||||
STR_FLAGGED = "🚩"
|
||||
STR_MARKED = "❓"
|
||||
STR_NUMBERS = [" ", *(str(i) for i in range(1, 9))]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameData:
|
||||
width: int
|
||||
height: int
|
||||
bombs: int
|
||||
game_over: bool = False
|
||||
win: bool = True
|
||||
bombs_in_field: list[tuple[int, int]] = field(default_factory=list)
|
||||
field: list[list[int]] = field(default_factory=list)
|
||||
|
||||
def generate(self) -> "GameData":
|
||||
self.game_over = False
|
||||
self.win
|
||||
|
||||
self.field.clear()
|
||||
self.bombs_in_field.clear()
|
||||
|
||||
for _ in range(self.width):
|
||||
self.field.append([CellState.CLOSED for _ in range(self.height)])
|
||||
|
||||
return self
|
||||
|
||||
def generate_bombs(self, ex: int, ey: int):
|
||||
while len(self.bombs_in_field) < self.bombs:
|
||||
x = randint(0, self.width - 1)
|
||||
y = randint(0, self.height - 1)
|
||||
if x == ex and y == ey:
|
||||
continue
|
||||
|
||||
skip = False
|
||||
for x1, y1 in self.bombs_in_field:
|
||||
if x1 == x and y1 == y:
|
||||
skip = True
|
||||
break
|
||||
|
||||
if skip:
|
||||
continue
|
||||
|
||||
self.bombs_in_field.append((x, y))
|
||||
|
||||
def is_bomb(self, x: int, y: int) -> bool:
|
||||
for x1, y1 in self.bombs_in_field:
|
||||
if x1 == x and y1 == y:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_number(self, x: int, y: int) -> int:
|
||||
bombs = 0
|
||||
|
||||
for i in range(-1, 2):
|
||||
for j in range(-1, 2):
|
||||
if i == 0 and j == 0:
|
||||
continue
|
||||
if self.is_bomb(x + i, y + j):
|
||||
bombs += 1
|
||||
|
||||
return bombs
|
||||
|
||||
def is_win(self) -> bool:
|
||||
if len(self.bombs_in_field) == 0:
|
||||
return False
|
||||
|
||||
must_be_open = self.width * self.height - self.bombs
|
||||
flaged_cells = 0
|
||||
open_cells = 0
|
||||
|
||||
for x in range(self.width):
|
||||
for y in range(self.height):
|
||||
if self.field[x][y] == CellState.FLAGGED and self.is_bomb(x, y):
|
||||
flaged_cells += 1
|
||||
if self.field[x][y] == CellState.OPEN:
|
||||
open_cells += 1
|
||||
|
||||
if open_cells == must_be_open or self.bombs == flaged_cells:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def game_router(
|
||||
server: Socket,
|
||||
socket: Socket,
|
||||
room: Socket,
|
||||
nickname: str,
|
||||
width=0,
|
||||
height=0,
|
||||
bombs=0,
|
||||
):
|
||||
async def emit_all_in_room(event: str, *args):
|
||||
await socket.emit(event, *args)
|
||||
await room.emit(event, *args)
|
||||
|
||||
async def update_field():
|
||||
str_field = []
|
||||
flags = 0
|
||||
|
||||
for x, row in enumerate(data.field):
|
||||
str_row = []
|
||||
for y, cell in enumerate(row):
|
||||
if (
|
||||
data.game_over
|
||||
and (not data.win or cell != CellState.FLAGGED)
|
||||
and data.is_bomb(x, y)
|
||||
):
|
||||
str_row.append(STR_BOMB)
|
||||
else:
|
||||
match cell:
|
||||
case CellState.CLOSED:
|
||||
str_row.append(STR_CLOSED)
|
||||
case CellState.FLAGGED:
|
||||
str_row.append(STR_FLAGGED)
|
||||
flags += 1
|
||||
case CellState.MARKED:
|
||||
str_row.append(STR_MARKED)
|
||||
case _:
|
||||
str_row.append(STR_NUMBERS[data.get_number(x, y)])
|
||||
|
||||
str_field.append(str_row)
|
||||
|
||||
if data.game_over:
|
||||
text = "Победа!" if data.win else "Поражение!"
|
||||
else:
|
||||
text = ""
|
||||
await emit_all_in_room("update-field", str_field, flags, data.bombs, text)
|
||||
|
||||
is_host = width > 0 and height > 0 and bombs > 0
|
||||
room_id = room.sid
|
||||
data = room.room_data(lambda: GameData(width, height, bombs).generate())
|
||||
|
||||
@socket.on("chat")
|
||||
async def chat(msg: str):
|
||||
await room.emit("chat", nickname, msg)
|
||||
|
||||
@socket.on("disconnect")
|
||||
async def disconnect(_reason):
|
||||
if is_host:
|
||||
await room.emit("reload-page")
|
||||
else:
|
||||
await room.emit("other-disconnect", socket.sid, nickname)
|
||||
|
||||
@socket.on("about-me")
|
||||
async def about_me():
|
||||
await room.emit("about-me", socket.sid, nickname + (" 👑" if is_host else ""))
|
||||
|
||||
@socket.on("cursor")
|
||||
async def cursor(x: float, y: float):
|
||||
await room.emit("cursor", socket.sid, nickname, x, y)
|
||||
|
||||
@socket.on("restart")
|
||||
async def restart():
|
||||
data.generate()
|
||||
await update_field()
|
||||
|
||||
def openCells(x: int, y: int):
|
||||
if (
|
||||
x < 0
|
||||
or y < 0
|
||||
or x >= data.width
|
||||
or y >= data.height
|
||||
or data.field[x][y] == CellState.OPEN
|
||||
):
|
||||
return
|
||||
|
||||
data.field[x][y] = CellState.OPEN
|
||||
|
||||
if data.get_number(x, y) == 0:
|
||||
for i in range(-1, 2):
|
||||
for j in range(-1, 2):
|
||||
openCells(x + i, y + j)
|
||||
|
||||
@socket.on("click")
|
||||
async def click(left: bool, x: int, y: int):
|
||||
if data.game_over or x < 0 or y < 0 or x >= data.width or y >= data.height:
|
||||
return
|
||||
|
||||
cell = data.field[x][y]
|
||||
|
||||
if cell == CellState.OPEN:
|
||||
return
|
||||
|
||||
if left:
|
||||
if cell != CellState.CLOSED:
|
||||
return
|
||||
if data.is_bomb(x, y):
|
||||
data.game_over = True
|
||||
data.field[x][y] = CellState.OPEN
|
||||
else:
|
||||
if len(data.bombs_in_field) == 0:
|
||||
data.generate_bombs(x, y)
|
||||
openCells(x, y)
|
||||
data.win = data.is_win()
|
||||
if data.win:
|
||||
data.game_over = True
|
||||
|
||||
else:
|
||||
cell += 1
|
||||
if cell == CellState.NOT_A_FIELD:
|
||||
cell = CellState.CLOSED
|
||||
data.field[x][y] = cell
|
||||
if cell == CellState.FLAGGED:
|
||||
data.win = data.is_win()
|
||||
if data.win:
|
||||
data.game_over = True
|
||||
|
||||
await update_field()
|
||||
|
||||
await socket.emit("room-join", room_id)
|
||||
await room.emit("other-join", socket.sid, nickname)
|
||||
await update_field()
|
||||
123
back/socket.py
Normal file
123
back/socket.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, TypeVar
|
||||
from socketio import AsyncServer
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SocketMemory:
|
||||
callbacks: dict[str, dict[str, Callable]] = field(default_factory=dict)
|
||||
room_data: dict[str, tuple[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Socket:
|
||||
sio: AsyncServer
|
||||
from_sid: str | None
|
||||
sid: str | None
|
||||
mem: SocketMemory
|
||||
|
||||
def __init__(self, sio: AsyncServer, mem: SocketMemory | None = None) -> None:
|
||||
self.sio = sio
|
||||
self.from_sid = None
|
||||
self.sid = None
|
||||
if mem is None:
|
||||
self.mem = SocketMemory()
|
||||
self.on("disconnect")(lambda: None)
|
||||
else:
|
||||
self.mem = mem
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.sid is None:
|
||||
return "Server Socket"
|
||||
elif self.from_sid is None:
|
||||
return "Client Socket {id: " + self.sid + "}"
|
||||
else:
|
||||
return "Room Socket {id: " + self.sid + ", room_id: " + self.from_sid + "}"
|
||||
|
||||
def clear(self, sid: str) -> None:
|
||||
for event in self.mem.callbacks:
|
||||
if sid in self.mem.callbacks[event]:
|
||||
self.mem.callbacks[event].pop(sid)
|
||||
|
||||
for room in tuple(self.mem.room_data.keys()):
|
||||
owner = self.mem.room_data[room][0]
|
||||
if owner == sid:
|
||||
self.mem.room_data.pop(room)
|
||||
|
||||
def to(self, sid: str) -> "Socket":
|
||||
socket = Socket(self.sio, self.mem)
|
||||
socket.from_sid = self.sid
|
||||
socket.sid = sid
|
||||
|
||||
return socket
|
||||
|
||||
def on(self, event: str):
|
||||
def wrapper(f: Callable):
|
||||
if event not in self.mem.callbacks:
|
||||
self.mem.callbacks[event] = {}
|
||||
|
||||
@self.sio.on(event) # type: ignore
|
||||
async def handler(sid: str, *args):
|
||||
if sid in self.mem.callbacks[event]:
|
||||
await self.mem.callbacks[event][sid](*args)
|
||||
|
||||
if event == "disconnect":
|
||||
self.clear(sid)
|
||||
|
||||
if self.sid is not None:
|
||||
|
||||
async def wrapper_inner(*args):
|
||||
try:
|
||||
await f(*args)
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
|
||||
self.mem.callbacks[event][self.sid] = wrapper_inner
|
||||
|
||||
return f
|
||||
|
||||
return wrapper
|
||||
|
||||
async def emit(self, event: str, *args):
|
||||
if self.from_sid is None:
|
||||
await self.sio.emit(event, args, to=self.sid)
|
||||
else:
|
||||
await self.sio.emit(event, args, room=self.sid, skip_sid=self.from_sid)
|
||||
|
||||
async def join(self, room: str) -> "Socket":
|
||||
await self.sio.enter_room(self.sid, room)
|
||||
|
||||
return self.to(room)
|
||||
|
||||
async def leave(self, room: str):
|
||||
await self.sio.leave_room(self.sid, room)
|
||||
|
||||
def room_exists(self, room: str):
|
||||
rooms = self.sio.manager.rooms.get("/", {})
|
||||
return room in rooms and len(rooms[room]) > 0
|
||||
|
||||
def room_data(self, f: Callable[[], T]) -> T:
|
||||
if self.from_sid is None or self.sid is None:
|
||||
raise Exception(f"{self} cannot use room data")
|
||||
if self.sid in self.mem.room_data:
|
||||
data = self.mem.room_data[self.sid][1]
|
||||
return data
|
||||
else:
|
||||
data = f()
|
||||
self.mem.room_data[self.sid] = self.from_sid, data
|
||||
return data
|
||||
|
||||
|
||||
async def connect(sio: AsyncServer, sid: str):
|
||||
server = Socket(sio)
|
||||
socket = server.to(sid)
|
||||
|
||||
print(f"Client #{sid} connected")
|
||||
|
||||
await socket.emit("msg", "Test", 123, [1, True, None])
|
||||
|
||||
@socket.on("msg")
|
||||
async def test(s: str, n: int, l: list):
|
||||
print(s, n * 10, l[0])
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
main:
|
||||
image: express-reverse-proxy:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tss-net
|
||||
networks:
|
||||
tss-net:
|
||||
external: true
|
||||
16
front/__init__.py
Normal file
16
front/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sys import prefix
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
|
||||
def get_frontend() -> APIRouter:
|
||||
router = APIRouter(prefix="")
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
with open("./static/index.html", "r") as file:
|
||||
content = file.read()
|
||||
return content
|
||||
|
||||
return router
|
||||
23
main.py
Normal file
23
main.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from socketio import ASGIApp
|
||||
|
||||
import back
|
||||
import front
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
router, sio = back.get_backend()
|
||||
app.include_router(router)
|
||||
app.include_router(front.get_frontend())
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
app.mount("/", ASGIApp(sio))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
python-socketio
|
||||
1
start.sh
Executable file
1
start.sh
Executable file
@@ -0,0 +1 @@
|
||||
python3 -m uvicorn main:app --host 0.0.0.0 --reload
|
||||
1
static/colorpicker.iife.min.js
vendored
Normal file
1
static/colorpicker.iife.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/colorpicker.min.css
vendored
Normal file
1
static/colorpicker.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
226
static/game.js
Normal file
226
static/game.js
Normal file
@@ -0,0 +1,226 @@
|
||||
// CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
|
||||
// if (w < 2 * r) r = w / 2;
|
||||
// if (h < 2 * r) r = h / 2;
|
||||
// this.beginPath();
|
||||
// this.moveTo(x+r, y);
|
||||
// this.arcTo(x+w, y, x+w, y+h, r);
|
||||
// this.arcTo(x+w, y+h, x, y+h, r);
|
||||
// this.arcTo(x, y+h, x, y, r);
|
||||
// this.arcTo(x, y, x+w, y, r);
|
||||
// this.closePath();
|
||||
// return this;
|
||||
// }
|
||||
|
||||
function startGame() {
|
||||
const canvas = document.querySelector("canvas");
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.font = "24px Montserrat"
|
||||
ctx.imageSmoothingEnabled = true
|
||||
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
|
||||
|
||||
// Adjust for potential canvas scaling (e.g. high DPI)
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
socket.emit("cursor", x * scaleX, y * scaleY);
|
||||
})
|
||||
|
||||
canvas.addEventListener('click', e => click(true, e, canvas))
|
||||
canvas.addEventListener('contextmenu', e => click(false, e, canvas))
|
||||
|
||||
gameLoop(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {boolean} left
|
||||
* @param {PointerEvent} e
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
function click(left, e, canvas) {
|
||||
e.preventDefault()
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
|
||||
|
||||
// Adjust for potential canvas scaling (e.g. high DPI)
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
socket.emit("click", left, Math.floor((x * scaleX - offsetX()) / CELL_SIZE), Math.floor((y * scaleY - offsetY()) / CELL_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
function gameLoop(ctx) {
|
||||
ctx.save()
|
||||
ctx.clearRect(0, 0, 800, 600)
|
||||
|
||||
|
||||
for (let x = 0; x < data.field.length; x++)
|
||||
for (let y = 0; y < data.field[x].length; y++)
|
||||
drawCell(ctx, x, y)
|
||||
|
||||
const x = 400
|
||||
const y = 600 - (600 - (CELL_SIZE * MAX_HEIGTH)) * 0.5
|
||||
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = getColor('fg')
|
||||
|
||||
const w = data.field.length
|
||||
const h = w && data.field[0].length
|
||||
ctx.fillText(`${data.text} 🚩 ${data.flags} 💣 ${data.bombs} Поле ${w}x${h}`.trim(), x, y)
|
||||
|
||||
for (const sid of Object.keys(data.cursors))
|
||||
drawCursor(ctx, sid)
|
||||
|
||||
|
||||
ctx.restore();
|
||||
window.requestAnimationFrame(() => gameLoop(ctx))
|
||||
}
|
||||
|
||||
const HALF_CELL_SIZE = 16
|
||||
const CELL_SIZE = HALF_CELL_SIZE * 2
|
||||
const MAX_WIDTH = 25
|
||||
const MAX_HEIGTH = 15
|
||||
|
||||
function offsetX() {
|
||||
return (MAX_WIDTH - data.field.length) * HALF_CELL_SIZE
|
||||
}
|
||||
function offsetY() {
|
||||
return (MAX_HEIGTH - (data.field.length && data.field[0].length)) * HALF_CELL_SIZE
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} cx
|
||||
* @param {number} cy
|
||||
*/
|
||||
function drawCell(ctx, cx, cy) {
|
||||
let cell = data.field[cx][cy]
|
||||
switch (cell) {
|
||||
case "":
|
||||
case "🚩":
|
||||
case "❓":
|
||||
ctx.fillStyle = getColor("bgCellClosed")
|
||||
break;
|
||||
case " ":
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5":
|
||||
case "6":
|
||||
case "7":
|
||||
case "8":
|
||||
ctx.fillStyle = getColor("bgCellOpen")
|
||||
break
|
||||
default:
|
||||
ctx.fillStyle = getColor("bgCellBomb")
|
||||
break
|
||||
}
|
||||
ctx.strokeStyle = getColor("bg")
|
||||
ctx.lineWidth = 4
|
||||
|
||||
const x = cx * CELL_SIZE + offsetX();
|
||||
const y = cy * CELL_SIZE + offsetY();
|
||||
ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE)
|
||||
ctx.strokeRect(x, y, CELL_SIZE, CELL_SIZE)
|
||||
switch (cell) {
|
||||
case "":
|
||||
case " ":
|
||||
return
|
||||
case "1":
|
||||
case "2":
|
||||
case "3":
|
||||
case "4":
|
||||
case "5":
|
||||
case "6":
|
||||
case "7":
|
||||
case "8":
|
||||
ctx.fillStyle = getColor('fgCell' + cell)
|
||||
break
|
||||
default:
|
||||
ctx.fillStyle = "white"
|
||||
break
|
||||
}
|
||||
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(cell, x + HALF_CELL_SIZE, y + HALF_CELL_SIZE)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {string} sid
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
function drawCursor(ctx, sid, w = 15, h = 20) {
|
||||
const { x, y, nickname } = data.cursors[sid];
|
||||
|
||||
const a = sid.charCodeAt(0) % 256;
|
||||
const b = sid.charCodeAt(1) % 256;
|
||||
const c = sid.charCodeAt(2) % 3;
|
||||
|
||||
const aStr = a < 16 ? "0" + a.toString(16) : a.toString(16);
|
||||
const bStr = b < 16 ? "0" + b.toString(16) : b.toString(16);
|
||||
|
||||
let color = "#";
|
||||
switch (c) {
|
||||
case 0:
|
||||
color += "ff" + aStr + bStr;
|
||||
break;
|
||||
case 1:
|
||||
color += aStr + "ff" + bStr;
|
||||
break;
|
||||
default:
|
||||
color += aStr + bStr + "ff";
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x + w, y + h * 0.6);
|
||||
ctx.lineTo(x + w * 0.6, y + h * 0.5);
|
||||
ctx.lineTo(x + w, y + h * 0.9);
|
||||
ctx.lineTo(x + w * 0.9, y + h);
|
||||
ctx.lineTo(x + w * 0.5, y + h * 0.6);
|
||||
ctx.lineTo(x + w * 0.6, y + h);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "black"
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke();
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = "#00000077";
|
||||
const { width: tw, ideographicBaseline: th } = ctx.measureText(nickname)
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x + w * 2, y, tw + w * 2, Math.abs(th), [w])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.fillText(nickname, x + w * 3, y)
|
||||
}
|
||||
166
static/index.html
Normal file
166
static/index.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Сапер мультиплеер</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/colorpicker.min.css" />
|
||||
<script src="/static/socket.io.js"></script>
|
||||
<script src="/static/colorpicker.iife.min.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="content">
|
||||
<div class="block" id="create-room" data-block="create-room">
|
||||
<div>
|
||||
<h3>Ваш никнейм</h3>
|
||||
</div>
|
||||
<div>
|
||||
<input id="nickname" type="text" placeholder="Введите никнейм">
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<div class="block">
|
||||
<div>
|
||||
<h3>Присоединиться</h3>
|
||||
</div>
|
||||
<div>
|
||||
<input id="room-id" type="text" placeholder="Код">
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="join()">Присоединиться</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h3>Новая комната</h3>
|
||||
<div>
|
||||
<input id="width" type="number" placeholder="25" min="5" max="25" value="25">
|
||||
<b>X</b>
|
||||
<input id="height" type="number" placeholder="15" min="5" max="15" value="15">
|
||||
<div></div>
|
||||
<b>Бомб:</b>
|
||||
<input id="bombs" type="number" placeholder="15" min="5" max="150" value="100">
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="create()">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div>
|
||||
<h3>Основные цвета</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg"></button>
|
||||
<button id="color-bg-2"></button>
|
||||
<button id="color-fg"></button>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<h3>Цвета чата</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div class="block">
|
||||
<div>
|
||||
<h3>Уведомления</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-chat-service"></button>
|
||||
<button id="color-fg-chat-service"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
|
||||
<div>
|
||||
<h3>Исходящие</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-chat-self"></button>
|
||||
<button id="color-fg-chat-self"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<div>
|
||||
<h3>Входящие</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-chat-other"></button>
|
||||
<button id="color-fg-chat-other"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<h3>Цвета клеток</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div class="block">
|
||||
<div>
|
||||
<h3>Фон открытой</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-cell-open"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
|
||||
<div>
|
||||
<h3>Фон закрытой</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-cell-closed"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<div>
|
||||
<h3>Фон с бомбой</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-bg-cell-bomb"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Цвета цифер</h3>
|
||||
</div>
|
||||
<div>
|
||||
<button id="color-fg-cell-1"></button>
|
||||
<button id="color-fg-cell-2"></button>
|
||||
<button id="color-fg-cell-3"></button>
|
||||
<button id="color-fg-cell-4"></button>
|
||||
<button id="color-fg-cell-5"></button>
|
||||
<button id="color-fg-cell-6"></button>
|
||||
<button id="color-fg-cell-7"></button>
|
||||
<button id="color-fg-cell-8"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="game" data-block="game" style="display: none;">
|
||||
<canvas id="game-canvas" width="800" height="600"></canvas>
|
||||
<div id="game-tools">
|
||||
<div id="chat-and-users">
|
||||
<div id="chat"></div>
|
||||
<div id="users"></div>
|
||||
</div>
|
||||
<div id="buttons">
|
||||
<input id="chat-msg" placeholder="Сообщение..." type="text">
|
||||
<button onclick="restart()">Перезапустить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Сапер от <a href="https://t.me/fasterino" target="_blank">@fasterino</a> и <a
|
||||
href="http://t.me/synthoria_what" target="_blank">@synthoria_what</a>
|
||||
</footer>
|
||||
|
||||
<script src="/static/main.js"></script>
|
||||
<script src="/static/game.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
208
static/main.js
Normal file
208
static/main.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const socket = io("/");
|
||||
|
||||
const data = { is_host: false, cursors: {}, field: [], flags: 0, bombs: 0, text: "" }
|
||||
|
||||
/** @returns {string} */
|
||||
function getNickname() {
|
||||
return document.getElementById("nickname").value
|
||||
}
|
||||
/** @returns {string} */
|
||||
function getRoomId() {
|
||||
return document.getElementById("room-id").value
|
||||
}
|
||||
/** @returns {string} */
|
||||
function getWidth() {
|
||||
return document.getElementById("width").value
|
||||
}
|
||||
/** @returns {string} */
|
||||
function getHeight() {
|
||||
return document.getElementById("height").value
|
||||
}
|
||||
/** @returns {string} */
|
||||
function getBombs() {
|
||||
return document.getElementById("bombs").value
|
||||
}
|
||||
|
||||
function create() {
|
||||
data.is_host = true;
|
||||
socket.emit("create-room", getNickname(), getWidth(), getHeight(), getBombs())
|
||||
}
|
||||
|
||||
function join() {
|
||||
data.is_host = false;
|
||||
socket.emit("join-room", getNickname(), getRoomId())
|
||||
}
|
||||
|
||||
function restart() {
|
||||
socket.emit("restart")
|
||||
}
|
||||
|
||||
/**
|
||||
* @argument {string} block
|
||||
*/
|
||||
function show(block) {
|
||||
document.querySelectorAll('[data-block]').forEach(x => x.style.display = 'none');
|
||||
document.querySelectorAll('[data-block="' + block + '"]').forEach(x => x.style.display = '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @argument {string} msg
|
||||
*/
|
||||
function roomError(msg) {
|
||||
alert(msg)
|
||||
}
|
||||
/**
|
||||
* @argument {string} roomId
|
||||
*/
|
||||
function roomJoin(roomId) {
|
||||
// alert(`Вы подключились к комнате #${roomId}`)
|
||||
localStorage.setItem("__saper_nickname", getNickname())
|
||||
otherJoin(socket.id, getNickname() + (data.is_host ? " (Вы 👑)" : " (Вы)"), true)
|
||||
chatMessage('service', "Вы подключены к комнате #" + roomId)
|
||||
show('game')
|
||||
|
||||
startGame()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"service" | "self" | "other"} type
|
||||
* @param {string} message
|
||||
*/
|
||||
function chatMessage(type, message) {
|
||||
const msg = document.createElement('div')
|
||||
msg.classList.add('chat-' + type + '-msg')
|
||||
msg.innerText = message
|
||||
document.getElementById('chat').appendChild(msg)
|
||||
msg.scrollIntoView({ 'behavior': 'smooth' })
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {string} sid
|
||||
* @param {string} nickname
|
||||
* @param {boolean} onlyList
|
||||
*/
|
||||
function otherJoin(sid, nickname, onlyList = false) {
|
||||
if (!document.querySelector('[data-sid="' + sid + '"]')) {
|
||||
const user = document.createElement('div');
|
||||
user.classList.add('chat-service-msg')
|
||||
user.innerText = nickname;
|
||||
user.setAttribute('data-sid', sid)
|
||||
document.getElementById('users').appendChild(user)
|
||||
}
|
||||
|
||||
if (!onlyList) {
|
||||
chatMessage('service', "К комнате подключился " + nickname)
|
||||
socket.emit("about-me")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} sid
|
||||
*/
|
||||
function otherDisconnect(sid, nickname) {
|
||||
const user = document.querySelector('[data-sid="' + sid + '"]')
|
||||
if (user)
|
||||
user.remove();
|
||||
|
||||
chatMessage('service', "От комнаты отключился " + nickname)
|
||||
if (data.cursors[sid])
|
||||
delete data.cursors[sid];
|
||||
}
|
||||
|
||||
function chat(nickname, msg) {
|
||||
chatMessage("other", nickname + ": " + msg)
|
||||
}
|
||||
|
||||
function cursor(sid, nickname, x, y) {
|
||||
data.cursors[sid] = { x, y, nickname }
|
||||
}
|
||||
|
||||
function updateField(field, flags, bombs, text) {
|
||||
data.field = field
|
||||
data.flags = flags
|
||||
data.bombs = bombs
|
||||
data.text = text
|
||||
}
|
||||
|
||||
socket.on("room-error", roomError)
|
||||
socket.on("room-join", roomJoin)
|
||||
socket.on("other-join", otherJoin)
|
||||
socket.on("about-me", (sid, nickname) => otherJoin(sid, nickname, true))
|
||||
socket.on("other-disconnect", otherDisconnect)
|
||||
socket.on("chat", chat)
|
||||
socket.on("reload-page", () => window.location.reload())
|
||||
socket.on("cursor", cursor)
|
||||
socket.on('update-field', updateField)
|
||||
|
||||
document.getElementById("nickname").value = localStorage.getItem("__saper_nickname") || ""
|
||||
|
||||
const chatMsg = document.getElementById("chat-msg")
|
||||
|
||||
const colors = {
|
||||
bg: new ColorPicker('#color-bg'),
|
||||
bg2: new ColorPicker('#color-bg-2'),
|
||||
fg: new ColorPicker('#color-fg'),
|
||||
bgChatService: new ColorPicker('#color-bg-chat-service'),
|
||||
fgChatService: new ColorPicker('#color-fg-chat-service'),
|
||||
bgChatSelf: new ColorPicker('#color-bg-chat-self'),
|
||||
fgChatSelf: new ColorPicker('#color-fg-chat-self'),
|
||||
bgChatOther: new ColorPicker('#color-bg-chat-other'),
|
||||
fgChatOther: new ColorPicker('#color-fg-chat-other'),
|
||||
bgCellOpen: new ColorPicker("#color-bg-cell-open"),
|
||||
bgCellClosed: new ColorPicker("#color-bg-cell-closed"),
|
||||
bgCellBomb: new ColorPicker("#color-bg-cell-bomb"),
|
||||
fgCell1: new ColorPicker("#color-fg-cell-1"),
|
||||
fgCell2: new ColorPicker("#color-fg-cell-2"),
|
||||
fgCell3: new ColorPicker("#color-fg-cell-3"),
|
||||
fgCell4: new ColorPicker("#color-fg-cell-4"),
|
||||
fgCell5: new ColorPicker("#color-fg-cell-5"),
|
||||
fgCell6: new ColorPicker("#color-fg-cell-6"),
|
||||
fgCell7: new ColorPicker("#color-fg-cell-7"),
|
||||
fgCell8: new ColorPicker("#color-fg-cell-8"),
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {keyof colors} color
|
||||
* @returns {string}
|
||||
*/
|
||||
function getColor(color) {
|
||||
return colors[color].color.string('hex')
|
||||
}
|
||||
|
||||
const root = document.querySelector('html')
|
||||
const computeStyles = window.getComputedStyle(root);
|
||||
for (const color_e of Object.values(colors)) {
|
||||
const color_var = color_e.element.id.replace('color-', '--')
|
||||
const color_key = "__saper_" + color_e.element.id
|
||||
const color = localStorage.getItem(color_key) || computeStyles.getPropertyValue(color_var);
|
||||
color_e.setColor(color)
|
||||
const text = document.createElement('span')
|
||||
text.innerText = color_var.replace('--', '')
|
||||
color_e.element.appendChild(text)
|
||||
root.style.setProperty(color_var, color)
|
||||
color_e.on('pick', c => {
|
||||
const color = c.string('hex')
|
||||
console.log(color)
|
||||
root.style.setProperty(color_var, color)
|
||||
localStorage.setItem(color_key, color)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
chatMsg.addEventListener("keyup", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
const msg = chatMsg.value;
|
||||
chatMsg.value = "";
|
||||
socket.emit("chat", msg)
|
||||
chatMessage("self", "Вы: " + msg)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.getElementById("room-id").addEventListener("keyup", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
join()
|
||||
}
|
||||
});
|
||||
5523
static/socket.io.js
Normal file
5523
static/socket.io.js
Normal file
File diff suppressed because it is too large
Load Diff
162
static/style.css
Normal file
162
static/style.css
Normal file
@@ -0,0 +1,162 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #334;
|
||||
--bg-2: #557;
|
||||
--fg: #fff;
|
||||
--bg-chat-service: #ccc;
|
||||
--fg-chat-service: #000;
|
||||
--bg-chat-self: #fff;
|
||||
--fg-chat-self: #000;
|
||||
--bg-chat-other: #335;
|
||||
--fg-chat-other: #fff;
|
||||
--bg-cell-open: #eee;
|
||||
--bg-cell-closed: #adf;
|
||||
--bg-cell-bomb: #f55;
|
||||
--fg-cell-1: #6B9EFF;
|
||||
--fg-cell-2: #6BCF8A;
|
||||
--fg-cell-3: #FF7B7B;
|
||||
--fg-cell-4: #6B7BFF;
|
||||
--fg-cell-5: #C79B6B;
|
||||
--fg-cell-6: #6BD1C7;
|
||||
--fg-cell-7: #666;
|
||||
--fg-cell-8: #999;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
#content {
|
||||
margin: 50px;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.block>div {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.block>div>* {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#game {
|
||||
background: #fff;
|
||||
|
||||
border: 1px solid var(--bg);
|
||||
|
||||
display: flex;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#game,
|
||||
#game-canvas {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
border-right: 1px solid var(--bg);
|
||||
background-color: var(--bg-2);
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
#game-tools {
|
||||
background-color: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
#chat-and-users {
|
||||
border-bottom: 1px solid var(--bg);
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#chat-and-users>* {
|
||||
flex: 1 1 0;
|
||||
/* display: flex;
|
||||
flex-direction: column; */
|
||||
|
||||
/* justify-content: flex-end; */
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-service-msg,
|
||||
.chat-self-msg,
|
||||
.chat-other-msg {
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
/* width: 100%; */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-service-msg {
|
||||
background-color: var(--bg-chat-service);
|
||||
color: var(--fg-chat-service);
|
||||
}
|
||||
|
||||
.chat-self-msg {
|
||||
background-color: var(--bg-chat-self);
|
||||
color: var(--fg-chat-self);
|
||||
}
|
||||
|
||||
.chat-other-msg {
|
||||
background-color: var(--bg-chat-other);
|
||||
color: var(--fg-chat-other);
|
||||
}
|
||||
|
||||
#buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#buttons>input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker>span {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: 1.5em;
|
||||
margin-top: 100px;
|
||||
font-family: 500;
|
||||
text-align: center;
|
||||
}
|
||||
Reference in New Issue
Block a user