Читать дальше...
Что сделано и как.
Ну во-первых появилась сущность Базы
class Base {
constructor(x, y, gameMap = null) {
this.x = x;
this.y = y;
this.width = BASE_SIZE;
this.height = BASE_SIZE;
// Состояние
this.destroyed = false;
// Временная защита бетоном (бонус "лопата")
this.fortified = false;
this.fortifyTimeLeft = 0;
// Ссылка на карту
this.gameMap = gameMap;
}
}
Там на самом деле много служебных методов, типа рендера и получить границы. Но их сюда не пихаю, так как там ничего интересного. Есть ещё метод takeDamage, который просто удаляет визуальное отображение базы с экрана и помечает базу как уничтоженную.
В main.js появились такие нюансы:
for (const bullet of bullets) {
if (!bullet.active) continue;
bullet.update(gameMap, allTanks);
// Создаём эффекты при попадании
if (bullet.hitResult === 'base') {
// Попадание в базу - большой взрыв и мгновенный Game Over
if (base && !base.destroyed) {
const baseCenterX = base.x + base.width / 2;
const baseCenterY = base.y + base.height / 2;
effects.push(createBigExplosion(baseCenterX, baseCenterY));
base.takeDamage();
gameOver = true;
}
} else if (bullet.hitResult === 'tank' && bullet.hitTarget) {
// Попадание в танк - большой взрыв в центре танка
...
} else if (bullet.hitResult === 'wall') {
// Попадание в стену - маленький взрыв в позиции пули
...
}
}
//Не очень мне нравится это, скорее всего на финальном этапе полировки, перепишу
Внутри "коллизии" появился новый метод
export function checkBulletBaseCollision(bullet, base) {
if (!base || base.destroyed) return false;
const bulletBox = {
x: bullet.x,
y: bullet.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
const baseBox = base.getBounds();
return checkAABBCollision(bulletBox, baseBox);
}
В оригинальной игре квадратики справа от игрового поля показывали, сколько танков ещё должно родиться, я решил сделать, чтоб отображалось сколько танков ещё осталось убить для завершения уровня. Ну а сам спавн сделан вот таким образом:
function updateEnemySpawn() {
// Не спавним если игра окончена или уровень пройден
if (gameOver || stageComplete) return;
// Не спавним если больше нет врагов
if (enemiesRemaining <= 0) return;
// Не спавним если на экране максимум врагов
if (enemies.length >= SpawnSettings.MAX_ON_SCREEN) return;
// Уменьшаем таймер
spawnCooldown--;
// Если таймер истёк - спавним врага
if (spawnCooldown <= 0) {
// Получаем текущую точку спавна
const spawnPoint = ENEMY_SPAWN_POINTS[spawnPointIndex];
// Спавним врага
spawnEnemy(spawnPoint.x, spawnPoint.y);
// Уменьшаем счётчик оставшихся врагов
enemiesRemaining--;
// Переключаемся на следующую точку (по кругу)
spawnPointIndex = (spawnPointIndex + 1) % ENEMY_SPAWN_POINTS.length;
// Сбрасываем таймер
spawnCooldown = SpawnSettings.SPAWN_COOLDOWN;
// Обновляем ссылки на танки
updateAllTanksReferences();
}
}
Я, честно признаться, не помню как там оно было на дэнди, поэтому решил, что начиная с этого этапа уже не буду пытаться повторить танки какими они были на самом деле, а буду делать такими, какими я их помню/вижу в своей голове. Так что тут могут начаться расхождения с каноном. Но, учитывая, что я в итоге собираюсь сделать с игрой, это меньшее зло))
Что ж. Намеченный план был выполнен полностью.
Было три итерации, сначала сделал так, чтоб танки могли крошить всех (в том числе и враги уничтожали друг друга).
Потом я запретил им убивать друг друга, а на третьем этапе ещё добавил взаимоуничтожение снарядов игрока и врага при попадании.
Давайте посмотрим, что там по коду интересного и не очень.
Из нового: появился файлик effects.js, который отвечает за всякие красивости, из самого заметного: взрывы)
const EFFECT_CONFIG = {
[EffectType.EXPLOSION_SMALL]: {
maxFrames: 3,
frameTime: 3, // 3 игровых кадра на кадр анимации (100ms при 30 FPS)
size: BULLET_SIZE * 2,
colors: ['#FFFF00', '#FFA500', '#FF4500'] // Жёлтый → оранжевый → красный
},
[EffectType.EXPLOSION_BIG]: {
maxFrames: 5,
frameTime: 3,
size: TANK_SIZE * 1.5,
colors: ['#FFFFFF', '#FFFF00', '#FFA500', '#FF4500', '#8B0000'] // Белый → жёлтый → оранжевый → красный → тёмно-красный
},
[EffectType.SPAWN]: {
maxFrames: 4,
frameTime: 4,
size: TANK_SIZE,
colors: ['#FFFFFF', '#888888', '#FFFFFF', '#888888'] // Мигание
}
};
И сам класс эффекта:
export class Effect {
/**
* @param {number} x - Логическая координата X (центр эффекта)
* @param {number} y - Логическая координата Y (центр эффекта)
* @param {string} type - Тип эффекта (EffectType)
*/
constructor(x, y, type = EffectType.EXPLOSION_SMALL) {
this.x = x;
this.y = y;
this.type = type;
// Получаем конфигурацию для этого типа эффекта
const config = EFFECT_CONFIG[type] || EFFECT_CONFIG[EffectType.EXPLOSION_SMALL];
this.maxFrames = config.maxFrames;
this.frameTime = config.frameTime;
this.size = config.size;
this.colors = config.colors;
// Состояние анимации
this.frame = 0;
this.age = 0;
}
/**
* Обновление состояния эффекта
* @param {number} dt - Время кадра (не используется при fixed timestep)
*/
update(dt) {
this.age++;
// Переход к следующему кадру анимации
if (this.age >= this.frameTime) {
this.frame++;
this.age = 0;
}
}
/**
* Проверка завершения анимации
* @returns {boolean} true если анимация закончилась
*/
isDone() {
return this.frame >= this.maxFrames;
}
/**
* Отрисовка эффекта
*/
render() {
if (this.isDone()) return;
const ctx = foregroundCtx;
// Конвертация логических координат в физические
const px = GAME_FIELD_X + this.x * GAME_SCALE;
const py = GAME_FIELD_Y + this.y * GAME_SCALE;
// Размер эффекта меняется в зависимости от кадра
const progress = this.frame / (this.maxFrames - 1);
const currentSize = this.size * GAME_SCALE * (0.5 + progress * 0.5);
// Цвет из массива цветов
const colorIndex = Math.min(this.frame, this.colors.length - 1);
const color = this.colors[colorIndex];
// Рисуем взрыв как круг
ctx.beginPath();
ctx.arc(px, py, currentSize / 2, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// Внутренний круг (ядро взрыва)
if (this.type === EffectType.EXPLOSION_BIG && this.frame < this.maxFrames - 1) {
ctx.beginPath();
ctx.arc(px, py, currentSize / 4, 0, Math.PI * 2);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
}
}
}
Часть этого кода перепишется, когда я переведу игру на спрайты, оставив только смену кадров и "возраст", а все остальные визуальности удалив.
В файле коллизий появилась ещё пара новых методов:
export function checkBulletTankCollision(bullet, tanks) {
const bulletBox = {
x: bullet.x,
y: bullet.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
// Определяем, является ли владелец пули врагом
const ownerIsEnemy = bullet.owner && bullet.owner.type !== TankType.PLAYER;
for (const tank of tanks) {
// Пропускаем владельца пули и уничтоженных
if (tank === bullet.owner || tank.destroyed) continue;
// Вражеские пули пролетают сквозь других врагов (как в оригинале)
if (ownerIsEnemy && tank.type !== TankType.PLAYER) continue;
const tankBox = tank.getBounds();
if (checkAABBCollision(bulletBox, tankBox)) {
return tank; // Попадание в танк
}
}
return null;
}
export function checkBulletBulletCollisions(bullets) {
const collisions = [];
for (let i = 0; i < bullets.length; i++) {
const bullet1 = bullets[i];
if (!bullet1.active) continue;
const box1 = {
x: bullet1.x,
y: bullet1.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
// Определяем, принадлежит ли пуля игроку
const isPlayer1 = bullet1.owner && bullet1.owner.type === TankType.PLAYER;
for (let j = i + 1; j < bullets.length; j++) {
const bullet2 = bullets[j];
if (!bullet2.active) continue;
const isPlayer2 = bullet2.owner && bullet2.owner.type === TankType.PLAYER;
// Сталкиваются только пули разных команд (игрок vs враг)
if (isPlayer1 === isPlayer2) continue;
const box2 = {
x: bullet2.x,
y: bullet2.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
if (checkAABBCollision(box1, box2)) {
collisions.push({ bullet1, bullet2 });
}
}
}
return collisions;
}
В целом, тут можно было бы выбрать и иные решения, коллизия и методы их проверки могли бы стать более универсальными, как это сделано, скажем, в Unity. Навесить на все объекты box collider'ы и просто проверять их столкновения без отдельных методов (проверить ТанкТанк, проверить ТанкПуля, проверить ПуляПуля). В таком случае логичнее было бы добавить возможность делать коллизии триггерными, для отработки логики уже внутри самих объектов. Но в таком случае для такой простой игры значительно усложнилась бы архитектура. Поэтому в жертву универсальности я пошёл по более прямолинейному пути.
План на следующий этап:
Я тут вспомнил, что в прошлом посте забыл добавить планы на будущее. Планами было: добавить врагов.
Соответственно: точки спавна, их перемещение по карте, стрельба.
Давайте посмотрим на результат.
Появился новый класс, отвечающий за поведение врагов
export class EnemyAI {
constructor(tank) {
this.tank = tank;
// Таймер смены направления
this.changeDirectionTimer = 0;
this.changeDirectionInterval = 60; // ~2 сек при 30 FPS
// Cooldown стрельбы
this.shootCooldown = 0;
this.shootInterval = 30; // ~1 сек при 30 FPS
// Флаг застревания (для смены направления при коллизии)
this.wasBlocked = false;
}
randomDirection() {
const directions = [Direction.UP, Direction.RIGHT, Direction.DOWN, Direction.LEFT];
return directions[Math.floor(Math.random() * 4)];
}
update(allTanks, gameMap, bullets) {
const tank = this.tank;
if (tank.destroyed) return;
// === 1. Логика смены направления ===
this.changeDirectionTimer++;
// Смена направления по таймеру
if (this.changeDirectionTimer >= this.changeDirectionInterval) {
tank.direction = this.randomDirection();
this.changeDirectionTimer = 0;
// Рандомизируем интервал (40-80 кадров)
this.changeDirectionInterval = 40 + Math.floor(Math.random() * 40);
}
// === 2. Попытка движения ===
const prevX = tank.x;
const prevY = tank.y;
tank.move(tank.direction);
// Проверка: застрял ли танк (не сдвинулся)
const stuck = (tank.x === prevX && tank.y === prevY && !tank.moving);
if (stuck && !this.wasBlocked) {
// Танк только что застрял — меняем направление
tank.direction = this.randomDirection();
this.wasBlocked = true;
this.changeDirectionTimer = 0;
} else if (!stuck) {
this.wasBlocked = false;
}
// === 3. Автоматическая стрельба ===
this.shootCooldown--;
if (this.shootCooldown <= 0 && tank.canShoot()) {
const bullet = tank.shoot();
if (bullet) {
bullets.push(bullet);
// Рандомизируем cooldown (20-50 кадров)
this.shootCooldown = 20 + Math.floor(Math.random() * 30);
}
}
}
}
Он простой как три рубля, так что, думаю, можно обойтись без объяснений (тем более что комментариев в коде и так предостаточно).
К файле main появились коллекции врагов и их "мозгов"
/** @type {Array<Tank>} */
let enemies = [];
/** @type {Array<EnemyAI>} */
let enemyAIs = [];
Спавн врагов при старте игры
spawnEnemy(0, 0); // Левый угол
spawnEnemy(12 * 8, 0); // Центр (96px)
spawnEnemy(24 * 8 - 16, 0); // Правый угол (176px, учитываем размер танка)
// Пока спавню сразу все три, а не по очереди, как в оригинале
function spawnEnemy(x, y) {
const enemy = new Tank(x, y, TankType.ENEMY_BASIC);
enemy.direction = Direction.DOWN; // Враги всегда смотрят вниз
enemy.setMap(gameMap);
// Создаём AI для врага
const ai = new EnemyAI(enemy);
enemies.push(enemy);
enemyAIs.push(ai);
console.log(`Enemy spawned at (${x}, ${y})`);
}
И рисуем это
for (const enemy of enemies) {
if (!enemy.destroyed) {
enemy.render();
}
}
Там ещё есть обновление ссылок чтоб чистить из памяти уничтоженные танки, но в целом ничего больше интересного.
Оптимистичный план на следующий этап вот такой:
А теперь давайте посмотрим, что изменилось.
В первую очередь, конечно же, изменился метод checkTileCollision внутри класс патрона.
checkTileCollision(newX, newY, gameMap) {
const tiles = getTilesUnderEntity(newX, newY, BULLET_SIZE, BULLET_SIZE);
if (tiles.length === 0) return false;
// Находим тайлы на передней кромке по направлению движения
let frontTiles;
switch (this.direction) {
case Direction.UP:
// Минимальный Y = передняя кромка
const minY = Math.min(...tiles.map(t => t.ty));
frontTiles = tiles.filter(t => t.ty === minY);
break;
case Direction.DOWN:
// Максимальный Y = передняя кромка
const maxY = Math.max(...tiles.map(t => t.ty));
frontTiles = tiles.filter(t => t.ty === maxY);
break;
case Direction.LEFT:
// Минимальный X = передняя кромка
const minX = Math.min(...tiles.map(t => t.tx));
frontTiles = tiles.filter(t => t.tx === minX);
break;
case Direction.RIGHT:
// Максимальный X = передняя кромка
const maxX = Math.max(...tiles.map(t => t.tx));
frontTiles = tiles.filter(t => t.tx === maxX);
break;
default:
frontTiles = tiles;
}
let hasCollision = false;
// Проверяем только тайлы на передней кромке
for (const {tx, ty} of frontTiles) {
const tileId = gameMap.getTile(tx, ty);
const tileDef = TILE_DEFS[tileId];
if (!tileDef) continue;
// Пуля блокируется этим тайлом?
if (tileDef.blocksBullet) {
hasCollision = true;
// Пытаемся разрушить тайл
gameMap.damageTile(tx, ty, this.direction, this.power);
}
}
if (hasCollision) {
this.destroy();
return true;
}
return false;
}
Изменился внутренний мир объекта, отвечающего за направления
DamageState = Object.freeze({
//было
FULL: 0b11,
HALF_LEFT: 0b10,
HALF_RIGHT: 0b01,
DESTROYED: 0b00
//стало
FULL: 0,
HALF_LEFT: 1, // Осталась левая половина (вертикальный срез)
HALF_RIGHT: 2, // Осталась правая половина (вертикальный срез)
HALF_TOP: 3, // Осталась верхняя половина (горизонтальный срез)
HALF_BOTTOM: 4, // Осталась нижняя половина (горизонтальный срез)
DESTROYED: 5
});
Эта побитовая маска оказалась весьма нестабильной историей на тестах.
Ну и как говорил в прошлом выпуске, мне не нравилось, как у меня был сделан инпут, поэтому я его переделал. Он больше не принимает на вход карту и коллекцию патронов, а делает только то, что должен: отслеживает инпут. Логика обработки вынесена уже в main.js
export function getPlayerActions() {
const actions = {
movement: null, // Direction.UP/DOWN/LEFT/RIGHT или null
shoot: false
};
// Собираем ввод с клавиатуры
collectKeyboardActions(actions);
// Собираем ввод с геймпада
collectGamepadActions(actions);
return actions;
}
function update(dt) {
// 1. Получаем действия игрока из input
const actions = getPlayerActions();
// 2. Применяем действия к игроку
if (player && !player.destroyed) {
// Движение
if (actions.movement !== null) {
player.move(actions.movement);
}
// Стрельба
if (actions.shoot && player.canShoot()) {
const bullet = player.shoot();
if (bullet) {
bullets.push(bullet);
}
}
player.update(dt);
}
// 3. Обновление пуль
for (const bullet of bullets) {
bullet.update(gameMap);
}
// 4. Удаление неактивных пуль
bullets = bullets.filter(b => b.active);
// 5. Подсчёт FPS для отладки
frameCount++;
fpsTimer += dt;
if (fpsTimer >= 1000) {
fps = frameCount;
frameCount = 0;
fpsTimer = 0;
// Обновляем FPS на странице
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = fps;
}
}
}
Внутри карты поменялось получение и редактирование карты урона
getDamage(x, y) {
const key = `${x},${y}`;
// Если нет в маске — значит либо FULL, либо не разрушаемый
if (!this.damageMask.has(key)) {
return DamageState.FULL;
}
return this.damageMask.get(key);
}
setDamage(x, y, state) {
const key = `${x},${y}`;
if (state === DamageState.DESTROYED) {
this.damageMask.delete(key);
this.tileGrid[y][x] = TileType.EMPTY;
} else if (state === DamageState.FULL) {
// FULL — удаляем из маски (дефолтное состояние)
this.damageMask.delete(key);
} else {
this.damageMask.set(key, state);
}
this.dirty = true;
}
И появился большой новый метод, который и занимается непосредственно разрушением:
damageTile(x, y, direction, power) {
if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE) return false;
const tileId = this.getTile(x, y);
const tileDef = TILE_DEFS[tileId];
if (!tileDef) return false;
// Проверяем можно ли разрушить этот тайл
if (!canBulletDestroy(tileDef, power)) {
return false;
}
// Получаем текущее состояние повреждения
const currentDamage = this.getDamage(x, y);
// Если уже разрушен — ничего не делаем
if (currentDamage === DamageState.DESTROYED) {
return false;
}
// Определяем новое состояние в зависимости от направления пули
let newDamage;
if (currentDamage === DamageState.FULL) {
// Первое попадание — создаём половину
// Пуля убирает ту часть, в которую летит
switch (direction) {
case Direction.UP:
// Пуля летит вверх → убирает нижнюю часть → остаётся верхняя
newDamage = DamageState.HALF_TOP;
break;
case Direction.DOWN:
// Пуля летит вниз → убирает верхнюю часть → остаётся нижняя
newDamage = DamageState.HALF_BOTTOM;
break;
case Direction.LEFT:
// Пуля летит влево → убирает правую часть → остаётся левая
newDamage = DamageState.HALF_LEFT;
break;
case Direction.RIGHT:
// Пуля летит вправо → убирает левую часть → остаётся правая
newDamage = DamageState.HALF_RIGHT;
break;
}
} else {
// Второе попадание — полное разрушение
newDamage = DamageState.DESTROYED;
}
// Обновляем состояние
this.setDamage(x, y, newDamage);
return true;
}
Пока складывается впечатление, что начинаю наворачивать мрак и ужас. Поэтому со следующего выпуска начну помимо введения новых фишек смотреть, что можно упростить и улучшить по архитектуре и коду. Как я уже в этот раз сделал с системой ввода.
Начал переносить видео на фоксфрейм. Качество в результате переноса пострадало, но лучше так, чем никак...
В прошлой серии вы видели: стены более непротицаемы.
В этой серии мы научим наш так шмалять белыми квадратами по сторонам! И будем убеждать себя, что это снаряды.
В первую очередь мы, получается, должны создать сущность снаряда. Оборачиваем это безобразие в класс:
export class Bullet {
constructor(x, y, direction, owner, power = 0) {
this.x = x;
this.y = y;
this.direction = direction;
this.speed = Speed.BULLET;
this.power = power;
this.owner = owner;
// Состояние
this.active = true;
}
}
Сущность пули помнит, кто её владелец, знает свою скорость и мощность. Мощность нам понадобится на следующих этапах. Например, стандартная пуля может только «брить» кирпичи, более мощная сможет уничтожать уже и бетонные/стальные блоки. В каких-то версиях пиратских танков была возможность даже сбривать кусты.
Далее у пули есть служебные методы, которые нужны для просчитывания коллизий, отрисовки положения в пространстве и т.д.
update(gameMap) {
if (!this.active) return;
// Вычисляем смещение
let dx = 0;
let dy = 0;
switch (this.direction) {
case Direction.UP:
dy = -this.speed;
break;
case Direction.DOWN:
dy = this.speed;
break;
case Direction.LEFT:
dx = -this.speed;
break;
case Direction.RIGHT:
dx = this.speed;
break;
}
// Новая позиция
const newX = this.x + dx;
const newY = this.y + dy;
// Проверка границ карты
if (newX < 0 || newX + BULLET_SIZE > LOGICAL_FIELD_SIZE ||
newY < 0 || newY + BULLET_SIZE > LOGICAL_FIELD_SIZE) {
this.destroy();
return;
}
// Проверка коллизий с тайлами
if (gameMap && this.checkTileCollision(newX, newY, gameMap)) {
return; // Пуля уничтожена в checkTileCollision
}
this.x = newX;
this.y = newY;
}
checkTileCollision(newX, newY, gameMap) {
const tiles = getTilesUnderEntity(newX, newY, BULLET_SIZE, BULLET_SIZE);
for (const {tx, ty} of tiles) {
const tileId = gameMap.getTile(tx, ty);
const tileDef = TILE_DEFS[tileId];
if (!tileDef) continue;
// Пуля блокируется этим тайлом?
if (tileDef.blocksBullet) {
this.destroy();
// TODO: Разрушение тайла (на следующем этапе)
return true;
}
}
return false;
}
destroy() {
if (!this.active) return;
this.active = false;
if (this.owner) {
this.owner.activeBullets = Math.max(0, this.owner.activeBullets - 1);
}
// TODO: Создание эффекта взрыва (Не скоро, ещё этапа через три)
}
render() {
if (!this.active) return;
const ctx = foregroundCtx;
// Конвертация логических координат в физические
const px = GAME_FIELD_X + this.x * GAME_SCALE;
const py = GAME_FIELD_Y + this.y * GAME_SCALE;
const size = BULLET_SIZE * GAME_SCALE;
// Рисуем пулю как белый квадрат
ctx.fillStyle = Colors.BULLET;
ctx.fillRect(px, py, size, size);
}
getBounds() {
return {
x: this.x,
y: this.y,
width: BULLET_SIZE,
height: BULLET_SIZE
};
}
В самом танке обновляем метод стрельбы. Раньше там не было нифига, теперь вот:
shoot() {
if (this.activeBullets >= this.bulletCount) {
return null;
}
//return null; - так было
// так стало:
// Вычисляем позицию пули (центр передней части танка)
let bulletX, bulletY;
const centerOffset = (TANK_SIZE - BULLET_SIZE) / 2;
switch (this.direction) {
case Direction.UP:
bulletX = this.x + centerOffset;
bulletY = this.y - BULLET_SIZE;
break;
case Direction.DOWN:
bulletX = this.x + centerOffset;
bulletY = this.y + TANK_SIZE;
break;
case Direction.LEFT:
bulletX = this.x - BULLET_SIZE;
bulletY = this.y + centerOffset;
break;
case Direction.RIGHT:
bulletX = this.x + TANK_SIZE;
bulletY = this.y + centerOffset;
break;
}
// Создаём пулю
const bullet = new Bullet(bulletX, bulletY, this.direction, this, this.bulletLevel);
this.activeBullets++;
return bullet;
}
Изменилась обработка пользовательского ввода
function processInput(player, gameMap = null, bullets = []) {
if (!player || player.destroyed) return;
// Обрабатываем клавиатуру
processKeyboardInput(player, gameMap, bullets);
// Обрабатываем геймпад
processGamepadInput(player, gameMap, bullets);
}
И вот тут я прям серьёзно задумался: «А не хуйню ли я делаю?». Как будто передавать карту и массив снарядов в обработку инпутов – гавно идея...
К следующему посту поправлю.
А пока вот так:
if (keys['Space'] || keys['Enter']) {
if (player.canShoot()) {
const bullet = player.shoot();
if (bullet) {
bullets.push(bullet);
}
}
}
В основном методе update теперь вызываем и апдейт всех пуль
for (const bullet of bullets) {
bullet.update(gameMap);
}
И в основном рендере их рисуем
// Рендер пуль
for (const bullet of bullets) {
bullet.render();
}
Если есть идеи по улучшению кода, рад буду почитать
План на следующий этап
Всем хороших книг!
Жува - жаргонное название жевательной резинки. У детей в 1990х считалось нормальным попросить жуву пожевать у жующего. Жевачка была ценностью, ее долго не выбрасывали, а могли заклеить, например, за у...
В такую погоду это прям обязательно:)
Указующим перстом! И вообще, я тут пришел умы смущать и непотребное вкидывать, а вы меня думать заставляете!