This commit is contained in:
Ruan Fernandes Guimaraes 2025-06-29 10:55:10 -03:00
commit 1f0daf82cb
15 changed files with 730 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
# Keep environment variables out of version control
.env
# Ignore build output
package-lock.json
# Ignore logs
logs

4
.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"tabWidth": 4,
"singleQuote": true
}

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "graalclone",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@colyseus/schema": "^3.0.42",
"colyseus": "^0.16.4",
"cors": "^2.8.5",
"dotenv": "^17.0.0",
"express": "^5.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.0.7",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

28
public/index.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FreneticalPVP</title>
<script src="https://cdn.jsdelivr.net/npm/phaser@v3.90.0/dist/phaser.min.js"></script>
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
</head>
<body>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
}
canvas {
display: block;
margin: 0 auto;
}
</style>
<script src="./js/main.js"></script>
</body>
</html>

344
public/js/main.js Normal file
View file

@ -0,0 +1,344 @@
const client = new Colyseus.Client('ws://localhost:3000');
let room;
let player;
const otherPlayers = new Map();
let scene;
const config = {
type: Phaser.AUTO,
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: '#1a1a1a',
physics: {
default: 'arcade',
arcade: { debug: false },
},
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: {
preload,
create,
update,
},
};
const game = new Phaser.Game(config);
const MessageType = {
SERVER_CONNECT: 1,
SERVER_DISCONNECT: 2,
SERVER_MOVE: 3,
SERVER_CHAT: 4,
CLIENT_WELCOME: 1001,
CLIENT_PLAYER_JOINED: 1002,
CLIENT_PLAYER_LEFT: 1003,
CLIENT_UPDATE_PLAYER: 1004,
CLIENT_CHAT_MESSAGE: 1005,
CLIENT_ERROR: 1404,
CLIENT_PLAYERS_LIST: 1006, // Novo tipo para lista de jogadores
};
// Variáveis para movimento fluido
const keys = {};
let lastMovementTime = 0;
const MOVEMENT_INTERVAL = 16; // ~60fps
const SPEED = 500; // pixels por segundo
function preload() {
console.log('🎮 Preload phase started');
}
function create() {
scene = this;
console.log('🎯 Create phase started');
// Criar o player visual
player = createPlayerObject(this, 400, 300, 0x00ff00, 'Você');
// Configurar input contínuo
setupContinuousInput(this);
// Conectar ao servidor
console.log('🔗 Tentando conectar ao servidor...');
client
.joinOrCreate('world', { nickname: prompt('Nickname:') })
.then((r) => {
room = r;
console.log('✅ Conectado com sucesso:', room.sessionId);
// Configurar handlers de mensagem
setupMessageHandlers(scene);
})
.catch((error) => {
console.error('❌ Erro ao conectar:', error);
});
}
function createPlayerObject(scene, x, y, color, nickname) {
const playerContainer = scene.add.container(x, y);
// Corpo do jogador
const body = scene.add.rectangle(0, 0, 32, 32, color);
// Nome do jogador
const nameText = scene.add
.text(0, -25, nickname, {
fontSize: '12px',
fill: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
})
.setOrigin(0.5);
// Texto de chat (inicialmente oculto)
const chatText = scene.add
.text(0, -50, '', {
fontSize: '10px',
fill: '#ffffff',
backgroundColor: '#000000aa',
padding: { x: 4, y: 2 },
})
.setOrigin(0.5)
.setVisible(false);
playerContainer.add([body, nameText, chatText]);
// Adicionar propriedades customizadas
playerContainer.body = body;
playerContainer.nameText = nameText;
playerContainer.chatText = chatText;
playerContainer.targetX = x;
playerContainer.targetY = y;
return playerContainer;
}
function setupContinuousInput(scene) {
// Configurar teclas
const cursors = scene.input.keyboard.createCursorKeys();
const wasd = scene.input.keyboard.addKeys('W,S,A,D');
// Armazenar referências das teclas
keys.up = cursors.up;
keys.down = cursors.down;
keys.left = cursors.left;
keys.right = cursors.right;
keys.w = wasd.W;
keys.s = wasd.S;
keys.a = wasd.A;
keys.d = wasd.D;
// Configurar tecla Enter para chat
scene.input.keyboard.on('keydown-ENTER', () => {
if (!room) return;
const msg = prompt('Mensagem:');
if (msg && msg.trim()) {
console.log('💬 Enviando mensagem:', msg);
room.send(MessageType.SERVER_CHAT, { message: msg.trim() });
}
});
}
function setupMessageHandlers(scene) {
// Welcome message
room.onMessage(MessageType.CLIENT_WELCOME, (data) => {
console.log('👋 Welcome:', data.message);
});
// Lista de jogadores online (novo)
room.onMessage(MessageType.CLIENT_PLAYERS_LIST, (players) => {
console.log('📋 Players list:', players);
players.forEach((playerData) => {
if (playerData.sessionId !== room.sessionId) {
createOtherPlayer(playerData);
}
});
});
// Player joined
room.onMessage(
MessageType.CLIENT_PLAYER_JOINED,
({ sessionId, options }) => {
console.log('🧍 Player joined:', sessionId, options);
if (sessionId === room.sessionId) return;
const newPlayer = createPlayerObject(
scene,
400,
300,
0xff0000,
options?.nickname || 'Player'
);
otherPlayers.set(sessionId, newPlayer);
}
);
// Player left
room.onMessage(MessageType.CLIENT_PLAYER_LEFT, ({ sessionId }) => {
console.log('👋 Player left:', sessionId);
const playerObj = otherPlayers.get(sessionId);
if (playerObj) {
playerObj.destroy();
otherPlayers.delete(sessionId);
}
});
// Player update - com interpolação suave
room.onMessage(
MessageType.CLIENT_UPDATE_PLAYER,
({ sessionId, position }) => {
if (sessionId === room.sessionId) {
return; // Não atualizar nosso próprio player
}
const playerObj = otherPlayers.get(sessionId);
if (playerObj) {
// Definir posição alvo para interpolação suave
playerObj.targetX = position.x;
playerObj.targetY = position.y;
}
}
);
// Chat message - exibir acima da cabeça
room.onMessage(
MessageType.CLIENT_CHAT_MESSAGE,
({ sessionId, message }) => {
console.log(`💬 [${sessionId}]:`, message);
let playerObj;
if (sessionId === room.sessionId) {
playerObj = player;
} else {
playerObj = otherPlayers.get(sessionId);
}
if (playerObj && playerObj.chatText) {
showChatMessage(playerObj, message);
}
}
);
// Error message
room.onMessage(MessageType.CLIENT_ERROR, ({ message }) => {
console.error('❌ Erro do servidor:', message);
});
// Connection events
room.onStateChange((state) => {
console.log('🔄 State changed:', state);
});
room.onError((code, message) => {
console.error('❌ Room error:', code, message);
});
room.onLeave((code) => {
console.log('👋 Left room with code:', code);
});
}
function createOtherPlayer(playerData) {
const newPlayer = createPlayerObject(
scene,
playerData.position.x,
playerData.position.y,
0xff0000,
playerData.nickname || 'Player'
);
otherPlayers.set(playerData.sessionId, newPlayer);
}
function showChatMessage(playerObj, message) {
if (!playerObj.chatText) return;
playerObj.chatText.setText(message);
playerObj.chatText.setVisible(true);
// Ocultar mensagem após 3 segundos
setTimeout(() => {
if (playerObj.chatText) {
playerObj.chatText.setVisible(false);
}
}, 3000);
}
function update(time, delta) {
// Movimento fluido baseado em input contínuo
if (room && player) {
handleContinuousMovement(delta);
}
// Interpolação suave para outros jogadores
interpolateOtherPlayers(delta);
}
function handleContinuousMovement(delta) {
const currentTime = Date.now();
// Verificar se é hora de enviar movimento
if (currentTime - lastMovementTime < MOVEMENT_INTERVAL) {
return;
}
let isMoving = false;
const movement = { x: 0, y: 0 };
const speed = (SPEED * delta) / 1000; // pixels por frame
// Verificar input
if (keys.up.isDown || keys.w.isDown) {
movement.y = -speed;
isMoving = true;
}
if (keys.down.isDown || keys.s.isDown) {
movement.y = speed;
isMoving = true;
}
if (keys.left.isDown || keys.a.isDown) {
movement.x = -speed;
isMoving = true;
}
if (keys.right.isDown || keys.d.isDown) {
movement.x = speed;
isMoving = true;
}
if (isMoving) {
// Atualizar posição local imediatamente
player.x += movement.x;
player.y += movement.y;
// Enviar para o servidor
room.send(MessageType.SERVER_MOVE, { x: player.x, y: player.y });
lastMovementTime = currentTime;
}
}
function interpolateOtherPlayers(delta) {
const lerpSpeed = 0.1; // Velocidade de interpolação (0-1)
otherPlayers.forEach((playerObj) => {
if (
playerObj.targetX !== undefined &&
playerObj.targetY !== undefined
) {
// Interpolação linear suave
const deltaX = playerObj.targetX - playerObj.x;
const deltaY = playerObj.targetY - playerObj.y;
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
playerObj.x += deltaX * lerpSpeed;
playerObj.y += deltaY * lerpSpeed;
} else {
playerObj.x = playerObj.targetX;
playerObj.y = playerObj.targetY;
}
}
});
}

