This commit is contained in:
Fasterino
2025-10-20 21:08:52 +03:00
commit 4aaa436079
20 changed files with 6824 additions and 0 deletions

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

File diff suppressed because one or more lines are too long

226
static/game.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

162
static/style.css Normal file
View 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;
}