Слияние feature/pvp-game-logic: PvP-логика на сервере и клиенте

This commit is contained in:
2026-07-28 12:05:18 +05:00
17 changed files with 6632 additions and 5 deletions
+31
View File
@@ -88,3 +88,34 @@
content: '- ';
color: var(--color-accent-dim);
}
#battlecity-canvas-container {
max-width: 640px;
margin: 1.5rem 0;
}
#battlecity-canvas-container canvas {
border: var(--border);
background: var(--color-bg);
display: block;
}
#battle-result {
color: var(--color-accent);
font-size: 1.1rem;
margin: 0.75rem 0;
}
#battle-back {
background: transparent;
border: 1px solid var(--color-accent-dim);
color: var(--color-accent);
font-family: var(--font-mono);
padding: 0.35rem 0.75rem;
cursor: pointer;
}
#battle-back:hover {
border-color: var(--color-accent);
box-shadow: 0 0 6px var(--color-accent-dim);
}
+7 -2
View File
@@ -25,13 +25,18 @@
</header>
<main id="game-root">
<!-- TODO: лобби (этап 3) и игровое поле на Quintus.js (этапы 4-5) -->
<!-- TODO: coop-режим с AI и защитой базы — этап 5 -->
<h2 class="prompt">./battlecity</h2>
<div id="lobby"></div>
<div id="battlecity-canvas-container"></div>
<div id="battlecity-canvas-container" style="display: none"></div>
</main>
</div>
<script src="/vendor/quintus/quintus.js"></script>
<script src="/vendor/quintus/quintus_2d.js"></script>
<script src="/vendor/quintus/quintus_sprites.js"></script>
<script src="/vendor/quintus/quintus_scenes.js"></script>
<script src="/vendor/quintus/quintus_input.js"></script>
<script type="module" src="/js/matrix-rain.js"></script>
<script type="module" src="/js/game/main.js"></script>
</body>
+195
View File
@@ -0,0 +1,195 @@
import { ClientMessage, ServerMessage } from './protocol.js';
const TILE_PX = 32;
// Quintus транслирует и вращает контекст сам (Sprite.render -> matrix.setContextTransform)
// на основе p.x/p.y/p.angle — draw() получает уже трансформированный контекст
// и должен рисовать спрайт центрированным в (0,0), без своих translate/rotate.
const DIR_DEGREES = { up: 0, right: 90, down: 180, left: 270 };
let Q = null;
let net = null;
let myPlayerId = null;
let stage = null;
let tankSprites = new Map();
let bulletSprites = new Map();
let lastSentInput = null;
let seq = 0;
let polling = false;
function setupEntities() {
Q.Sprite.extend('Tank', {
init(p) {
this._super(p, { w: 26, h: 26, renderAlways: true });
},
draw(ctx) {
const p = this.p;
ctx.fillStyle = p.isMe ? '#00ff41' : '#9fa3a0';
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.fillStyle = '#0a0e0c';
ctx.fillRect(-3, -p.h / 2 - 8, 6, 12);
},
});
Q.Sprite.extend('Bullet', {
init(p) {
this._super(p, { w: 8, h: 8, renderAlways: true });
},
draw(ctx) {
ctx.fillStyle = '#c7c7c7';
ctx.beginPath();
ctx.arc(0, 0, this.p.w / 2, 0, Math.PI * 2);
ctx.fill();
},
});
Q.Sprite.extend('Wall', {
init(p) {
this._super(p, { w: TILE_PX, h: TILE_PX });
},
draw(ctx) {
const p = this.p;
if (p.tile === 2) {
ctx.fillStyle = '#9fa3a0';
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
} else {
ctx.fillStyle = '#0a8f2c';
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.strokeStyle = '#0a0e0c';
ctx.strokeRect(-p.w / 2 + 1, -p.h / 2 + 1, p.w - 2, p.h - 2);
}
},
});
}
function ensureQuintus(cols, rows) {
if (Q) {
return;
}
Q = window.Quintus().include('Sprites, Scenes, 2D, Input').setup({
width: cols * TILE_PX,
height: rows * TILE_PX,
});
Q.input.keyboardControls({
LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down',
A: 'left', D: 'right', W: 'up', S: 'down',
SPACE: 'fire',
});
setupEntities();
Q.scene('battle', (stageRef) => {
stage = stageRef;
});
document.getElementById('battlecity-canvas-container').appendChild(Q.el);
}
function buildMap(map) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
const tile = map[y][x];
if (tile === 0) {
continue;
}
stage.insert(
new Q.Wall({
x: x * TILE_PX + TILE_PX / 2,
y: y * TILE_PX + TILE_PX / 2,
tile,
})
);
}
}
}
function pollInput() {
if (!polling) {
return;
}
const input = {
seq: seq++,
up: !!Q.inputs.up,
down: !!Q.inputs.down,
left: !!Q.inputs.left,
right: !!Q.inputs.right,
fire: !!Q.inputs.fire,
};
const key = `${input.up}${input.down}${input.left}${input.right}${input.fire}`;
if (key !== lastSentInput) {
lastSentInput = key;
net.send(ClientMessage.GAME_INPUT, input);
}
requestAnimationFrame(pollInput);
}
function onState(payload) {
for (const t of payload.tanks) {
let sprite = tankSprites.get(t.id);
if (!sprite) {
sprite = new Q.Tank({ x: t.x * TILE_PX, y: t.y * TILE_PX, isMe: t.id === myPlayerId });
stage.insert(sprite);
tankSprites.set(t.id, sprite);
}
sprite.p.x = t.x * TILE_PX;
sprite.p.y = t.y * TILE_PX;
sprite.p.angle = DIR_DEGREES[t.direction] || 0;
sprite.p.hidden = !t.alive;
}
const seenBullets = new Set();
for (const b of payload.bullets) {
seenBullets.add(b.id);
let sprite = bulletSprites.get(b.id);
if (!sprite) {
sprite = new Q.Bullet({ x: b.x * TILE_PX, y: b.y * TILE_PX });
stage.insert(sprite);
bulletSprites.set(b.id, sprite);
}
sprite.p.x = b.x * TILE_PX;
sprite.p.y = b.y * TILE_PX;
}
for (const [id, sprite] of bulletSprites) {
if (!seenBullets.has(id)) {
sprite.destroy();
bulletSprites.delete(id);
}
}
}
function onOver(payload) {
polling = false;
const resultText = { win: 'Победа', lose: 'Поражение', draw: 'Ничья' }[payload.result] || payload.result;
const resultEl = document.getElementById('battle-result');
resultEl.textContent = `${resultText}${payload.reason}`;
document.getElementById('battle-back').style.display = 'inline-block';
}
export function startBattle(networkInstance, payload, ownPlayerId) {
net = networkInstance;
myPlayerId = ownPlayerId;
const rows = payload.map.length;
const cols = payload.map[0].length;
document.getElementById('lobby').style.display = 'none';
const container = document.getElementById('battlecity-canvas-container');
container.style.display = 'block';
container.innerHTML = '<p id="battle-result"></p><button id="battle-back" style="display:none">выйти в лобби</button>';
ensureQuintus(cols, rows);
Q.stageScene('battle', 0);
tankSprites = new Map();
bulletSprites = new Map();
buildMap(payload.map);
net.on(ServerMessage.GAME_STATE, onState);
net.on(ServerMessage.GAME_OVER, onOver);
lastSentInput = null;
seq = 0;
polling = true;
requestAnimationFrame(pollInput);
document.getElementById('battle-back').addEventListener('click', () => {
location.reload();
});
}
+18 -2
View File
@@ -1,8 +1,11 @@
import { Network, ServerMessage } from './network.js';
import { ClientMessage } from './protocol.js';
import { startBattle } from './battle.js';
const container = document.getElementById('lobby');
let net;
let myPlayerId = null;
let rooms = [];
let currentRoom = null;
let errorMessage = '';
@@ -47,14 +50,19 @@ function renderLobbyView() {
}
function renderRoomView() {
const readyBtn =
currentRoom.mode === 'pvp'
? '<button data-action="ready">готов</button>'
: '<p class="lobby-error">coop ещё не реализован (этап 5)</p>';
container.innerHTML = `
<div class="lobby-panel">
${errorBanner()}
<h3 class="prompt">room ${currentRoom.name}</h3>
<p>режим: <span class="tag">${currentRoom.mode}</span></p>
<ul class="room-players">
${currentRoom.players.map((p) => `<li>${p.nickname}</li>`).join('')}
${currentRoom.players.map((p) => `<li>${p.nickname}${p.ready ? ' — готов' : ''}</li>`).join('')}
</ul>
${readyBtn}
<button data-action="leave">выйти в лобби</button>
</div>`;
}
@@ -78,6 +86,9 @@ container?.addEventListener('click', (e) => {
case 'join':
net.joinRoom(btn.dataset.roomId);
break;
case 'ready':
net.send(ClientMessage.GAME_READY);
break;
case 'leave':
currentRoom = null;
net.leaveRoom();
@@ -99,8 +110,9 @@ export function initLobby() {
net = new Network();
net.on('_open', () => net.hello(nickname));
net.on(ServerMessage.HELLO_ACK, () => {
net.on(ServerMessage.HELLO_ACK, (payload) => {
connected = true;
myPlayerId = payload.player_id;
net.listRooms();
});
net.on('_close', () => {
@@ -125,6 +137,10 @@ export function initLobby() {
}
});
net.on(ServerMessage.GAME_START, (payload) => {
startBattle(net, payload, myPlayerId);
});
net.on(ServerMessage.ERROR, (payload) => {
console.error('[gameserver] error:', payload);
errorMessage = payload.message;
View File
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2011 Cykod LLC, http://coderdeck.com/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2292
View File
File diff suppressed because it is too large Load Diff
+550
View File
@@ -0,0 +1,550 @@
/*global Quintus:false, module:false */
var quintus2D = function(Quintus) {
"use strict";
Quintus["2D"] = function(Q) {
Q.component('viewport',{
added: function() {
this.entity.on('prerender',this,'prerender');
this.entity.on('render',this,'postrender');
this.x = 0;
this.y = 0;
this.offsetX = 0;
this.offsetY = 0;
this.centerX = Q.width/2;
this.centerY = Q.height/2;
this.scale = 1;
},
extend: {
follow: function(sprite,directions,boundingBox) {
this.off('poststep',this.viewport,'follow');
this.viewport.directions = directions || { x: true, y: true };
this.viewport.following = sprite;
if(Q._isUndefined(boundingBox) && this.lists.TileLayer !== undefined) {
this.viewport.boundingBox = Q._detect(this.lists.TileLayer, function(layer) {
return layer.p.boundingBox ? { minX: 0, maxX: layer.p.w, minY: 0, maxY: layer.p.h } : null;
});
} else {
this.viewport.boundingBox = boundingBox;
}
this.on('poststep',this.viewport,'follow');
this.viewport.follow(true);
},
unfollow: function() {
this.off('poststep',this.viewport,'follow');
},
centerOn: function(x,y) {
this.viewport.centerOn(x,y);
},
moveTo: function(x,y) {
return this.viewport.moveTo(x,y);
}
},
follow: function(first) {
var followX = Q._isFunction(this.directions.x) ? this.directions.x(this.following) : this.directions.x;
var followY = Q._isFunction(this.directions.y) ? this.directions.y(this.following) : this.directions.y;
this[first === true ? 'centerOn' : 'softCenterOn'](
followX ?
this.following.p.x - this.offsetX :
undefined,
followY ?
this.following.p.y - this.offsetY :
undefined
);
},
offset: function(x,y) {
this.offsetX = x;
this.offsetY = y;
},
softCenterOn: function(x,y) {
if(x !== void 0) {
var dx = (x - Q.width / 2 / this.scale - this.x)/3;
if(this.boundingBox) {
if(this.x + dx < this.boundingBox.minX) {
this.x = this.boundingBox.minX / this.scale;
}
else if(this.x + dx > (this.boundingBox.maxX - Q.width) / this.scale) {
this.x = Math.max(this.boundingBox.maxX - Q.width, this.boundingBox.minX) / this.scale;
}
else {
this.x += dx;
}
}
else {
this.x += dx;
}
}
if(y !== void 0) {
var dy = (y - Q.height / 2 / this.scale - this.y)/3;
if(this.boundingBox) {
if(this.y + dy < this.boundingBox.minY) {
this.y = this.boundingBox.minY / this.scale;
}
else if(this.y + dy > (this.boundingBox.maxY - Q.height) / this.scale) {
this.y = Math.max(this.boundingBox.maxY - Q.height, this.boundingBox.minY) / this.scale;
}
else {
this.y += dy;
}
}
else {
this.y += dy;
}
}
},
centerOn: function(x,y) {
if(x !== void 0) {
this.x = x - Q.width / 2 / this.scale;
}
if(y !== void 0) {
this.y = y - Q.height / 2 / this.scale;
}
},
moveTo: function(x,y) {
if(x !== void 0) {
this.x = x;
}
if(y !== void 0) {
this.y = y;
}
return this.entity;
},
prerender: function() {
this.centerX = this.x + Q.width / 2 /this.scale;
this.centerY = this.y + Q.height / 2 /this.scale;
Q.ctx.save();
Q.ctx.translate(Math.floor(Q.width/2),Math.floor(Q.height/2));
Q.ctx.scale(this.scale,this.scale);
Q.ctx.translate(-Math.floor(this.centerX), -Math.floor(this.centerY));
},
postrender: function() {
Q.ctx.restore();
}
});
Q.Sprite.extend("TileLayer",{
init: function(props) {
this._super(props,{
tileW: 32,
tileH: 32,
blockTileW: 10,
blockTileH: 10,
type: 1,
renderAlways: true
});
if(this.p.dataAsset) {
this.load(this.p.dataAsset);
}
this.setDimensions();
this.blocks = [];
this.p.blockW = this.p.tileW * this.p.blockTileW;
this.p.blockH = this.p.tileH * this.p.blockTileH;
this.colBounds = {};
this.directions = [ 'top','left','right','bottom'];
this.tileProperties = {};
this.collisionObject = {
p: {
w: this.p.tileW,
h: this.p.tileH,
cx: this.p.tileW/2,
cy: this.p.tileH/2
}
};
this.tileCollisionObjects = {};
this.collisionNormal = { separate: []};
this._generateCollisionObjects();
},
// Generate the tileCollisionObject overrides where needed
_generateCollisionObjects: function() {
var self = this;
function returnPoint(pt) {
return [ pt[0] * self.p.tileW - self.p.tileW/2,
pt[1] * self.p.tileH - self.p.tileH/2
];
}
if(this.sheet() && this.sheet().frameProperties) {
var frameProperties = this.sheet().frameProperties;
for(var k in frameProperties) {
var colObj = this.tileCollisionObjects[k] = { p: Q._clone(this.collisionObject.p) };
Q._extend(colObj.p,frameProperties[k]);
if(colObj.p.points) {
colObj.p.points = Q._map(colObj.p.points, returnPoint);
}
this.tileCollisionObjects[k] = colObj;
}
}
},
load: function(dataAsset) {
var fileParts = dataAsset.split("."),
fileExt = fileParts[fileParts.length-1].toLowerCase(),
data;
if (fileExt === "json") {
data = Q._isString(dataAsset) ? Q.asset(dataAsset) : dataAsset;
}
else {
throw "file type not supported";
}
this.p.tiles = data;
},
setDimensions: function() {
var tiles = this.p.tiles;
if(tiles) {
this.p.rows = tiles.length;
this.p.cols = tiles[0].length;
this.p.w = this.p.cols * this.p.tileW;
this.p.h = this.p.rows * this.p.tileH;
}
},
getTile: function(tileX,tileY) {
return this.p.tiles[tileY] && this.p.tiles[tileY][tileX];
},
getTileProperty: function(tile, prop) {
if(this.tileProperties[tile] !== undefined) {
return this.tileProperties[tile][prop];
} else {
return;
}
},
getTileProperties: function(tile) {
if(this.tileProperties[tile] !== undefined) {
return this.tileProperties[tile];
} else {
return {};
}
},
getTilePropertyAt: function(tileX, tileY, prop) {
return this.getTileProperty(this.getTile(tileX, tileY), prop);
},
getTilePropertiesAt: function(tileX, tileY) {
return this.getTileProperties(this.getTile(tileX, tileY));
},
tileHasProperty: function(tile, prop) {
return(this.getTileProperty(tile, prop) !== undefined);
},
setTile: function(x,y,tile) {
var p = this.p,
blockX = Math.floor(x/p.blockTileW),
blockY = Math.floor(y/p.blockTileH);
if(x >= 0 && x < this.p.cols &&
y >= 0 && y < this.p.rows) {
this.p.tiles[y][x] = tile;
if(this.blocks[blockY]) {
this.blocks[blockY][blockX] = null;
}
}
},
tilePresent: function(tileX,tileY) {
return this.p.tiles[tileY] && this.collidableTile(this.p.tiles[tileY][tileX]);
},
// Overload this method to draw tiles at frame 0 or not draw
// tiles at higher number frames
drawableTile: function(tileNum) {
return tileNum > 0;
},
// Overload this method to control which tiles trigger a collision
// (defaults to all tiles > number 0)
collidableTile: function(tileNum) {
return tileNum > 0;
},
getCollisionObject: function(tileX, tileY) {
var p = this.p,
tile = this.getTile(tileX, tileY),
colObj;
colObj = (this.tileCollisionObjects[tile] !== undefined) ?
this.tileCollisionObjects[tile] : this.collisionObject;
colObj.p.x = tileX * p.tileW + p.x + p.tileW/2;
colObj.p.y = tileY * p.tileH + p.y + p.tileH/2;
return colObj;
},
collide: function(obj) {
var p = this.p,
objP = obj.c || obj.p,
tileStartX = Math.floor((objP.x - objP.cx - p.x) / p.tileW),
tileStartY = Math.floor((objP.y - objP.cy - p.y) / p.tileH),
tileEndX = Math.ceil((objP.x - objP.cx + objP.w - p.x) / p.tileW),
tileEndY = Math.ceil((objP.y - objP.cy + objP.h - p.y) / p.tileH),
normal = this.collisionNormal,
col, colObj;
normal.collided = false;
for(var tileY = tileStartY; tileY<=tileEndY; tileY++) {
for(var tileX = tileStartX; tileX<=tileEndX; tileX++) {
if(this.tilePresent(tileX,tileY)) {
colObj = this.getCollisionObject(tileX, tileY);
col = Q.collision(obj,colObj);
if(col && col.magnitude > 0) {
if(colObj.p.sensor) {
colObj.tile = this.getTile(tileX,tileY);
if(obj.trigger) {
obj.trigger('sensor.tile',colObj);
}
} else if(!normal.collided || normal.magnitude < col.magnitude ) {
normal.collided = true;
normal.separate[0] = col.separate[0];
normal.separate[1] = col.separate[1];
normal.magnitude = col.magnitude;
normal.distance = col.distance;
normal.normalX = col.normalX;
normal.normalY = col.normalY;
normal.tileX = tileX;
normal.tileY = tileY;
normal.tile = this.getTile(tileX,tileY);
if(obj.p.collisions !== undefined) {
obj.p.collisions.push(normal);
}
}
}
}
}
}
return normal.collided ? normal : false;
},
prerenderBlock: function(blockX,blockY) {
var p = this.p,
tiles = p.tiles,
sheet = this.sheet(),
blockOffsetX = blockX*p.blockTileW,
blockOffsetY = blockY*p.blockTileH;
if(blockOffsetX < 0 || blockOffsetX >= this.p.cols ||
blockOffsetY < 0 || blockOffsetY >= this.p.rows) {
return;
}
var canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = p.blockW;
canvas.height= p.blockH;
this.blocks[blockY] = this.blocks[blockY] || {};
this.blocks[blockY][blockX] = canvas;
for(var y=0;y<p.blockTileH;y++) {
if(tiles[y+blockOffsetY]) {
for(var x=0;x<p.blockTileW;x++) {
if(this.drawableTile(tiles[y+blockOffsetY][x+blockOffsetX])) {
sheet.draw(ctx,
x*p.tileW,
y*p.tileH,
tiles[y+blockOffsetY][x+blockOffsetX]);
}
}
}
}
},
drawBlock: function(ctx, blockX, blockY) {
var p = this.p,
startX = Math.floor(blockX * p.blockW + p.x),
startY = Math.floor(blockY * p.blockH + p.y);
if(!this.blocks[blockY] || !this.blocks[blockY][blockX]) {
this.prerenderBlock(blockX,blockY);
}
if(this.blocks[blockY] && this.blocks[blockY][blockX]) {
ctx.drawImage(this.blocks[blockY][blockX],startX,startY);
}
},
draw: function(ctx) {
var p = this.p,
viewport = this.stage.viewport,
scale = viewport ? viewport.scale : 1,
x = viewport ? viewport.x : 0,
y = viewport ? viewport.y : 0,
viewW = Q.width / scale,
viewH = Q.height / scale,
startBlockX = Math.floor((x - p.x) / p.blockW),
startBlockY = Math.floor((y - p.y) / p.blockH),
endBlockX = Math.floor((x + viewW - p.x) / p.blockW),
endBlockY = Math.floor((y + viewH - p.y) / p.blockH);
for(var iy=startBlockY;iy<=endBlockY;iy++) {
for(var ix=startBlockX;ix<=endBlockX;ix++) {
this.drawBlock(ctx,ix,iy);
}
}
}
});
Q.gravityY = 9.8*100;
Q.gravityX = 0;
Q.component('2d',{
added: function() {
var entity = this.entity;
Q._defaults(entity.p,{
vx: 0,
vy: 0,
ax: 0,
ay: 0,
gravity: 1,
collisionMask: Q.SPRITE_DEFAULT
});
entity.on('step',this,"step");
entity.on('hit',this,'collision');
},
collision: function(col,last) {
var entity = this.entity,
p = entity.p,
magnitude = 0;
if(col.obj.p && col.obj.p.sensor) {
col.obj.trigger("sensor",entity);
return;
}
col.impact = 0;
var impactX = Math.abs(p.vx);
var impactY = Math.abs(p.vy);
p.x -= col.separate[0];
p.y -= col.separate[1];
// Top collision
if(col.normalY < -0.3) {
if(!p.skipCollide && p.vy > 0) { p.vy = 0; }
col.impact = impactY;
entity.trigger("bump.bottom",col);
entity.trigger("bump",col);
}
if(col.normalY > 0.3) {
if(!p.skipCollide && p.vy < 0) { p.vy = 0; }
col.impact = impactY;
entity.trigger("bump.top",col);
entity.trigger("bump",col);
}
if(col.normalX < -0.3) {
if(!p.skipCollide && p.vx > 0) { p.vx = 0; }
col.impact = impactX;
entity.trigger("bump.right",col);
entity.trigger("bump",col);
}
if(col.normalX > 0.3) {
if(!p.skipCollide && p.vx < 0) { p.vx = 0; }
col.impact = impactX;
entity.trigger("bump.left",col);
entity.trigger("bump",col);
}
},
step: function(dt) {
var p = this.entity.p,
dtStep = dt;
// TODO: check the entity's magnitude of vx and vy,
// reduce the max dtStep if necessary to prevent
// skipping through objects.
while(dtStep > 0) {
dt = Math.min(1/30,dtStep);
// Updated based on the velocity and acceleration
p.vx += p.ax * dt + (p.gravityX === void 0 ? Q.gravityX : p.gravityX) * dt * p.gravity;
p.vy += p.ay * dt + (p.gravityY === void 0 ? Q.gravityY : p.gravityY) * dt * p.gravity;
p.x += p.vx * dt;
p.y += p.vy * dt;
this.entity.stage.collide(this.entity);
dtStep -= dt;
}
}
});
Q.component('aiBounce', {
added: function() {
this.entity.on("bump.right",this,"goLeft");
this.entity.on("bump.left",this,"goRight");
},
goLeft: function(col) {
this.entity.p.vx = -col.impact;
if(this.entity.p.defaultDirection === 'right') {
this.entity.p.flip = 'x';
}
else {
this.entity.p.flip = false;
}
},
goRight: function(col) {
this.entity.p.vx = col.impact;
if(this.entity.p.defaultDirection === 'left') {
this.entity.p.flip = 'x';
}
else {
this.entity.p.flip = false;
}
}
});
};
};
if(typeof Quintus === 'undefined') {
module.exports = quintus2D;
} else {
quintus2D(Quintus);
}
+986
View File
@@ -0,0 +1,986 @@
/*global Quintus:false, module:false */
/**
Quintus HTML5 Game Engine - Input Module
The code in `quintus_input.js` defines the `Quintus.Input` module, which
concerns itself with game-type (pretty anything besides touchscreen input)
@module Quintus.Input
*/
var quintusInput = function(Quintus) {
"use strict";
/**
* Quintus Input Module
*
* @class Quintus.Input
*/
Quintus.Input = function(Q) {
/**
* Provided key names mapped to key codes - add more names and key codes as necessary
*
* @for Quintus.Input
* @property KEY_NAMES
* @type Object
* @static
*/
var KEY_NAMES = Q.KEY_NAMES = {
LEFT: 37, RIGHT: 39,
UP: 38, DOWN: 40,
ZERO : 48, ONE : 49, TWO : 50,
THREE : 51, FOUR : 52, FIVE : 53,
SIX : 54, SEVEN : 55, EIGHT : 56,
NINE : 57,
A : 65, B : 66, C : 67,
D : 68, E : 69, F : 70,
G : 71, H : 72, I : 73,
J : 74, K : 75, L : 76,
M : 77, N : 78, O : 79,
P : 80, Q : 81, R : 82,
S : 83, T : 84, U : 85,
V : 86, W : 87, X : 88,
Y : 89, Z : 90,
ENTER: 13,
ESC: 27,
BACKSPACE : 8,
TAB : 9,
SHIFT : 16,
CTRL : 17,
ALT : 18,
SPACE: 32,
HOME : 36, END : 35,
PGGUP : 33, PGDOWN : 34
};
var DEFAULT_KEYS = {
LEFT: 'left', RIGHT: 'right',
UP: 'up', DOWN: 'down',
SPACE: 'fire',
Z: 'fire',
X: 'action',
ENTER: 'confirm',
ESC: 'esc',
P: 'P',
S: 'S'
};
var DEFAULT_TOUCH_CONTROLS = [ ['left','<' ],
['right','>' ],
[],
['action','b'],
['fire', 'a' ]];
// Clockwise from midnight (a la CSS)
var DEFAULT_JOYPAD_INPUTS = [ 'up','right','down','left'];
/**
* Current state of bound inputs
*
* @for Quintus.Input
* @property Q.inputs
* @type Object
*/
Q.inputs = {};
Q.joypad = {};
var hasTouch = !!('ontouchstart' in window);
/**
*
* Convert a canvas point to a stage point, x dimension
*
* @method Q.canvasToStageX
* @for Quintus.Input
* @param {Float} x
* @param {Q.Stage} stage
* @returns {Integer} x
*/
Q.canvasToStageX = function(x,stage) {
x = x / Q.cssWidth * Q.width;
if(stage.viewport) {
x /= stage.viewport.scale;
x += stage.viewport.x;
}
return x;
};
/**
*
* Convert a canvas point to a stage point, y dimension
*
* @method Q.canvasToStageY
* @param {Float} y
* @param {Q.Stage} stage
* @returns {Integer} y
*/
Q.canvasToStageY = function(y,stage) {
y = y / Q.cssWidth * Q.width;
if(stage.viewport) {
y /= stage.viewport.scale;
y += stage.viewport.y;
}
return y;
};
/**
*
* Button and mouse input subsystem for Quintus.
* An instance of this class is auto-created as {{#crossLink "Q.input"}}{{/crossLink}}
*
* @class Q.InputSystem
* @extends Q.Evented
* @for Quintus.Input
*/
Q.InputSystem = Q.Evented.extend({
keys: {},
keypad: {},
keyboardEnabled: false,
touchEnabled: false,
joypadEnabled: false,
/**
* Bind a key name or keycode to an action name (used by `keyboardControls`)
*
* @method bindKey
* @for Q.InputSystem
* @param {String or Integer} key - name or integer keycode for to bind
* @param {String} name - name of action to bind to
*/
bindKey: function(key,name) {
Q.input.keys[KEY_NAMES[key] || key] = name;
},
/**
* Enable keyboard controls by binding to events
*
* @for Q.InputSystem
* @method enableKeyboard
*/
enableKeyboard: function() {
if(this.keyboardEnabled) { return false; }
// Make selectable and remove an :focus outline
Q.el.tabIndex = 0;
Q.el.style.outline = 0;
Q.el.addEventListener("keydown",function(e) {
if(Q.input.keys[e.keyCode]) {
var actionName = Q.input.keys[e.keyCode];
Q.inputs[actionName] = true;
Q.input.trigger(actionName);
Q.input.trigger('keydown',e.keyCode);
}
if(!e.ctrlKey && !e.metaKey) {
e.preventDefault();
}
},false);
Q.el.addEventListener("keyup",function(e) {
if(Q.input.keys[e.keyCode]) {
var actionName = Q.input.keys[e.keyCode];
Q.inputs[actionName] = false;
Q.input.trigger(actionName + "Up");
Q.input.trigger('keyup',e.keyCode);
}
e.preventDefault();
},false);
if(Q.options.autoFocus) { Q.el.focus(); }
this.keyboardEnabled = true;
},
/**
* Convenience method to activate keyboard controls (call `bindKey` and `enableKeyboard` internally)
*
* @method keyboardControls
* @for Q.InputSystem
* @param {Object} [keys] - hash of key names or codes to actions
*/
keyboardControls: function(keys) {
keys = keys || DEFAULT_KEYS;
Q._each(keys,function(name,key) {
this.bindKey(key,name);
},Q.input);
this.enableKeyboard();
},
_containerOffset: function() {
Q.input.offsetX = 0;
Q.input.offsetY = 0;
var el = Q.el;
do {
Q.input.offsetX += el.offsetLeft;
Q.input.offsetY += el.offsetTop;
} while(el = el.offsetParent);
},
touchLocation: function(touch) {
var el = Q.el,
posX = touch.offsetX,
posY = touch.offsetY,
touchX, touchY;
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
posX = touch.layerX;
posY = touch.layerY;
}
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
if(Q.input.offsetX === void 0) { Q.input._containerOffset(); }
posX = touch.pageX - Q.input.offsetX;
posY = touch.pageY - Q.input.offsetY;
}
touchX = Q.width * posX / Q.cssWidth;
touchY = Q.height * posY / Q.cssHeight;
return { x: touchX, y: touchY };
},
/**
* Activate touch button controls - pass in an options hash to override
*
* Default Options:
*
* {
* left: 0,
* gutter:10,
* controls: DEFAULT_TOUCH_CONTROLS,
* width: Q.width,
* bottom: Q.height
* }
*
* Default controls are left and right buttons, a space, and 'a' and 'b' buttons, as defined as an Array of Arrays below:
*
* [ ['left','<' ],
* ['right','>' ],
* [], // use an empty array as a spacer
* ['action','b'],
* ['fire', 'a' ]]
*
* @method touchControls
* @for Q.InputSystem
* @param {Object} [opts] - Options hash
*/
touchControls: function(opts) {
if(this.touchEnabled) { return false; }
if(!hasTouch) { return false; }
Q.input.keypad = opts = Q._extend({
left: 0,
gutter:10,
controls: DEFAULT_TOUCH_CONTROLS,
width: Q.width,
bottom: Q.height,
fullHeight: false
},opts);
opts.unit = (opts.width / opts.controls.length);
opts.size = opts.unit - (opts.gutter * 2);
function getKey(touch) {
var pos = Q.input.touchLocation(touch),
minY = opts.bottom - opts.unit;
for(var i=0,len=opts.controls.length;i<len;i++) {
var minX = i * opts.unit + opts.gutter;
if(pos.x >= minX && pos.x <= (minX+opts.size) && (opts.fullHeight || (pos.y >= minY + opts.gutter && pos.y <= (minY+opts.unit - opts.gutter))))
{
return opts.controls[i][0];
}
}
}
function touchDispatch(event) {
var wasOn = {},
i, len, tch, key, actionName;
// Reset all the actions bound to controls
// but keep track of all the actions that were on
for(i=0,len = opts.controls.length;i<len;i++) {
actionName = opts.controls[i][0];
if(Q.inputs[actionName]) { wasOn[actionName] = true; }
Q.inputs[actionName] = false;
}
var touches = event.touches ? event.touches : [ event ];
for(i=0,len=touches.length;i<len;i++) {
tch = touches[i];
key = getKey(tch);
if(key) {
// Mark this input as on
Q.inputs[key] = true;
// Either trigger a new action
// or remove from wasOn list
if(!wasOn[key]) {
Q.input.trigger(key);
} else {
delete wasOn[key];
}
}
}
// Any remaining were on the last frame
// and need to trigger an up action
for(actionName in wasOn) {
Q.input.trigger(actionName + "Up");
}
return null;
}
this.touchDispatchHandler = function(e) {
touchDispatch(e);
e.preventDefault();
};
Q._each(["touchstart","touchend","touchmove","touchcancel"],function(evt) {
Q.el.addEventListener(evt,this.touchDispatchHandler);
},this);
this.touchEnabled = true;
},
/**
* Turn off touch (button and joypad) controls and remove event listeners
*
* @method disableTouchControls
* @for Q.InputSystem
*/
disableTouchControls: function() {
Q._each(["touchstart","touchend","touchmove","touchcancel"],function(evt) {
Q.el.removeEventListener(evt,this.touchDispatchHandler);
},this);
Q.el.removeEventListener('touchstart',this.joypadStart);
Q.el.removeEventListener('touchmove',this.joypadMove);
Q.el.removeEventListener('touchend',this.joypadEnd);
Q.el.removeEventListener('touchcancel',this.joypadEnd);
this.touchEnabled = false;
// clear existing inputs
for(var input in Q.inputs) {
Q.inputs[input] = false;
}
},
/**
* Activate joypad controls (i.e. 4-way touch controls)
*
* Lots of options, defaults are:
*
* {
* size: 50,
* trigger: 20,
* center: 25,
* color: "#CCC",
* background: "#000",
* alpha: 0.5,
* zone: Q.width / 2,
* inputs: DEFAULT_JOYPAD_INPUTS
* }
*
* Default joypad controls is an array that defines the inputs to bind to:
*
* // Clockwise from midnight (a la CSS)
* var DEFAULT_JOYPAD_INPUTS = [ 'up','right','down','left'];
*
* @method joypadControls
* @for Q.InputSystem
* @param {Object} [opts] - joypad options
*/
joypadControls: function(opts) {
if(this.joypadEnabled) { return false; }
if(!hasTouch) { return false; }
var joypad = Q.joypad = Q._defaults(opts || {},{
size: 50,
trigger: 20,
center: 25,
color: "#CCC",
background: "#000",
alpha: 0.5,
zone: Q.width / 2,
joypadTouch: null,
inputs: DEFAULT_JOYPAD_INPUTS,
triggers: []
});
this.joypadStart = function(evt) {
if(joypad.joypadTouch === null) {
var touch = evt.changedTouches[0],
loc = Q.input.touchLocation(touch);
if(loc.x < joypad.zone) {
joypad.joypadTouch = touch.identifier;
joypad.centerX = loc.x;
joypad.centerY = loc.y;
joypad.x = null;
joypad.y = null;
}
}
};
this.joypadMove = function(e) {
if(joypad.joypadTouch !== null) {
var evt = e;
for(var i=0,len=evt.changedTouches.length;i<len;i++) {
var touch = evt.changedTouches[i];
if(touch.identifier === joypad.joypadTouch) {
var loc = Q.input.touchLocation(touch),
dx = loc.x - joypad.centerX,
dy = loc.y - joypad.centerY,
dist = Math.sqrt(dx * dx + dy * dy),
overage = Math.max(1,dist / joypad.size),
ang = Math.atan2(dx,dy);
if(overage > 1) {
dx /= overage;
dy /= overage;
dist /= overage;
}
var triggers = [
dy < -joypad.trigger,
dx > joypad.trigger,
dy > joypad.trigger,
dx < -joypad.trigger
];
for(var k=0;k<triggers.length;k++) {
var actionName = joypad.inputs[k];
if(triggers[k]) {
Q.inputs[actionName] = true;
if(!joypad.triggers[k]) {
Q.input.trigger(actionName);
}
} else {
Q.inputs[actionName] = false;
if(joypad.triggers[k]) {
Q.input.trigger(actionName + "Up");
}
}
}
Q._extend(joypad, {
dx: dx, dy: dy,
x: joypad.centerX + dx,
y: joypad.centerY + dy,
dist: dist,
ang: ang,
triggers: triggers
});
break;
}
}
}
e.preventDefault();
};
this.joypadEnd = function(e) {
var evt = e;
if(joypad.joypadTouch !== null) {
for(var i=0,len=evt.changedTouches.length;i<len;i++) {
var touch = evt.changedTouches[i];
if(touch.identifier === joypad.joypadTouch) {
for(var k=0;k<joypad.triggers.length;k++) {
var actionName = joypad.inputs[k];
Q.inputs[actionName] = false;
if(joypad.triggers[k]) {
Q.input.trigger(actionName + "Up");
}
}
joypad.joypadTouch = null;
break;
}
}
}
e.preventDefault();
};
Q.el.addEventListener("touchstart",this.joypadStart);
Q.el.addEventListener("touchmove",this.joypadMove);
Q.el.addEventListener("touchend",this.joypadEnd);
Q.el.addEventListener("touchcancel",this.joypadEnd);
this.joypadEnabled = true;
},
/**
* Activate mouse controls - mouse controls don't trigger events, but just set `Q.inputs['mouseX']` & `Q.inputs['mouseY']` on each frame.
*
* Default options:
*
* {
* stageNum: 0,
* mouseX: "mouseX",
* mouseY: "mouseY",
* cursor: "off"
* }
*
* @method mouseControls
* @for Q.InputSystem
* @param {Object} [options] - override default options
*/
mouseControls: function(options) {
options = options || {};
var stageNum = options.stageNum || 0;
var mouseInputX = options.mouseX || "mouseX";
var mouseInputY = options.mouseY || "mouseY";
var cursor = options.cursor || "off";
var mouseMoveObj = {};
if(cursor !== "on") {
if(cursor === "off") {
Q.el.style.cursor = 'none';
}
else {
Q.el.style.cursor = cursor;
}
}
Q.inputs[mouseInputX] = 0;
Q.inputs[mouseInputY] = 0;
Q._mouseMove = function(e) {
e.preventDefault();
var touch = e.touches ? e.touches[0] : e;
var el = Q.el,
rect = el.getBoundingClientRect(),
style = window.getComputedStyle(el),
posX = touch.clientX - rect.left - parseInt(style.paddingLeft, 10),
posY = touch.clientY - rect.top - parseInt(style.paddingTop, 10);
var stage = Q.stage(stageNum);
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
posX = touch.offsetX;
posY = touch.offsetY;
}
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
posX = touch.layerX;
posY = touch.layerY;
}
if(Q._isUndefined(posX) || Q._isUndefined(posY)) {
if(Q.input.offsetX === void 0) { Q.input._containerOffset(); }
posX = touch.pageX - Q.input.offsetX;
posY = touch.pageY - Q.input.offsetY;
}
if(stage) {
mouseMoveObj.x= Q.canvasToStageX(posX,stage);
mouseMoveObj.y= Q.canvasToStageY(posY,stage);
Q.inputs[mouseInputX] = mouseMoveObj.x;
Q.inputs[mouseInputY] = mouseMoveObj.y;
Q.input.trigger('mouseMove',mouseMoveObj);
}
};
/**
* Fired when the user scrolls the mouse wheel up or down
* Anyone subscribing to the "mouseWheel" event will receive an event with one numeric parameter
* indicating the scroll direction. -1 for down, 1 for up.
* @private
*/
Q._mouseWheel = function(e) {
// http://www.sitepoint.com/html5-javascript-mouse-wheel/
// cross-browser wheel delta
e = window.event || e; // old IE support
var delta = Math.max(-1, Math.min(1, (e.wheelDelta || -e.detail)));
Q.input.trigger('mouseWheel', delta);
};
Q.el.addEventListener('mousemove',Q._mouseMove,true);
Q.el.addEventListener('touchstart',Q._mouseMove,true);
Q.el.addEventListener('touchmove',Q._mouseMove,true);
Q.el.addEventListener('mousewheel',Q._mouseWheel,true);
Q.el.addEventListener('DOMMouseScroll',Q._mouseWheel,true);
},
/**
* Turn off mouse controls
*
* @method disableMouseControls
* @for Q.InputSystem
*/
disableMouseControls: function() {
if(Q._mouseMove) {
Q.el.removeEventListener("mousemove",Q._mouseMove, true);
Q.el.removeEventListener("mousewheel",Q._mouseWheel, true);
Q.el.removeEventListener("DOMMouseScroll",Q._mouseWheel, true);
Q.el.style.cursor = 'inherit';
Q._mouseMove = null;
}
},
/**
* Draw the touch buttons on the screen
*
* overload this to change how buttons are drawn
*
* @method drawButtons
* @for Q.InputSystem
*/
drawButtons: function() {
var keypad = Q.input.keypad,
ctx = Q.ctx;
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for(var i=0;i<keypad.controls.length;i++) {
var control = keypad.controls[i];
if(control[0]) {
ctx.font = "bold " + (keypad.size/2) + "px arial";
var x = keypad.left + i * keypad.unit + keypad.gutter,
y = keypad.bottom - keypad.unit,
key = Q.inputs[control[0]];
ctx.fillStyle = keypad.color || "#FFFFFF";
ctx.globalAlpha = key ? 1.0 : 0.5;
ctx.fillRect(x,y,keypad.size,keypad.size);
ctx.fillStyle = keypad.text || "#000000";
ctx.fillText(control[1],
x+keypad.size/2,
y+keypad.size/2);
}
}
ctx.restore();
},
drawCircle: function(x,y,color,size) {
var ctx = Q.ctx,
joypad = Q.joypad;
ctx.save();
ctx.beginPath();
ctx.globalAlpha=joypad.alpha;
ctx.fillStyle = color;
ctx.arc(x, y, size, 0, Math.PI*2, true);
ctx.closePath();
ctx.fill();
ctx.restore();
},
/**
* Draw the joypad on the screen
*
* overload this to change how joypad is drawn
*
* @method drawJoypad
* @for Q.InputSystem
*/
drawJoypad: function() {
var joypad = Q.joypad;
if(joypad.joypadTouch !== null) {
Q.input.drawCircle(joypad.centerX,
joypad.centerY,
joypad.background,
joypad.size);
if(joypad.x !== null) {
Q.input.drawCircle(joypad.x,
joypad.y,
joypad.color,
joypad.center);
}
}
},
/**
* Called each frame by the stage game loop to render any onscreen UI
*
* calls `drawJoypad` and `drawButtons` if enabled
*
* @method drawCanvas
* @for Q.InputSystem
*/
drawCanvas: function() {
if(this.touchEnabled) {
this.drawButtons();
}
if(this.joypadEnabled) {
this.drawJoypad();
}
}
});
/**
* Instance of the input subsytem that is actually used during gameplay
*
* @property Q.input
* @for Quintus.Input
* @type Q.InputSystem
*/
Q.input = new Q.InputSystem();
/**
* Helper method to activate controls with default options
*
* @for Quintus.Input
* @method Q.controls
* @param {Boolean} joypad - enable 4-way joypad (true) or just left, right controls (false, undefined)
*/
Q.controls = function(joypad) {
Q.input.keyboardControls();
if(joypad) {
Q.input.touchControls({
controls: [ [],[],[],['action','b'],['fire','a']]
});
Q.input.joypadControls();
} else {
Q.input.touchControls();
}
return Q;
};
/**
* Platformer Control Component
*
* Adds 2D platformer controls onto a Sprite
*
* Platformer controls bind to left, and right and allow the player to jump.
*
* Adds the following properties to the entity to control speed and jumping:
*
* {
* speed: 200,
* jumpSpeed: -300
* }
*
*
* @class platformerControls
* @for Quintus.Input
*/
Q.component("platformerControls", {
defaults: {
speed: 200,
jumpSpeed: -300,
collisions: []
},
added: function() {
var p = this.entity.p;
Q._defaults(p,this.defaults);
this.entity.on("step",this,"step");
this.entity.on("bump.bottom",this,"landed");
p.landed = 0;
p.direction ='right';
},
landed: function(col) {
var p = this.entity.p;
p.landed = 1/5;
},
step: function(dt) {
var p = this.entity.p;
if(p.ignoreControls === undefined || !p.ignoreControls) {
var collision = null;
// Follow along the current slope, if possible.
if(p.collisions !== undefined && p.collisions.length > 0 && (Q.inputs['left'] || Q.inputs['right'] || p.landed > 0)) {
if(p.collisions.length === 1) {
collision = p.collisions[0];
} else {
// If there's more than one possible slope, follow slope with negative Y normal
collision = null;
for(var i = 0; i < p.collisions.length; i++) {
if(p.collisions[i].normalY < 0) {
collision = p.collisions[i];
}
}
}
// Don't climb up walls.
if(collision !== null && collision.normalY > -0.3 && collision.normalY < 0.3) {
collision = null;
}
}
if(Q.inputs['left']) {
p.direction = 'left';
if(collision && p.landed > 0) {
p.vx = p.speed * collision.normalY;
p.vy = -p.speed * collision.normalX;
} else {
p.vx = -p.speed;
}
} else if(Q.inputs['right']) {
p.direction = 'right';
if(collision && p.landed > 0) {
p.vx = -p.speed * collision.normalY;
p.vy = p.speed * collision.normalX;
} else {
p.vx = p.speed;
}
} else {
p.vx = 0;
if(collision && p.landed > 0) {
p.vy = 0;
}
}
if(p.landed > 0 && (Q.inputs['up'] || Q.inputs['action']) && !p.jumping) {
p.vy = p.jumpSpeed;
p.landed = -dt;
p.jumping = true;
} else if(Q.inputs['up'] || Q.inputs['action']) {
this.entity.trigger('jump', this.entity);
p.jumping = true;
}
if(p.jumping && !(Q.inputs['up'] || Q.inputs['action'])) {
p.jumping = false;
this.entity.trigger('jumped', this.entity);
if(p.vy < p.jumpSpeed / 3) {
p.vy = p.jumpSpeed / 3;
}
}
}
p.landed -= dt;
}
});
/**
* Step Controls component
*
* Adds Step (square grid based) 4-ways controls onto a Sprite
*
* Adds the following properties to the entity:
*
* {
* stepDistance: 32, // should be tile size
* stepDelay: 0.2 // seconds to delay before next step
* }
*
*
* @class stepControls
* @for Quintus.Input
*/
Q.component("stepControls", {
added: function() {
var p = this.entity.p;
if(!p.stepDistance) { p.stepDistance = 32; }
if(!p.stepDelay) { p.stepDelay = 0.2; }
p.stepWait = 0;
this.entity.on("step",this,"step");
this.entity.on("hit", this,"collision");
},
collision: function(col) {
var p = this.entity.p;
if(p.stepping) {
p.stepping = false;
p.x = p.origX;
p.y = p.origY;
}
},
step: function(dt) {
var p = this.entity.p,
moved = false;
p.stepWait -= dt;
if(p.stepping) {
p.x += p.diffX * dt / p.stepDelay;
p.y += p.diffY * dt / p.stepDelay;
}
if(p.stepWait > 0) { return; }
if(p.stepping) {
p.x = p.destX;
p.y = p.destY;
}
p.stepping = false;
p.diffX = 0;
p.diffY = 0;
if(Q.inputs['left']) {
p.diffX = -p.stepDistance;
} else if(Q.inputs['right']) {
p.diffX = p.stepDistance;
}
if(Q.inputs['up']) {
p.diffY = -p.stepDistance;
} else if(Q.inputs['down']) {
p.diffY = p.stepDistance;
}
if(p.diffY || p.diffX ) {
p.stepping = true;
p.origX = p.x;
p.origY = p.y;
p.destX = p.x + p.diffX;
p.destY = p.y + p.diffY;
p.stepWait = p.stepDelay;
}
}
});
};
};
if(typeof Quintus === 'undefined') {
module.exports = quintusInput;
} else {
quintusInput(Quintus);
}
File diff suppressed because it is too large Load Diff
+770
View File
@@ -0,0 +1,770 @@
/*global Quintus:false, module:false */
/**
Quintus HTML5 Game Engine - Sprites Module
The code in `quintus_sprites.js` defines the `Quintus.Sprites` module, which
add support for sprite sheets and the base sprite class.
Most games will include at a minimum `Quintus.Sprites` and `Quintus.Scenes`
@module Quintus.Sprites
*/
var quintusSprites = function(Quintus) {
"use strict";
/**
* Quintus Sprites Module Class
*
* @class Quintus.Sprites
*/
Quintus.Sprites = function(Q) {
/**
Sprite sheet class - generally instantiated with `Q.sheet` new `new`
@class Q.SpriteSheet
@extends Q.Class
@for Quintus.Sprites
*/
Q.Class.extend("SpriteSheet",{
/**
constructor
Options:
* tileW - tile width
* tileH - tile height
* w - width of the sprite block
* h - height of the sprite block
* sx - start x
* sy - start y
* spacingX - spacing between each tile x (after 1st)
* spacingY - spacing between each tile y
* marginX - margin around each tile x
* marginY - margin around each tile y
* cols - number of columns per row
@constructor
@for Q.SpriteSheet
@method init
@param {String} name
@param {String} asset
@param {Object} options
*/
init: function(name, asset,options) {
if(!Q.asset(asset)) { throw "Invalid Asset:" + asset; }
Q._extend(this,{
name: name,
asset: asset,
w: Q.asset(asset).width,
h: Q.asset(asset).height,
tileW: 64,
tileH: 64,
sx: 0,
sy: 0,
spacingX: 0,
spacingY: 0,
frameProperties: {}
});
if(options) { Q._extend(this,options); }
// fix for old tilew instead of tileW
if(this.tilew) {
this.tileW = this.tilew;
delete this['tilew'];
}
if(this.tileh) {
this.tileH = this.tileh;
delete this['tileh'];
}
this.cols = this.cols ||
Math.floor((this.w + this.spacingX) / (this.tileW + this.spacingX));
this.frames = this.cols * (Math.floor(this.h/(this.tileH + this.spacingY)));
},
/**
Returns the starting x position of a single frame
@method fx
@for Q.SpriteSheet
@param {Integer} frame
*/
fx: function(frame) {
return Math.floor((frame % this.cols) * (this.tileW + this.spacingX) + this.sx);
},
/**
Returns the starting y position of a single frame
@method fy
@for Q.SpriteSheet
@param {Integer} frame
*/
fy: function(frame) {
return Math.floor(Math.floor(frame / this.cols) * (this.tileH + this.spacingY) + this.sy);
},
/**
Draw a single frame at x,y on the provided context
@method draw
@for Q.SpriteSheet
@param {Context2D} ctx
@param {Float} x
@param {Float} y
@param {Integer} frame
*/
draw: function(ctx, x, y, frame) {
if(!ctx) { ctx = Q.ctx; }
ctx.drawImage(Q.asset(this.asset),
this.fx(frame),this.fy(frame),
this.tileW, this.tileH,
Math.floor(x),Math.floor(y),
this.tileW, this.tileH);
}
});
Q.sheets = {};
/**
Return a `Q.SpriteSheet` or create a new sprite sheet
@method Q.sheet
@for Quintus.Sprites
@param {String} name - name of sheet to return or create
@param {String} [asset] - if provided, will create a sprite sheet using this asset
@param {Object} [options] - if provided, will be passed as options to `Q.SpriteSheet`
*/
Q.sheet = function(name,asset,options) {
if(asset) {
Q.sheets[name] = new Q.SpriteSheet(name,asset,options);
} else {
return Q.sheets[name];
}
};
/**
Create a number of `Q.SpriteSheet` objects from an image asset and a sprite data JSON asset
@method Q.compileSheets
@for Quintus.Sprites
@param {String} imageAsset
@param {String spriteDataAsset
*/
Q.compileSheets = function(imageAsset,spriteDataAsset) {
var data = Q.asset(spriteDataAsset);
Q._each(data,function(spriteData,name) {
Q.sheet(name,imageAsset,spriteData);
});
};
/**
Bitmask 0 to indicate no sprites
@property Q.SPRITE_NONE
@for Quintus.Sprites
@final
*/
Q.SPRITE_NONE = 0;
/**
default sprite type 1
@property Q.SPRITE_DEFAULT
@for Quintus.Sprites
@final
*/
Q.SPRITE_DEFAULT = 1;
/**
particle sprite type 2
@property Q.SPRITE_PARTICLE
@for Quintus.Sprites
@final
*/
Q.SPRITE_PARTICLE = 2;
/**
active sprite type 4
@property Q.SPRITE_ACTIVE
@for Quintus.Sprites
@final
*/
Q.SPRITE_ACTIVE = 4;
/**
friendly sprite type 8
@property Q.SPRITE_FRIENDLY
@for Quintus.Sprites
@final
*/
Q.SPRITE_FRIENDLY = 8;
/**
enemy sprite type 16
@property Q.SPRITE_ENEMY
@for Quintus.Sprites
@final
*/
Q.SPRITE_ENEMY = 16;
/**
powerup sprite type 32
@property Q.SPRITE_POWERUP
@for Quintus.Sprites
@final
*/
Q.SPRITE_POWERUP = 32;
/**
UI sprite type 64
@property Q.SPRITE_UI
@for Quintus.Sprites
@final
*/
Q.SPRITE_UI = 64;
/**
all sprite type - 0xFFFF
@property Q.SPRITE_ALL
@for Quintus.Sprites
@final
*/
Q.SPRITE_ALL = 0xFFFF;
/**
generate a square set of `p.points` on an object from `p.w` and `p.h`
`p.points` represent the collision points for an object in object coordinates.
@method q._generatePoints
@for Quintus.Sprites
@param {Q.Sprite} obj - object to add points to
@param {Boolean} force - if set to true, will regenerate `p.points` even if it already exists, otherwise if p.points exist it'll be left alone
*/
Q._generatePoints = function(obj,force) {
if(obj.p.points && !force) { return; }
var p = obj.p,
halfW = p.w/2,
halfH = p.h/2;
p.points = [
[ -halfW, -halfH ],
[ halfW, -halfH ],
[ halfW, halfH ],
[ -halfW, halfH ]
];
};
/**
Generate a square set of `c.points` on an object from the object transform matrix and `p.points`
`c.points` represents the collision points of an sprite in world coordinates, scaled, rotate and taking into account any parent transforms.
@method Q._generateCollisionPoints
@for Quintus.Sprites
@param {q.sprite} obj - object to add collision points to
*/
Q._generateCollisionPoints = function(obj) {
if(!obj.matrix && !obj.refreshMatrix) { return; }
if(!obj.c) { obj.c = { points: [] }; }
var p = obj.p, c = obj.c;
if(!p.moved &&
c.origX === p.x &&
c.origY === p.y &&
c.origScale === p.scale &&
c.origAngle === p.angle) {
return;
}
c.origX = p.x;
c.origY = p.y;
c.origScale = p.scale;
c.origAngle = p.angle;
obj.refreshMatrix();
var i;
// Early out if we don't need to rotate / scale / deal with a container
if(!obj.container && (!p.scale || p.scale === 1) && p.angle === 0) {
for(i=0;i<obj.p.points.length;i++) {
obj.c.points[i] = obj.c.points[i] || [];
obj.c.points[i][0] = p.x + obj.p.points[i][0];
obj.c.points[i][1] = p.y + obj.p.points[i][1];
}
c.x = p.x; c.y = p.y;
c.cx = p.cx; c.cy = p.cy;
c.w = p.w; c.h = p.h;
} else {
var container = obj.container || Q._nullContainer;
c.x = container.matrix.transformX(p.x,p.y);
c.y = container.matrix.transformY(p.x,p.y);
c.angle = p.angle + container.c.angle;
c.scale = (container.c.scale || 1) * (p.scale || 1);
var minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for(i=0;i<obj.p.points.length;i++) {
if(!obj.c.points[i]) {
obj.c.points[i] = [];
}
obj.matrix.transformArr(obj.p.points[i],obj.c.points[i]);
var x = obj.c.points[i][0],
y = obj.c.points[i][1];
if(x < minX) { minX = x; }
if(x > maxX) { maxX = x; }
if(y < minY) { minY = y; }
if(y > maxY) { maxY = y; }
}
if(minX === maxX) { maxX+=1; }
if(minY === maxY) { maxY+=1; }
c.cx = c.x - minX;
c.cy = c.y - minY;
c.w = maxX - minX;
c.h = maxY - minY;
}
p.moved = false;
// TODO: Invoke moved on children
if(obj.children && obj.children.length > 0) {
Q._invoke(obj.children,"moved");
}
};
/**
Basic sprite class - will render either and asset or a frame from a sprite sheet.
Auto sets the width and height (`p.w` and `p.h`) from the provided image asset and
centers the sprite so 0,0 is the center of the provide image.
Most of the times you'll sub-class `Q.Sprite`
@extends Q.GameObject
@class Q.Sprite
@for Quintus.Sprites
*/
Q.GameObject.extend("Sprite",{
/**
Default sprite constructor, takes in a set of properties and a set of default properties (useful when you create a subclass of sprite)
Default properties:
{
asset: null, // asset to use
sheet: null, // sprite sheet to use (overrides asset)
x: 0,
y: 0,
z: 0,
w: 0, // width, set from p.asset or p.sheet
h: 0, // height, set from p.asset or p.sheet
cx: w/2, // center x, defaults to center of the asset or sheet
cy: h/2, // center y, default same as cx
// points defines the collision shape, override to customer the collision shape,
// must be a convex polygon in clockwise order
points: [ [ -w/2, -h/2 ], [ w/2, -h/2 ], [ w/2, h/2 ], [ -w/2, h/2 ] ],
opacity: 1,
angle: 0,
frame: 0
type: Q.SPRITE_DEFAULT | Q.SPRITE_ACTIVE,
name: '',
sort: false, // set to true to force children to be sorted by theier p.z,
hidden: false, // set to true to hide the sprite
flip: "" // set to "x", "y", or "xy" to flip sprite over that dimension
}
@method init
@for Q.Sprite
@param {Object} props - property has that will be turned into `p`
@param {Object} [defaultProps] - default properties that are assigned only if there's not a corresponding value in `props`
*/
init: function(props,defaultProps) {
this.p = Q._extend({
x: 0,
y: 0,
z: 0,
opacity: 1,
angle: 0,
frame: 0,
type: Q.SPRITE_DEFAULT | Q.SPRITE_ACTIVE,
name: '',
spriteProperties: {}
},defaultProps);
this.matrix = new Q.Matrix2D();
this.children = [];
Q._extend(this.p,props);
this.size();
this.p.id = this.p.id || Q._uniqueId();
this.refreshMatrix();
},
/**
Resets the width, height and center based on the
asset or sprite sheet
@method size
@for Q.Sprite
@param {Boolean} force - force a reset (call if w or h changes)
*/
size: function(force) {
if(force || (!this.p.w || !this.p.h)) {
if(this.asset()) {
this.p.w = this.asset().width;
this.p.h = this.asset().height;
} else if(this.sheet()) {
this.p.w = this.sheet().tileW;
this.p.h = this.sheet().tileH;
}
}
this.p.cx = (force || this.p.cx === void 0) ? (this.p.w / 2) : this.p.cx;
this.p.cy = (force || this.p.cy === void 0) ? (this.p.h / 2) : this.p.cy;
},
/**
Get or set the asset associate with this sprite
@method asset
@for Q.Sprite
@param {String} [name] - leave empty to return the asset, add to set the asset
@param {Boolean} [resize] - force a call to `size()` and `_generatePoints`
*/
asset: function(name,resize) {
if(!name) { return Q.asset(this.p.asset); }
this.p.asset = name;
if(resize) {
this.size(true);
Q._generatePoints(this,true);
}
},
/**
Get or set the sheet associate with this sprite
@method sheet
@for Q.Sprite
@param {String} [name] - leave empty to return the sprite sheet, add to resize
@param {Boolean} [resize] - force a resize
*/
sheet: function(name,resize) {
if(!name) { return Q.sheet(this.p.sheet); }
this.p.sheet = name;
if(resize) {
this.size(true);
Q._generatePoints(this,true);
}
},
/**
Hide the sprite (render returns without rendering)
@method hide
@for Q.Sprite
*/
hide: function() {
this.p.hidden = true;
},
/**
Show the sprite
@method show
@for Q.Sprite
*/
show: function() {
this.p.hidden = false;
},
/**
Set a set of `p` properties on a Sprite
@method set
@for Q.Sprite
@param {Object} properties - hash of properties to set
*/
set: function(properties) {
Q._extend(this.p,properties);
return this;
},
_sortChild: function(a,b) {
return ((a.p && a.p.z) || -1) - ((b.p && b.p.z) || -1);
},
_flipArgs: {
"x": [ -1, 1],
"y": [ 1, -1],
"xy": [ -1, -1]
},
/**
Default render method for the sprite. Don't overload this unless you want to
handle all the transform and scale stuff yourself. Rather overload the `draw` method.
@method render
@for Q.Sprite
@param {Context2D} ctx - context to render to
*/
render: function(ctx) {
var p = this.p;
if(p.hidden || p.opacity === 0) { return; }
if(!ctx) { ctx = Q.ctx; }
this.trigger('predraw',ctx);
ctx.save();
if(this.p.opacity !== void 0 && this.p.opacity !== 1) {
ctx.globalAlpha = this.p.opacity;
}
this.matrix.setContextTransform(ctx);
if(this.p.flip) { ctx.scale.apply(ctx,this._flipArgs[this.p.flip]); }
this.trigger('beforedraw',ctx);
this.draw(ctx);
this.trigger('draw',ctx);
ctx.restore();
// Children set up their own complete matrix
// from the base stage matrix
if(this.p.sort) { this.children.sort(this._sortChild); }
Q._invoke(this.children,"render",ctx);
this.trigger('postdraw',ctx);
if(Q.debug) { this.debugRender(ctx); }
},
/**
Center sprite inside of it's container (or the stage)
@method center
@for Q.Sprite
*/
center: function() {
if(this.container) {
this.p.x = 0;
this.p.y = 0;
} else {
this.p.x = Q.width / 2;
this.p.y = Q.height / 2;
}
},
/**
Draw the asset on the stage. the context passed in is alreay transformed.
All you need to do is a draw the sprite centered at 0,0
@method draw
@for Q.Sprite
@param {Context2D} ctx
*/
draw: function(ctx) {
var p = this.p;
if(p.sheet) {
this.sheet().draw(ctx,-p.cx,-p.cy,p.frame);
} else if(p.asset) {
ctx.drawImage(Q.asset(p.asset),-p.cx,-p.cy);
} else if(p.color) {
ctx.fillStyle = p.color;
ctx.fillRect(-p.cx,-p.cy,p.w,p.h);
}
},
debugRender: function(ctx) {
if(!this.p.points) {
Q._generatePoints(this);
}
ctx.save();
this.matrix.setContextTransform(ctx);
ctx.beginPath();
ctx.fillStyle = this.p.hit ? "blue" : "red";
ctx.strokeStyle = "#FF0000";
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.moveTo(this.p.points[0][0],this.p.points[0][1]);
for(var i=0;i<this.p.points.length;i++) {
ctx.lineTo(this.p.points[i][0],this.p.points[i][1]);
}
ctx.lineTo(this.p.points[0][0],this.p.points[0][1]);
ctx.stroke();
if(Q.debugFill) { ctx.fill(); }
ctx.restore();
if(this.c) {
var c = this.c;
ctx.save();
ctx.globalAlpha = 1;
ctx.lineWidth = 2;
ctx.strokeStyle = "#FF00FF";
ctx.beginPath();
ctx.moveTo(c.x - c.cx, c.y - c.cy);
ctx.lineTo(c.x - c.cx + c.w, c.y - c.cy);
ctx.lineTo(c.x - c.cx + c.w, c.y - c.cy + c.h);
ctx.lineTo(c.x - c.cx , c.y - c.cy + c.h);
ctx.lineTo(c.x - c.cx, c.y - c.cy);
ctx.stroke();
ctx.restore();
}
},
/**
Update method is called each step with the time elapsed since the last step.
Doesn't do anything other than trigger events, call a `step` method if defined
and run update on all its children.
Generally leave this method alone and define a `step` method that will be called
@method update
@for Q.Sprite
@param {Float} dt - time elapsed since last call
*/
update: function(dt) {
this.trigger('prestep',dt);
if(this.step) { this.step(dt); }
this.trigger('step',dt);
Q._generateCollisionPoints(this);
// Ugly coupling to stage - workaround?
if(this.stage && this.children.length > 0) {
this.stage.updateSprites(this.children,dt,true);
}
// Reset collisions if we're tracking them
if(this.p.collisions) { this.p.collisions = []; }
},
/*
Regenerates this sprite's transformation matrix
@method refreshMatrix
@for Q.Sprite
*/
refreshMatrix: function() {
var p = this.p;
this.matrix.identity();
if(this.container) { this.matrix.multiply(this.container.matrix); }
this.matrix.translate(p.x,p.y);
if(p.scale) { this.matrix.scale(p.scale,p.scale); }
this.matrix.rotateDeg(p.angle);
},
/*
Marks a sprite as having been moved
@method moved
@for Q.Sprite
*/
moved: function() {
this.p.moved = true;
}
});
/**
Simple sprite that adds in basic newtonian physics on each step:
p.vx += p.ax * dt;
p.vy += p.ay * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
@class Q.MovingSprite
@extends Q.Sprite
@for Quintus.Sprites
*/
Q.Sprite.extend("MovingSprite",{
init: function(props,defaultProps) {
this._super(Q._extend({
vx: 0,
vy: 0,
ax: 0,
ay: 0
},props),defaultProps);
},
step: function(dt) {
var p = this.p;
p.vx += p.ax * dt;
p.vy += p.ay * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
});
return Q;
};
};
if(typeof Quintus === 'undefined') {
module.exports = quintusSprites;
} else {
quintusSprites(Quintus);
}
+1
View File
@@ -40,6 +40,7 @@ target_compile_definitions(usockets PUBLIC LIBUS_NO_SSL LIBUS_USE_EPOLL)
add_executable(gameserver
src/main.cpp
src/Lobby.cpp
src/Game.cpp
)
target_include_directories(gameserver PRIVATE
${uwebsockets_SOURCE_DIR}/src
+1 -1
View File
@@ -8,7 +8,7 @@ WORKDIR /app
# Заглушки вместо src/, чтобы cmake configure не падал на несуществующих
# исходниках — они не компилируются, т.к. просим собрать только usockets.
COPY CMakeLists.txt ./
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp
RUN mkdir src && touch src/main.cpp src/Lobby.cpp src/Lobby.hpp src/Game.cpp src/Game.hpp
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --target usockets -j"$(nproc)"
# Реальный код поверх уже собранных зависимостей — пересобирается за секунды.
+402
View File
@@ -0,0 +1,402 @@
#include "Game.hpp"
#include <App.h>
#include <libusockets.h>
#include <algorithm>
#include <cmath>
namespace {
constexpr double kTankHalf = 0.4;
constexpr double kTankSpeed = 4.0; // тайлов/сек
constexpr double kBulletSpeed = 8.0; // тайлов/сек
constexpr double kBulletHalf = 0.08;
constexpr int kFireCooldownTicks = 10; // 0.5с при 20 Гц
constexpr double kDt = Game::kTickMs / 1000.0;
} // namespace
Game::Game(Room &room, std::function<void()> onFinished) : onFinished_(std::move(onFinished)) {
buildMap();
for (int i = 0; i < 2; i++) {
Tank &t = tanks_[i];
t.player_id = room.players[i].player_id;
t.ws = room.players[i].ws;
if (i == 0) {
t.x = 1.5;
t.y = kMapHeight - 2.5;
t.dir = Dir::Up;
} else {
t.x = kMapWidth - 2.5;
t.y = 1.5;
t.dir = Dir::Down;
}
}
}
Game::~Game() {
if (timer_) {
us_timer_close(timer_);
}
}
void Game::buildMap() {
map_.assign(kMapHeight, std::vector<int>(kMapWidth, kEmpty));
for (int y = 0; y < kMapHeight; y++) {
for (int x = 0; x < kMapWidth; x++) {
if (x == 0 || y == 0 || x == kMapWidth - 1 || y == kMapHeight - 1) {
map_[y][x] = kSteel;
}
}
}
// Симметричные (180°) кластеры кирпича/стали в интерьере карты.
auto placeMirrored = [this](int x, int y, int w, int h, int tile) {
for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) {
map_[y + dy][x + dx] = tile;
map_[kMapHeight - 1 - (y + dy)][kMapWidth - 1 - (x + dx)] = tile;
}
}
};
placeMirrored(3, 3, 2, 2, kBrick);
placeMirrored(6, 3, 1, 2, kBrick);
placeMirrored(3, 6, 2, 1, kSteel);
placeMirrored(6, 6, 3, 3, kBrick);
}
void Game::start() {
timer_ = us_create_timer((us_loop_t *)uWS::Loop::get(), 0, sizeof(Game *));
*(Game **)us_timer_ext(timer_) = this;
us_timer_set(
timer_,
[](us_timer_t *t) {
Game *self = *(Game **)us_timer_ext(t);
self->tick();
},
kTickMs, kTickMs);
}
void Game::handleInput(const std::string &player_id, const json &payload) {
for (auto &t : tanks_) {
if (t.player_id == player_id) {
t.up = payload.value("up", false);
t.down = payload.value("down", false);
t.left = payload.value("left", false);
t.right = payload.value("right", false);
t.fire = payload.value("fire", false);
return;
}
}
}
bool Game::tileSolid(int tx, int ty) const {
if (tx < 0 || ty < 0 || ty >= kMapHeight || tx >= kMapWidth) {
return true;
}
return map_[ty][tx] != kEmpty;
}
bool Game::rectHitsSolid(double x, double y) const {
int minX = (int)std::floor(x - kTankHalf);
int maxX = (int)std::floor(x + kTankHalf - 1e-6);
int minY = (int)std::floor(y - kTankHalf);
int maxY = (int)std::floor(y + kTankHalf - 1e-6);
for (int ty = minY; ty <= maxY; ty++) {
for (int tx = minX; tx <= maxX; tx++) {
if (tileSolid(tx, ty)) {
return true;
}
}
}
return false;
}
bool Game::rectHitsTank(double x, double y, const Tank &self) const {
for (const auto &t : tanks_) {
if (&t == &self || !t.alive) {
continue;
}
if (std::abs(x - t.x) < 2 * kTankHalf && std::abs(y - t.y) < 2 * kTankHalf) {
return true;
}
}
return false;
}
void Game::applyInput(Tank &tank) {
if (!tank.alive) {
return;
}
if (tank.fire_cooldown > 0) {
tank.fire_cooldown--;
}
Dir moveDir = tank.dir;
bool moving = false;
if (tank.up) {
moveDir = Dir::Up;
moving = true;
} else if (tank.down) {
moveDir = Dir::Down;
moving = true;
} else if (tank.left) {
moveDir = Dir::Left;
moving = true;
} else if (tank.right) {
moveDir = Dir::Right;
moving = true;
}
tank.dir = moveDir;
if (moving) {
double nx = tank.x, ny = tank.y;
double step = kTankSpeed * kDt;
switch (moveDir) {
case Dir::Up: ny -= step; break;
case Dir::Down: ny += step; break;
case Dir::Left: nx -= step; break;
case Dir::Right: nx += step; break;
}
if (!rectHitsSolid(nx, ny) && !rectHitsTank(nx, ny, tank)) {
tank.x = nx;
tank.y = ny;
}
}
if (tank.fire) {
fireBullet(tank);
}
}
void Game::fireBullet(Tank &tank) {
if (tank.fire_cooldown > 0) {
return;
}
bool hasOwnBullet = std::any_of(bullets_.begin(), bullets_.end(),
[&](const Bullet &b) { return b.owner_id == tank.player_id; });
if (hasOwnBullet) {
return;
}
Bullet b;
b.id = next_bullet_id_++;
b.owner_id = tank.player_id;
b.dir = tank.dir;
double offset = kTankHalf + kBulletHalf + 0.01;
b.x = tank.x;
b.y = tank.y;
switch (tank.dir) {
case Dir::Up: b.y -= offset; break;
case Dir::Down: b.y += offset; break;
case Dir::Left: b.x -= offset; break;
case Dir::Right: b.x += offset; break;
}
bullets_.push_back(b);
tank.fire_cooldown = kFireCooldownTicks;
}
void Game::updateBullets() {
double step = kBulletSpeed * kDt;
for (auto &b : bullets_) {
switch (b.dir) {
case Dir::Up: b.y -= step; break;
case Dir::Down: b.y += step; break;
case Dir::Left: b.x -= step; break;
case Dir::Right: b.x += step; break;
}
}
// Пуля против пули: встречные уничтожают друг друга.
std::vector<bool> dead(bullets_.size(), false);
for (size_t i = 0; i < bullets_.size(); i++) {
if (dead[i]) continue;
for (size_t j = i + 1; j < bullets_.size(); j++) {
if (dead[j] || bullets_[i].owner_id == bullets_[j].owner_id) continue;
if (std::abs(bullets_[i].x - bullets_[j].x) < 2 * kBulletHalf &&
std::abs(bullets_[i].y - bullets_[j].y) < 2 * kBulletHalf) {
dead[i] = dead[j] = true;
}
}
}
int loserIndex = -1;
bool anyoneHit = false;
for (size_t i = 0; i < bullets_.size(); i++) {
if (dead[i]) continue;
Bullet &b = bullets_[i];
int tx = (int)std::floor(b.x);
int ty = (int)std::floor(b.y);
if (tx < 0 || ty < 0 || tx >= kMapWidth || ty >= kMapHeight) {
dead[i] = true;
continue;
}
if (map_[ty][tx] == kSteel) {
dead[i] = true;
continue;
}
if (map_[ty][tx] == kBrick) {
map_[ty][tx] = kEmpty;
dead[i] = true;
continue;
}
for (int ti = 0; ti < 2; ti++) {
Tank &t = tanks_[ti];
if (!t.alive || t.player_id == b.owner_id) continue;
if (std::abs(b.x - t.x) < kTankHalf + kBulletHalf && std::abs(b.y - t.y) < kTankHalf + kBulletHalf) {
dead[i] = true;
t.lives--;
anyoneHit = true;
if (t.lives <= 0) {
t.alive = false;
loserIndex = ti;
} else {
// респаун на стартовой позиции
t.x = (ti == 0) ? 1.5 : kMapWidth - 2.5;
t.y = (ti == 0) ? kMapHeight - 2.5 : 1.5;
}
break;
}
}
}
std::vector<Bullet> alive;
for (size_t i = 0; i < bullets_.size(); i++) {
if (!dead[i]) alive.push_back(bullets_[i]);
}
bullets_ = std::move(alive);
(void)anyoneHit;
if (loserIndex != -1) {
endGame(1 - loserIndex);
}
}
void Game::tick() {
if (finished_) {
return;
}
tick_count_++;
for (auto &t : tanks_) {
applyInput(t);
}
updateBullets();
if (!finished_) {
broadcastState();
}
}
json Game::dirToJson(Dir d) {
switch (d) {
case Dir::Up: return "up";
case Dir::Down: return "down";
case Dir::Left: return "left";
case Dir::Right: return "right";
}
return "up";
}
json Game::serializeTank(const Tank &t) const {
return {
{"id", t.player_id},
{"x", t.x},
{"y", t.y},
{"direction", dirToJson(t.dir)},
{"lives", t.lives},
{"alive", t.alive},
};
}
json Game::serializeBullet(const Bullet &b) const {
return {
{"id", b.id},
{"owner_id", b.owner_id},
{"x", b.x},
{"y", b.y},
{"direction", dirToJson(b.dir)},
};
}
void Game::sendTo(WS *ws, const std::string &type, json payload) {
json msg = {{"type", type}, {"payload", std::move(payload)}};
ws->send(msg.dump(), uWS::OpCode::TEXT);
}
void Game::broadcastState() {
json tanks = json::array();
for (const auto &t : tanks_) tanks.push_back(serializeTank(t));
json bullets = json::array();
for (const auto &b : bullets_) bullets.push_back(serializeBullet(b));
json payload = {{"tick", tick_count_}, {"tanks", tanks}, {"bullets", bullets}};
for (const auto &t : tanks_) {
sendTo(t.ws, "game.state", payload);
}
}
json Game::buildStartPayload(const std::string &mode) const {
json mapJson = json::array();
for (const auto &row : map_) {
mapJson.push_back(row);
}
json players = json::array();
for (const auto &t : tanks_) {
players.push_back({{"id", t.player_id}});
}
return {
{"map", mapJson},
{"mode", mode},
{"players", players},
{"tick_rate", 1000 / kTickMs},
};
}
void Game::handleDisconnect(WS *ws) {
if (finished_) {
return;
}
for (int i = 0; i < 2; i++) {
if (tanks_[i].ws == ws) {
int winnerIndex = 1 - i;
finished_ = true;
if (timer_) {
us_timer_close(timer_);
timer_ = nullptr;
}
sendTo(tanks_[winnerIndex].ws, "game.over", {{"result", "win"}, {"reason", "соперник отключился"}});
if (onFinished_) {
onFinished_();
}
return;
}
}
}
void Game::endGame(int winnerIndex) {
finished_ = true;
if (timer_) {
us_timer_close(timer_);
timer_ = nullptr;
}
for (int i = 0; i < 2; i++) {
std::string result = (winnerIndex == -1) ? "draw" : (i == winnerIndex ? "win" : "lose");
std::string reason = (winnerIndex == -1) ? "оба танка уничтожены одновременно"
: (i == winnerIndex) ? "противник уничтожен"
: "танк уничтожен";
sendTo(tanks_[i].ws, "game.over", {{"result", result}, {"reason", reason}});
}
if (onFinished_) {
onFinished_();
}
}
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include "Lobby.hpp"
#include <array>
#include <functional>
#include <string>
#include <vector>
struct us_timer_t;
// Authoritative-игра для одной комнаты в режиме pvp (этап 4 из TZ.md).
// Coop с AI — этап 5, наследует эту же тик-логику.
class Game {
public:
static constexpr int kTickMs = 50; // 20 Гц
static constexpr int kMapWidth = 15;
static constexpr int kMapHeight = 15;
static constexpr int kStartLives = 3;
enum Tile { kEmpty = 0, kBrick = 1, kSteel = 2 };
enum class Dir { Up, Right, Down, Left };
struct Tank {
std::string player_id;
WS *ws;
double x, y;
Dir dir = Dir::Up;
int lives = kStartLives;
int fire_cooldown = 0;
bool up = false, down = false, left = false, right = false, fire = false;
bool alive = true;
};
struct Bullet {
int id;
std::string owner_id;
double x, y;
Dir dir;
};
// room.players.size() должен быть == 2 на момент создания.
Game(Room &room, std::function<void()> onFinished);
~Game();
void start();
void handleInput(const std::string &player_id, const json &payload);
// Технический проигрыш отключившегося — второй игрок побеждает.
void handleDisconnect(WS *ws);
json buildStartPayload(const std::string &mode) const;
private:
std::vector<std::vector<int>> map_;
std::array<Tank, 2> tanks_;
std::vector<Bullet> bullets_;
int next_bullet_id_ = 1;
int tick_count_ = 0;
bool finished_ = false;
us_timer_t *timer_ = nullptr;
std::function<void()> onFinished_;
void buildMap();
void tick();
void applyInput(Tank &tank);
void updateBullets();
bool tileSolid(int tx, int ty) const;
bool rectHitsSolid(double x, double y) const;
bool rectHitsTank(double x, double y, const Tank &self) const;
void fireBullet(Tank &tank);
void endGame(int winnerIndex); // -1 = ничья
void broadcastState();
void sendTo(WS *ws, const std::string &type, json payload);
static json dirToJson(Dir d);
json serializeTank(const Tank &t) const;
json serializeBullet(const Bullet &b) const;
};
+98
View File
@@ -1,9 +1,13 @@
#include "Lobby.hpp"
#include "Game.hpp"
#include <algorithm>
#include <iomanip>
#include <sstream>
Lobby::Lobby() = default;
Lobby::~Lobby() = default;
void Lobby::send(WS *ws, const std::string &type, json payload) {
json msg = {{"type", type}, {"payload", std::move(payload)}};
ws->send(msg.dump(), uWS::OpCode::TEXT);
@@ -41,6 +45,7 @@ json Lobby::roomPlayers(const Room &room) const {
{"id", room.players[i].player_id},
{"nickname", room.players[i].nickname},
{"slot", i},
{"ready", room.players[i].ready},
});
}
return players;
@@ -69,6 +74,7 @@ void Lobby::onOpen(WS *ws) {
}
void Lobby::onClose(WS *ws) {
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
sockets_.erase(ws);
}
@@ -96,6 +102,10 @@ void Lobby::onMessage(WS *ws, std::string_view message) {
handleJoinRoom(ws, payload);
} else if (type == "lobby.leave_room") {
handleLeaveRoom(ws);
} else if (type == "game.ready") {
handleGameReady(ws);
} else if (type == "game.input") {
handleGameInput(ws, payload);
} else {
sendError(ws, "unknown_type", "unknown message type: " + type);
}
@@ -197,10 +207,98 @@ void Lobby::handleLeaveRoom(WS *ws) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
endActiveGameIfAny(ws->getUserData()->room_id, ws);
removePlayerFromRoom(ws);
broadcastRoomsToLobby();
}
void Lobby::handleGameReady(WS *ws) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
sendError(ws, "not_in_room", "you are not in a room");
return;
}
auto it = rooms_.find(data->room_id);
if (it == rooms_.end()) {
return;
}
for (auto &p : it->second.players) {
if (p.ws == ws) {
p.ready = true;
}
}
broadcastRoomUpdated(it->second);
maybeStartGame(data->room_id);
}
void Lobby::handleGameInput(WS *ws, const json &payload) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
return;
}
auto it = games_.find(data->room_id);
if (it == games_.end()) {
return;
}
it->second->handleInput(data->player_id, payload);
}
void Lobby::maybeStartGame(const std::string &room_id) {
auto it = rooms_.find(room_id);
if (it == rooms_.end()) {
return;
}
Room &room = it->second;
if (room.mode != "pvp" || room.state != "waiting" || room.players.size() != kMaxPlayersPerRoom) {
return;
}
bool allReady = std::all_of(room.players.begin(), room.players.end(),
[](const RoomPlayer &p) { return p.ready; });
if (!allReady) {
return;
}
room.state = "playing";
auto game = std::make_unique<Game>(room, [this, room_id]() {
auto rit = rooms_.find(room_id);
if (rit != rooms_.end()) {
rit->second.state = "waiting";
for (auto &p : rit->second.players) {
p.ready = false;
}
broadcastRoomUpdated(rit->second);
}
broadcastRoomsToLobby();
// Мы всё ещё внутри вызова метода Game (tick/handleDisconnect), который
// и вызвал этот колбэк — удалять объект прямо сейчас было бы use-after-free
// на возврате из этого вызова. Откладываем erase на следующий тик луп'а.
uWS::Loop::get()->defer([this, room_id]() { games_.erase(room_id); });
});
json startPayload = game->buildStartPayload(room.mode);
for (const auto &p : room.players) {
send(p.ws, "game.start", startPayload);
}
game->start();
games_[room_id] = std::move(game);
broadcastRoomsToLobby();
}
void Lobby::endActiveGameIfAny(const std::string &room_id, WS *leavingWs) {
if (room_id.empty()) {
return;
}
auto it = games_.find(room_id);
if (it == games_.end()) {
return;
}
it->second->handleDisconnect(leavingWs);
games_.erase(it);
}
void Lobby::removePlayerFromRoom(WS *ws) {
auto *data = ws->getUserData();
if (data->room_id.empty()) {
+12
View File
@@ -1,6 +1,7 @@
#pragma once
#include <App.h>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
@@ -17,10 +18,13 @@ struct PerSocketData {
using WS = uWS::WebSocket<false, true, PerSocketData>;
class Game;
struct RoomPlayer {
std::string player_id;
std::string nickname;
WS *ws;
bool ready = false;
};
struct Room {
@@ -33,6 +37,9 @@ struct Room {
// Лобби и реестр комнат (этап 3 из TZ.md). Игровая логика — этапы 4-5.
class Lobby {
public:
Lobby();
~Lobby();
void onOpen(WS *ws);
void onMessage(WS *ws, std::string_view message);
void onClose(WS *ws);
@@ -41,6 +48,7 @@ private:
static constexpr size_t kMaxPlayersPerRoom = 2;
std::unordered_map<std::string, Room> rooms_;
std::unordered_map<std::string, std::unique_ptr<Game>> games_;
std::unordered_set<WS *> sockets_;
int next_player_id_ = 1;
int next_room_id_ = 1;
@@ -50,7 +58,11 @@ private:
void handleCreateRoom(WS *ws, const json &payload);
void handleJoinRoom(WS *ws, const json &payload);
void handleLeaveRoom(WS *ws);
void handleGameReady(WS *ws);
void handleGameInput(WS *ws, const json &payload);
void removePlayerFromRoom(WS *ws);
void maybeStartGame(const std::string &room_id);
void endActiveGameIfAny(const std::string &room_id, WS *leavingWs);
void broadcastRoomsToLobby();
void broadcastRoomUpdated(const Room &room, WS *exclude = nullptr);