25
src/callbacks/chat.ts Normal file
View file

@ -0,0 +1,25 @@
import { Client } from 'colyseus';
import { MessageType } from '../protocol/MessageType';
import { GameRoom } from '../rooms/GameRoom';
export default function chatCallback(
client: Client,
options: { message: string },
room: GameRoom
) {
if (!room || !room.getRoomPlayersKeys().includes(client.sessionId)) {
client.send(MessageType.CLIENT_ERROR, {
message: 'You are not connected to this room.',
});
console.warn(
`Client ${client.sessionId} tried to chat but is not connected to the room.`
);
return;
}
// Enviar mensagem para todos os jogadores, incluindo o remetente
room.broadcast(MessageType.CLIENT_CHAT_MESSAGE, {
sessionId: client.sessionId,
message: options.message,
});
}

48
src/callbacks/connect.ts Normal file
View file

@ -0,0 +1,48 @@
import { Client } from 'colyseus';
import { MessageType } from '../protocol/MessageType';
import { GameRoom } from '../rooms/GameRoom';
import { Player } from '../classes/player';
export default function connectCallback(
client: Client,
options: any,
room: GameRoom
) {
console.log(
'Client connected:',
client.sessionId,
'with options:',
options
);
const player: Player = {
nickname:
options.nickname || 'Player ' + Math.floor(Math.random() * 10000),
sessionId: client.sessionId,
position: { x: 0, y: 0 },
health: 100,
};
room.addPlayer(client.sessionId, player);
client.send(MessageType.CLIENT_WELCOME, {
message: 'Welcome to the game!',
});
client.send(
MessageType.CLIENT_PLAYER_LIST,
room
.getAllPlayers()
.filter((p) => p.sessionId !== client.sessionId)
.map((p) => ({
sessionId: p.sessionId,
nickname: p.nickname,
position: p.position,
}))
);
room.broadcast(MessageType.CLIENT_PLAYER_JOINED, {
sessionId: client.sessionId,
options: options,
});
}

