diff --git a/app.py b/app.py new file mode 100644 index 0000000..a049e38 --- /dev/null +++ b/app.py @@ -0,0 +1,152 @@ +import os +from flask import Flask, request, render_template +from flask_socketio import SocketIO, emit, join_room, leave_room +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime, timedelta +import uuid +import threading +import time + +app = Flask(__name__) +app.config['SECRET_KEY'] = 'super-secret-key' +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///game.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +db = SQLAlchemy(app) +socketio = SocketIO(app, cors_allowed_origins="*") + +# --- Models --- + +class Session(db.Model): + id = db.Column(db.String(36), primary_key=True) + room_id = db.Column(db.String(50), unique=True, nullable=False) + player1_id = db.Column(db.String(36), nullable=True) + player2_id = db.Column(db.String(36), nullable=True) + current_turn_player_id = db.Column(db.String(36), nullable=True) + status = db.Column(db.String(20), default='active') # active, finished, abandoned + last_action_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + +class Player(db.Model): + id = db.Column(db.String(36), primary_key=True) + session_id = db.Column(db.String(36), db.ForeignKey('session.id')) + nickname = db.Column(db.String(50), nullable=False) + side = db.Column(db.String(10)) # player1, player2 + player_token = db.Column(db.String(100), unique=True) + sid = db.Column(db.String(100), nullable=True) + +class Unit(db.Model): + id = db.Column(db.String(36), primary_key=True) + session_id = db.Column(db.String(36), db.ForeignKey('session.id')) + owner_id = db.Column(db.String(36), db.ForeignKey('player.id')) + unit_type = db.Column(db.String(20)) + x = db.Column(db.Integer) + y = db.Column(db.Integer) + health = db.Column(db.Integer) + +# --- Routes --- + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/game/') +def game(room_id): + return render_template('game.html', room_id=room_id) + +# --- Helpers --- + +def update_session_activity(session_id): + session = Session.query.get(session_id) + if session: + session.last_action_at = datetime.utcnow() + db.session.commit() + +# --- SocketIO Events --- + +@socketio.on('create_game') +def handle_create_game(data): + nickname = data.get('nickname') + room_id = str(uuid.uuid4())[:8] + session_id = str(uuid.uuid4()) + player_id = str(uuid.uuid4()) + player_token = str(uuid.uuid4()) + + new_session = Session(id=session_id, room_id=room_id, status='active', current_turn_player_id=player_id) + new_player = Player(id=player_id, session_id=session_id, nickname=nickname, side='player1', player_token=player_token, sid=request.sid) + + db.session.add(new_session) + db.session.add(new_player) + new_session.player1_id = player_id + db.session.commit() + + join_room(room_id) + emit('game_created', {'room_id': room_id, 'player_token': player_token}, room=request.sid) + +@socketio.on('join_game') +def handle_join_game(data): + room_id = data.get('room_id') + session = Session.query.filter_by(room_id=room_id).first() + + player_token = data.get('player_token') + nickname = data.get('nickname') + + if not session or session.status != 'active': + emit('error', {'message': 'Room not found or game ended'}) + return + + if player_token: + # Case 1: Re-connecting player (creator) + player = Player.query.filter_by(player_token=player_token).first() + if player and player.session_id == session.id: + player.sid = request.sid + if nickname: # Update nickname if provided during reconnect + player.nickname = nickname + db.session.commit() + join_room(room_id) + emit('game_joined', {'room_id': room_id}, room=request.sid) + return + else: + emit('error', {'message': 'Invalid player token'}) + return + + # Case 2: Second player joining via link + if session.player2_id is None: + player_id = str(uuid.uuid4()) + new_player = Player(id=player_id, session_id=session.id, nickname=nickname, side='player2', player_token=str(uuid.uuid4()), sid=request.sid) + session.player2_id = player_id + db.session.add(new_player) + db.session.commit() + + join_room(room_id) + emit('game_joined', {'room_id': room_id}, room=request.sid) + emit('player_joined', {'nickname': nickname}, room=room_id) + else: + emit('error', {'message': 'Room full'}) + +@socketio.on('connect') +def handle_connect(): + # Logic for reconnection would go here: + # Check if sid is associated with an existing player_token in the database + pass + +# --- Background Task --- + +def cleanup_task(): + with app.app_context(): # Note: app.app_context() + while True: + time.sleep(60) + timeout_limit = datetime.utcnow() - timedelta(minutes=20) + expired_sessions = Session.query.filter(Session.status == 'active', Session.last_action_at < timeout_limit).all() + + for s in expired_sessions: + s.status = 'abandoned' + db.session.commit() + print(f"Session {s.room_id} abandoned due to inactivity.") + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + + threading.Thread(target=cleanup_task, daemon=True).start() + socketio.run(app, debug=True, port=5000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..712a8b1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +aiosqlite +pydantic +websockets diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..0f2008e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,81 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #2c3e50; + color: #ecf0f1; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; +} + +.container { + background-color: #34495e; + padding: 2rem; + border-radius: 10px; + box-shadow: 0 10px 25px rgba(0,0,0,0.5); + width: 400px; + text-align: center; +} + +.form-group { + margin-bottom: 1.5rem; +} + +input[type="text"] { + width: 100%; + padding: 10px; + border-radius: 5px; + border: none; + box-sizing: border-box; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 5px; + cursor: pointer; + font-weight: bold; + transition: background 0.3s; +} + +.btn-primary { background-color: #3498db; color: white; } +.btn-primary:hover { background-format: #2980b9; } +.btn-secondary { background-color: #95a5a6; color: white; } + +.hidden { display: none; } +.error { color: #e74c3c; margin-top: 10px; font-size: 0.9rem; } + +#game-container { + width: 90vw; + max-width: 800px; +} + +#status-bar { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + background: #2c3e50; + padding: 10px; + border-radius: 5px; +} + +#game-board { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 2px; + background-color: #7f8c8d; + padding: 2px; + border: 5px solid #34495e; +} + +.cell { + aspect-ratio: 1/1; + background-color: #ecf0f1; + border: 1px solid #bdc3c7; +} + +#controls { + margin-top: 20px; + text-align: center; +} diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..7ff4330 --- /dev/null +++ b/templates/game.html @@ -0,0 +1,88 @@ + + + + + + Animals at War - Game + + + + +
+
+
+ | Connecting... +
+
Waiting for players...
+
+ +
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..41852d6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,66 @@ + + + + + + Animals at War - Lobby + + + + +
+

Animals at War

+
+ +
+
+ +
+ +
+
+ + + +