Compare commits
9
Commits
df24edd0ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6746d89233 | ||
|
|
6ce29bb986 | ||
|
|
bb194ad33f | ||
|
|
cb25497bb8 | ||
|
|
3e14c8f381 | ||
|
|
abd57e1cdf | ||
|
|
9c7caf50a7 | ||
|
|
c9de8da955 | ||
|
|
5e99bc8c45 |
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>./blocks — cacto</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/arcade.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||
cacto<span class="cursor">_</span>
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">whoami</a></li>
|
||||
<li><a href="/projects">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main>
|
||||
<h2 class="prompt">./blocks</h2>
|
||||
<div id="arcade-root"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/blocks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
/* --- общие стили для одиночных аркад (./blocks, ./invaders) --- */
|
||||
|
||||
.arcade-crosslinks {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.arcade-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.arcade-canvas-slot {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.arcade-canvas-slot canvas {
|
||||
border: var(--border);
|
||||
background: var(--color-bg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arcade-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
padding-top: 0.5rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.arcade-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-silver);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arcade-stats b {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.arcade-next-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arcade-next-slot span {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arcade-next-slot canvas {
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.arcade-controls__hint {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.arcade-dpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 44px);
|
||||
grid-template-rows: repeat(2, 44px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.arcade-dpad button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.arcade-dpad button:active {
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-dpad__up { grid-column: 2; grid-row: 1; }
|
||||
.arcade-dpad__left { grid-column: 1; grid-row: 2; }
|
||||
.arcade-dpad__right { grid-column: 3; grid-row: 2; }
|
||||
.arcade-dpad__down { grid-column: 2; grid-row: 2; }
|
||||
|
||||
/* --- пара кнопок влево/вправо (./invaders — вертикаль не нужна) --- */
|
||||
.arcade-lr {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.arcade-lr button {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 62px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.arcade-lr button:active {
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-action-btn {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.6rem 1.2rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.arcade-action-btn:active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.arcade-result-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 14, 12, 0.92);
|
||||
z-index: 5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arcade-result {
|
||||
padding: 2rem;
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.arcade-result h2 {
|
||||
font-size: 2.2rem;
|
||||
margin: 0 0 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 0 0 16px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.arcade-result p {
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.arcade-result__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arcade-result__actions button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.5rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arcade-result__actions button:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 6px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.arcade-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.arcade-controls {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
.arcade-crosslinks {
|
||||
color: var(--color-silver);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.lobby-panel {
|
||||
border: var(--border);
|
||||
background: var(--color-bg-raised);
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<div class="container">
|
||||
<main id="game-root">
|
||||
<h2 class="prompt">./tanks</h2>
|
||||
<p class="arcade-crosslinks"><span id="game-crosslinks-label"></span> <a href="/blocks">./blocks</a> · <a href="/invaders">./invaders</a></p>
|
||||
<div id="lobby"></div>
|
||||
<div id="tanks-canvas-container" style="display: none"></div>
|
||||
</main>
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@
|
||||
</main>
|
||||
|
||||
<footer id="site-footer" class="site-footer">
|
||||
<p>P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.</p>
|
||||
<p>P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>./invaders — cacto</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/cactus-logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/arcade.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="matrix-rain"></canvas>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a href="/" class="logo" aria-label="cacto — на главную">
|
||||
<img src="/assets/cactus-logo.svg" width="20" height="20" alt="" />
|
||||
cacto<span class="cursor">_</span>
|
||||
</a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">whoami</a></li>
|
||||
<li><a href="/projects">ls projects/</a></li>
|
||||
<li><button id="lang-toggle" class="lang-btn" type="button">EN</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<main>
|
||||
<h2 class="prompt">./invaders</h2>
|
||||
<div id="arcade-root"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/matrix-rain.js"></script>
|
||||
<script type="module" src="/js/invaders.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,440 @@
|
||||
import { getLang, initLangToggle } from './i18n.js';
|
||||
|
||||
const COLS = 10;
|
||||
const ROWS = 20;
|
||||
const CELL = 24;
|
||||
const GRAVITY_START_MS = 800;
|
||||
const GRAVITY_MIN_MS = 100;
|
||||
|
||||
const SHAPES = {
|
||||
I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
|
||||
J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
|
||||
L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
|
||||
O: [[1, 1], [1, 1]],
|
||||
S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
|
||||
T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
|
||||
Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]],
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
I: '#00ff41',
|
||||
O: '#9fa3a0',
|
||||
T: '#39e075',
|
||||
S: '#7cffb2',
|
||||
Z: '#1fae56',
|
||||
J: '#c7c7c7',
|
||||
L: '#0a8f2c',
|
||||
};
|
||||
|
||||
const PIECE_TYPES = Object.keys(SHAPES);
|
||||
|
||||
const STR = {
|
||||
ru: {
|
||||
hint: 'стрелки — двигать, вверх — вращать, пробел — сброс',
|
||||
score: 'счёт',
|
||||
lines: 'линии',
|
||||
level: 'уровень',
|
||||
next: 'дальше',
|
||||
hardDrop: 'сброс',
|
||||
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||
finalScore: (s) => `счёт: ${s}`,
|
||||
again: 'ещё раз',
|
||||
crosslinks: 'ещё игры:',
|
||||
},
|
||||
en: {
|
||||
hint: 'arrows to move, up to rotate, space to hard-drop',
|
||||
score: 'score',
|
||||
lines: 'lines',
|
||||
level: 'level',
|
||||
next: 'next',
|
||||
hardDrop: 'drop',
|
||||
gameOver: 'GAME OVER',
|
||||
finalScore: (s) => `score: ${s}`,
|
||||
again: 'play again',
|
||||
crosslinks: 'more games:',
|
||||
},
|
||||
};
|
||||
|
||||
function t() {
|
||||
return STR[getLang()];
|
||||
}
|
||||
|
||||
function emptyBoard() {
|
||||
return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
|
||||
}
|
||||
|
||||
function rotateMatrix(matrix) {
|
||||
const n = matrix.length;
|
||||
const result = Array.from({ length: n }, () => Array(n).fill(0));
|
||||
for (let y = 0; y < n; y++) {
|
||||
for (let x = 0; x < n; x++) {
|
||||
result[x][n - 1 - y] = matrix[y][x];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomType() {
|
||||
return PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
|
||||
}
|
||||
|
||||
class BlocksGame {
|
||||
constructor(canvas, nextCanvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.nextCanvas = nextCanvas;
|
||||
this.nextCtx = nextCanvas.getContext('2d');
|
||||
canvas.width = COLS * CELL;
|
||||
canvas.height = ROWS * CELL;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.board = emptyBoard();
|
||||
this.score = 0;
|
||||
this.lines = 0;
|
||||
this.level = 1;
|
||||
this.over = false;
|
||||
this.nextType = randomType();
|
||||
this.spawn();
|
||||
this.dropTimer = 0;
|
||||
this.lastTime = null;
|
||||
}
|
||||
|
||||
spawn() {
|
||||
const type = this.nextType;
|
||||
this.nextType = randomType();
|
||||
const matrix = SHAPES[type].map((row) => row.slice());
|
||||
this.piece = {
|
||||
type,
|
||||
matrix,
|
||||
x: Math.floor((COLS - matrix.length) / 2),
|
||||
y: 0,
|
||||
};
|
||||
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
|
||||
this.over = true;
|
||||
}
|
||||
}
|
||||
|
||||
collides(matrix, offX, offY) {
|
||||
for (let y = 0; y < matrix.length; y++) {
|
||||
for (let x = 0; x < matrix[y].length; x++) {
|
||||
if (!matrix[y][x]) continue;
|
||||
const bx = offX + x;
|
||||
const by = offY + y;
|
||||
if (bx < 0 || bx >= COLS || by >= ROWS) return true;
|
||||
if (by >= 0 && this.board[by][bx]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
move(dx, dy) {
|
||||
if (this.over) return false;
|
||||
const p = this.piece;
|
||||
if (!this.collides(p.matrix, p.x + dx, p.y + dy)) {
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
rotate() {
|
||||
if (this.over) return;
|
||||
const p = this.piece;
|
||||
const rotated = rotateMatrix(p.matrix);
|
||||
const kicks = [0, 1, -1, 2, -2];
|
||||
for (const kick of kicks) {
|
||||
if (!this.collides(rotated, p.x + kick, p.y)) {
|
||||
p.matrix = rotated;
|
||||
p.x += kick;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardDrop() {
|
||||
if (this.over) return;
|
||||
while (this.move(0, 1)) {
|
||||
this.score += 1;
|
||||
}
|
||||
this.lock();
|
||||
}
|
||||
|
||||
lock() {
|
||||
const p = this.piece;
|
||||
for (let y = 0; y < p.matrix.length; y++) {
|
||||
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||
if (p.matrix[y][x]) {
|
||||
const by = p.y + y;
|
||||
const bx = p.x + x;
|
||||
if (by >= 0) this.board[by][bx] = p.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearLines();
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
clearLines() {
|
||||
let cleared = 0;
|
||||
this.board = this.board.filter((row) => {
|
||||
const full = row.every((cell) => cell);
|
||||
if (full) cleared++;
|
||||
return !full;
|
||||
});
|
||||
while (this.board.length < ROWS) {
|
||||
this.board.unshift(Array(COLS).fill(null));
|
||||
}
|
||||
if (cleared > 0) {
|
||||
const points = [0, 100, 300, 500, 800][cleared] || 800;
|
||||
this.score += points * this.level;
|
||||
this.lines += cleared;
|
||||
this.level = 1 + Math.floor(this.lines / 10);
|
||||
}
|
||||
}
|
||||
|
||||
gravityInterval() {
|
||||
return Math.max(GRAVITY_MIN_MS, GRAVITY_START_MS - (this.level - 1) * 60);
|
||||
}
|
||||
|
||||
tick(dtMs) {
|
||||
if (this.over) return;
|
||||
this.dropTimer += dtMs;
|
||||
const interval = this.gravityInterval();
|
||||
if (this.dropTimer >= interval) {
|
||||
this.dropTimer = 0;
|
||||
if (!this.move(0, 1)) {
|
||||
this.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCell(ctx, x, y, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||
ctx.strokeStyle = '#0a0e0c';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x * CELL + 1, y * CELL + 1, CELL - 2, CELL - 2);
|
||||
}
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
if (this.board[y][x]) {
|
||||
this.drawCell(ctx, x, y, COLORS[this.board[y][x]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const p = this.piece;
|
||||
if (p) {
|
||||
for (let y = 0; y < p.matrix.length; y++) {
|
||||
for (let x = 0; x < p.matrix[y].length; x++) {
|
||||
if (p.matrix[y][x] && p.y + y >= 0) {
|
||||
this.drawCell(ctx, p.x + x, p.y + y, COLORS[p.type]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// сетка поверх, лёгкая
|
||||
ctx.strokeStyle = 'rgba(80, 255, 140, 0.06)';
|
||||
for (let x = 1; x < COLS; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL, 0);
|
||||
ctx.lineTo(x * CELL, this.canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 1; y < ROWS; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL);
|
||||
ctx.lineTo(this.canvas.width, y * CELL);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
this.drawNext();
|
||||
}
|
||||
|
||||
drawNext() {
|
||||
const ctx = this.nextCtx;
|
||||
const size = this.nextCanvas.width;
|
||||
ctx.fillStyle = '#0f1512';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const matrix = SHAPES[this.nextType];
|
||||
const cell = Math.floor(size / 4);
|
||||
const offset = (4 - matrix.length) / 2;
|
||||
for (let y = 0; y < matrix.length; y++) {
|
||||
for (let x = 0; x < matrix[y].length; x++) {
|
||||
if (matrix[y][x]) {
|
||||
ctx.fillStyle = COLORS[this.nextType];
|
||||
ctx.fillRect((x + offset) * cell + 1, (y + offset) * cell + 1, cell - 2, cell - 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let game = null;
|
||||
let rafId = null;
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('arcade-score').textContent = game.score;
|
||||
document.getElementById('arcade-lines').textContent = game.lines;
|
||||
document.getElementById('arcade-level').textContent = game.level;
|
||||
}
|
||||
|
||||
function showGameOver() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="arcade-result">
|
||||
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||
<p>${t().finalScore(game.score)}</p>
|
||||
<div class="arcade-result__actions">
|
||||
<button data-action="again">${t().again}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
function loop(time) {
|
||||
if (game.lastTime === null) game.lastTime = time;
|
||||
const dt = time - game.lastTime;
|
||||
game.lastTime = time;
|
||||
const wasOver = game.over;
|
||||
game.tick(dt);
|
||||
game.draw();
|
||||
updateStats();
|
||||
if (game.over && !wasOver) {
|
||||
showGameOver();
|
||||
}
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function restart() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
game.reset();
|
||||
}
|
||||
|
||||
function bindKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!game) return;
|
||||
if (game.over) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||
return;
|
||||
}
|
||||
switch (e.code) {
|
||||
case 'ArrowLeft':
|
||||
game.move(-1, 0);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
game.move(1, 0);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (game.move(0, 1)) game.score += 1;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
game.rotate();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'Space':
|
||||
game.hardDrop();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindTouchControls(root) {
|
||||
const actions = {
|
||||
left: () => game.move(-1, 0),
|
||||
right: () => game.move(1, 0),
|
||||
down: () => {
|
||||
if (game.move(0, 1)) game.score += 1;
|
||||
},
|
||||
rotate: () => game.rotate(),
|
||||
drop: () => game.hardDrop(),
|
||||
};
|
||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||
const action = actions[btn.dataset.input];
|
||||
btn.addEventListener('click', () => {
|
||||
if (game.over) return;
|
||||
action();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(root) {
|
||||
root.innerHTML = `
|
||||
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/invaders">./invaders</a></p>
|
||||
<div class="arcade-layout">
|
||||
<div class="arcade-canvas-slot">
|
||||
<canvas id="arcade-canvas"></canvas>
|
||||
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||
</div>
|
||||
<div class="arcade-controls">
|
||||
<div class="arcade-stats">
|
||||
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||
<div><span id="arcade-lines-label">${t().lines.toUpperCase()}</span> <b id="arcade-lines">0</b></div>
|
||||
<div><span id="arcade-level-label">${t().level.toUpperCase()}</span> <b id="arcade-level">1</b></div>
|
||||
</div>
|
||||
<div class="arcade-next-slot">
|
||||
<span id="arcade-next-label">${t().next}</span>
|
||||
<canvas id="arcade-next" width="80" height="80"></canvas>
|
||||
</div>
|
||||
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||
<div class="arcade-dpad">
|
||||
<button class="arcade-dpad__up" data-input="rotate">↑</button>
|
||||
<button class="arcade-dpad__left" data-input="left">←</button>
|
||||
<button class="arcade-dpad__right" data-input="right">→</button>
|
||||
<button class="arcade-dpad__down" data-input="down">↓</button>
|
||||
</div>
|
||||
<button class="arcade-action-btn" data-input="drop" id="arcade-drop-btn">${t().hardDrop}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Обновляет статичные подписи без пересоздания игры — иначе переключение
|
||||
// языка посреди партии сбрасывало бы прогресс, как уже было с ./tanks.
|
||||
function updateStaticText() {
|
||||
const byId = (id) => document.getElementById(id);
|
||||
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||
if (byId('arcade-lines-label')) byId('arcade-lines-label').textContent = t().lines.toUpperCase();
|
||||
if (byId('arcade-level-label')) byId('arcade-level-label').textContent = t().level.toUpperCase();
|
||||
if (byId('arcade-next-label')) byId('arcade-next-label').textContent = t().next;
|
||||
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||
if (byId('arcade-drop-btn')) byId('arcade-drop-btn').textContent = t().hardDrop;
|
||||
if (game?.over) showGameOver();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = document.getElementById('arcade-root');
|
||||
renderShell(root);
|
||||
|
||||
const canvas = document.getElementById('arcade-canvas');
|
||||
const nextCanvas = document.getElementById('arcade-next');
|
||||
game = new BlocksGame(canvas, nextCanvas);
|
||||
|
||||
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-action="again"]')) restart();
|
||||
});
|
||||
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
bindKeyboard();
|
||||
init();
|
||||
initLangToggle(updateStaticText);
|
||||
@@ -23,6 +23,7 @@ const STR = {
|
||||
room: 'комната',
|
||||
mode: 'режим:',
|
||||
leave: 'выйти в лобби',
|
||||
crosslinks: 'ещё игры:',
|
||||
},
|
||||
en: {
|
||||
connecting: 'connecting...',
|
||||
@@ -38,6 +39,7 @@ const STR = {
|
||||
room: 'room',
|
||||
mode: 'mode:',
|
||||
leave: 'leave to lobby',
|
||||
crosslinks: 'more games:',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,6 +129,8 @@ function renderRoomView() {
|
||||
}
|
||||
|
||||
function render() {
|
||||
const crosslinksLabel = document.getElementById('game-crosslinks-label');
|
||||
if (crosslinksLabel) crosslinksLabel.textContent = t().crosslinks;
|
||||
if (currentRoom) {
|
||||
renderRoomView();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { getLang, initLangToggle } from './i18n.js';
|
||||
|
||||
const CANVAS_W = 420;
|
||||
const CANVAS_H = 520;
|
||||
const PLAYER_Y = CANVAS_H - 40;
|
||||
const PLAYER_W = 28;
|
||||
const PLAYER_H = 14;
|
||||
const PLAYER_SPEED = 220; // px/s
|
||||
const BULLET_SPEED = 380;
|
||||
const ENEMY_BULLET_SPEED = 180;
|
||||
const FIRE_COOLDOWN_MS = 350;
|
||||
const ENEMY_COLS = 8;
|
||||
const ENEMY_ROWS = 4;
|
||||
const ENEMY_W = 28;
|
||||
const ENEMY_H = 18;
|
||||
const ENEMY_GAP_X = 12;
|
||||
const ENEMY_GAP_Y = 16;
|
||||
const ENEMY_TOP = 50;
|
||||
const ENEMY_STEP_DOWN = 18;
|
||||
|
||||
const STR = {
|
||||
ru: {
|
||||
hint: 'стрелки — двигать, пробел — огонь',
|
||||
score: 'счёт',
|
||||
lives: 'жизни',
|
||||
wave: 'волна',
|
||||
gameOver: 'ИГРА ОКОНЧЕНА',
|
||||
finalScore: (s) => `счёт: ${s}`,
|
||||
again: 'ещё раз',
|
||||
crosslinks: 'ещё игры:',
|
||||
fire: 'огонь',
|
||||
},
|
||||
en: {
|
||||
hint: 'arrows to move, space to fire',
|
||||
score: 'score',
|
||||
lives: 'lives',
|
||||
wave: 'wave',
|
||||
gameOver: 'GAME OVER',
|
||||
finalScore: (s) => `score: ${s}`,
|
||||
again: 'play again',
|
||||
crosslinks: 'more games:',
|
||||
fire: 'fire',
|
||||
},
|
||||
};
|
||||
|
||||
function t() {
|
||||
return STR[getLang()];
|
||||
}
|
||||
|
||||
function rectsOverlap(a, b) {
|
||||
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
}
|
||||
|
||||
class InvadersGame {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
this.input = { left: false, right: false, fire: false };
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.score = 0;
|
||||
this.lives = 3;
|
||||
this.wave = 1;
|
||||
this.over = false;
|
||||
this.player = { x: CANVAS_W / 2 - PLAYER_W / 2, y: PLAYER_Y };
|
||||
this.bullets = [];
|
||||
this.enemyBullets = [];
|
||||
this.fireTimer = 0;
|
||||
this.hitFlash = 0;
|
||||
this.spawnWave();
|
||||
}
|
||||
|
||||
spawnWave() {
|
||||
this.enemies = [];
|
||||
const totalWidth = ENEMY_COLS * (ENEMY_W + ENEMY_GAP_X) - ENEMY_GAP_X;
|
||||
const startX = (CANVAS_W - totalWidth) / 2;
|
||||
for (let row = 0; row < ENEMY_ROWS; row++) {
|
||||
for (let col = 0; col < ENEMY_COLS; col++) {
|
||||
this.enemies.push({
|
||||
x: startX + col * (ENEMY_W + ENEMY_GAP_X),
|
||||
y: ENEMY_TOP + row * (ENEMY_H + ENEMY_GAP_Y),
|
||||
w: ENEMY_W,
|
||||
h: ENEMY_H,
|
||||
alive: true,
|
||||
points: (ENEMY_ROWS - row) * 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.enemyDir = 1;
|
||||
this.enemySpeed = 30 + (this.wave - 1) * 12;
|
||||
this.enemyShootTimer = 0;
|
||||
}
|
||||
|
||||
aliveEnemies() {
|
||||
return this.enemies.filter((e) => e.alive);
|
||||
}
|
||||
|
||||
tick(dtMs) {
|
||||
if (this.over) return;
|
||||
const dt = dtMs / 1000;
|
||||
|
||||
if (this.input.left) this.player.x -= PLAYER_SPEED * dt;
|
||||
if (this.input.right) this.player.x += PLAYER_SPEED * dt;
|
||||
this.player.x = Math.max(0, Math.min(CANVAS_W - PLAYER_W, this.player.x));
|
||||
|
||||
this.fireTimer -= dtMs;
|
||||
if (this.input.fire && this.fireTimer <= 0) {
|
||||
this.bullets.push({ x: this.player.x + PLAYER_W / 2 - 2, y: this.player.y, w: 4, h: 10 });
|
||||
this.fireTimer = FIRE_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
for (const b of this.bullets) b.y -= BULLET_SPEED * dt;
|
||||
this.bullets = this.bullets.filter((b) => b.y + b.h > 0);
|
||||
|
||||
for (const b of this.enemyBullets) b.y += ENEMY_BULLET_SPEED * dt;
|
||||
this.enemyBullets = this.enemyBullets.filter((b) => b.y < CANVAS_H);
|
||||
|
||||
const alive = this.aliveEnemies();
|
||||
if (alive.length === 0) {
|
||||
this.wave += 1;
|
||||
this.spawnWave();
|
||||
return;
|
||||
}
|
||||
|
||||
let hitEdge = false;
|
||||
for (const e of alive) {
|
||||
e.x += this.enemyDir * this.enemySpeed * dt;
|
||||
if (e.x <= 0 || e.x + e.w >= CANVAS_W) hitEdge = true;
|
||||
}
|
||||
if (hitEdge) {
|
||||
this.enemyDir *= -1;
|
||||
for (const e of alive) {
|
||||
e.y += ENEMY_STEP_DOWN;
|
||||
if (e.y + e.h >= this.player.y) {
|
||||
this.over = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.enemyShootTimer -= dtMs;
|
||||
if (this.enemyShootTimer <= 0 && alive.length > 0) {
|
||||
const shooter = alive[Math.floor(Math.random() * alive.length)];
|
||||
this.enemyBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
|
||||
this.enemyShootTimer = Math.max(250, 900 - this.wave * 60);
|
||||
}
|
||||
|
||||
// пуля игрока vs враги
|
||||
for (const bullet of this.bullets) {
|
||||
for (const e of alive) {
|
||||
if (!e.alive) continue;
|
||||
if (rectsOverlap(bullet, e)) {
|
||||
e.alive = false;
|
||||
bullet.hit = true;
|
||||
this.score += e.points;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.bullets = this.bullets.filter((b) => !b.hit);
|
||||
|
||||
// пуля врага vs игрок
|
||||
const playerRect = { x: this.player.x, y: this.player.y, w: PLAYER_W, h: PLAYER_H };
|
||||
for (const b of this.enemyBullets) {
|
||||
if (rectsOverlap(b, playerRect)) {
|
||||
b.hit = true;
|
||||
this.lives -= 1;
|
||||
this.hitFlash = 200;
|
||||
if (this.lives <= 0) this.over = true;
|
||||
}
|
||||
}
|
||||
this.enemyBullets = this.enemyBullets.filter((b) => !b.hit);
|
||||
|
||||
if (this.hitFlash > 0) this.hitFlash -= dtMs;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
ctx.fillStyle = '#39e075';
|
||||
ctx.fillRect(e.x, e.y, e.w, e.h);
|
||||
ctx.fillStyle = '#0a0e0c';
|
||||
ctx.fillRect(e.x + 4, e.y + 4, 4, 4);
|
||||
ctx.fillRect(e.x + e.w - 8, e.y + 4, 4, 4);
|
||||
}
|
||||
|
||||
ctx.fillStyle = this.hitFlash > 0 ? '#ffffff' : '#00ff41';
|
||||
ctx.fillRect(this.player.x, this.player.y, PLAYER_W, PLAYER_H);
|
||||
ctx.fillRect(this.player.x + PLAYER_W / 2 - 3, this.player.y - 6, 6, 6);
|
||||
|
||||
ctx.fillStyle = '#c7c7c7';
|
||||
for (const b of this.bullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.fillStyle = '#9fa3a0';
|
||||
for (const b of this.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
}
|
||||
}
|
||||
|
||||
let game = null;
|
||||
let rafId = null;
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('arcade-score').textContent = game.score;
|
||||
document.getElementById('arcade-lives').textContent = game.lives;
|
||||
document.getElementById('arcade-wave').textContent = game.wave;
|
||||
}
|
||||
|
||||
function showGameOver() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="arcade-result">
|
||||
<h2 class="glitch" data-text="${t().gameOver}">${t().gameOver}</h2>
|
||||
<p>${t().finalScore(game.score)}</p>
|
||||
<div class="arcade-result__actions">
|
||||
<button data-action="again">${t().again}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
function loop(time) {
|
||||
if (game.lastTime === undefined || game.lastTime === null) game.lastTime = time;
|
||||
const dt = time - game.lastTime;
|
||||
game.lastTime = time;
|
||||
const wasOver = game.over;
|
||||
game.tick(dt);
|
||||
game.draw();
|
||||
updateStats();
|
||||
if (game.over && !wasOver) showGameOver();
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function restart() {
|
||||
const overlay = document.getElementById('arcade-result-overlay');
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
game.reset();
|
||||
}
|
||||
|
||||
function bindKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!game) return;
|
||||
if (game.over) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') restart();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'ArrowLeft') { game.input.left = true; e.preventDefault(); }
|
||||
if (e.code === 'ArrowRight') { game.input.right = true; e.preventDefault(); }
|
||||
if (e.code === 'Space') { game.input.fire = true; e.preventDefault(); }
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (!game) return;
|
||||
if (e.code === 'ArrowLeft') game.input.left = false;
|
||||
if (e.code === 'ArrowRight') game.input.right = false;
|
||||
if (e.code === 'Space') game.input.fire = false;
|
||||
});
|
||||
}
|
||||
|
||||
function bindTouchControls(root) {
|
||||
const press = (key, value) => {
|
||||
if (game) game.input[key] = value;
|
||||
};
|
||||
root.querySelectorAll('[data-input]').forEach((btn) => {
|
||||
const key = btn.dataset.input;
|
||||
btn.addEventListener('mousedown', () => press(key, true));
|
||||
btn.addEventListener('mouseup', () => press(key, false));
|
||||
btn.addEventListener('mouseleave', () => press(key, false));
|
||||
btn.addEventListener('touchstart', (e) => { e.preventDefault(); press(key, true); });
|
||||
btn.addEventListener('touchend', (e) => { e.preventDefault(); press(key, false); });
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(root) {
|
||||
root.innerHTML = `
|
||||
<p class="arcade-crosslinks"><span id="arcade-crosslinks-label">${t().crosslinks}</span> <a href="/game">./tanks</a> · <a href="/blocks">./blocks</a></p>
|
||||
<div class="arcade-layout">
|
||||
<div class="arcade-canvas-slot">
|
||||
<canvas id="arcade-canvas"></canvas>
|
||||
<div id="arcade-result-overlay" class="arcade-result-overlay"></div>
|
||||
</div>
|
||||
<div class="arcade-controls">
|
||||
<div class="arcade-stats">
|
||||
<div><span id="arcade-score-label">${t().score.toUpperCase()}</span> <b id="arcade-score">0</b></div>
|
||||
<div><span id="arcade-lives-label">${t().lives.toUpperCase()}</span> <b id="arcade-lives">3</b></div>
|
||||
<div><span id="arcade-wave-label">${t().wave.toUpperCase()}</span> <b id="arcade-wave">1</b></div>
|
||||
</div>
|
||||
<p class="arcade-controls__hint" id="arcade-hint">${t().hint}</p>
|
||||
<div class="arcade-lr">
|
||||
<button data-input="left">←</button>
|
||||
<button data-input="right">→</button>
|
||||
</div>
|
||||
<button class="arcade-action-btn" data-input="fire" id="arcade-fire-btn">${t().fire}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function updateStaticText() {
|
||||
const byId = (id) => document.getElementById(id);
|
||||
if (byId('arcade-crosslinks-label')) byId('arcade-crosslinks-label').textContent = t().crosslinks;
|
||||
if (byId('arcade-score-label')) byId('arcade-score-label').textContent = t().score.toUpperCase();
|
||||
if (byId('arcade-lives-label')) byId('arcade-lives-label').textContent = t().lives.toUpperCase();
|
||||
if (byId('arcade-wave-label')) byId('arcade-wave-label').textContent = t().wave.toUpperCase();
|
||||
if (byId('arcade-hint')) byId('arcade-hint').textContent = t().hint;
|
||||
if (byId('arcade-fire-btn')) byId('arcade-fire-btn').textContent = t().fire;
|
||||
if (game?.over) showGameOver();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = document.getElementById('arcade-root');
|
||||
renderShell(root);
|
||||
|
||||
const canvas = document.getElementById('arcade-canvas');
|
||||
game = new InvadersGame(canvas);
|
||||
|
||||
bindTouchControls(root.querySelector('.arcade-controls'));
|
||||
root.querySelector('#arcade-result-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-action="again"]')) restart();
|
||||
});
|
||||
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
bindKeyboard();
|
||||
init();
|
||||
initLangToggle(updateStaticText);
|
||||
@@ -13,6 +13,14 @@ const DATA = {
|
||||
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||
linkLabel: 'открыть',
|
||||
},
|
||||
{
|
||||
name: 'home_automatization_controllers',
|
||||
description:
|
||||
'Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.',
|
||||
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
|
||||
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
|
||||
linkLabel: 'открыть',
|
||||
},
|
||||
{
|
||||
name: 'cactoz.su',
|
||||
description:
|
||||
@@ -35,6 +43,14 @@ const DATA = {
|
||||
url: 'https://git.cactoz.su/cacto/home_automatization',
|
||||
linkLabel: 'open',
|
||||
},
|
||||
{
|
||||
name: 'home_automatization_controllers',
|
||||
description:
|
||||
'ESP32 firmware for the grow-box zone: a DHT22 sensor and three relay actuators (light, watering, ventilation), talking to the platform over MQTT.',
|
||||
tags: ['C++', 'ESP32', 'Arduino', 'MQTT', 'DHT22'],
|
||||
url: 'https://git.cactoz.su/cacto/home_automatization_controllers',
|
||||
linkLabel: 'open',
|
||||
},
|
||||
{
|
||||
name: 'cactoz.su',
|
||||
description:
|
||||
|
||||
@@ -81,7 +81,7 @@ const DATA = {
|
||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||
resumeLabel: 'скачать резюме (PDF)',
|
||||
giteaLabel: 'git.cactoz.su/cacto',
|
||||
footerHtml: 'P.S. есть ещё <a href="/game">./tanks</a> — мультиплеерная мини-игра, если будет пара свободных минут.',
|
||||
footerHtml: 'P.S. есть ещё пара мини-игр — <a href="/game">./tanks</a> (мультиплеер), <a href="/blocks">./blocks</a> и <a href="/invaders">./invaders</a>, если будет пара свободных минут.',
|
||||
},
|
||||
en: {
|
||||
name: 'Dmitry Gammel',
|
||||
@@ -163,7 +163,7 @@ const DATA = {
|
||||
contact: { email: 'cactozzz93@gmail.com', telegram: 't.me/Cactozz', telegramUrl: 'https://t.me/Cactozz' },
|
||||
resumeLabel: 'download résumé (PDF)',
|
||||
giteaLabel: 'git.cactoz.su/cacto',
|
||||
footerHtml: 'P.S. there\'s also <a href="/game">./tanks</a> — a small multiplayer game, if you have a couple of minutes.',
|
||||
footerHtml: 'P.S. there are also a couple of mini-games — <a href="/game">./tanks</a> (multiplayer), <a href="/blocks">./blocks</a> and <a href="/invaders">./invaders</a>, if you have a couple of minutes.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,20 @@
|
||||
<li class="tag">MQTT</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="project-card">
|
||||
<div class="project-card__header">
|
||||
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> home_automatization_controllers</div>
|
||||
<a href="https://git.cactoz.su/cacto/home_automatization_controllers" target="_blank" rel="noopener">открыть →</a>
|
||||
</div>
|
||||
<p class="project-card__desc">Прошивка ESP32 для зоны «Гроубокс»: датчик DHT22 и три реле-актуатора (свет, полив, вентиляция), общается с платформой по MQTT.</p>
|
||||
<ul class="tag-list">
|
||||
<li class="tag">C++</li>
|
||||
<li class="tag">ESP32</li>
|
||||
<li class="tag">Arduino</li>
|
||||
<li class="tag">MQTT</li>
|
||||
<li class="tag">DHT22</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="project-card">
|
||||
<div class="project-card__header">
|
||||
<div class="project-card__name"><span class="project-card__perms">drwxr-xr-x</span> cactoz.su</div>
|
||||
|
||||
@@ -10,6 +10,9 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# не светим точную версию nginx в заголовке Server и в error-страницах
|
||||
server_tokens off;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
Reference in New Issue
Block a user