View file

@ -0,0 +1,32 @@
import { Client } from 'colyseus';
import { MessageType } from '../protocol/MessageType';
import { GameRoom } from '../rooms/GameRoom';
export default function disconnectCallback(
client: Client,
consented: boolean,
room: GameRoom
) {
console.log(
'Client disconnected:',
client.sessionId,
'consented:',
consented
);
room.broadcast(MessageType.CLIENT_PLAYER_LEFT, {
sessionId: client.sessionId,
});
room.deletePlayer(client.sessionId);
if (consented) {
client.send(MessageType.CLIENT_WELCOME, {
message: 'You have successfully disconnected.',
});
} else {
client.send(MessageType.CLIENT_ERROR, {
message: 'You were disconnected unexpectedly.',
});
}
}

36
src/callbacks/move.ts Normal file
View file

@ -0,0 +1,36 @@
import { Client } from 'colyseus';
import { MessageType } from '../protocol/MessageType';
import { GameRoom } from '../rooms/GameRoom';
export default function moveCallback(
client: Client,
options: { x: number; y: number },
room: GameRoom
) {
if (!room || !room.getRoomPlayersKeys().includes(client.sessionId)) {
client.send(MessageType.CLIENT_ERROR, {
message: 'You are not connected to this room.',
});
console.warn(
`Client ${client.sessionId} tried to move but is not connected to the room.`
);
return;
}
room.movePlayer(client.sessionId, {
x: options.x,
y: options.y,
});
room.broadcast(
MessageType.CLIENT_UPDATE_PLAYER,
{
sessionId: client.sessionId,
position: {
x: options.x,
y: options.y,
},
},
{ except: client }
);
}

6
src/classes/player.ts Normal file
View file

@ -0,0 +1,6 @@
export class Player {
nickname: string = 'Player ' + Math.floor(Math.random() * 10000);
sessionId: string = '';
position: { x: number; y: number } = { x: 0, y: 0 };
health: number = 100;
}

View file

@ -0,0 +1,16 @@
export enum MessageType {
// Client to Server Messages
SERVER_CONNECT = 1,
SERVER_DISCONNECT = 2,
SERVER_MOVE = 3,
SERVER_CHAT = 4,
// Server to Client Messages
CLIENT_WELCOME = 1001,
CLIENT_PLAYER_JOINED = 1002,
CLIENT_PLAYER_LEFT = 1003,
CLIENT_UPDATE_PLAYER = 1004,
CLIENT_CHAT_MESSAGE = 1005,
CLIENT_PLAYER_LIST = 1006,
CLIENT_ERROR = 1404,
}

13
src/rooms/GameRoom.ts Normal file
View file

@ -0,0 +1,13 @@
import { Room } from 'colyseus';
import { Player } from '../classes/player';
export abstract class GameRoom extends Room {
abstract getRoomPlayersKeys(): string[];
abstract movePlayer(
sessionId: string,
{ x, y }: { x: number; y: number }
): void;
abstract deletePlayer(sessionId: string): void;
abstract addPlayer(sessionId: string, playerData: Player): void;
abstract getAllPlayers(): Player[];
}

71
src/rooms/WorldRoom.ts Normal file
View file

@ -0,0 +1,71 @@
import { Client } from 'colyseus';
import { MessageType } from '../protocol/MessageType';
import connectCallback from '../callbacks/connect';
import disconnectCallback from '../callbacks/disconnect';
import moveCallback from '../callbacks/move';
import { GameRoom } from './GameRoom';
import { Player } from '../classes/player';
import chatCallback from '../callbacks/chat';
export class WorldRoom extends GameRoom {
maxClients = 32;
players: Map<string, Player> = new Map();
onCreate(options: any) {
console.log('WorldRoom created with options:', options);
this.setMessageHandlers();
}
onJoin(client: Client, options: any) {
connectCallback(client, options, this);
}
onLeave(client: Client, consented: boolean) {
disconnectCallback(client, consented, this);
}
onDispose() {
console.log('WorldRoom disposed');
this.players.clear();
}
private setMessageHandlers() {
this.onMessage(
MessageType.SERVER_MOVE,
(client: Client, options: { x: number; y: number }) => {
moveCallback(client, options, this);
}
);
this.onMessage(
MessageType.SERVER_CHAT,
(client: Client, options: { message: string }) => {
chatCallback(client, options, this);
}
);
}
public getRoomPlayersKeys() {
return Array.from(this.players.keys());
}
public movePlayer(
sessionId: string,
{ x, y }: { x: number; y: number }
): void {
this.players.get(sessionId)!.position = { x, y };
}
public deletePlayer(sessionId: string) {
this.players.delete(sessionId);
}
public addPlayer(sessionId: string, playerData: Player) {
this.players.set(sessionId, playerData);
}
public getAllPlayers(): Player[] {
return Array.from(this.players.values());
}
}

50
src/server.ts Normal file
View file

@ -0,0 +1,50 @@
import express from 'express';
import { createServer } from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import 'dotenv/config';
import { Server } from 'colyseus';
import { WorldRoom } from './rooms/WorldRoom';
import cors from 'cors';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const server = createServer(app);
app.use(
cors({
origin: 'https://game.ruanfergui.com.br',
credentials: true,
})
);
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.json());
const gameServer = new Server({
server,
});
gameServer.define('world', WorldRoom);
app.get('/api/status', (req, res) => {
res.json({
status: 'online',
timestamp: new Date().toISOString(),
});
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(`🎮 Colyseus listening for game rooms...`);
});
export { app, server };

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowImportingTsExtensions": false,
"noEmit": false,
"outDir": "./dist",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}