From ad7cdca051c6cd9a846f534ee060a8f5898845d9 Mon Sep 17 00:00:00 2001
From: jimmy2822
Date: Sat, 9 Aug 2025 19:19:55 +0800
Subject: [PATCH 001/175] git init
---
.ruby-version | 1 +
CLAUDE.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 111 insertions(+)
create mode 100644 .ruby-version
create mode 100644 CLAUDE.md
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..9c25013
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+3.3.6
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a75082b
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,110 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Lively is a Ruby framework for building interactive web applications for creative coding. It provides real-time communication between client and server using WebSockets through the Live gem, and runs on the Falcon web server.
+
+## Key Commands
+
+### Dependencies
+```bash
+# Install Ruby dependencies
+bundle install
+
+# Install Node dependencies (for client-side Live.js)
+npm install
+```
+
+### Running Applications
+```bash
+# Run the main application
+bundle exec lively lib/lively/application.rb
+
+# Run examples (from example directory)
+cd examples/hello-world
+bundle exec lively application.rb
+
+# Alternative: use the lively executable directly
+./bin/lively examples/hello-world/application.rb
+```
+
+### Testing
+```bash
+# Run tests with Sus test framework
+bundle exec sus
+
+# Run a specific test file
+bundle exec sus test/lively.rb
+```
+
+### Code Quality
+```bash
+# Run RuboCop for linting
+bundle exec rubocop
+
+# Run RuboCop with auto-fix
+bundle exec rubocop -a
+```
+
+## Architecture
+
+### Core Components
+
+**Lively::Application** (`lib/lively/application.rb`)
+- Base class for Lively applications, extends `Protocol::HTTP::Middleware`
+- Handles WebSocket connections at `/live` endpoint
+- Factory method `Application[ViewClass]` creates application instances for specific view classes
+- Manages Live::Page instances for real-time updates
+
+**Live::View Pattern**
+- Views inherit from `Live::View` and implement `render(builder)` method
+- Views bind to pages and can trigger updates with `update!`
+- Builder provides HTML generation methods
+
+**Asset Pipeline** (`lib/lively/assets.rb`)
+- Serves static files from `public/` directories
+- Client-side JavaScript components in `public/_components/`
+- Includes morphdom for DOM diffing and @socketry/live for WebSocket handling
+
+### Application Structure
+
+```
+application.rb # Defines Application class with View
+gems.rb # Dependencies (points to main gem)
+public/ # Static assets
+ _static/ # CSS, images, audio files
+```
+
+### WebSocket Communication
+- Live updates happen through WebSocket connections to `/live`
+- Live::Page manages the connection between server views and client DOM
+- Uses Live::Resolver to control which classes can be instantiated on the client
+
+### Running Examples
+Each example is self-contained with its own `application.rb` that:
+1. Defines view classes extending `Live::View`
+2. Creates an Application using `Lively::Application[ViewClass]`
+3. Can be run with `bundle exec lively application.rb`
+
+## Key Patterns
+
+### Creating a New Application
+```ruby
+class MyView < Live::View
+ def render(builder)
+ # Build HTML using builder methods
+ end
+end
+
+Application = Lively::Application[MyView]
+```
+
+### View Updates
+Views can trigger client updates by calling `update!` which sends the new rendered HTML over the WebSocket connection.
+
+### Static Asset Organization
+- Application-specific assets go in `./public/_static/`
+- Shared framework assets are in the gem's `public/` directory
+- Both are automatically served by the Assets middleware
\ No newline at end of file
From 3345596da5e75bd9da2085f7c385d56e1030d140 Mon Sep 17 00:00:00 2001
From: jimmy2822
Date: Sat, 9 Aug 2025 20:00:44 +0800
Subject: [PATCH 002/175] Add CS2D game example application
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduces a Counter-Strike 2D-inspired multiplayer game example built with Lively framework, demonstrating real-time WebSocket communication for game state synchronization and player interactions.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
examples/cs2d/CS16_MVP_PLAN.md | 285 ++++
examples/cs2d/IMPLEMENTATION_PLAN.md | 199 +++
examples/cs2d/README.md | 130 ++
examples/cs2d/application.rb | 596 +++++++++
examples/cs2d/cs16_server.rb | 1162 +++++++++++++++++
examples/cs2d/docs/GAMEPLAY_GUIDE.md | 374 ++++++
examples/cs2d/docs/QUICK_START.md | 198 +++
examples/cs2d/docs/README.md | 197 +++
examples/cs2d/docs/TECHNICAL.md | 656 ++++++++++
examples/cs2d/game/bullet.rb | 35 +
examples/cs2d/game/game_room.rb | 243 ++++
examples/cs2d/game/game_state.rb | 102 ++
examples/cs2d/game/mvp_bomb_system.rb | 208 +++
examples/cs2d/game/mvp_economy.rb | 63 +
examples/cs2d/game/mvp_game_room.rb | 496 +++++++
examples/cs2d/game/mvp_game_server.rb | 36 +
examples/cs2d/game/mvp_map.rb | 87 ++
examples/cs2d/game/mvp_player.rb | 164 +++
examples/cs2d/game/mvp_round_manager.rb | 145 ++
examples/cs2d/game/player.rb | 229 ++++
examples/cs2d/gems.rb | 8 +
examples/cs2d/mvp_application.rb | 238 ++++
examples/cs2d/public/_static/cs16_mvp.js | 520 ++++++++
examples/cs2d/public/_static/style.css | 328 +++++
examples/hello-world/.ruby-version | 1 +
examples/hello-world/.tool-versions | 1 +
examples/hello-world/cs2d_app.rb | 596 +++++++++
examples/hello-world/game/bullet.rb | 35 +
examples/hello-world/game/game_room.rb | 243 ++++
examples/hello-world/game/game_state.rb | 102 ++
examples/hello-world/game/player.rb | 229 ++++
examples/hello-world/public/_static/style.css | 328 +++++
examples/hello-world/simple_server.rb | 348 +++++
33 files changed, 8582 insertions(+)
create mode 100644 examples/cs2d/CS16_MVP_PLAN.md
create mode 100644 examples/cs2d/IMPLEMENTATION_PLAN.md
create mode 100644 examples/cs2d/README.md
create mode 100644 examples/cs2d/application.rb
create mode 100644 examples/cs2d/cs16_server.rb
create mode 100644 examples/cs2d/docs/GAMEPLAY_GUIDE.md
create mode 100644 examples/cs2d/docs/QUICK_START.md
create mode 100644 examples/cs2d/docs/README.md
create mode 100644 examples/cs2d/docs/TECHNICAL.md
create mode 100644 examples/cs2d/game/bullet.rb
create mode 100644 examples/cs2d/game/game_room.rb
create mode 100644 examples/cs2d/game/game_state.rb
create mode 100644 examples/cs2d/game/mvp_bomb_system.rb
create mode 100644 examples/cs2d/game/mvp_economy.rb
create mode 100644 examples/cs2d/game/mvp_game_room.rb
create mode 100644 examples/cs2d/game/mvp_game_server.rb
create mode 100644 examples/cs2d/game/mvp_map.rb
create mode 100644 examples/cs2d/game/mvp_player.rb
create mode 100644 examples/cs2d/game/mvp_round_manager.rb
create mode 100644 examples/cs2d/game/player.rb
create mode 100644 examples/cs2d/gems.rb
create mode 100644 examples/cs2d/mvp_application.rb
create mode 100644 examples/cs2d/public/_static/cs16_mvp.js
create mode 100644 examples/cs2d/public/_static/style.css
create mode 100644 examples/hello-world/.ruby-version
create mode 100644 examples/hello-world/.tool-versions
create mode 100644 examples/hello-world/cs2d_app.rb
create mode 100644 examples/hello-world/game/bullet.rb
create mode 100644 examples/hello-world/game/game_room.rb
create mode 100644 examples/hello-world/game/game_state.rb
create mode 100644 examples/hello-world/game/player.rb
create mode 100644 examples/hello-world/public/_static/style.css
create mode 100644 examples/hello-world/simple_server.rb
diff --git a/examples/cs2d/CS16_MVP_PLAN.md b/examples/cs2d/CS16_MVP_PLAN.md
new file mode 100644
index 0000000..acdbe3f
--- /dev/null
+++ b/examples/cs2d/CS16_MVP_PLAN.md
@@ -0,0 +1,285 @@
+# CS 1.6 完整規則 MVP 實作計畫
+
+## 🎯 MVP 核心目標
+實現一個具備 CS 1.6 核心玩法的 2D 網頁遊戲,支援 2v2 到 5v5 的多人對戰。
+
+## 📋 必要功能清單
+
+### 1. 核心遊戲規則 ✅ 必須實現
+
+#### 回合制系統
+```ruby
+class RoundManager
+ ROUND_TIME = 115 # 1:55 戰鬥時間
+ BUY_TIME = 15 # 15秒購買時間
+ FREEZE_TIME = 5 # 5秒凍結時間
+ MAX_ROUNDS = 30 # 最多30回合
+ HALF_TIME = 15 # 15回合換邊
+
+ def round_flow
+ # 1. 凍結時間 - 玩家重生,不能移動
+ # 2. 購買時間 - 可以移動和購買
+ # 3. 戰鬥時間 - 正常遊戲
+ # 4. 回合結束 - 計算勝負,發放獎金
+ end
+end
+```
+
+#### 勝利條件
+- **T 勝利**:
+ - 炸彈爆炸
+ - 消滅所有 CT
+ - 時間結束且炸彈已安裝
+
+- **CT 勝利**:
+ - 拆除炸彈
+ - 消滅所有 T
+ - 時間結束(炸彈未安裝)
+
+#### 死亡系統
+- 死亡後變成觀察者模式
+- 可以觀看隊友視角
+- 下回合開始時復活
+
+### 2. 炸彈機制 🔴 最重要
+
+```ruby
+class BombSystem
+ PLANT_TIME = 3.0 # 安裝炸彈需要3秒
+ DEFUSE_TIME = 10.0 # 拆彈需要10秒(有鉗子5秒)
+ BOMB_TIMER = 45.0 # 炸彈倒數45秒
+ EXPLOSION_RADIUS = 500 # 爆炸半徑
+
+ def plant_bomb(player, bomb_site)
+ # 只有T可以安裝
+ # 必須在炸彈點A或B
+ # 需要持續按住E鍵3秒
+ end
+
+ def defuse_bomb(player)
+ # 只有CT可以拆除
+ # 需要持續按住E鍵10秒(或5秒with鉗子)
+ end
+end
+```
+
+### 3. 經濟系統 💰
+
+```ruby
+class Economy
+ # 起始金錢
+ STARTING_MONEY = 800
+ MAX_MONEY = 16000
+
+ # 擊殺獎勵
+ KILL_REWARD = {
+ knife: 1500,
+ pistol: 300,
+ smg: 600,
+ rifle: 300,
+ awp: 100
+ }
+
+ # 回合獎勵
+ ROUND_WIN_REWARD = 3250
+ ROUND_LOSS_REWARD = 1400 # +500 per consecutive loss, max 3400
+ BOMB_PLANT_REWARD = 800 # T 全隊
+ BOMB_DEFUSE_REWARD = 3500 # CT 全隊
+
+ # 武器價格
+ WEAPONS = {
+ # 手槍
+ usp: 0, # CT 預設
+ glock: 0, # T 預設
+ deagle: 650,
+
+ # 步槍
+ ak47: 2700, # T 專用
+ m4a1: 3100, # CT 專用
+ awp: 4750,
+
+ # 裝備
+ kevlar: 650,
+ helmet: 350, # 需要先有kevlar
+ defuse_kit: 400, # CT 專用
+
+ # 投擲物
+ flashbang: 200,
+ hegrenade: 300,
+ smoke: 300
+ }
+end
+```
+
+### 4. 簡化地圖設計
+
+#### de_dust2_mini
+```
+[T Spawn] [CT Spawn]
+ | |
+ ├──────[Mid]───────────────┤
+ | | |
+[Bomb A] [X] [Bomb B]
+```
+
+特點:
+- 2個炸彈點(A/B)
+- 3條主要路線
+- 簡單的牆壁碰撞
+- 明確的視線遮擋
+
+### 5. 武器系統簡化
+
+只實現核心武器:
+- **手槍**:USP(CT)、Glock(T)
+- **步槍**:AK-47(T)、M4A1(CT)
+- **狙擊**:AWP(通用)
+- **刀**:近戰武器
+
+武器特性:
+```javascript
+const WEAPONS = {
+ ak47: {
+ damage: 36,
+ firerate: 0.1,
+ magazine: 30,
+ recoil: 'high',
+ price: 2700,
+ moveSpeed: 0.85
+ },
+ m4a1: {
+ damage: 33,
+ firerate: 0.09,
+ magazine: 30,
+ recoil: 'medium',
+ price: 3100,
+ moveSpeed: 0.9
+ }
+}
+```
+
+### 6. 多人連線架構
+
+```ruby
+class GameServer
+ def initialize
+ @rooms = {} # 遊戲房間
+ @players = {} # 在線玩家
+ end
+
+ def create_room(name, max_players = 10)
+ @rooms[name] = GameRoom.new(name, max_players)
+ end
+
+ def join_room(player, room_name)
+ room = @rooms[room_name]
+ room.add_player(player)
+
+ # 自動分配隊伍
+ if room.ct_count <= room.t_count
+ player.team = :ct
+ else
+ player.team = :t
+ end
+ end
+end
+```
+
+## 🚀 實作優先順序
+
+### Phase 1: 基礎對戰(1週)
+1. ✅ 玩家移動與射擊
+2. ✅ 基本 UI 與控制
+3. 多人連線同步
+4. 死亡與觀察者模式
+
+### Phase 2: 回合制(3天)
+1. 回合計時器
+2. 購買階段
+3. 重生系統
+4. 隊伍切換
+
+### Phase 3: 炸彈模式(4天)
+1. 炸彈安裝/拆除
+2. 炸彈點設置
+3. 爆炸效果
+4. 勝利判定
+
+### Phase 4: 經濟系統(3天)
+1. 金錢管理
+2. 購買選單
+3. 連敗獎勵
+4. 武器掉落
+
+### Phase 5: 優化(3天)
+1. 地圖碰撞
+2. 視線系統
+3. 音效提示
+4. 記分板
+
+## 📊 成功指標
+
+MVP 完成標準:
+- [ ] 支援 2v2 對戰
+- [ ] 完整回合流程
+- [ ] 炸彈可安裝/拆除
+- [ ] 經濟系統運作
+- [ ] 基本武器差異
+- [ ] 勝負判定正確
+
+## 🎮 簡化決策
+
+為了快速實現 MVP,以下功能暫不實作:
+- ❌ 手榴彈系統
+- ❌ 複雜地圖
+- ❌ 語音通訊
+- ❌ 皮膚系統
+- ❌ 段位系統
+- ❌ 觀戰模式切換
+
+## 💻 技術實現要點
+
+### 狀態同步
+```javascript
+// 每 tick 同步的數據
+{
+ players: {
+ id: { x, y, angle, health, team, alive, money }
+ },
+ bomb: {
+ planted: false,
+ position: null,
+ timer: 45,
+ planting_progress: 0,
+ defusing_progress: 0
+ },
+ round: {
+ phase: 'buy|playing|ended',
+ timer: 115,
+ number: 1,
+ score: { ct: 0, t: 0 }
+ }
+}
+```
+
+### 網路優化
+- 使用 WebSocket 二進制格式
+- 客戶端預測 + 伺服器調和
+- 差異更新而非全量同步
+- 20-30 tick rate
+
+## 🔧 開發工具需求
+
+- **Lively Framework**:基礎架構
+- **WebSocket**:即時通訊
+- **Canvas 2D**:渲染引擎
+- **Web Audio API**:音效系統
+
+## 📅 時間估算
+
+**總開發時間**:2-3 週
+- Week 1: 基礎系統 + 多人連線
+- Week 2: 炸彈模式 + 經濟系統
+- Week 3: 優化 + 測試 + 平衡調整
+
+這個 MVP 將提供完整的 CS 1.6 核心體驗!
\ No newline at end of file
diff --git a/examples/cs2d/IMPLEMENTATION_PLAN.md b/examples/cs2d/IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..d990390
--- /dev/null
+++ b/examples/cs2d/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,199 @@
+# CS2D - 2D 版 Counter Strike 1.6 實作計畫
+
+## 專案概述
+使用 Lively 框架開發一個 2D 俯視角的 Counter Strike 遊戲,支援即時多人對戰。
+
+## 核心功能需求
+
+### 1. 遊戲機制
+- **隊伍系統**:恐怖分子(T) vs 反恐小組(CT)
+- **回合制**:每回合有時間限制,完成目標或消滅對方獲勝
+- **遊戲模式**:
+ - 炸彈拆除模式 (de_)
+ - 人質救援模式 (cs_)
+ - 死鬥模式 (dm_)
+- **復活機制**:回合開始時復活,死亡後觀戰
+
+### 2. 玩家系統
+- **移動**:WASD 控制,滑鼠瞄準
+- **生命值**:100 HP,護甲系統
+- **速度**:根據武器重量調整移動速度
+- **碰撞檢測**:玩家、牆壁、子彈
+
+### 3. 武器系統
+- **武器類型**:
+ - 手槍:USP, Glock, Desert Eagle
+ - 步槍:AK-47, M4A1, AWP
+ - 衝鋒槍:MP5, P90
+ - 手榴彈:閃光彈、煙霧彈、高爆彈
+- **彈道系統**:後座力、精準度、傷害衰減
+- **彈藥管理**:彈匣與備用彈藥
+
+### 4. 經濟系統
+- **起始金錢**:$800
+- **收入來源**:擊殺獎勵、回合獎勵、完成目標
+- **購買時間**:回合開始 15 秒
+
+## 技術架構
+
+### 後端 (Ruby/Lively)
+```ruby
+# 核心類別結構
+class CS2DApplication < Lively::Application
+ # 遊戲主應用
+end
+
+class GameRoom < Live::View
+ # 遊戲房間管理
+ attr_accessor :players, :state, :round_timer
+end
+
+class Player
+ attr_accessor :id, :name, :team, :position, :health, :armor
+ attr_accessor :money, :weapons, :current_weapon
+end
+
+class GameState
+ # 遊戲狀態管理:等待中、進行中、回合結束
+end
+
+class Physics
+ # 碰撞檢測、移動計算
+end
+```
+
+### 前端 (JavaScript/Canvas)
+```javascript
+// 遊戲渲染引擎
+class GameRenderer {
+ constructor(canvas) {
+ this.ctx = canvas.getContext('2d');
+ this.sprites = {};
+ }
+
+ render(gameState) {
+ // 渲染地圖、玩家、子彈、特效
+ }
+}
+
+// 輸入控制
+class InputController {
+ handleMouseMove(e) { /* 瞄準 */ }
+ handleKeyboard(keys) { /* 移動 */ }
+ handleClick(e) { /* 射擊 */ }
+}
+```
+
+### 網路同步
+```ruby
+class NetworkSync
+ def broadcast_player_state(player)
+ # 廣播玩家位置、狀態
+ end
+
+ def sync_bullet(bullet_data)
+ # 同步子彈軌跡
+ end
+
+ def handle_lag_compensation
+ # 延遲補償機制
+ end
+end
+```
+
+## 實作步驟
+
+### 第一階段:基礎框架 (1-2 週)
+1. 建立專案結構
+2. 實作基本 WebSocket 連線
+3. 建立遊戲畫布與渲染循環
+4. 玩家連線與斷線處理
+
+### 第二階段:核心遊戲機制 (2-3 週)
+1. 玩家移動系統
+2. 基本碰撞檢測
+3. 簡單武器射擊
+4. 生命值系統
+
+### 第三階段:多人同步 (2 週)
+1. 狀態同步協議
+2. 延遲補償
+3. 客戶端預測
+4. 插值與外推
+
+### 第四階段:遊戲模式 (2 週)
+1. 回合制邏輯
+2. 隊伍系統
+3. 炸彈模式實作
+4. 計分板
+
+### 第五階段:進階功能 (2-3 週)
+1. 完整武器系統
+2. 經濟系統
+3. 購買選單 UI
+4. 地圖載入系統
+
+### 第六階段:優化與完善 (1-2 週)
+1. 效能優化
+2. 音效系統
+3. 視覺特效
+4. Bug 修復
+
+## 檔案結構
+```
+examples/cs2d/
+├── application.rb # 主應用程式
+├── gems.rb # 依賴管理
+├── game/
+│ ├── player.rb # 玩家類別
+│ ├── weapon.rb # 武器系統
+│ ├── physics.rb # 物理引擎
+│ ├── game_room.rb # 房間管理
+│ └── network_sync.rb # 網路同步
+├── maps/
+│ ├── de_dust2.json # 地圖資料
+│ └── map_loader.rb # 地圖載入器
+└── public/
+ ├── _static/
+ │ ├── sprites/ # 遊戲圖片
+ │ ├── sounds/ # 音效檔案
+ │ ├── game.js # 前端遊戲邏輯
+ │ └── style.css # 樣式
+ └── index.html # 遊戲頁面
+```
+
+## 關鍵技術挑戰
+
+### 1. 網路延遲處理
+- **客戶端預測**:本地立即執行動作,等待伺服器確認
+- **延遲補償**:伺服器回溯時間驗證擊中
+- **插值**:平滑顯示其他玩家動作
+
+### 2. 防作弊機制
+- 所有關鍵邏輯在伺服器執行
+- 客戶端只負責顯示與輸入
+- 驗證所有客戶端請求
+
+### 3. 效能優化
+- 使用 Object Pool 管理子彈物件
+- 空間分割優化碰撞檢測
+- 只同步視野內的物件
+
+## 開發優先順序
+1. **MVP 版本**:單一地圖、基本武器、死鬥模式
+2. **Alpha 版本**:加入隊伍、回合制、經濟系統
+3. **Beta 版本**:多地圖、完整武器、所有模式
+4. **正式版本**:優化、平衡性調整、排行榜
+
+## 測試計畫
+- 單元測試:遊戲邏輯、物理計算
+- 整合測試:網路同步、狀態管理
+- 壓力測試:多人同時連線
+- 遊戲測試:平衡性、可玩性
+
+## 預估時程
+- **總時程**:10-12 週
+- **MVP**:3-4 週
+- **Alpha**:6-7 週
+- **Beta**:9-10 週
+- **正式版**:12 週
\ No newline at end of file
diff --git a/examples/cs2d/README.md b/examples/cs2d/README.md
new file mode 100644
index 0000000..d2d45de
--- /dev/null
+++ b/examples/cs2d/README.md
@@ -0,0 +1,130 @@
+# CS2D - 2D Counter Strike
+
+一個使用 Lively 框架開發的 2D 版 Counter Strike 多人對戰遊戲。
+
+## 功能特色
+
+- 即時多人對戰
+- 恐怖分子 vs 反恐小組團隊模式
+- 武器購買系統
+- 回合制遊戲機制
+- 經濟系統
+- 即時聊天功能
+
+## 快速開始
+
+### 安裝依賴
+
+```bash
+cd examples/cs2d
+bundle install
+```
+
+### 啟動遊戲伺服器
+
+```bash
+bundle exec lively application.rb
+```
+
+然後在瀏覽器開啟 http://localhost:9292
+
+## 遊戲控制 (Mac 觸控板優化)
+
+### 移動與戰鬥
+- **WASD** - 移動 (按住 Shift 加速跑)
+- **方向鍵/IJKL** - 控制瞄準方向
+ - 左右鍵/JL - 旋轉瞄準
+ - 上下鍵/IK - 調整瞄準距離
+- **空白鍵** - 射擊 (或單指點擊)
+- **R** - 換彈
+- **Q** - 快速 180° 轉身
+- **V** - 切換自動瞄準輔助
+
+### 觸控板手勢
+- **雙指橫向滑動** - 旋轉瞄準角度
+- **雙指縱向滑動** - 調整瞄準距離
+- **雙指點擊** - 射擊(右鍵)
+- **單指點擊** - 射擊
+
+### 介面控制
+- **B** - 開啟購買選單
+- **1-5** - 快速購買武器
+ - 1: AK-47
+ - 2: M4A1
+ - 3: AWP
+ - 4: Desert Eagle
+ - 5: 護甲
+- **Tab** - 查看計分板
+- **T** - 開啟聊天
+
+## 遊戲規則
+
+### 回合制
+- 每回合 2 分鐘
+- 消滅對方隊伍獲勝
+- 先贏得 16 回合的隊伍獲得最終勝利
+
+### 經濟系統
+- 起始金錢:$800
+- 擊殺獎勵:$300
+- 回合勝利:$3250
+- 回合失敗:$1400
+
+### 武器價格
+- **手槍**
+ - Glock-18:預設 (T)
+ - USP-S:預設 (CT)
+ - Desert Eagle:$700
+
+- **步槍**
+ - AK-47:$2700
+ - M4A1:$3100
+ - AWP:$4750
+
+- **衝鋒槍**
+ - MP5:$1500
+ - P90:$2350
+
+- **裝備**
+ - 防彈衣:$650
+ - 頭盔+防彈衣:$1000
+
+## 開發架構
+
+### 後端 (Ruby/Lively)
+- `application.rb` - 主應用程式入口
+- `game/game_room.rb` - 遊戲房間邏輯
+- `game/player.rb` - 玩家類別
+- `game/bullet.rb` - 子彈物理
+- `game/game_state.rb` - 遊戲狀態管理
+
+### 前端 (JavaScript)
+- Canvas 2D 渲染
+- WebSocket 即時通訊
+- 客戶端預測與插值
+
+## 待實作功能
+
+- [ ] 炸彈模式
+- [ ] 人質救援模式
+- [ ] 地圖系統
+- [ ] 手榴彈系統
+- [ ] 語音通訊
+- [ ] 戰績統計
+- [ ] 排行榜
+- [ ] 自訂房間
+
+## 效能優化
+
+- 使用物件池管理子彈
+- 視野裁剪優化渲染
+- 狀態壓縮減少網路傳輸
+- 客戶端預測減少延遲感
+
+## 貢獻
+
+歡迎提交 Issue 和 Pull Request!
+
+## 授權
+
+MIT License
\ No newline at end of file
diff --git a/examples/cs2d/application.rb b/examples/cs2d/application.rb
new file mode 100644
index 0000000..b13d312
--- /dev/null
+++ b/examples/cs2d/application.rb
@@ -0,0 +1,596 @@
+#!/usr/bin/env lively
+# frozen_string_literal: true
+
+require_relative "game/game_room"
+require_relative "game/player"
+require_relative "game/game_state"
+
+class CS2DView < Live::View
+ def initialize(...)
+ super
+ @game_room = GameRoom.new
+ end
+
+ def bind(page)
+ super
+ @page = page
+ @game_room.add_player(page.id)
+ self.update!
+ end
+
+ def close
+ @game_room.remove_player(@page.id)
+ super
+ end
+
+ def handle(event)
+ case event[:type]
+ when "player_move"
+ @game_room.update_player_position(@page.id, event[:x], event[:y])
+ when "player_shoot"
+ @game_room.player_shoot(@page.id, event[:angle])
+ when "player_reload"
+ @game_room.player_reload(@page.id)
+ when "change_team"
+ @game_room.change_team(@page.id, event[:team])
+ when "buy_weapon"
+ @game_room.buy_weapon(@page.id, event[:weapon])
+ when "chat_message"
+ @game_room.broadcast_chat(@page.id, event[:message])
+ end
+
+ broadcast_game_state
+ end
+
+ def broadcast_game_state
+ @game_room.players.each do |player_id, player|
+ if page = Live::Page.pages[player_id]
+ page.live.push(game_state_json)
+ end
+ end
+ end
+
+ def game_state_json
+ {
+ type: "game_update",
+ players: @game_room.players_data,
+ bullets: @game_room.bullets_data,
+ round_time: @game_room.round_time,
+ scores: @game_room.scores
+ }.to_json
+ end
+
+ def render(builder)
+ builder.tag(:div, id: "cs2d-container", style: "width: 100%; height: 100vh; margin: 0; padding: 0; overflow: hidden;") do
+ builder.tag(:canvas, id: "game-canvas", style: "display: block;")
+
+ builder.tag(:div, id: "game-ui", style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%;") do
+ render_hud(builder)
+ render_scoreboard(builder)
+ render_buy_menu(builder)
+ render_chat(builder)
+ end
+ end
+
+ builder.tag(:script, type: "module") do
+ builder.text(client_game_script)
+ end
+ end
+
+ private
+
+ def render_hud(builder)
+ builder.tag(:div, id: "hud", style: "position: absolute; bottom: 20px; left: 20px; color: white; font-family: monospace;") do
+ builder.tag(:div, id: "health") { builder.text("HP: 100") }
+ builder.tag(:div, id: "armor") { builder.text("Armor: 0") }
+ builder.tag(:div, id: "ammo") { builder.text("Ammo: 30/90") }
+ builder.tag(:div, id: "money") { builder.text("$800") }
+ end
+ end
+
+ def render_scoreboard(builder)
+ builder.tag(:div, id: "scoreboard", style: "position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: white; padding: 10px; display: none;") do
+ builder.tag(:h3) { builder.text("Scoreboard") }
+ builder.tag(:div, id: "team-ct") { builder.text("Counter-Terrorists") }
+ builder.tag(:div, id: "team-t") { builder.text("Terrorists") }
+ end
+ end
+
+ def render_buy_menu(builder)
+ builder.tag(:div, id: "buy-menu", style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.9); color: white; padding: 20px; display: none;") do
+ builder.tag(:h2) { builder.text("Buy Menu") }
+ builder.tag(:div, class: "weapon-categories") do
+ builder.tag(:button, onclick: "buyWeapon('ak47')") { builder.text("AK-47 - $2700") }
+ builder.tag(:button, onclick: "buyWeapon('m4a1')") { builder.text("M4A1 - $3100") }
+ builder.tag(:button, onclick: "buyWeapon('awp')") { builder.text("AWP - $4750") }
+ end
+ end
+ end
+
+ def render_chat(builder)
+ builder.tag(:div, id: "chat", style: "position: absolute; bottom: 100px; left: 20px; width: 300px; height: 150px;") do
+ builder.tag(:div, id: "chat-messages", style: "background: rgba(0,0,0,0.5); color: white; padding: 5px; height: 120px; overflow-y: auto;")
+ builder.tag(:input, id: "chat-input", type: "text", placeholder: "Press T to chat...",
+ style: "width: 100%; display: none;")
+ end
+ end
+
+ def client_game_script
+ <<~JAVASCRIPT
+ import Live from "/_components/@socketry/live/Live.js";
+
+ class CS2DGame {
+ constructor() {
+ this.canvas = document.getElementById('game-canvas');
+ this.ctx = this.canvas.getContext('2d');
+ this.setupCanvas();
+ this.setupInput();
+ this.players = {};
+ this.bullets = [];
+ this.localPlayer = null;
+ this.live = Live.connect();
+ this.setupNetworking();
+
+ // Mac 優化:瞄準系統
+ this.aimAngle = 0;
+ this.aimDistance = 100;
+ this.autoAimEnabled = true;
+ this.aimSensitivity = 0.15;
+ this.lastShootTime = 0;
+ this.shootCooldown = 100; // ms
+
+ this.gameLoop();
+ this.showControls();
+ }
+
+ setupCanvas() {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ window.addEventListener('resize', () => {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ });
+ }
+
+ setupInput() {
+ this.keys = {};
+
+ // 鍵盤控制
+ document.addEventListener('keydown', (e) => {
+ this.keys[e.key.toLowerCase()] = true;
+
+ // 空白鍵射擊
+ if (e.key === ' ') {
+ e.preventDefault();
+ this.shoot();
+ }
+
+ // 方向鍵或 IJKL 控制瞄準
+ if (e.key === 'ArrowLeft' || e.key === 'j') {
+ this.aimAngle -= this.aimSensitivity;
+ }
+ if (e.key === 'ArrowRight' || e.key === 'l') {
+ this.aimAngle += this.aimSensitivity;
+ }
+ if (e.key === 'ArrowUp' || e.key === 'i') {
+ this.aimDistance = Math.min(this.aimDistance + 10, 200);
+ }
+ if (e.key === 'ArrowDown' || e.key === 'k') {
+ this.aimDistance = Math.max(this.aimDistance - 10, 50);
+ }
+
+ // 快速 180 度轉身
+ if (e.key === 'q' || e.key === 'Q') {
+ this.aimAngle += Math.PI;
+ }
+
+ // 切換自動瞄準
+ if (e.key === 'v' || e.key === 'V') {
+ this.autoAimEnabled = !this.autoAimEnabled;
+ this.showNotification(this.autoAimEnabled ? '自動瞄準:開啟' : '自動瞄準:關閉');
+ }
+
+ if (e.key === 'b' || e.key === 'B') {
+ this.toggleBuyMenu();
+ }
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ this.toggleScoreboard();
+ }
+ if (e.key === 't' || e.key === 'T') {
+ e.preventDefault();
+ this.toggleChat();
+ }
+ if (e.key === 'r' || e.key === 'R') {
+ this.reload();
+ }
+
+ // 數字鍵快速購買
+ if (e.key >= '1' && e.key <= '5') {
+ this.quickBuy(e.key);
+ }
+ });
+
+ document.addEventListener('keyup', (e) => {
+ this.keys[e.key.toLowerCase()] = false;
+ });
+
+ // 觸控板支援
+ this.setupTouchpad();
+ }
+
+ setupTouchpad() {
+ let touchStartX = 0;
+ let touchStartY = 0;
+
+ // 雙指滑動控制瞄準
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ // 水平滾動改變瞄準角度
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ this.aimAngle += e.deltaX * 0.01;
+ }
+ // 垂直滾動改變瞄準距離
+ else {
+ this.aimDistance = Math.max(50, Math.min(200, this.aimDistance - e.deltaY));
+ }
+ });
+
+ // 雙指點擊射擊
+ this.canvas.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this.shoot();
+ });
+
+ // 單指點擊也可射擊
+ this.canvas.addEventListener('click', (e) => {
+ // 如果點擊的是遊戲區域,則射擊
+ if (e.target === this.canvas) {
+ this.shoot();
+ }
+ });
+ }
+
+ setupNetworking() {
+ this.live.addEventListener('message', (event) => {
+ const data = JSON.parse(event.data);
+ if (data.type === 'game_update') {
+ this.updateGameState(data);
+ }
+ });
+ }
+
+ updateGameState(data) {
+ this.players = data.players || {};
+ this.bullets = data.bullets || [];
+ this.updateUI(data);
+
+ // 自動瞄準輔助
+ if (this.autoAimEnabled && this.localPlayer) {
+ this.applyAutoAim();
+ }
+ }
+
+ applyAutoAim() {
+ const player = this.players[this.localPlayer];
+ if (!player) return;
+
+ let closestEnemy = null;
+ let closestDistance = Infinity;
+
+ // 找最近的敵人
+ Object.values(this.players).forEach(enemy => {
+ if (enemy.id === this.localPlayer || enemy.team === player.team || enemy.dead) return;
+
+ const distance = Math.sqrt(
+ Math.pow(enemy.x - player.x, 2) +
+ Math.pow(enemy.y - player.y, 2)
+ );
+
+ if (distance < closestDistance && distance < 300) {
+ closestDistance = distance;
+ closestEnemy = enemy;
+ }
+ });
+
+ // 緩慢調整瞄準角度朝向最近的敵人
+ if (closestEnemy) {
+ const targetAngle = Math.atan2(
+ closestEnemy.y - player.y,
+ closestEnemy.x - player.x
+ );
+
+ // 平滑過渡
+ const angleDiff = targetAngle - this.aimAngle;
+ this.aimAngle += angleDiff * 0.1;
+ }
+ }
+
+ updateUI(data) {
+ if (this.localPlayer) {
+ const player = this.players[this.localPlayer];
+ if (player) {
+ document.getElementById('health').textContent = `HP: ${player.health}`;
+ document.getElementById('armor').textContent = `Armor: ${player.armor}`;
+ document.getElementById('money').textContent = `$${player.money}`;
+ document.getElementById('ammo').textContent = `Ammo: ${player.ammo || '30/90'}`;
+ }
+ }
+ }
+
+ handleMovement() {
+ let dx = 0, dy = 0;
+ if (this.keys['w']) dy -= 1;
+ if (this.keys['s']) dy += 1;
+ if (this.keys['a']) dx -= 1;
+ if (this.keys['d']) dx += 1;
+
+ // Shift 加速跑
+ const speed = this.keys['shift'] ? 7 : 5;
+
+ if (dx !== 0 || dy !== 0) {
+ const angle = Math.atan2(dy, dx);
+ const vx = Math.cos(angle) * speed;
+ const vy = Math.sin(angle) * speed;
+
+ this.live.push({
+ type: 'player_move',
+ x: vx,
+ y: vy
+ });
+ }
+ }
+
+ shoot() {
+ const now = Date.now();
+ if (now - this.lastShootTime < this.shootCooldown) return;
+
+ this.lastShootTime = now;
+ this.live.push({
+ type: 'player_shoot',
+ angle: this.aimAngle
+ });
+ }
+
+ reload() {
+ this.live.push({
+ type: 'player_reload'
+ });
+ }
+
+ quickBuy(key) {
+ const weapons = {
+ '1': 'ak47',
+ '2': 'm4a1',
+ '3': 'awp',
+ '4': 'deagle',
+ '5': 'armor'
+ };
+
+ if (weapons[key]) {
+ this.live.push({
+ type: 'buy_weapon',
+ weapon: weapons[key]
+ });
+ this.showNotification(`購買:${weapons[key].toUpperCase()}`);
+ }
+ }
+
+ showNotification(text) {
+ const notification = document.createElement('div');
+ notification.style.cssText = `
+ position: fixed;
+ top: 100px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0,0,0,0.8);
+ color: #ffaa00;
+ padding: 10px 20px;
+ border-radius: 5px;
+ font-size: 16px;
+ z-index: 10000;
+ animation: fadeOut 2s forwards;
+ `;
+ notification.textContent = text;
+ document.body.appendChild(notification);
+ setTimeout(() => notification.remove(), 2000);
+ }
+
+ showControls() {
+ const controls = document.createElement('div');
+ controls.id = 'controls-help';
+ controls.style.cssText = `
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ background: rgba(0,0,0,0.7);
+ color: white;
+ padding: 10px;
+ border-radius: 5px;
+ font-size: 12px;
+ font-family: monospace;
+ z-index: 1000;
+ `;
+ controls.innerHTML = `
+ Mac 優化控制
+ 移動:WASD (Shift 加速)
+ 瞄準:方向鍵 或 IJKL
+ 射擊:空白鍵 或 點擊
+ 換彈:R
+ 快速轉身:Q
+ 自動瞄準:V
+ 購買:B 或 數字鍵1-5
+
+ 觸控板手勢
+ 雙指橫滑:旋轉瞄準
+ 雙指縱滑:調整距離
+ 雙指點擊:射擊
+ `;
+ document.body.appendChild(controls);
+
+ // 5秒後自動隱藏,按 H 可再次顯示
+ setTimeout(() => {
+ controls.style.opacity = '0.3';
+ }, 5000);
+ }
+
+ render() {
+ this.ctx.fillStyle = '#2a2a2a';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // 繪製地圖格線
+ this.ctx.strokeStyle = '#333';
+ this.ctx.lineWidth = 0.5;
+ for (let x = 0; x < this.canvas.width; x += 50) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, 0);
+ this.ctx.lineTo(x, this.canvas.height);
+ this.ctx.stroke();
+ }
+ for (let y = 0; y < this.canvas.height; y += 50) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, y);
+ this.ctx.lineTo(this.canvas.width, y);
+ this.ctx.stroke();
+ }
+
+ const centerX = this.canvas.width / 2;
+ const centerY = this.canvas.height / 2;
+
+ // 繪製玩家
+ Object.values(this.players).forEach(player => {
+ // 玩家身體
+ this.ctx.fillStyle = player.team === 'ct' ? '#4444ff' : '#ff4444';
+ this.ctx.beginPath();
+ this.ctx.arc(player.x, player.y, 15, 0, Math.PI * 2);
+ this.ctx.fill();
+
+ // 玩家方向指示
+ if (player.id === this.localPlayer) {
+ this.ctx.strokeStyle = '#ffffff';
+ this.ctx.lineWidth = 3;
+ this.ctx.beginPath();
+ this.ctx.moveTo(player.x, player.y);
+ this.ctx.lineTo(
+ player.x + Math.cos(this.aimAngle) * 25,
+ player.y + Math.sin(this.aimAngle) * 25
+ );
+ this.ctx.stroke();
+ }
+
+ // 玩家名稱與血量條
+ this.ctx.fillStyle = 'white';
+ this.ctx.font = '12px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(player.name, player.x, player.y - 25);
+
+ // 血量條
+ const barWidth = 30;
+ const barHeight = 4;
+ this.ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth, barHeight);
+ this.ctx.fillStyle = player.health > 50 ? '#00ff00' : player.health > 25 ? '#ffaa00' : '#ff0000';
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth * (player.health/100), barHeight);
+ });
+
+ // 繪製子彈
+ this.ctx.shadowBlur = 5;
+ this.ctx.shadowColor = '#ffff00';
+ this.ctx.fillStyle = '#ffff00';
+ this.bullets.forEach(bullet => {
+ this.ctx.beginPath();
+ this.ctx.arc(bullet.x, bullet.y, 3, 0, Math.PI * 2);
+ this.ctx.fill();
+ });
+ this.ctx.shadowBlur = 0;
+
+ // 繪製瞄準系統 (針對本地玩家)
+ if (this.localPlayer && this.players[this.localPlayer]) {
+ const player = this.players[this.localPlayer];
+ const aimX = player.x + Math.cos(this.aimAngle) * this.aimDistance;
+ const aimY = player.y + Math.sin(this.aimAngle) * this.aimDistance;
+
+ // 瞄準線
+ this.ctx.strokeStyle = this.autoAimEnabled ? 'rgba(255,0,0,0.3)' : 'rgba(0,255,0,0.3)';
+ this.ctx.lineWidth = 1;
+ this.ctx.setLineDash([5, 5]);
+ this.ctx.beginPath();
+ this.ctx.moveTo(player.x, player.y);
+ this.ctx.lineTo(aimX, aimY);
+ this.ctx.stroke();
+ this.ctx.setLineDash([]);
+
+ // 準心
+ this.ctx.strokeStyle = this.autoAimEnabled ? '#ff4444' : '#00ff00';
+ this.ctx.lineWidth = 2;
+
+ // 圓形準心
+ this.ctx.beginPath();
+ this.ctx.arc(aimX, aimY, 15, 0, Math.PI * 2);
+ this.ctx.stroke();
+
+ // 十字準心
+ this.ctx.beginPath();
+ this.ctx.moveTo(aimX - 20, aimY);
+ this.ctx.lineTo(aimX - 8, aimY);
+ this.ctx.moveTo(aimX + 8, aimY);
+ this.ctx.lineTo(aimX + 20, aimY);
+ this.ctx.moveTo(aimX, aimY - 20);
+ this.ctx.lineTo(aimX, aimY - 8);
+ this.ctx.moveTo(aimX, aimY + 8);
+ this.ctx.lineTo(aimX, aimY + 20);
+ this.ctx.stroke();
+
+ // 自動瞄準指示器
+ if (this.autoAimEnabled) {
+ this.ctx.fillStyle = 'rgba(255,0,0,0.5)';
+ this.ctx.font = '10px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText('AUTO', aimX, aimY - 25);
+ }
+ }
+ }
+
+ toggleBuyMenu() {
+ const menu = document.getElementById('buy-menu');
+ menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
+ }
+
+ toggleScoreboard() {
+ const scoreboard = document.getElementById('scoreboard');
+ scoreboard.style.display = scoreboard.style.display === 'none' ? 'block' : 'none';
+ }
+
+ toggleChat() {
+ const input = document.getElementById('chat-input');
+ input.style.display = input.style.display === 'none' ? 'block' : 'none';
+ if (input.style.display === 'block') {
+ input.focus();
+ }
+ }
+
+ gameLoop() {
+ this.handleMovement();
+ this.render();
+ requestAnimationFrame(() => this.gameLoop());
+ }
+ }
+
+ // 啟動遊戲
+ window.addEventListener('DOMContentLoaded', () => {
+ new CS2DGame();
+ });
+
+ // 購買武器函數
+ window.buyWeapon = function(weapon) {
+ Live.current?.push({
+ type: 'buy_weapon',
+ weapon: weapon
+ });
+ document.getElementById('buy-menu').style.display = 'none';
+ };
+ JAVASCRIPT
+ end
+end
+
+Application = Lively::Application[CS2DView]
\ No newline at end of file
diff --git a/examples/cs2d/cs16_server.rb b/examples/cs2d/cs16_server.rb
new file mode 100644
index 0000000..e09c67a
--- /dev/null
+++ b/examples/cs2d/cs16_server.rb
@@ -0,0 +1,1162 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'webrick'
+require 'json'
+
+puts "🎮 Starting CS 1.6 2D MVP Server..."
+puts "📱 Mac touchpad optimized!"
+puts "🌐 Open http://localhost:9292 in your browser"
+puts "Press Ctrl+C to stop"
+
+class CS16Server < WEBrick::HTTPServlet::AbstractServlet
+ def do_GET(request, response)
+ response.status = 200
+ response['Content-Type'] = 'text/html; charset=utf-8'
+ response.body = <<~HTML
+
+
+
+
+ CS 1.6 2D - MVP
+
+
+
+
+
+
+
+
+
1:55
+
+
Round 1/30
+
Buy Time
+
+
+
+
+
+
$800
+
+
+ 30 / 90
+
+
Glock-18
+
+
+
+
+
🎮 Mac 優化控制
+ 移動: WASD (Shift 加速)
+ 瞄準: 方向鍵 或 IJKL
+ 射擊: 空白鍵 或 點擊
+ 換彈: R
+ 互動: E (安裝/拆彈)
+ 購買: B 或 數字鍵 1-5
+ 轉身: Q (180°)
+ 自動瞄準: V
+
+ 觸控板手勢:
+ 雙指橫滑 - 旋轉瞄準
+ 雙指縱滑 - 調整距離
+ 雙指點擊 - 射擊
+
+
+
+
+
+
+
+
+
+
+
+ HTML
+ end
+end
+
+server = WEBrick::HTTPServer.new(Port: 9292)
+server.mount '/', CS16Server
+trap('INT') { server.shutdown }
+
+server.start
\ No newline at end of file
diff --git a/examples/cs2d/docs/GAMEPLAY_GUIDE.md b/examples/cs2d/docs/GAMEPLAY_GUIDE.md
new file mode 100644
index 0000000..1be1f54
--- /dev/null
+++ b/examples/cs2d/docs/GAMEPLAY_GUIDE.md
@@ -0,0 +1,374 @@
+# CS2D - Complete Gameplay Guide
+
+## Table of Contents
+1. [Game Overview](#game-overview)
+2. [Controls](#controls)
+3. [Game Mechanics](#game-mechanics)
+4. [Weapons & Equipment](#weapons--equipment)
+5. [Economy System](#economy-system)
+6. [Maps & Objectives](#maps--objectives)
+7. [Strategies & Tips](#strategies--tips)
+8. [Mac Optimization](#mac-optimization)
+
+## Game Overview
+
+CS2D is a 2D top-down adaptation of Counter-Strike 1.6, featuring the classic bomb defusal gameplay optimized for web browsers and Mac touchpad controls.
+
+### Teams
+- **Counter-Terrorists (CT)** - Blue team, defend bomb sites
+- **Terrorists (T)** - Orange team, plant the bomb
+
+### Victory Conditions
+
+**Terrorists Win:**
+- Successfully detonate the bomb
+- Eliminate all Counter-Terrorists
+- Time runs out with bomb planted
+
+**Counter-Terrorists Win:**
+- Defuse the planted bomb
+- Eliminate all Terrorists
+- Prevent bomb plant until time expires
+
+## Controls
+
+### Movement & Combat
+
+| Action | Primary Key | Alternative | Description |
+|--------|------------|------------|-------------|
+| **Move** | W/A/S/D | - | Move up/left/down/right |
+| **Sprint** | Shift + WASD | - | Move faster (consumes stamina) |
+| **Aim** | Arrow Keys | I/J/K/L | Rotate aim direction |
+| **Shoot** | Spacebar | Left Click | Fire weapon |
+| **Reload** | R | - | Reload current weapon |
+| **Quick Turn** | Q | - | Instant 180° rotation |
+| **Interact** | E | - | Plant/defuse bomb |
+
+### Interface Controls
+
+| Action | Key | Description |
+|--------|-----|-------------|
+| **Buy Menu** | B | Open/close buy menu |
+| **Scoreboard** | Tab | View match statistics |
+| **Chat** | T | Open team chat |
+| **Auto-aim Toggle** | V | Enable/disable aim assist |
+
+### Quick Buy Shortcuts
+
+| Key | Weapon | Price | Team |
+|-----|--------|-------|------|
+| **1** | AK-47 | $2700 | T only |
+| **2** | M4A1 | $3100 | CT only |
+| **3** | AWP | $4750 | Both |
+| **4** | Desert Eagle | $650 | Both |
+| **5** | Kevlar Vest | $650 | Both |
+
+## Game Mechanics
+
+### Round Structure
+
+1. **Freeze Time** (5 seconds)
+ - Players spawn at team bases
+ - Cannot move but can open buy menu
+ - Plan your strategy
+
+2. **Buy Time** (15 seconds)
+ - Movement enabled
+ - Purchase weapons and equipment
+ - Get into position
+
+3. **Round Time** (1:55)
+ - Main gameplay phase
+ - Complete objectives
+ - Eliminate enemies
+
+4. **Round End**
+ - Display results
+ - Award money based on performance
+ - Reset for next round
+
+### Health & Armor System
+
+- **Health Points**: 100 HP maximum
+ - No health regeneration
+ - Death at 0 HP
+
+- **Armor**: 0-100 points
+ - Absorbs 50% of damage
+ - Kevlar Vest: $650
+ - Helmet: +$350 (requires vest)
+
+### Damage Model
+
+| Body Part | Damage Multiplier |
+|-----------|------------------|
+| Head | 4x (without helmet) |
+| Head | 1.5x (with helmet) |
+| Chest | 1x |
+| Limbs | 0.75x |
+
+## Weapons & Equipment
+
+### Pistols
+
+| Weapon | Damage | Fire Rate | Magazine | Price | Notes |
+|--------|--------|-----------|----------|-------|-------|
+| **USP** | 35 | Slow | 12 | Free | CT default, accurate |
+| **Glock-18** | 28 | Fast | 20 | Free | T default, high capacity |
+| **Desert Eagle** | 48 | Very Slow | 7 | $650 | High damage, low fire rate |
+
+### Rifles
+
+| Weapon | Damage | Fire Rate | Magazine | Price | Team | Notes |
+|--------|--------|-----------|----------|-------|------|-------|
+| **AK-47** | 36 | Fast | 30 | $2700 | T | High damage, moderate recoil |
+| **M4A1** | 33 | Fast | 30 | $3100 | CT | Accurate, silenced option |
+| **AWP** | 115 | Very Slow | 10 | $4750 | Both | One-shot kill, slow movement |
+
+### Equipment
+
+| Item | Price | Team | Effect |
+|------|-------|------|--------|
+| **Kevlar Vest** | $650 | Both | 100 armor points |
+| **Helmet** | $350 | Both | Head protection (requires vest) |
+| **Defuse Kit** | $400 | CT | Reduces defuse time from 10s to 5s |
+
+## Economy System
+
+### Starting Money
+- First round: **$800**
+- Maximum money: **$16,000**
+
+### Kill Rewards
+
+| Weapon Type | Reward |
+|-------------|--------|
+| Knife | $1500 |
+| Pistol | $300 |
+| SMG | $600 |
+| Rifle | $300 |
+| AWP | $100 |
+
+### Round Rewards
+
+| Outcome | Reward |
+|---------|--------|
+| **Win Round** | $3250 |
+| **Lose Round** | $1400 + $500 per consecutive loss (max $3400) |
+| **Plant Bomb** | $800 (T team bonus) |
+| **Defuse Bomb** | $3500 (defuser bonus) |
+
+### Money Management Tips
+- Save on eco rounds
+- Buy as a team for maximum effectiveness
+- Consider force-buying after consecutive losses
+- Drop weapons for teammates when wealthy
+
+## Maps & Objectives
+
+### de_dust2_mini
+
+A simplified version of the classic map featuring:
+
+```
+[T Spawn] [CT Spawn]
+ | |
+ ├──────[Middle]──────────────┤
+ | | |
+[Bomb Site A] | [Bomb Site B]
+```
+
+#### Key Areas
+- **Bomb Site A**: Western bomb plant zone
+- **Bomb Site B**: Eastern bomb plant zone
+- **Middle**: Central corridor, key control point
+- **Spawn Areas**: Protected team starting zones
+
+### Bomb Mechanics
+
+#### Planting (Terrorists)
+1. Enter bomb site radius (marked zones)
+2. Hold **E** for 3 seconds
+3. Stay still during plant animation
+4. Bomb timer: 45 seconds after plant
+
+#### Defusing (Counter-Terrorists)
+1. Approach planted bomb
+2. Hold **E** for 10 seconds (5s with kit)
+3. Cannot move while defusing
+4. Must complete before explosion
+
+## Strategies & Tips
+
+### General Tips
+1. **Crosshair Placement**: Keep aim at head level
+2. **Economy Management**: Coordinate buys with team
+3. **Map Control**: Hold key positions early
+4. **Communication**: Use visual cues and positioning
+5. **Patience**: Don't rush unnecessarily
+
+### Terrorist Strategies
+- **Rush B**: Fast coordinated push to Site B
+- **Split A**: Divide team to attack from multiple angles
+- **Fake Plant**: Start planting to bait CTs, then rotate
+- **Save the AWP**: Protect expensive weapons on eco rounds
+
+### Counter-Terrorist Strategies
+- **Stack Sites**: Concentrate defense based on enemy patterns
+- **Retake Setup**: Position for coordinated site retakes
+- **Economy Denial**: Hunt saving enemies
+- **Kit Priority**: Ensure at least 2 defuse kits per round
+
+### Combat Techniques
+
+#### Peeking
+- **Wide Peek**: Fast movement to catch enemies off-guard
+- **Shoulder Peek**: Brief exposure to bait shots
+- **Jiggle Peek**: Repeated quick peeks for information
+
+#### Positioning
+- **Off-angles**: Unexpected positions that delay enemy reaction
+- **Headshot Angles**: Positions where only head is exposed
+- **Crossfire Setup**: Coordinate with teammates for multiple angles
+
+## Mac Optimization
+
+### Touchpad Gestures
+
+| Gesture | Action | Description |
+|---------|--------|-------------|
+| **Two-finger Horizontal Swipe** | Rotate Aim | Smooth aiming adjustment |
+| **Two-finger Vertical Swipe** | Aim Distance | Adjust crosshair distance |
+| **Two-finger Tap** | Shoot | Alternative fire method |
+| **Pinch** | Zoom View | Tactical overview (if supported) |
+
+### Recommended Settings for Mac Users
+
+1. **Enable Auto-Aim Assist** (V key)
+ - Helps compensate for touchpad precision
+ - Subtle tracking on nearby enemies
+ - Toggle based on preference
+
+2. **Use Keyboard Aiming**
+ - Arrow keys or IJKL for precise control
+ - More reliable than touchpad for quick adjustments
+ - Combine with auto-aim for best results
+
+3. **Quick Buy Binds**
+ - Memorize number keys 1-5
+ - Faster than navigating buy menu
+ - Essential for buy time efficiency
+
+### Performance Tips
+
+1. **Browser Choice**: Use Safari or Chrome for best performance
+2. **Close Background Apps**: Reduce system load
+3. **Fullscreen Mode**: F11 for immersive experience
+4. **Hardware Acceleration**: Enable in browser settings
+
+## Advanced Techniques
+
+### Movement Mechanics
+
+#### Strafing
+- Alternate A/D rapidly while aiming
+- Makes you harder to hit
+- Maintain accuracy by stopping before shooting
+
+#### Counter-Strafing
+- Tap opposite direction to stop instantly
+- Essential for accurate shooting
+- Practice: W→S or A→D quick taps
+
+### Weapon Control
+
+#### Recoil Management
+- **Burst Fire**: 2-3 bullet bursts for accuracy
+- **Spray Control**: Pull down gradually during full auto
+- **Reset Time**: Wait briefly between bursts
+
+#### Pre-firing
+- Shoot common angles before fully peeking
+- Effective against predictable positions
+- Combine with game sense and timing
+
+### Game Sense
+
+#### Sound Cues
+- Footsteps indicate enemy proximity
+- Reload sounds reveal vulnerability
+- Bomb plant/defuse audio is crucial
+
+#### Timing
+- Learn rotation times between sites
+- Track enemy economy for buy predictions
+- Monitor bomb timer for defuse decisions
+
+## Troubleshooting
+
+### Common Issues
+
+| Problem | Solution |
+|---------|----------|
+| **Lag/Stuttering** | Reduce browser tabs, check connection |
+| **Controls Not Working** | Refresh page, check keyboard language |
+| **Can't Buy Weapons** | Ensure buy time active, check money |
+| **Touchpad Too Sensitive** | Adjust system settings, use keyboard aim |
+
+### Performance Optimization
+1. Update browser to latest version
+2. Clear browser cache regularly
+3. Disable unnecessary browser extensions
+4. Use wired connection over WiFi when possible
+
+## Glossary
+
+| Term | Definition |
+|------|------------|
+| **Eco** | Economic round - saving money |
+| **Force Buy** | Spending despite low economy |
+| **Rotation** | Moving between bomb sites |
+| **Stack** | Multiple players at one site |
+| **Lurk** | Solo player playing for late-round picks |
+| **Entry Frag** | First kill when entering a site |
+| **Trade Kill** | Quick revenge kill after teammate dies |
+| **Clutch** | Winning when outnumbered |
+| **Ace** | One player kills entire enemy team |
+
+---
+
+## Quick Reference Card
+
+### Essential Binds
+```
+Movement: WASD + Shift
+Aim: ←↑→↓ or IJKL
+Shoot: Spacebar
+Reload: R
+Use: E
+Buy: B
+```
+
+### Buy Priorities
+```
+Round 1: Armor OR Utility
+Anti-eco: SMG + Armor
+Buy Round: Rifle + Armor + Utility
+Force Buy: Best available + Armor
+Save: Nothing or Pistol
+```
+
+### Communication Calls
+```
+"Rush B" - Fast B site push
+"Eco" - Save round
+"Force" - Force buy
+"Stack A" - Heavy A defense
+"Rotate" - Change sites
+"Save" - Don't fight, save weapon
+"Last one X" - Final enemy location
+```
+
+---
+
+*For more information and updates, visit the [CS2D GitHub Repository](https://github.com/yourusername/cs2d)*
\ No newline at end of file
diff --git a/examples/cs2d/docs/QUICK_START.md b/examples/cs2d/docs/QUICK_START.md
new file mode 100644
index 0000000..8eb561e
--- /dev/null
+++ b/examples/cs2d/docs/QUICK_START.md
@@ -0,0 +1,198 @@
+# CS2D Quick Start Guide
+
+## 🎮 Launch Game
+Open browser and navigate to: `http://localhost:9292`
+
+## 🎯 5-Minute Tutorial
+
+### Step 1: Choose Your Team
+- **Blue (CT)**: Defend & Defuse
+- **Orange (T)**: Attack & Plant
+
+### Step 2: Master Basic Controls
+```
+WASD → Move
+Spacebar → Shoot
+R → Reload
+E → Plant/Defuse Bomb
+B → Buy Menu
+```
+
+### Step 3: Your First Round
+1. **Buy Phase** (0:15) - Press `B`, buy armor with `5`
+2. **Move to Position** - Use `WASD` to reach bomb site
+3. **Engage Enemy** - Aim with `Arrow Keys`, shoot with `Space`
+4. **Complete Objective** - Plant/Defuse with `E` at marked sites
+
+## 🎨 Mac Touchpad Controls
+
+```yaml
+Two-Finger Swipe → : Rotate aim
+Two-Finger Swipe ↑ : Adjust distance
+Two-Finger Tap : Shoot
+Single Click : Shoot
+```
+
+## 💰 Quick Buy Guide
+
+| Key | Item | Price | Best For |
+|-----|------|-------|----------|
+| `1` | AK-47 | $2700 | T-side rifling |
+| `2` | M4A1 | $3100 | CT-side defense |
+| `3` | AWP | $4750 | Long-range picks |
+| `4` | Desert Eagle | $650 | Eco rounds |
+| `5` | Kevlar | $650 | Every round |
+
+## 🏆 Win Conditions
+
+### Terrorists (T)
+✅ Plant & detonate bomb
+✅ Eliminate all CTs
+✅ Time expires (bomb planted)
+
+### Counter-Terrorists (CT)
+✅ Defuse the bomb
+✅ Eliminate all Ts
+✅ Time expires (no bomb)
+
+## 📊 Round Economy
+
+```
+Starting Money: $800
+Kill Reward: $300
+Round Win: $3250
+Round Loss: $1400-$3400
+Max Money: $16000
+```
+
+## 🗺️ Map Callouts
+
+```
+ [CT SPAWN]
+ |
+ [A]━━[MID]━━[B]
+ |
+ [T SPAWN]
+```
+
+## 🚀 Pro Tips
+
+### Beginner
+- Always buy armor
+- Stick with teammates
+- Don't run while shooting
+- Save money on eco rounds
+
+### Intermediate
+- Learn spray patterns
+- Use utility effectively
+- Control middle area
+- Coordinate team buys
+
+### Advanced
+- Master counter-strafing
+- Pre-fire common angles
+- Fake plant/defuse
+- Economy tracking
+
+## ⚙️ Settings Optimization
+
+### For Best Performance
+```javascript
+// Recommended Browser Settings
+Hardware Acceleration: ON
+Fullscreen: F11
+Background Apps: CLOSED
+Browser: Chrome/Safari
+```
+
+### For Mac Users
+```javascript
+// System Preferences
+Trackpad Speed: 70%
+Auto-Aim: ON (Press V)
+Primary Aim: Arrow Keys
+Secondary: Touchpad Gestures
+```
+
+## 🎮 Game Modes
+
+### Competitive (5v5)
+- 30 rounds maximum
+- First to 16 wins
+- Side swap at round 15
+- Full economy system
+
+### Casual (2v2)
+- Reduced map size
+- Faster rounds
+- More forgiving economy
+- Great for practice
+
+### Deathmatch (FFA)
+- Instant respawn
+- Free weapons
+- No objectives
+- Pure combat practice
+
+## 🔧 Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| **Lag** | Close other tabs, check WiFi |
+| **Can't move** | Click on game window first |
+| **No sound** | Check browser audio permissions |
+| **Black screen** | Refresh page (F5) |
+| **Buy menu stuck** | Press B again or ESC |
+
+## 📱 Mobile Controls (Experimental)
+
+```
+Touch & Drag → Move view
+Tap → Shoot
+Double Tap → Reload
+Long Press → Plant/Defuse
+Swipe Up → Buy menu
+```
+
+## 🎯 Aim Training Routine
+
+### Daily 10-Minute Warmup
+1. **Tracking** (3 min) - Follow moving targets
+2. **Flicking** (3 min) - Quick aim adjustments
+3. **Spraying** (2 min) - Recoil control
+4. **Tapping** (2 min) - One-tap headshots
+
+## 📈 Rank Progression
+
+```
+Beginner → Learn controls & map
+Silver → Master economy & basics
+Gold → Develop game sense
+Platinum → Advanced techniques
+Diamond → Team coordination
+Elite → Tournament ready
+```
+
+## 🔗 Useful Commands
+
+### Console Commands (Future Update)
+```bash
+fps_max 300 # Uncap framerate
+cl_crosshair 1 # Custom crosshair
+net_graph 1 # Show network stats
+sensitivity 2.5 # Mouse sensitivity
+```
+
+## 📚 Resources
+
+- **Full Guide**: [GAMEPLAY_GUIDE.md](./GAMEPLAY_GUIDE.md)
+- **GitHub**: [CS2D Repository](#)
+- **Discord**: [Community Server](#)
+- **Updates**: [Changelog](#)
+
+---
+
+**Ready to Play?** → Open `http://localhost:9292` and dominate! 🎮
+
+*Press `H` in-game to show/hide help*
\ No newline at end of file
diff --git a/examples/cs2d/docs/README.md b/examples/cs2d/docs/README.md
new file mode 100644
index 0000000..0e9e3d9
--- /dev/null
+++ b/examples/cs2d/docs/README.md
@@ -0,0 +1,197 @@
+# CS2D Documentation
+
+Welcome to the CS2D documentation! This directory contains comprehensive guides and references for playing and developing CS2D.
+
+## 📚 Documentation Structure
+
+### For Players
+
+1. **[Quick Start Guide](./QUICK_START.md)** ⭐
+ - Get playing in under 5 minutes
+ - Essential controls and tips
+ - Perfect for beginners
+
+2. **[Complete Gameplay Guide](./GAMEPLAY_GUIDE.md)** 📖
+ - Detailed mechanics explanation
+ - Advanced strategies
+ - Full control reference
+
+3. **[Mac Optimization Guide](./GAMEPLAY_GUIDE.md#mac-optimization)** 🍎
+ - Touchpad gesture controls
+ - Performance settings
+ - Mac-specific tips
+
+### For Developers
+
+4. **[Technical Architecture](./TECHNICAL.md)** 🔧
+ - System design overview
+ - Network protocol
+ - Game state management
+
+5. **[API Reference](./API.md)** 💻
+ - Server endpoints
+ - WebSocket events
+ - Data structures
+
+## 🎮 Quick Links
+
+### Start Playing
+- **Local Server**: `http://localhost:9292`
+- **Required**: Ruby 3.2+, Modern browser
+- **Recommended**: Chrome/Safari, Mac with touchpad
+
+### Essential Controls
+```
+Move: WASD
+Aim: Arrow Keys
+Shoot: Spacebar
+Reload: R
+Use: E
+Buy: B
+```
+
+### First-Time Setup
+1. Install dependencies: `bundle install`
+2. Start server: `ruby cs16_server.rb`
+3. Open browser: `http://localhost:9292`
+4. Press `B` to buy weapons
+5. Complete objectives to win!
+
+## 🗺️ Game Modes
+
+| Mode | Players | Description |
+|------|---------|-------------|
+| **Classic** | 5v5 | Full competitive rules |
+| **Casual** | 2v2 | Simplified, faster rounds |
+| **Deathmatch** | FFA | Practice aim, instant respawn |
+| **Retake** | 3v3 | CT retakes planted bomb sites |
+
+## 🏆 Competitive Rules
+
+- **Round Format**: First to 16 rounds wins
+- **Round Time**: 1:55 per round
+- **Bomb Timer**: 45 seconds
+- **Buy Time**: 15 seconds
+- **Freeze Time**: 5 seconds
+- **Side Swap**: After round 15
+
+## 💰 Economy Quick Reference
+
+| Action | Reward |
+|--------|--------|
+| Round Win | $3250 |
+| Round Loss | $1400 (+$500/loss) |
+| Kill (Rifle) | $300 |
+| Kill (AWP) | $100 |
+| Kill (Knife) | $1500 |
+| Bomb Plant | $800 |
+| Bomb Defuse | $3500 |
+
+## 🔧 Troubleshooting
+
+### Common Issues
+
+**Game Won't Load**
+- Check Ruby version: `ruby --version` (need 3.2+)
+- Install WEBrick: `gem install webrick`
+- Check port 9292 is free
+
+**Performance Issues**
+- Close other browser tabs
+- Enable hardware acceleration
+- Use Chrome or Safari
+- Reduce visual quality in settings
+
+**Control Problems**
+- Click game window to focus
+- Check keyboard layout (US recommended)
+- Disable browser extensions
+- Try different browser
+
+## 📊 System Requirements
+
+### Minimum
+- **OS**: Windows 10, macOS 10.15, Linux
+- **Browser**: Chrome 90+, Safari 14+, Firefox 88+
+- **RAM**: 2GB
+- **Network**: Stable connection for multiplayer
+
+### Recommended
+- **OS**: macOS 12+ with touchpad
+- **Browser**: Latest Chrome/Safari
+- **RAM**: 4GB+
+- **Display**: 1920x1080
+- **Input**: Mechanical keyboard + gaming mouse
+
+## 🚀 Advanced Features
+
+### Mac Touchpad Gestures
+- **Two-finger swipe**: Precise aiming
+- **Two-finger tap**: Alternative fire
+- **Pinch**: Zoom tactical view
+- **Three-finger swipe**: Quick weapon switch
+
+### Keyboard Shortcuts
+- **F11**: Fullscreen toggle
+- **H**: Show/hide help
+- **M**: Toggle map overview
+- **Tab**: Scoreboard
+- **~**: Console (dev mode)
+
+## 📈 Performance Metrics
+
+Monitor your gameplay stats:
+- **K/D Ratio**: Kills/Deaths
+- **ADR**: Average Damage per Round
+- **HS%**: Headshot percentage
+- **KAST**: Rounds with Kill/Assist/Survived/Traded
+- **Rating 2.0**: Overall performance score
+
+## 🎯 Training Recommendations
+
+### Daily Practice (20 min)
+1. **Aim Training** (5 min)
+ - 100 kills on aim map
+ - Focus on headshot placement
+
+2. **Movement** (5 min)
+ - Practice strafing
+ - Learn jump spots
+
+3. **Utility** (5 min)
+ - Smoke lineups
+ - Flash timings
+
+4. **Deathmatch** (5 min)
+ - Real combat practice
+ - Work on positioning
+
+## 🌐 Community
+
+### Join Us
+- **Discord**: [CS2D Community](#)
+- **Reddit**: [r/CS2D](#)
+- **Twitter**: [@CS2DGame](#)
+
+### Contribute
+- Report bugs on [GitHub Issues](#)
+- Submit pull requests
+- Share strategies and guides
+- Create custom maps
+
+## 📝 License
+
+CS2D is open source under the MIT License. See [LICENSE](../../../LICENSE.md) for details.
+
+## 🙏 Credits
+
+- **Framework**: Lively (Ruby)
+- **Inspiration**: Counter-Strike 1.6
+- **Optimization**: Mac touchpad support
+- **Community**: All our players and contributors
+
+---
+
+**Need Help?** Start with the [Quick Start Guide](./QUICK_START.md) or dive into the [Complete Gameplay Guide](./GAMEPLAY_GUIDE.md).
+
+**Ready to Play?** Launch your server and visit `http://localhost:9292` 🎮
\ No newline at end of file
diff --git a/examples/cs2d/docs/TECHNICAL.md b/examples/cs2d/docs/TECHNICAL.md
new file mode 100644
index 0000000..bc8a933
--- /dev/null
+++ b/examples/cs2d/docs/TECHNICAL.md
@@ -0,0 +1,656 @@
+# CS2D Technical Documentation
+
+## Architecture Overview
+
+CS2D is built using a client-server architecture with real-time WebSocket communication for multiplayer gameplay.
+
+```
+┌─────────────┐ WebSocket ┌─────────────┐
+│ Browser │◄──────────────────────────►│ Ruby Server │
+│ (Canvas) │ JSON Events │ (WEBrick) │
+└─────────────┘ └─────────────┘
+ │ │
+ ▼ ▼
+┌─────────────┐ ┌─────────────┐
+│ Game Loop │ │ Game State │
+│ 30 FPS │ │ Manager │
+└─────────────┘ └─────────────┘
+```
+
+## Technology Stack
+
+### Backend
+- **Language**: Ruby 3.2+
+- **Framework**: Lively (WebSocket + Live Views)
+- **Server**: WEBrick HTTP Server
+- **Protocol**: WebSocket for real-time communication
+
+### Frontend
+- **Rendering**: HTML5 Canvas 2D Context
+- **Language**: Vanilla JavaScript ES6+
+- **Physics**: Custom 2D physics engine
+- **Audio**: Web Audio API (planned)
+
+## Network Protocol
+
+### WebSocket Events
+
+#### Client → Server
+
+```javascript
+// Player Input Event
+{
+ "type": "player_input",
+ "data": {
+ "move": { "x": 1, "y": 0, "shift": false },
+ "shoot": true,
+ "angle": 1.57,
+ "reload": false,
+ "use": false
+ }
+}
+
+// Buy Weapon Event
+{
+ "type": "buy_weapon",
+ "weapon": "ak47"
+}
+
+// Chat Message
+{
+ "type": "chat",
+ "message": "Rush B!"
+}
+
+// Bomb Actions
+{
+ "type": "plant_bomb" | "defuse_bomb"
+}
+```
+
+#### Server → Client
+
+```javascript
+// Game State Update (30Hz)
+{
+ "type": "game_state",
+ "data": {
+ "players": { /* player data */ },
+ "bullets": [ /* bullet array */ ],
+ "round": { /* round info */ },
+ "bomb": { /* bomb state */ },
+ "map": { /* map data */ }
+ }
+}
+
+// Event Notification
+{
+ "type": "event",
+ "event": "player_killed" | "bomb_planted" | "round_end",
+ "data": { /* event details */ }
+}
+```
+
+## Game State Management
+
+### State Structure
+
+```ruby
+class GameState
+ attr_accessor :players, # Hash of Player objects
+ :bullets, # Array of active bullets
+ :round, # RoundManager instance
+ :bomb, # BombSystem instance
+ :map, # Map instance
+ :economy # Economy manager
+end
+```
+
+### Player State
+
+```ruby
+class Player
+ # Identity
+ attr_accessor :id, :name, :team
+
+ # Position & Physics
+ attr_accessor :x, :y, :vx, :vy, :angle
+
+ # Combat Stats
+ attr_accessor :health, :armor, :alive
+
+ # Weapons & Equipment
+ attr_accessor :weapons, :current_weapon, :ammo
+
+ # Economy
+ attr_accessor :money, :kills, :deaths
+
+ # Network
+ attr_accessor :ping, :packet_loss
+end
+```
+
+### Update Loop
+
+```ruby
+# Server-side game loop (30 Hz)
+def game_loop
+ loop do
+ delta_time = 1.0 / 30.0
+
+ # 1. Process input queue
+ process_player_inputs
+
+ # 2. Update physics
+ update_physics(delta_time)
+
+ # 3. Check collisions
+ check_collisions
+
+ # 4. Update game logic
+ update_game_logic(delta_time)
+
+ # 5. Broadcast state
+ broadcast_game_state
+
+ sleep(delta_time)
+ end
+end
+```
+
+## Physics Engine
+
+### Movement System
+
+```javascript
+// Client-side prediction
+class MovementSystem {
+ constructor() {
+ this.position = { x: 0, y: 0 };
+ this.velocity = { x: 0, y: 0 };
+ this.acceleration = { x: 0, y: 0 };
+ }
+
+ update(deltaTime) {
+ // Apply acceleration
+ this.velocity.x += this.acceleration.x * deltaTime;
+ this.velocity.y += this.acceleration.y * deltaTime;
+
+ // Apply friction
+ this.velocity.x *= 0.9;
+ this.velocity.y *= 0.9;
+
+ // Update position
+ this.position.x += this.velocity.x * deltaTime;
+ this.position.y += this.velocity.y * deltaTime;
+
+ // Collision detection
+ this.checkCollisions();
+ }
+}
+```
+
+### Collision Detection
+
+```javascript
+// AABB Collision
+function checkAABB(a, b) {
+ return a.x < b.x + b.width &&
+ a.x + a.width > b.x &&
+ a.y < b.y + b.height &&
+ a.y + a.height > b.y;
+}
+
+// Circle Collision
+function checkCircle(a, b) {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ return distance < a.radius + b.radius;
+}
+
+// Line-Circle Intersection (for bullets)
+function lineCircleIntersection(line, circle) {
+ // Implementation for hitscan weapons
+}
+```
+
+## Networking
+
+### Lag Compensation
+
+```javascript
+// Client-side prediction
+class ClientPrediction {
+ constructor() {
+ this.stateBuffer = [];
+ this.inputBuffer = [];
+ this.sequenceNumber = 0;
+ }
+
+ predictMovement(input) {
+ // Apply input immediately
+ this.applyInput(input);
+
+ // Store for reconciliation
+ this.inputBuffer.push({
+ input: input,
+ sequence: this.sequenceNumber++,
+ timestamp: Date.now()
+ });
+ }
+
+ reconcile(serverState) {
+ // Find matching state
+ const matchingInput = this.inputBuffer.find(
+ i => i.sequence === serverState.lastProcessedInput
+ );
+
+ if (matchingInput) {
+ // Replay inputs from that point
+ this.replayInputs(matchingInput.sequence);
+ }
+ }
+}
+```
+
+### Interpolation
+
+```javascript
+// Entity interpolation for smooth movement
+class Interpolator {
+ constructor() {
+ this.buffer = [];
+ this.renderDelay = 100; // 100ms behind
+ }
+
+ addState(state) {
+ this.buffer.push({
+ state: state,
+ timestamp: Date.now()
+ });
+
+ // Keep only last 1 second
+ const cutoff = Date.now() - 1000;
+ this.buffer = this.buffer.filter(s => s.timestamp > cutoff);
+ }
+
+ getInterpolatedState() {
+ const renderTime = Date.now() - this.renderDelay;
+
+ // Find surrounding states
+ let before = null, after = null;
+ for (let i = 0; i < this.buffer.length - 1; i++) {
+ if (this.buffer[i].timestamp <= renderTime &&
+ this.buffer[i + 1].timestamp >= renderTime) {
+ before = this.buffer[i];
+ after = this.buffer[i + 1];
+ break;
+ }
+ }
+
+ if (before && after) {
+ // Linear interpolation
+ const t = (renderTime - before.timestamp) /
+ (after.timestamp - before.timestamp);
+ return this.lerp(before.state, after.state, t);
+ }
+
+ return this.buffer[this.buffer.length - 1]?.state;
+ }
+}
+```
+
+## Performance Optimization
+
+### Rendering Pipeline
+
+```javascript
+class RenderOptimizer {
+ constructor(canvas) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d', {
+ alpha: false, // No transparency
+ desynchronized: true // Reduce latency
+ });
+
+ // Object pooling
+ this.bulletPool = new ObjectPool(Bullet, 1000);
+ this.particlePool = new ObjectPool(Particle, 500);
+ }
+
+ render(gameState) {
+ // Frustum culling
+ const visibleEntities = this.frustumCull(gameState.entities);
+
+ // Batch rendering
+ this.batchRender(visibleEntities);
+
+ // Level of detail
+ this.applyLOD(visibleEntities);
+ }
+
+ frustumCull(entities) {
+ const camera = this.camera;
+ return entities.filter(e => {
+ return e.x > camera.left - 50 &&
+ e.x < camera.right + 50 &&
+ e.y > camera.top - 50 &&
+ e.y < camera.bottom + 50;
+ });
+ }
+}
+```
+
+### Memory Management
+
+```javascript
+// Object pooling to reduce garbage collection
+class ObjectPool {
+ constructor(Type, size) {
+ this.Type = Type;
+ this.pool = [];
+ this.active = [];
+
+ // Pre-allocate objects
+ for (let i = 0; i < size; i++) {
+ this.pool.push(new Type());
+ }
+ }
+
+ acquire() {
+ if (this.pool.length > 0) {
+ const obj = this.pool.pop();
+ this.active.push(obj);
+ return obj;
+ }
+ return new this.Type();
+ }
+
+ release(obj) {
+ const index = this.active.indexOf(obj);
+ if (index !== -1) {
+ this.active.splice(index, 1);
+ obj.reset();
+ this.pool.push(obj);
+ }
+ }
+}
+```
+
+## Data Structures
+
+### Spatial Indexing
+
+```javascript
+// Quadtree for efficient collision detection
+class Quadtree {
+ constructor(bounds, maxObjects = 10, maxLevels = 5, level = 0) {
+ this.bounds = bounds;
+ this.maxObjects = maxObjects;
+ this.maxLevels = maxLevels;
+ this.level = level;
+ this.objects = [];
+ this.nodes = [];
+ }
+
+ insert(object) {
+ if (this.nodes.length > 0) {
+ const index = this.getIndex(object);
+ if (index !== -1) {
+ this.nodes[index].insert(object);
+ return;
+ }
+ }
+
+ this.objects.push(object);
+
+ if (this.objects.length > this.maxObjects &&
+ this.level < this.maxLevels) {
+ if (this.nodes.length === 0) {
+ this.split();
+ }
+
+ let i = 0;
+ while (i < this.objects.length) {
+ const index = this.getIndex(this.objects[i]);
+ if (index !== -1) {
+ this.nodes[index].insert(this.objects.splice(i, 1)[0]);
+ } else {
+ i++;
+ }
+ }
+ }
+ }
+
+ retrieve(object) {
+ const index = this.getIndex(object);
+ let returnObjects = this.objects;
+
+ if (this.nodes.length > 0) {
+ if (index !== -1) {
+ returnObjects = returnObjects.concat(
+ this.nodes[index].retrieve(object)
+ );
+ } else {
+ for (let node of this.nodes) {
+ returnObjects = returnObjects.concat(
+ node.retrieve(object)
+ );
+ }
+ }
+ }
+
+ return returnObjects;
+ }
+}
+```
+
+## Security
+
+### Anti-Cheat Measures
+
+```ruby
+class AntiCheat
+ def validate_input(player, input)
+ # Movement speed check
+ return false if input[:move] &&
+ calculate_speed(input[:move]) > MAX_SPEED
+
+ # Fire rate check
+ return false if input[:shoot] &&
+ !can_shoot?(player)
+
+ # Position validation
+ return false if input[:position] &&
+ !valid_position?(input[:position])
+
+ # Aim angle validation
+ return false if input[:angle] &&
+ (input[:angle] < 0 || input[:angle] > 2 * Math::PI)
+
+ true
+ end
+
+ def validate_state(player)
+ # Health bounds
+ return false if player.health < 0 || player.health > 100
+
+ # Money bounds
+ return false if player.money < 0 || player.money > 16000
+
+ # Ammo bounds
+ return false if player.ammo[:clip] < 0 ||
+ player.ammo[:clip] > player.weapon[:clip_size]
+
+ true
+ end
+end
+```
+
+### Input Sanitization
+
+```javascript
+// Client-side input validation
+function sanitizeInput(input) {
+ const sanitized = {};
+
+ // Movement
+ if (input.move) {
+ sanitized.move = {
+ x: Math.max(-1, Math.min(1, input.move.x || 0)),
+ y: Math.max(-1, Math.min(1, input.move.y || 0)),
+ shift: Boolean(input.move.shift)
+ };
+ }
+
+ // Shooting
+ if (input.shoot !== undefined) {
+ sanitized.shoot = Boolean(input.shoot);
+ }
+
+ // Angle
+ if (input.angle !== undefined) {
+ sanitized.angle = Math.max(0, Math.min(2 * Math.PI, input.angle));
+ }
+
+ return sanitized;
+}
+```
+
+## Configuration
+
+### Server Configuration
+
+```yaml
+# config/server.yml
+server:
+ port: 9292
+ host: 0.0.0.0
+ max_players: 10
+ tick_rate: 30
+
+game:
+ round_time: 115
+ freeze_time: 5
+ buy_time: 15
+ bomb_timer: 45
+ max_rounds: 30
+
+network:
+ timeout: 30000
+ max_packet_size: 1024
+ compression: true
+
+performance:
+ max_bullets: 1000
+ max_entities: 500
+ view_distance: 1000
+```
+
+### Client Configuration
+
+```javascript
+// config/client.js
+const CONFIG = {
+ graphics: {
+ fps: 60,
+ resolution: 'auto',
+ shadows: true,
+ particles: true,
+ antialiasing: true
+ },
+
+ controls: {
+ sensitivity: 2.5,
+ autoAim: false,
+ invertY: false,
+ toggleCrouch: false
+ },
+
+ audio: {
+ master: 1.0,
+ effects: 0.8,
+ music: 0.5,
+ voice: 1.0
+ },
+
+ network: {
+ interpolation: 100,
+ extrapolation: true,
+ prediction: true
+ }
+};
+```
+
+## API Reference
+
+### REST Endpoints
+
+```
+GET /api/status # Server status
+GET /api/players # Active players list
+GET /api/rooms # Available game rooms
+POST /api/rooms # Create new room
+GET /api/stats/:id # Player statistics
+```
+
+### WebSocket Commands
+
+```javascript
+// Join game
+ws.send(JSON.stringify({
+ type: 'join',
+ data: { name: 'Player', team: 'auto' }
+}));
+
+// Leave game
+ws.send(JSON.stringify({
+ type: 'leave'
+}));
+
+// Team change
+ws.send(JSON.stringify({
+ type: 'change_team',
+ team: 'ct' | 't'
+}));
+
+// Vote
+ws.send(JSON.stringify({
+ type: 'vote',
+ vote: 'kick' | 'map' | 'restart',
+ target: 'player_id' | 'map_name'
+}));
+```
+
+## Development
+
+### Building from Source
+
+```bash
+# Clone repository
+git clone https://github.com/yourusername/cs2d.git
+cd cs2d
+
+# Install dependencies
+bundle install
+npm install
+
+# Run development server
+bundle exec ruby cs16_server.rb
+
+# Run tests
+bundle exec rspec
+npm test
+
+# Build for production
+rake build
+```
+
+### Contributing
+
+Please read [CONTRIBUTING.md](../CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details.
\ No newline at end of file
diff --git a/examples/cs2d/game/bullet.rb b/examples/cs2d/game/bullet.rb
new file mode 100644
index 0000000..18b926d
--- /dev/null
+++ b/examples/cs2d/game/bullet.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Bullet
+ attr_accessor :x, :y, :hit
+ attr_reader :owner_id, :angle, :damage, :speed, :penetration
+
+ def initialize(owner_id:, x:, y:, angle:, damage:, speed:, penetration:)
+ @owner_id = owner_id
+ @x = x
+ @y = y
+ @angle = angle
+ @damage = damage
+ @speed = speed
+ @penetration = penetration
+ @hit = false
+ @distance_traveled = 0
+ @max_distance = 1000
+ end
+
+ def update
+ # 更新子彈位置
+ @x += Math.cos(@angle) * @speed
+ @y += Math.sin(@angle) * @speed
+ @distance_traveled += @speed
+ end
+
+ def hits?(target_x, target_y, radius)
+ distance = Math.sqrt((target_x - @x) ** 2 + (target_y - @y) ** 2)
+ distance <= radius
+ end
+
+ def out_of_bounds?
+ @x < 0 || @x > 1280 || @y < 0 || @y > 720 || @distance_traveled > @max_distance
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/game_room.rb b/examples/cs2d/game/game_room.rb
new file mode 100644
index 0000000..f359710
--- /dev/null
+++ b/examples/cs2d/game/game_room.rb
@@ -0,0 +1,243 @@
+# frozen_string_literal: true
+
+require_relative "player"
+require_relative "bullet"
+require_relative "game_state"
+
+class GameRoom
+ attr_reader :players, :bullets, :game_state, :round_time, :scores
+
+ def initialize
+ @players = {}
+ @bullets = []
+ @game_state = GameState.new
+ @round_time = 120 # 秒
+ @scores = { ct: 0, t: 0 }
+ @round_start_time = Time.now
+ @map = load_map("de_dust2")
+ end
+
+ def add_player(player_id)
+ @players[player_id] = Player.new(
+ id: player_id,
+ name: "Player#{player_id[0..4]}",
+ team: @players.size.even? ? :ct : :t,
+ x: rand(100..700),
+ y: rand(100..500)
+ )
+ end
+
+ def remove_player(player_id)
+ @players.delete(player_id)
+ end
+
+ def update_player_position(player_id, dx, dy)
+ return unless player = @players[player_id]
+ return if player.dead?
+
+ # 計算新位置
+ new_x = player.x + dx
+ new_y = player.y + dy
+
+ # 檢查碰撞
+ unless check_collision(new_x, new_y)
+ player.x = new_x.clamp(20, 1260)
+ player.y = new_y.clamp(20, 700)
+ end
+
+ player.last_update = Time.now
+ end
+
+ def player_shoot(player_id, angle)
+ return unless player = @players[player_id]
+ return if player.dead?
+ return unless player.can_shoot?
+
+ weapon = player.current_weapon
+
+ # 創建子彈
+ @bullets << Bullet.new(
+ owner_id: player_id,
+ x: player.x,
+ y: player.y,
+ angle: angle,
+ damage: weapon[:damage],
+ speed: weapon[:bullet_speed],
+ penetration: weapon[:penetration]
+ )
+
+ player.shoot!
+ end
+
+ def player_reload(player_id)
+ return unless player = @players[player_id]
+ return if player.dead?
+
+ player.reload!
+ end
+
+ def change_team(player_id, team)
+ return unless player = @players[player_id]
+ return unless [:ct, :t].include?(team.to_sym)
+
+ player.team = team.to_sym
+ player.reset_for_new_round
+ end
+
+ def buy_weapon(player_id, weapon_name)
+ return unless player = @players[player_id]
+ return if player.dead?
+ return unless @game_state.buy_time?
+
+ weapon_price = WEAPONS[weapon_name.to_sym][:price]
+
+ if player.money >= weapon_price
+ player.money -= weapon_price
+ player.add_weapon(weapon_name.to_sym)
+ true
+ else
+ false
+ end
+ end
+
+ def broadcast_chat(player_id, message)
+ return unless player = @players[player_id]
+
+ {
+ type: "chat",
+ player_name: player.name,
+ team: player.team,
+ message: message[0..100] # 限制訊息長度
+ }
+ end
+
+ def update_bullets
+ @bullets.each do |bullet|
+ bullet.update
+
+ # 檢查子彈是否擊中玩家
+ @players.each do |id, player|
+ next if id == bullet.owner_id
+ next if player.dead?
+
+ if bullet.hits?(player.x, player.y, 15)
+ player.take_damage(bullet.damage)
+
+ # 擊殺獎勵
+ if player.dead?
+ killer = @players[bullet.owner_id]
+ killer.money += 300 if killer
+ killer.kills += 1 if killer
+ end
+
+ bullet.hit = true
+ end
+ end
+ end
+
+ # 移除已擊中或超出範圍的子彈
+ @bullets.reject! { |b| b.hit || b.out_of_bounds? }
+ end
+
+ def update_round
+ current_time = Time.now
+ elapsed = current_time - @round_start_time
+ @round_time = [120 - elapsed.to_i, 0].max
+
+ # 檢查回合結束條件
+ if @round_time <= 0 || team_eliminated?(:ct) || team_eliminated?(:t)
+ end_round
+ end
+ end
+
+ def team_eliminated?(team)
+ @players.values.select { |p| p.team == team && !p.dead? }.empty?
+ end
+
+ def end_round
+ # 計算獲勝隊伍
+ if team_eliminated?(:t)
+ @scores[:ct] += 1
+ award_team_money(:ct, 3250)
+ award_team_money(:t, 1400)
+ elsif team_eliminated?(:ct)
+ @scores[:t] += 1
+ award_team_money(:t, 3250)
+ award_team_money(:ct, 1400)
+ else
+ # 時間結束,CT 獲勝
+ @scores[:ct] += 1
+ award_team_money(:ct, 3250)
+ award_team_money(:t, 1400)
+ end
+
+ start_new_round
+ end
+
+ def start_new_round
+ @round_start_time = Time.now
+ @bullets.clear
+
+ @players.each do |_, player|
+ player.reset_for_new_round
+ end
+
+ @game_state.start_buy_phase
+ end
+
+ def award_team_money(team, amount)
+ @players.values.select { |p| p.team == team }.each do |player|
+ player.money = [player.money + amount, 16000].min
+ end
+ end
+
+ def players_data
+ @players.transform_values do |player|
+ {
+ id: player.id,
+ name: player.name,
+ team: player.team,
+ x: player.x,
+ y: player.y,
+ health: player.health,
+ armor: player.armor,
+ money: player.money,
+ dead: player.dead?,
+ weapon: player.current_weapon[:name]
+ }
+ end
+ end
+
+ def bullets_data
+ @bullets.map do |bullet|
+ {
+ x: bullet.x,
+ y: bullet.y,
+ angle: bullet.angle
+ }
+ end
+ end
+
+ private
+
+ def check_collision(x, y)
+ # 簡單的邊界檢查,之後可以加入地圖牆壁碰撞
+ false
+ end
+
+ def load_map(map_name)
+ # 載入地圖資料
+ {}
+ end
+
+ WEAPONS = {
+ glock: { name: "Glock-18", price: 400, damage: 28, rate: 0.15, magazine: 20, bullet_speed: 20, penetration: 1 },
+ usp: { name: "USP-S", price: 500, damage: 35, rate: 0.17, magazine: 12, bullet_speed: 20, penetration: 1 },
+ deagle: { name: "Desert Eagle", price: 700, damage: 48, rate: 0.225, magazine: 7, bullet_speed: 25, penetration: 2 },
+ ak47: { name: "AK-47", price: 2700, damage: 36, rate: 0.1, magazine: 30, bullet_speed: 22, penetration: 2 },
+ m4a1: { name: "M4A1", price: 3100, damage: 33, rate: 0.09, magazine: 30, bullet_speed: 23, penetration: 2 },
+ awp: { name: "AWP", price: 4750, damage: 115, rate: 1.45, magazine: 10, bullet_speed: 30, penetration: 3 },
+ mp5: { name: "MP5", price: 1500, damage: 26, rate: 0.075, magazine: 30, bullet_speed: 20, penetration: 1 },
+ p90: { name: "P90", price: 2350, damage: 26, rate: 0.07, magazine: 50, bullet_speed: 21, penetration: 1 }
+ }.freeze
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/game_state.rb b/examples/cs2d/game/game_state.rb
new file mode 100644
index 0000000..a26e153
--- /dev/null
+++ b/examples/cs2d/game/game_state.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+class GameState
+ attr_reader :phase, :round_number, :max_rounds
+
+ PHASES = {
+ waiting: :waiting, # 等待玩家
+ buy_time: :buy_time, # 購買時間
+ playing: :playing, # 遊戲進行中
+ round_end: :round_end, # 回合結束
+ game_over: :game_over # 遊戲結束
+ }.freeze
+
+ def initialize
+ @phase = PHASES[:waiting]
+ @round_number = 1
+ @max_rounds = 30
+ @phase_start_time = Time.now
+ @buy_time_duration = 15 # 秒
+ @round_end_duration = 5 # 秒
+ end
+
+ def waiting?
+ @phase == PHASES[:waiting]
+ end
+
+ def buy_time?
+ @phase == PHASES[:buy_time]
+ end
+
+ def playing?
+ @phase == PHASES[:playing]
+ end
+
+ def round_ended?
+ @phase == PHASES[:round_end]
+ end
+
+ def game_over?
+ @phase == PHASES[:game_over]
+ end
+
+ def start_buy_phase
+ @phase = PHASES[:buy_time]
+ @phase_start_time = Time.now
+
+ # 自動過渡到遊戲階段
+ Thread.new do
+ sleep(@buy_time_duration)
+ start_playing_phase
+ end
+ end
+
+ def start_playing_phase
+ @phase = PHASES[:playing]
+ @phase_start_time = Time.now
+ end
+
+ def end_round
+ @phase = PHASES[:round_end]
+ @phase_start_time = Time.now
+ @round_number += 1
+
+ if @round_number > @max_rounds
+ end_game
+ else
+ # 自動開始新回合
+ Thread.new do
+ sleep(@round_end_duration)
+ start_buy_phase
+ end
+ end
+ end
+
+ def end_game
+ @phase = PHASES[:game_over]
+ end
+
+ def time_remaining_in_phase
+ case @phase
+ when PHASES[:buy_time]
+ [@buy_time_duration - (Time.now - @phase_start_time), 0].max
+ when PHASES[:round_end]
+ [@round_end_duration - (Time.now - @phase_start_time), 0].max
+ else
+ 0
+ end
+ end
+
+ def can_buy?
+ buy_time? && time_remaining_in_phase > 0
+ end
+
+ def to_h
+ {
+ phase: @phase,
+ round_number: @round_number,
+ max_rounds: @max_rounds,
+ time_in_phase: time_remaining_in_phase
+ }
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_bomb_system.rb b/examples/cs2d/game/mvp_bomb_system.rb
new file mode 100644
index 0000000..a77ea69
--- /dev/null
+++ b/examples/cs2d/game/mvp_bomb_system.rb
@@ -0,0 +1,208 @@
+# frozen_string_literal: true
+
+class MVPBombSystem
+ attr_reader :planted, :bomb_site, :bomb_timer, :planting_progress, :defusing_progress
+
+ PLANT_TIME = 3.0
+ DEFUSE_TIME = 10.0
+ DEFUSE_TIME_WITH_KIT = 5.0
+ BOMB_TIMER = 45.0
+ EXPLOSION_RADIUS = 500
+
+ def initialize(game_room)
+ @game_room = game_room
+ @planted = false
+ @bomb_site = nil
+ @bomb_position = { x: 0, y: 0 }
+ @bomb_timer = 0
+
+ @planting = false
+ @planting_player = nil
+ @planting_progress = 0
+
+ @defusing = false
+ @defusing_player = nil
+ @defusing_progress = 0
+ end
+
+ def planted?
+ @planted
+ end
+
+ def update(delta_time)
+ # 更新安裝進度
+ if @planting && @planting_player
+ player = @game_room.players[@planting_player]
+ if player && player.alive
+ @planting_progress += delta_time
+ if @planting_progress >= PLANT_TIME
+ plant_bomb
+ end
+ else
+ stop_planting
+ end
+ end
+
+ # 更新拆彈進度
+ if @defusing && @defusing_player
+ player = @game_room.players[@defusing_player]
+ if player && player.alive
+ defuse_time = player.has_defuse_kit? ? DEFUSE_TIME_WITH_KIT : DEFUSE_TIME
+ @defusing_progress += delta_time
+
+ if @defusing_progress >= defuse_time
+ defuse_bomb
+ end
+ else
+ stop_defusing
+ end
+ end
+
+ # 更新炸彈倒數
+ if @planted
+ @bomb_timer -= delta_time
+ if @bomb_timer <= 0
+ explode
+ end
+ end
+ end
+
+ def start_planting(player_id, site)
+ return if @planted
+ return unless site
+
+ player = @game_room.players[player_id]
+ return unless player && player.team == :t && player.alive
+
+ @planting = true
+ @planting_player = player_id
+ @planting_progress = 0
+ @bomb_site = site
+ end
+
+ def stop_planting
+ @planting = false
+ @planting_player = nil
+ @planting_progress = 0
+ end
+
+ def start_defusing(player_id)
+ return unless @planted
+
+ player = @game_room.players[player_id]
+ return unless player && player.team == :ct && player.alive
+
+ # 檢查是否在炸彈附近
+ distance = Math.sqrt(
+ (player.x - @bomb_position[:x])**2 +
+ (player.y - @bomb_position[:y])**2
+ )
+
+ return if distance > 50
+
+ @defusing = true
+ @defusing_player = player_id
+ @defusing_progress = 0
+ end
+
+ def stop_defusing
+ @defusing = false
+ @defusing_player = nil
+ @defusing_progress = 0
+ end
+
+ def stop_action(player_id)
+ stop_planting if @planting_player == player_id
+ stop_defusing if @defusing_player == player_id
+ end
+
+ def can_defuse?(x, y)
+ return false unless @planted
+
+ distance = Math.sqrt(
+ (x - @bomb_position[:x])**2 +
+ (y - @bomb_position[:y])**2
+ )
+
+ distance <= 50
+ end
+
+ def reset
+ @planted = false
+ @bomb_site = nil
+ @bomb_position = { x: 0, y: 0 }
+ @bomb_timer = 0
+
+ stop_planting
+ stop_defusing
+ end
+
+ def get_state
+ {
+ planted: @planted,
+ bomb_site: @bomb_site,
+ bomb_timer: @bomb_timer.to_i,
+ bomb_position: @planted ? @bomb_position : nil,
+ planting: @planting,
+ planting_progress: @planting_progress / PLANT_TIME,
+ defusing: @defusing,
+ defusing_progress: @defusing ? calculate_defuse_progress : 0
+ }
+ end
+
+ def explosion_damage(players)
+ return unless @planted
+
+ players.each_value do |player|
+ next unless player.alive
+
+ distance = Math.sqrt(
+ (player.x - @bomb_position[:x])**2 +
+ (player.y - @bomb_position[:y])**2
+ )
+
+ if distance < EXPLOSION_RADIUS
+ # 傷害根據距離遞減
+ damage = (1.0 - distance / EXPLOSION_RADIUS) * 200
+ player.take_damage(damage.to_i)
+ end
+ end
+ end
+
+ private
+
+ def plant_bomb
+ player = @game_room.players[@planting_player]
+ return unless player
+
+ @planted = true
+ @bomb_position = { x: player.x, y: player.y }
+ @bomb_timer = BOMB_TIMER
+
+ stop_planting
+
+ # T隊全體獲得炸彈安裝獎勵
+ @game_room.players.each_value do |p|
+ if p.team == :t
+ p.money += 800
+ end
+ end
+ end
+
+ def defuse_bomb
+ @game_room.on_bomb_defused(@defusing_player)
+ reset
+ end
+
+ def explode
+ @game_room.on_bomb_exploded
+ end
+
+ def calculate_defuse_progress
+ player = @game_room.players[@defusing_player]
+ return 0 unless player
+
+ defuse_time = player.has_defuse_kit? ? DEFUSE_TIME_WITH_KIT : DEFUSE_TIME
+ @defusing_progress / defuse_time
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_economy.rb b/examples/cs2d/game/mvp_economy.rb
new file mode 100644
index 0000000..8d8d32b
--- /dev/null
+++ b/examples/cs2d/game/mvp_economy.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+class MVPEconomy
+ STARTING_MONEY = 800
+ MAX_MONEY = 16000
+
+ # 回合獎勵
+ ROUND_WIN_REWARD = 3250
+ ROUND_LOSS_BASE = 1400
+ ROUND_LOSS_INCREMENT = 500
+ ROUND_LOSS_MAX = 3400
+
+ # 特殊獎勵
+ BOMB_PLANT_REWARD = 800 # T全隊
+ BOMB_DEFUSE_REWARD = 3500 # 拆彈者額外獎勵
+
+ # 武器價格
+ WEAPONS = {
+ # 手槍
+ usp: 0,
+ glock: 0,
+ deagle: 650,
+
+ # 步槍
+ ak47: 2700,
+ m4a1: 3100,
+ awp: 4750,
+
+ # 裝備
+ kevlar: 650,
+ helmet: 350,
+ defuse: 400
+ }.freeze
+
+ # 擊殺獎勵
+ KILL_REWARDS = {
+ knife: 1500,
+ pistol: 300,
+ smg: 600,
+ rifle: 300,
+ awp: 100
+ }.freeze
+
+ def self.kill_reward(weapon_name)
+ case weapon_name.to_s.downcase
+ when 'knife'
+ KILL_REWARDS[:knife]
+ when 'usp', 'glock', 'deagle', 'desert eagle'
+ KILL_REWARDS[:pistol]
+ when 'ak47', 'ak-47', 'm4a1'
+ KILL_REWARDS[:rifle]
+ when 'awp'
+ KILL_REWARDS[:awp]
+ else
+ 300
+ end
+ end
+
+ def self.calculate_loss_bonus(consecutive_losses)
+ bonus = ROUND_LOSS_BASE + (consecutive_losses * ROUND_LOSS_INCREMENT)
+ [bonus, ROUND_LOSS_MAX].min
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_game_room.rb b/examples/cs2d/game/mvp_game_room.rb
new file mode 100644
index 0000000..55f02ce
--- /dev/null
+++ b/examples/cs2d/game/mvp_game_room.rb
@@ -0,0 +1,496 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+require_relative 'mvp_player'
+require_relative 'mvp_round_manager'
+require_relative 'mvp_bomb_system'
+require_relative 'mvp_economy'
+require_relative 'mvp_map'
+
+class MVPGameRoom
+ attr_reader :name, :players, :round_manager, :bomb_system, :map
+
+ MAX_PLAYERS = 10
+ MIN_PLAYERS = 2
+ TICK_RATE = 30.0
+
+ def initialize(name)
+ @name = name
+ @players = {}
+ @bullets = []
+ @round_manager = MVPRoundManager.new(self)
+ @bomb_system = MVPBombSystem.new(self)
+ @economy = MVPEconomy.new
+ @map = MVPMap.new
+ @started = false
+ @last_update = Time.now
+ end
+
+ def add_player(player_id)
+ return if @players[player_id]
+
+ # 分配隊伍
+ team = assign_team
+
+ @players[player_id] = MVPPlayer.new(
+ id: player_id,
+ name: "Player#{player_id[0..4]}",
+ team: team
+ )
+
+ # 給予起始金錢
+ @players[player_id].money = MVPEconomy::STARTING_MONEY
+
+ # 如果遊戲已開始,玩家需要等待下回合
+ if @started && @round_manager.phase != :freeze
+ @players[player_id].alive = false
+ end
+
+ # 檢查是否可以開始遊戲
+ check_game_start
+ end
+
+ def remove_player(player_id)
+ @players.delete(player_id)
+
+ # 如果人數不足,暫停遊戲
+ if @players.size < MIN_PLAYERS
+ @started = false
+ @round_manager.pause
+ end
+ end
+
+ def handle_player_input(player_id, input)
+ player = @players[player_id]
+ return unless player && player.alive
+
+ # 凍結時間不能移動
+ return if @round_manager.phase == :freeze
+
+ # 處理移動
+ if input[:move]
+ move_player(player, input[:move])
+ end
+
+ # 處理射擊
+ if input[:shoot]
+ shoot(player, input[:angle])
+ end
+
+ # 處理換彈
+ if input[:reload]
+ player.reload
+ end
+
+ # 處理互動(安裝/拆除炸彈)
+ if input[:use]
+ handle_use_action(player)
+ else
+ # 停止安裝/拆除
+ @bomb_system.stop_action(player_id)
+ end
+ end
+
+ def buy_weapon(player_id, weapon_name)
+ player = @players[player_id]
+ return unless player
+
+ # 只能在購買時間購買
+ return unless @round_manager.can_buy?
+
+ # 檢查金錢並購買
+ price = MVPEconomy::WEAPONS[weapon_name.to_sym]
+ return unless price
+
+ if player.money >= price
+ # 檢查隊伍限制武器
+ if weapon_restricted?(weapon_name, player.team)
+ return false
+ end
+
+ player.money -= price
+ player.add_weapon(weapon_name.to_sym)
+ true
+ else
+ false
+ end
+ end
+
+ def update
+ return unless @started
+
+ delta_time = Time.now - @last_update
+ @last_update = Time.now
+
+ # 更新回合管理器
+ @round_manager.update(delta_time)
+
+ # 更新炸彈系統
+ @bomb_system.update(delta_time)
+
+ # 更新子彈
+ update_bullets(delta_time)
+
+ # 更新玩家
+ @players.each_value do |player|
+ player.update(delta_time)
+ end
+
+ # 檢查勝利條件
+ check_victory_conditions
+ end
+
+ def get_state
+ {
+ players: players_state,
+ bullets: bullets_state,
+ round: @round_manager.get_state,
+ bomb: @bomb_system.get_state,
+ map: @map.get_state
+ }
+ end
+
+ def start_planting(player_id)
+ player = @players[player_id]
+ return unless player && player.alive && player.team == :t
+
+ # 檢查是否在炸彈點
+ site = @map.get_bomb_site_at(player.x, player.y)
+ return unless site
+
+ @bomb_system.start_planting(player_id, site)
+ end
+
+ def start_defusing(player_id)
+ player = @players[player_id]
+ return unless player && player.alive && player.team == :ct
+
+ @bomb_system.start_defusing(player_id)
+ end
+
+ def send_chat(player_id, message)
+ player = @players[player_id]
+ return unless player
+
+ {
+ type: "chat",
+ player_name: player.name,
+ team: player.team,
+ message: message[0..100]
+ }
+ end
+
+ def full?
+ @players.size >= MAX_PLAYERS
+ end
+
+ def empty?
+ @players.empty?
+ end
+
+ def started?
+ @started
+ end
+
+ # 回合結束回調
+ def on_round_end(winning_team, reason)
+ # 發放獎金
+ award_round_money(winning_team, reason)
+
+ # 重置玩家狀態
+ reset_players_for_new_round
+
+ # 重置炸彈
+ @bomb_system.reset
+ end
+
+ # 炸彈爆炸回調
+ def on_bomb_exploded
+ # T 勝利
+ @round_manager.end_round(:t, :bomb_exploded)
+
+ # 爆炸傷害
+ @bomb_system.explosion_damage(@players)
+ end
+
+ # 炸彈拆除回調
+ def on_bomb_defused(defuser_id)
+ # CT 勝利
+ @round_manager.end_round(:ct, :bomb_defused)
+
+ # 拆彈獎勵
+ if defuser = @players[defuser_id]
+ defuser.money += MVPEconomy::BOMB_DEFUSE_REWARD
+ end
+ end
+
+ private
+
+ def assign_team
+ ct_count = @players.values.count { |p| p.team == :ct }
+ t_count = @players.values.count { |p| p.team == :t }
+
+ ct_count <= t_count ? :ct : :t
+ end
+
+ def check_game_start
+ if @players.size >= MIN_PLAYERS && !@started
+ @started = true
+ @round_manager.start_game
+ reset_players_for_new_round
+ end
+ end
+
+ def move_player(player, move_input)
+ # 計算移動
+ dx = move_input[:x] || 0
+ dy = move_input[:y] || 0
+
+ return if dx == 0 && dy == 0
+
+ # 根據武器調整速度
+ speed = player.get_move_speed
+ speed *= 1.3 if move_input[:shift] # 奔跑
+
+ # 正規化向量
+ magnitude = Math.sqrt(dx * dx + dy * dy)
+ dx = dx / magnitude * speed
+ dy = dy / magnitude * speed
+
+ # 新位置
+ new_x = player.x + dx
+ new_y = player.y + dy
+
+ # 碰撞檢測
+ unless @map.check_collision(new_x, new_y, 15)
+ player.x = new_x
+ player.y = new_y
+ end
+ end
+
+ def shoot(player, angle)
+ return unless player.can_shoot?
+
+ weapon = player.current_weapon
+ player.shoot
+
+ # 創建子彈
+ @bullets << {
+ id: SecureRandom.hex(4),
+ owner_id: player.id,
+ team: player.team,
+ x: player.x,
+ y: player.y,
+ vx: Math.cos(angle) * 20,
+ vy: Math.sin(angle) * 20,
+ damage: weapon[:damage],
+ penetration: weapon[:penetration],
+ life: 2.0 # 2秒生命週期
+ }
+ end
+
+ def update_bullets(delta_time)
+ @bullets.each do |bullet|
+ # 更新位置
+ bullet[:x] += bullet[:vx] * delta_time * 30
+ bullet[:y] += bullet[:vy] * delta_time * 30
+ bullet[:life] -= delta_time
+
+ # 檢查碰撞
+ if @map.check_collision(bullet[:x], bullet[:y], 2)
+ bullet[:life] = 0
+ next
+ end
+
+ # 檢查擊中玩家
+ @players.each_value do |player|
+ next if player.id == bullet[:owner_id]
+ next if player.team == bullet[:team] # 友軍傷害關閉
+ next unless player.alive
+
+ distance = Math.sqrt((player.x - bullet[:x])**2 + (player.y - bullet[:y])**2)
+ if distance < 15
+ # 計算傷害
+ damage = calculate_damage(bullet, player, distance)
+ player.take_damage(damage)
+
+ # 擊殺獎勵
+ if !player.alive
+ on_player_killed(bullet[:owner_id], player.id, bullet[:weapon_name])
+ end
+
+ bullet[:life] = 0
+ end
+ end
+ end
+
+ # 移除過期子彈
+ @bullets.reject! { |b| b[:life] <= 0 }
+ end
+
+ def calculate_damage(bullet, player, distance)
+ damage = bullet[:damage]
+
+ # 距離衰減
+ damage *= (1.0 - distance / 500.0) if distance > 100
+
+ # 護甲減傷
+ if player.armor > 0
+ damage *= 0.5
+ end
+
+ damage.to_i
+ end
+
+ def on_player_killed(killer_id, victim_id, weapon_name)
+ killer = @players[killer_id]
+ victim = @players[victim_id]
+
+ return unless killer && victim
+
+ # 擊殺獎勵
+ reward = MVPEconomy.kill_reward(weapon_name)
+ killer.money = [killer.money + reward, MVPEconomy::MAX_MONEY].min
+ killer.kills += 1
+
+ victim.deaths += 1
+
+ # 檢查團滅
+ check_team_elimination
+ end
+
+ def check_team_elimination
+ ct_alive = @players.values.count { |p| p.team == :ct && p.alive }
+ t_alive = @players.values.count { |p| p.team == :t && p.alive }
+
+ if ct_alive == 0
+ @round_manager.end_round(:t, :elimination)
+ elsif t_alive == 0
+ @round_manager.end_round(:ct, :elimination)
+ end
+ end
+
+ def check_victory_conditions
+ return unless @round_manager.phase == :playing
+
+ # 時間結束
+ if @round_manager.round_time <= 0
+ if @bomb_system.planted?
+ # 炸彈已安裝,T勝利
+ @round_manager.end_round(:t, :time)
+ else
+ # 炸彈未安裝,CT勝利
+ @round_manager.end_round(:ct, :time)
+ end
+ end
+ end
+
+ def handle_use_action(player)
+ if player.team == :t && !@bomb_system.planted?
+ # T 嘗試安裝炸彈
+ site = @map.get_bomb_site_at(player.x, player.y)
+ @bomb_system.start_planting(player.id, site) if site
+ elsif player.team == :ct && @bomb_system.planted?
+ # CT 嘗試拆彈
+ if @bomb_system.can_defuse?(player.x, player.y)
+ @bomb_system.start_defusing(player.id)
+ end
+ end
+ end
+
+ def award_round_money(winning_team, reason)
+ @players.each_value do |player|
+ if player.team == winning_team
+ player.money += MVPEconomy::ROUND_WIN_REWARD
+ else
+ # 連敗獎勵
+ loss_bonus = MVPEconomy.calculate_loss_bonus(player.consecutive_losses)
+ player.money += loss_bonus
+ player.consecutive_losses += 1
+ end
+
+ # 上限檢查
+ player.money = [player.money, MVPEconomy::MAX_MONEY].min
+ end
+
+ # 炸彈相關獎勵
+ if reason == :bomb_planted && winning_team == :t
+ @players.values.select { |p| p.team == :t }.each do |player|
+ player.money += MVPEconomy::BOMB_PLANT_REWARD
+ end
+ end
+ end
+
+ def reset_players_for_new_round
+ # 獲取重生點
+ ct_spawns = @map.ct_spawns
+ t_spawns = @map.t_spawns
+
+ ct_index = 0
+ t_index = 0
+
+ @players.each_value do |player|
+ player.reset_for_round
+
+ # 設置重生位置
+ if player.team == :ct
+ spawn = ct_spawns[ct_index % ct_spawns.size]
+ ct_index += 1
+ else
+ spawn = t_spawns[t_index % t_spawns.size]
+ t_index += 1
+ end
+
+ player.x = spawn[:x]
+ player.y = spawn[:y]
+ end
+
+ # 清空子彈
+ @bullets.clear
+ end
+
+ def weapon_restricted?(weapon_name, team)
+ case weapon_name.to_sym
+ when :ak47
+ team != :t
+ when :m4a1
+ team != :ct
+ when :defuse
+ team != :ct
+ else
+ false
+ end
+ end
+
+ def players_state
+ @players.transform_values do |player|
+ {
+ id: player.id,
+ name: player.name,
+ team: player.team,
+ x: player.x,
+ y: player.y,
+ angle: player.angle,
+ health: player.health,
+ armor: player.armor,
+ money: player.money,
+ alive: player.alive,
+ weapon: player.current_weapon[:name],
+ ammo: player.ammo,
+ kills: player.kills,
+ deaths: player.deaths
+ }
+ end
+ end
+
+ def bullets_state
+ @bullets.map do |bullet|
+ {
+ id: bullet[:id],
+ x: bullet[:x],
+ y: bullet[:y],
+ team: bullet[:team]
+ }
+ end
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_game_server.rb b/examples/cs2d/game/mvp_game_server.rb
new file mode 100644
index 0000000..0580e90
--- /dev/null
+++ b/examples/cs2d/game/mvp_game_server.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'singleton'
+require_relative 'mvp_game_room'
+
+class MVPGameServer
+ include Singleton
+
+ attr_reader :rooms
+
+ def initialize
+ @rooms = {}
+ @room_counter = 0
+ end
+
+ def find_or_create_room
+ # 尋找有空位的房間
+ available_room = @rooms.values.find { |room| !room.full? && !room.started? }
+
+ if available_room
+ available_room
+ else
+ create_room
+ end
+ end
+
+ def create_room(name = nil)
+ @room_counter += 1
+ name ||= "Room-#{@room_counter}"
+ @rooms[name] = MVPGameRoom.new(name)
+ end
+
+ def remove_empty_rooms
+ @rooms.delete_if { |_, room| room.empty? }
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_map.rb b/examples/cs2d/game/mvp_map.rb
new file mode 100644
index 0000000..a9e91f6
--- /dev/null
+++ b/examples/cs2d/game/mvp_map.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+class MVPMap
+ attr_reader :width, :height, :walls, :bomb_sites
+
+ def initialize
+ @width = 1280
+ @height = 720
+ @walls = []
+ @bomb_sites = []
+
+ setup_dust2_mini
+ end
+
+ def setup_dust2_mini
+ # 簡化版 de_dust2 地圖
+ # 外牆
+ @walls << { x: 0, y: 0, width: @width, height: 20 } # 上
+ @walls << { x: 0, y: @height - 20, width: @width, height: 20 } # 下
+ @walls << { x: 0, y: 0, width: 20, height: @height } # 左
+ @walls << { x: @width - 20, y: 0, width: 20, height: @height } # 右
+
+ # 中間結構
+ @walls << { x: 300, y: 200, width: 100, height: 320 } # 左側牆
+ @walls << { x: 880, y: 200, width: 100, height: 320 } # 右側牆
+ @walls << { x: 500, y: 300, width: 280, height: 120 } # 中間障礙
+
+ # 炸彈點
+ @bomb_sites << { name: 'A', x: 200, y: 360, radius: 100 }
+ @bomb_sites << { name: 'B', x: 1080, y: 360, radius: 100 }
+ end
+
+ def ct_spawns
+ [
+ { x: 640, y: 100 },
+ { x: 600, y: 100 },
+ { x: 680, y: 100 },
+ { x: 560, y: 130 },
+ { x: 720, y: 130 }
+ ]
+ end
+
+ def t_spawns
+ [
+ { x: 640, y: 620 },
+ { x: 600, y: 620 },
+ { x: 680, y: 620 },
+ { x: 560, y: 590 },
+ { x: 720, y: 590 }
+ ]
+ end
+
+ def check_collision(x, y, radius)
+ # 邊界檢查
+ return true if x - radius < 0 || x + radius > @width
+ return true if y - radius < 0 || y + radius > @height
+
+ # 牆壁碰撞
+ @walls.each do |wall|
+ if x + radius > wall[:x] &&
+ x - radius < wall[:x] + wall[:width] &&
+ y + radius > wall[:y] &&
+ y - radius < wall[:y] + wall[:height]
+ return true
+ end
+ end
+
+ false
+ end
+
+ def get_bomb_site_at(x, y)
+ @bomb_sites.each do |site|
+ distance = Math.sqrt((x - site[:x])**2 + (y - site[:y])**2)
+ return site[:name] if distance <= site[:radius]
+ end
+ nil
+ end
+
+ def get_state
+ {
+ width: @width,
+ height: @height,
+ walls: @walls,
+ bomb_sites: @bomb_sites
+ }
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_player.rb b/examples/cs2d/game/mvp_player.rb
new file mode 100644
index 0000000..d78551c
--- /dev/null
+++ b/examples/cs2d/game/mvp_player.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+class MVPPlayer
+ attr_accessor :id, :name, :team, :x, :y, :angle, :health, :armor, :money
+ attr_accessor :alive, :kills, :deaths, :consecutive_losses
+ attr_reader :weapons, :current_weapon_index, :ammo
+
+ def initialize(id:, name:, team:)
+ @id = id
+ @name = name
+ @team = team
+ @x = 0
+ @y = 0
+ @angle = 0
+ @health = 100
+ @armor = 0
+ @money = 800
+ @alive = true
+ @kills = 0
+ @deaths = 0
+ @consecutive_losses = 0
+
+ # 武器系統
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @ammo = { clip: 30, reserve: 90 }
+ @reloading = false
+ @reload_timer = 0
+ @last_shot_time = 0
+ end
+
+ def current_weapon
+ @weapons[@current_weapon_index]
+ end
+
+ def default_weapon
+ case @team
+ when :ct
+ { name: "USP", damage: 35, firerate: 0.17, clip_size: 12, reload_time: 2.2, move_speed: 1.0, penetration: 1 }
+ when :t
+ { name: "Glock", damage: 28, firerate: 0.15, clip_size: 20, reload_time: 2.2, move_speed: 1.0, penetration: 1 }
+ else
+ { name: "Knife", damage: 50, firerate: 0.5, clip_size: 0, reload_time: 0, move_speed: 1.1, penetration: 0 }
+ end
+ end
+
+ def add_weapon(weapon_type)
+ weapon = case weapon_type
+ when :ak47
+ { name: "AK-47", damage: 36, firerate: 0.1, clip_size: 30, reload_time: 2.5, move_speed: 0.85, penetration: 2 }
+ when :m4a1
+ { name: "M4A1", damage: 33, firerate: 0.09, clip_size: 30, reload_time: 2.5, move_speed: 0.9, penetration: 2 }
+ when :awp
+ { name: "AWP", damage: 115, firerate: 1.45, clip_size: 10, reload_time: 3.7, move_speed: 0.7, penetration: 3 }
+ when :deagle
+ { name: "Desert Eagle", damage: 48, firerate: 0.225, clip_size: 7, reload_time: 2.2, move_speed: 0.95, penetration: 2 }
+ else
+ return
+ end
+
+ # 替換主武器或副武器
+ if [:ak47, :m4a1, :awp].include?(weapon_type)
+ @weapons[1] = weapon
+ @current_weapon_index = 1
+ else
+ @weapons[0] = weapon
+ @current_weapon_index = 0
+ end
+
+ # 重置彈藥
+ @ammo = { clip: weapon[:clip_size], reserve: weapon[:clip_size] * 3 }
+ end
+
+ def can_shoot?
+ return false unless @alive
+ return false if @reloading
+ return false if @ammo[:clip] <= 0
+
+ Time.now.to_f - @last_shot_time > current_weapon[:firerate]
+ end
+
+ def shoot
+ return unless can_shoot?
+
+ @ammo[:clip] -= 1
+ @last_shot_time = Time.now.to_f
+
+ # 自動換彈
+ reload if @ammo[:clip] == 0 && @ammo[:reserve] > 0
+ end
+
+ def reload
+ return if @reloading
+ return if @ammo[:clip] >= current_weapon[:clip_size]
+ return if @ammo[:reserve] <= 0
+
+ @reloading = true
+ @reload_timer = current_weapon[:reload_time]
+ end
+
+ def update(delta_time)
+ # 更新換彈
+ if @reloading
+ @reload_timer -= delta_time
+ if @reload_timer <= 0
+ finish_reload
+ end
+ end
+ end
+
+ def finish_reload
+ needed = current_weapon[:clip_size] - @ammo[:clip]
+ available = [@ammo[:reserve], needed].min
+
+ @ammo[:clip] += available
+ @ammo[:reserve] -= available
+ @reloading = false
+ @reload_timer = 0
+ end
+
+ def take_damage(damage)
+ return unless @alive
+
+ # 護甲吸收
+ if @armor > 0
+ absorbed = [damage * 0.5, @armor].min.to_i
+ @armor -= absorbed
+ actual_damage = damage - absorbed * 0.5
+ else
+ actual_damage = damage
+ end
+
+ @health = [@health - actual_damage, 0].max.to_i
+
+ if @health <= 0
+ @alive = false
+ @deaths += 1
+ end
+ end
+
+ def get_move_speed
+ base_speed = 5.0
+ base_speed * current_weapon[:move_speed]
+ end
+
+ def reset_for_round
+ @health = 100
+ @alive = true
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @ammo = { clip: 30, reserve: 90 }
+ @reloading = false
+ @reload_timer = 0
+ # 保留護甲(如果有的話)
+ end
+
+ def has_defuse_kit?
+ @has_defuse_kit ||= false
+ end
+
+ def buy_defuse_kit
+ @has_defuse_kit = true
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/mvp_round_manager.rb b/examples/cs2d/game/mvp_round_manager.rb
new file mode 100644
index 0000000..acc3a31
--- /dev/null
+++ b/examples/cs2d/game/mvp_round_manager.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+class MVPRoundManager
+ attr_reader :phase, :round_number, :round_time, :ct_score, :t_score
+
+ FREEZE_TIME = 5.0
+ BUY_TIME = 15.0
+ ROUND_TIME = 115.0
+ MAX_ROUNDS = 30
+ HALF_TIME = 15
+
+ PHASES = [:freeze, :buy, :playing, :ended].freeze
+
+ def initialize(game_room)
+ @game_room = game_room
+ @phase = :freeze
+ @round_number = 0
+ @round_time = 0
+ @phase_timer = 0
+ @ct_score = 0
+ @t_score = 0
+ @paused = true
+ end
+
+ def start_game
+ @round_number = 1
+ @ct_score = 0
+ @t_score = 0
+ @paused = false
+ start_new_round
+ end
+
+ def pause
+ @paused = true
+ end
+
+ def update(delta_time)
+ return if @paused
+
+ @phase_timer -= delta_time
+ @round_time -= delta_time if @phase == :playing
+
+ # 階段轉換
+ case @phase
+ when :freeze
+ if @phase_timer <= 0
+ enter_buy_phase
+ end
+ when :buy
+ if @phase_timer <= 0
+ enter_playing_phase
+ end
+ when :playing
+ # 遊戲房間會檢查勝利條件
+ when :ended
+ if @phase_timer <= 0
+ start_new_round
+ end
+ end
+ end
+
+ def can_buy?
+ @phase == :buy || (@phase == :playing && @round_time > (ROUND_TIME - BUY_TIME))
+ end
+
+ def end_round(winning_team, reason)
+ return if @phase == :ended
+
+ @phase = :ended
+ @phase_timer = 5.0 # 5秒顯示結果
+
+ # 更新分數
+ case winning_team
+ when :ct
+ @ct_score += 1
+ when :t
+ @t_score += 1
+ end
+
+ # 通知遊戲房間
+ @game_room.on_round_end(winning_team, reason)
+
+ # 檢查遊戲結束
+ check_game_over
+ end
+
+ def get_state
+ {
+ phase: @phase,
+ round_number: @round_number,
+ round_time: @round_time.to_i,
+ phase_timer: @phase_timer.to_i,
+ ct_score: @ct_score,
+ t_score: @t_score,
+ max_rounds: MAX_ROUNDS,
+ can_buy: can_buy?
+ }
+ end
+
+ private
+
+ def start_new_round
+ # 檢查換邊
+ if @round_number == HALF_TIME
+ swap_teams
+ end
+
+ @phase = :freeze
+ @phase_timer = FREEZE_TIME
+ @round_time = ROUND_TIME
+ @round_number += 1 unless @round_number >= MAX_ROUNDS
+ end
+
+ def enter_buy_phase
+ @phase = :buy
+ @phase_timer = BUY_TIME
+ end
+
+ def enter_playing_phase
+ @phase = :playing
+ # round_time 已經在設置,不需要 phase_timer
+ end
+
+ def check_game_over
+ # 先贏得16回合的隊伍獲勝
+ if @ct_score >= 16 || @t_score >= 16
+ # 遊戲結束
+ @paused = true
+ # TODO: 顯示最終結果
+ elsif @round_number >= MAX_ROUNDS
+ # 平局或根據分數決定勝負
+ @paused = true
+ end
+ end
+
+ def swap_teams
+ # 交換隊伍
+ @game_room.players.each_value do |player|
+ player.team = player.team == :ct ? :t : :ct
+ end
+
+ # 交換分數
+ @ct_score, @t_score = @t_score, @ct_score
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/player.rb b/examples/cs2d/game/player.rb
new file mode 100644
index 0000000..cb6cd04
--- /dev/null
+++ b/examples/cs2d/game/player.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+class Player
+ attr_accessor :id, :name, :team, :x, :y, :health, :armor, :money
+ attr_accessor :kills, :deaths, :last_update
+ attr_reader :weapons, :current_weapon_index
+
+ def initialize(id:, name:, team:, x: 0, y: 0)
+ @id = id
+ @name = name
+ @team = team
+ @x = x
+ @y = y
+ @health = 100
+ @armor = 0
+ @money = 800
+ @kills = 0
+ @deaths = 0
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @last_shot_time = Time.now
+ @ammo = { magazine: 30, reserve: 90 }
+ @is_reloading = false
+ @reload_start_time = nil
+ @last_update = Time.now
+ end
+
+ def current_weapon
+ @weapons[@current_weapon_index] || default_weapon
+ end
+
+ def default_weapon
+ @team == :ct ? usp_weapon : glock_weapon
+ end
+
+ def glock_weapon
+ {
+ name: "Glock-18",
+ damage: 28,
+ rate: 0.15,
+ magazine: 20,
+ magazine_size: 20,
+ reserve: 120,
+ bullet_speed: 20,
+ penetration: 1,
+ reload_time: 2.2
+ }
+ end
+
+ def usp_weapon
+ {
+ name: "USP-S",
+ damage: 35,
+ rate: 0.17,
+ magazine: 12,
+ magazine_size: 12,
+ reserve: 72,
+ bullet_speed: 20,
+ penetration: 1,
+ reload_time: 2.2
+ }
+ end
+
+ def add_weapon(weapon_type)
+ weapon_data = GameRoom::WEAPONS[weapon_type].dup
+ weapon_data[:magazine] = weapon_data[:magazine]
+ weapon_data[:magazine_size] = weapon_data[:magazine]
+ weapon_data[:reserve] = weapon_data[:magazine] * 3
+ weapon_data[:reload_time] = calculate_reload_time(weapon_type)
+
+ # 替換主武器或副武器
+ if primary_weapon?(weapon_type)
+ @weapons[1] = weapon_data
+ @current_weapon_index = 1
+ else
+ @weapons[0] = weapon_data
+ @current_weapon_index = 0
+ end
+
+ @ammo = {
+ magazine: weapon_data[:magazine],
+ reserve: weapon_data[:reserve]
+ }
+ end
+
+ def primary_weapon?(weapon_type)
+ [:ak47, :m4a1, :awp, :mp5, :p90].include?(weapon_type)
+ end
+
+ def calculate_reload_time(weapon_type)
+ case weapon_type
+ when :awp then 3.7
+ when :ak47, :m4a1 then 2.5
+ when :mp5, :p90 then 2.3
+ when :deagle then 2.2
+ else 2.0
+ end
+ end
+
+ def can_shoot?
+ return false if dead?
+ return false if @is_reloading
+ return false if @ammo[:magazine] <= 0
+
+ time_since_last_shot = Time.now - @last_shot_time
+ time_since_last_shot >= current_weapon[:rate]
+ end
+
+ def shoot!
+ return unless can_shoot?
+
+ @ammo[:magazine] -= 1
+ @last_shot_time = Time.now
+
+ # 自動換彈
+ reload! if @ammo[:magazine] <= 0 && @ammo[:reserve] > 0
+ end
+
+ def reload!
+ return if @is_reloading
+ return if @ammo[:magazine] >= current_weapon[:magazine_size]
+ return if @ammo[:reserve] <= 0
+
+ @is_reloading = true
+ @reload_start_time = Time.now
+
+ # 這裡應該要設定計時器,暫時簡化處理
+ Thread.new do
+ sleep(current_weapon[:reload_time])
+ finish_reload
+ end
+ end
+
+ def finish_reload
+ return unless @is_reloading
+
+ needed = current_weapon[:magazine_size] - @ammo[:magazine]
+ available = [@ammo[:reserve], needed].min
+
+ @ammo[:magazine] += available
+ @ammo[:reserve] -= available
+ @is_reloading = false
+ @reload_start_time = nil
+ end
+
+ def take_damage(damage)
+ # 護甲減傷
+ if @armor > 0
+ armor_absorbed = [damage * 0.5, @armor].min
+ @armor -= armor_absorbed
+ actual_damage = damage - armor_absorbed * 0.5
+ else
+ actual_damage = damage
+ end
+
+ @health = [@health - actual_damage, 0].max
+
+ if dead?
+ @deaths += 1
+ end
+ end
+
+ def dead?
+ @health <= 0
+ end
+
+ def reset_for_new_round
+ @health = 100
+ @armor = 0
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @ammo = { magazine: 30, reserve: 90 }
+ @is_reloading = false
+ @reload_start_time = nil
+
+ # 重生位置
+ spawn_points = @team == :ct ? ct_spawn_points : t_spawn_points
+ spawn = spawn_points.sample
+ @x = spawn[:x]
+ @y = spawn[:y]
+ end
+
+ def switch_weapon(index)
+ return if @is_reloading
+ return unless @weapons[index]
+
+ @current_weapon_index = index
+ end
+
+ def buy_armor
+ return false if @money < 650
+ return false if @armor >= 100
+
+ @money -= 650
+ @armor = 100
+ true
+ end
+
+ def buy_helmet
+ return false if @money < 1000
+ return false if @armor >= 100
+
+ @money -= 1000
+ @armor = 100
+ true
+ end
+
+ private
+
+ def ct_spawn_points
+ [
+ { x: 100, y: 100 },
+ { x: 150, y: 100 },
+ { x: 100, y: 150 },
+ { x: 150, y: 150 },
+ { x: 125, y: 125 }
+ ]
+ end
+
+ def t_spawn_points
+ [
+ { x: 1100, y: 600 },
+ { x: 1150, y: 600 },
+ { x: 1100, y: 650 },
+ { x: 1150, y: 650 },
+ { x: 1125, y: 625 }
+ ]
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/gems.rb b/examples/cs2d/gems.rb
new file mode 100644
index 0000000..93c8920
--- /dev/null
+++ b/examples/cs2d/gems.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# Released under the MIT License.
+# Copyright, 2025, by Your Name.
+
+source "https://rubygems.org"
+
+gem "lively", path: "../../"
\ No newline at end of file
diff --git a/examples/cs2d/mvp_application.rb b/examples/cs2d/mvp_application.rb
new file mode 100644
index 0000000..0485e8d
--- /dev/null
+++ b/examples/cs2d/mvp_application.rb
@@ -0,0 +1,238 @@
+#!/usr/bin/env lively
+# frozen_string_literal: true
+
+require_relative "game/mvp_game_server"
+require_relative "game/mvp_round_manager"
+require_relative "game/mvp_bomb_system"
+require_relative "game/mvp_economy"
+
+class CS16MVPView < Live::View
+ def initialize(...)
+ super
+ @server = MVPGameServer.instance
+ @room = nil
+ @player_id = nil
+ end
+
+ def bind(page)
+ super
+ @page = page
+ @player_id = page.id
+
+ # 自動加入或創建房間
+ @room = @server.find_or_create_room
+ @room.add_player(@player_id)
+
+ # 開始遊戲循環
+ start_game_loop
+ self.update!
+ end
+
+ def close
+ @room&.remove_player(@player_id)
+ super
+ end
+
+ def handle(event)
+ return unless @room
+
+ case event[:type]
+ when "player_input"
+ @room.handle_player_input(@player_id, event[:data])
+ when "buy_weapon"
+ @room.buy_weapon(@player_id, event[:weapon])
+ when "plant_bomb"
+ @room.start_planting(@player_id)
+ when "defuse_bomb"
+ @room.start_defusing(@player_id)
+ when "chat"
+ @room.send_chat(@player_id, event[:message])
+ end
+ end
+
+ def start_game_loop
+ Thread.new do
+ loop do
+ sleep(1.0 / 30) # 30 FPS
+ @room&.update
+ broadcast_state
+ end
+ end
+ end
+
+ def broadcast_state
+ return unless @room
+
+ state = @room.get_state
+ @room.players.each_key do |player_id|
+ if page = Live::Page.pages[player_id]
+ page.live.push({
+ type: "game_state",
+ data: state
+ }.to_json)
+ end
+ end
+ end
+
+ def render(builder)
+ builder.tag(:div, id: "cs16-mvp", style: "width: 100vw; height: 100vh; margin: 0; padding: 0; overflow: hidden; background: #1a1a1a;") do
+ builder.tag(:canvas, id: "game-canvas", style: "display: block; width: 100%; height: 100%;")
+
+ # HUD
+ builder.tag(:div, id: "hud", style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;") do
+ render_top_bar(builder)
+ render_bottom_bar(builder)
+ render_buy_menu(builder)
+ render_bomb_indicator(builder)
+ render_death_screen(builder)
+ end
+ end
+
+ builder.tag(:script, type: "module") do
+ builder.text(cs16_mvp_client)
+ end
+ end
+
+ private
+
+ def render_top_bar(builder)
+ builder.tag(:div, id: "top-bar", style: "position: absolute; top: 0; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent); display: flex; justify-content: space-between; align-items: center; padding: 0 20px; color: white; font-family: 'Courier New', monospace;") do
+ # 隊伍分數
+ builder.tag(:div, style: "display: flex; gap: 20px;") do
+ builder.tag(:div, id: "ct-score", style: "color: #4488ff; font-size: 24px; font-weight: bold;") { builder.text("CT: 0") }
+ builder.tag(:div, style: "color: #666; font-size: 20px;") { builder.text("-") }
+ builder.tag(:div, id: "t-score", style: "color: #ff8844; font-size: 24px; font-weight: bold;") { builder.text("T: 0") }
+ end
+
+ # 回合時間
+ builder.tag(:div, id: "round-timer", style: "font-size: 32px; font-weight: bold;") do
+ builder.text("1:55")
+ end
+
+ # 回合數
+ builder.tag(:div, id: "round-info", style: "text-align: right;") do
+ builder.tag(:div, id: "round-number", style: "font-size: 14px; color: #aaa;") { builder.text("Round 1/30") }
+ builder.tag(:div, id: "round-phase", style: "font-size: 16px; color: #ffaa00;") { builder.text("Buy Time") }
+ end
+ end
+ end
+
+ def render_bottom_bar(builder)
+ builder.tag(:div, id: "bottom-bar", style: "position: absolute; bottom: 0; left: 0; right: 0; height: 100px; background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); display: flex; justify-content: space-between; align-items: flex-end; padding: 20px; color: white; font-family: 'Courier New', monospace;") do
+ # 生命值與護甲
+ builder.tag(:div, style: "display: flex; flex-direction: column; gap: 5px;") do
+ builder.tag(:div, style: "display: flex; align-items: center; gap: 10px;") do
+ builder.tag(:div, style: "background: #ff4444; width: 200px; height: 20px; position: relative;") do
+ builder.tag(:div, id: "health-bar", style: "background: #ff6666; height: 100%; width: 100%;")
+ builder.tag(:span, style: "position: absolute; top: 0; left: 50%; transform: translateX(-50%); line-height: 20px; font-size: 12px;") do
+ builder.text("100 HP")
+ end
+ end
+ end
+ builder.tag(:div, style: "display: flex; align-items: center; gap: 10px;") do
+ builder.tag(:div, style: "background: #444488; width: 200px; height: 20px; position: relative;") do
+ builder.tag(:div, id: "armor-bar", style: "background: #6666ff; height: 100%; width: 0%;")
+ builder.tag(:span, style: "position: absolute; top: 0; left: 50%; transform: translateX(-50%); line-height: 20px; font-size: 12px;") do
+ builder.text("0 Armor")
+ end
+ end
+ end
+ end
+
+ # 金錢
+ builder.tag(:div, id: "money", style: "font-size: 24px; color: #44ff44; font-weight: bold;") do
+ builder.text("$800")
+ end
+
+ # 彈藥
+ builder.tag(:div, id: "ammo", style: "text-align: right;") do
+ builder.tag(:div, style: "font-size: 32px; font-weight: bold;") do
+ builder.tag(:span, id: "ammo-clip") { builder.text("30") }
+ builder.tag(:span, style: "color: #666; font-size: 24px;") { builder.text(" / ") }
+ builder.tag(:span, id: "ammo-reserve", style: "color: #aaa; font-size: 24px;") { builder.text("90") }
+ end
+ builder.tag(:div, id: "weapon-name", style: "font-size: 14px; color: #aaa;") { builder.text("Glock-18") }
+ end
+ end
+ end
+
+ def render_buy_menu(builder)
+ builder.tag(:div, id: "buy-menu", style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.95); border: 2px solid #666; border-radius: 10px; padding: 20px; display: none; pointer-events: auto;") do
+ builder.tag(:h2, style: "color: #ffaa00; margin-bottom: 20px; text-align: center;") { builder.text("Buy Menu") }
+
+ builder.tag(:div, style: "display: grid; grid-template-columns: repeat(2, 200px); gap: 10px;") do
+ # 手槍
+ builder.tag(:div, class: "buy-category", style: "grid-column: span 2;") do
+ builder.tag(:h3, style: "color: #888; font-size: 14px; margin-bottom: 5px;") { builder.text("PISTOLS") }
+ builder.tag(:button, class: "buy-item", data: {weapon: "deagle", price: "650"}, style: "width: 100%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer;") do
+ builder.text("Desert Eagle - $650")
+ end
+ end
+
+ # 步槍
+ builder.tag(:div, class: "buy-category", style: "grid-column: span 2;") do
+ builder.tag(:h3, style: "color: #888; font-size: 14px; margin-bottom: 5px;") { builder.text("RIFLES") }
+ builder.tag(:button, class: "buy-item t-only", data: {weapon: "ak47", price: "2700"}, style: "width: 100%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer; margin-bottom: 5px;") do
+ builder.text("AK-47 - $2700")
+ end
+ builder.tag(:button, class: "buy-item ct-only", data: {weapon: "m4a1", price: "3100"}, style: "width: 100%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer; margin-bottom: 5px;") do
+ builder.text("M4A1 - $3100")
+ end
+ builder.tag(:button, class: "buy-item", data: {weapon: "awp", price: "4750"}, style: "width: 100%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer;") do
+ builder.text("AWP - $4750")
+ end
+ end
+
+ # 裝備
+ builder.tag(:div, class: "buy-category", style: "grid-column: span 2;") do
+ builder.tag(:h3, style: "color: #888; font-size: 14px; margin-bottom: 5px;") { builder.text("EQUIPMENT") }
+ builder.tag(:button, class: "buy-item", data: {weapon: "kevlar", price: "650"}, style: "width: 48%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer; margin-right: 4%;") do
+ builder.text("Kevlar - $650")
+ end
+ builder.tag(:button, class: "buy-item", data: {weapon: "helmet", price: "350"}, style: "width: 48%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer;") do
+ builder.text("Helmet - $350")
+ end
+ builder.tag(:button, class: "buy-item ct-only", data: {weapon: "defuse", price: "400"}, style: "width: 100%; padding: 8px; background: rgba(255,255,255,0.1); border: 1px solid #444; color: white; cursor: pointer; margin-top: 5px;") do
+ builder.text("Defuse Kit - $400")
+ end
+ end
+ end
+
+ builder.tag(:div, style: "margin-top: 20px; text-align: center; color: #666; font-size: 12px;") do
+ builder.text("Press B to close • Numbers 1-9 for quick buy")
+ end
+ end
+ end
+
+ def render_bomb_indicator(builder)
+ builder.tag(:div, id: "bomb-indicator", style: "position: absolute; top: 100px; left: 50%; transform: translateX(-50%); display: none;") do
+ builder.tag(:div, id: "bomb-timer", style: "background: rgba(255,0,0,0.8); color: white; padding: 10px 20px; border-radius: 5px; font-size: 24px; font-weight: bold; text-align: center;") do
+ builder.tag(:div) { builder.text("💣 BOMB PLANTED") }
+ builder.tag(:div, id: "bomb-countdown", style: "font-size: 32px; color: #ffff00;") { builder.text("0:45") }
+ end
+
+ builder.tag(:div, id: "defuse-progress", style: "background: rgba(0,0,255,0.8); color: white; padding: 10px 20px; border-radius: 5px; margin-top: 10px; display: none;") do
+ builder.tag(:div) { builder.text("Defusing...") }
+ builder.tag(:div, style: "width: 200px; height: 20px; background: rgba(0,0,0,0.5); margin-top: 5px;") do
+ builder.tag(:div, id: "defuse-bar", style: "width: 0%; height: 100%; background: #00ff00;")
+ end
+ end
+ end
+ end
+
+ def render_death_screen(builder)
+ builder.tag(:div, id: "death-screen", style: "position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: none; pointer-events: auto;") do
+ builder.tag(:div, style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white;") do
+ builder.tag(:h1, style: "color: #ff4444; font-size: 48px; margin-bottom: 20px;") { builder.text("YOU DIED") }
+ builder.tag(:div, id: "killer-info", style: "font-size: 20px; margin-bottom: 10px;") { builder.text("Killed by Player123 with AK-47") }
+ builder.tag(:div, style: "font-size: 16px; color: #aaa;") { builder.text("Spectating teammates...") }
+ end
+ end
+ end
+
+ def cs16_mvp_client
+ File.read(File.expand_path("public/_static/cs16_mvp.js", __dir__))
+ end
+end
+
+Application = Lively::Application[CS16MVPView]
\ No newline at end of file
diff --git a/examples/cs2d/public/_static/cs16_mvp.js b/examples/cs2d/public/_static/cs16_mvp.js
new file mode 100644
index 0000000..e7e45d1
--- /dev/null
+++ b/examples/cs2d/public/_static/cs16_mvp.js
@@ -0,0 +1,520 @@
+import Live from "/_components/@socketry/live/Live.js";
+
+class CS16MVP {
+ constructor() {
+ this.canvas = document.getElementById('game-canvas');
+ this.ctx = this.canvas.getContext('2d');
+ this.setupCanvas();
+
+ // 遊戲狀態
+ this.gameState = null;
+ this.localPlayerId = null;
+ this.keys = {};
+ this.mouseX = 0;
+ this.mouseY = 0;
+ this.angle = 0;
+
+ // Mac 優化
+ this.aimAngle = 0;
+ this.aimDistance = 150;
+ this.autoAim = false;
+
+ // 網路
+ this.live = Live.connect();
+ this.setupNetworking();
+ this.setupInput();
+
+ // 遊戲循環
+ this.lastUpdate = Date.now();
+ this.gameLoop();
+ }
+
+ setupCanvas() {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+
+ window.addEventListener('resize', () => {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ });
+ }
+
+ setupInput() {
+ // 鍵盤
+ document.addEventListener('keydown', (e) => {
+ this.keys[e.key.toLowerCase()] = true;
+
+ // 空白鍵射擊
+ if (e.key === ' ') {
+ e.preventDefault();
+ this.sendInput({ shoot: true, angle: this.aimAngle });
+ }
+
+ // 方向鍵瞄準
+ if (e.key === 'ArrowLeft' || e.key === 'j') this.aimAngle -= 0.15;
+ if (e.key === 'ArrowRight' || e.key === 'l') this.aimAngle += 0.15;
+ if (e.key === 'ArrowUp' || e.key === 'i') this.aimDistance = Math.min(this.aimDistance + 10, 250);
+ if (e.key === 'ArrowDown' || e.key === 'k') this.aimDistance = Math.max(this.aimDistance - 10, 50);
+
+ // Q 快速轉身
+ if (e.key === 'q') this.aimAngle += Math.PI;
+
+ // V 自動瞄準
+ if (e.key === 'v') {
+ this.autoAim = !this.autoAim;
+ this.showNotification(this.autoAim ? '自動瞄準: ON' : '自動瞄準: OFF');
+ }
+
+ // B 購買選單
+ if (e.key === 'b') {
+ this.toggleBuyMenu();
+ }
+
+ // E 互動(安裝/拆彈)
+ if (e.key === 'e') {
+ this.sendInput({ use: true });
+ }
+
+ // R 換彈
+ if (e.key === 'r') {
+ this.sendInput({ reload: true });
+ }
+
+ // Tab 計分板
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ // TODO: 顯示計分板
+ }
+
+ // 數字鍵快速購買
+ if (e.key >= '1' && e.key <= '5') {
+ this.quickBuy(e.key);
+ }
+ });
+
+ document.addEventListener('keyup', (e) => {
+ this.keys[e.key.toLowerCase()] = false;
+
+ // 停止互動
+ if (e.key === 'e') {
+ this.sendInput({ use: false });
+ }
+ });
+
+ // 觸控板
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ this.aimAngle += e.deltaX * 0.01;
+ } else {
+ this.aimDistance = Math.max(50, Math.min(250, this.aimDistance - e.deltaY));
+ }
+ });
+
+ // 點擊射擊
+ this.canvas.addEventListener('click', () => {
+ this.sendInput({ shoot: true, angle: this.aimAngle });
+ });
+
+ this.canvas.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this.sendInput({ shoot: true, angle: this.aimAngle });
+ });
+ }
+
+ setupNetworking() {
+ this.live.addEventListener('message', (event) => {
+ const data = JSON.parse(event.data);
+ if (data.type === 'game_state') {
+ this.gameState = data.data;
+ this.updateUI();
+ }
+ });
+ }
+
+ sendInput(input) {
+ this.live.push({
+ type: 'player_input',
+ data: input
+ });
+ }
+
+ handleMovement() {
+ let dx = 0, dy = 0;
+ if (this.keys['w']) dy = -1;
+ if (this.keys['s']) dy = 1;
+ if (this.keys['a']) dx = -1;
+ if (this.keys['d']) dx = 1;
+
+ if (dx !== 0 || dy !== 0) {
+ this.sendInput({
+ move: {
+ x: dx,
+ y: dy,
+ shift: this.keys['shift']
+ }
+ });
+ }
+ }
+
+ updateUI() {
+ if (!this.gameState) return;
+
+ const round = this.gameState.round;
+ const localPlayer = this.gameState.players[this.localPlayerId];
+
+ // 更新回合資訊
+ document.getElementById('ct-score').textContent = `CT: ${round.ct_score}`;
+ document.getElementById('t-score').textContent = `T: ${round.t_score}`;
+ document.getElementById('round-number').textContent = `Round ${round.round_number}/${round.max_rounds}`;
+
+ // 更新計時器
+ const minutes = Math.floor(round.round_time / 60);
+ const seconds = round.round_time % 60;
+ document.getElementById('round-timer').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
+
+ // 更新階段
+ const phaseText = {
+ freeze: 'Freeze Time',
+ buy: 'Buy Time',
+ playing: 'Round Live',
+ ended: 'Round Over'
+ };
+ document.getElementById('round-phase').textContent = phaseText[round.phase] || '';
+
+ // 更新玩家資訊
+ if (localPlayer) {
+ document.getElementById('health-bar').style.width = `${localPlayer.health}%`;
+ document.getElementById('armor-bar').style.width = `${localPlayer.armor}%`;
+ document.getElementById('money').textContent = `$${localPlayer.money}`;
+
+ if (localPlayer.ammo) {
+ document.getElementById('ammo-clip').textContent = localPlayer.ammo.clip;
+ document.getElementById('ammo-reserve').textContent = localPlayer.ammo.reserve;
+ }
+ document.getElementById('weapon-name').textContent = localPlayer.weapon;
+
+ // 死亡畫面
+ const deathScreen = document.getElementById('death-screen');
+ deathScreen.style.display = localPlayer.alive ? 'none' : 'block';
+ }
+
+ // 更新炸彈指示器
+ const bombIndicator = document.getElementById('bomb-indicator');
+ if (this.gameState.bomb.planted) {
+ bombIndicator.style.display = 'block';
+ const bombMinutes = Math.floor(this.gameState.bomb.bomb_timer / 60);
+ const bombSeconds = this.gameState.bomb.bomb_timer % 60;
+ document.getElementById('bomb-countdown').textContent = `${bombMinutes}:${bombSeconds.toString().padStart(2, '0')}`;
+
+ // 拆彈進度
+ const defuseProgress = document.getElementById('defuse-progress');
+ if (this.gameState.bomb.defusing) {
+ defuseProgress.style.display = 'block';
+ document.getElementById('defuse-bar').style.width = `${this.gameState.bomb.defusing_progress * 100}%`;
+ } else {
+ defuseProgress.style.display = 'none';
+ }
+ } else {
+ bombIndicator.style.display = 'none';
+ }
+ }
+
+ render() {
+ // 清空畫布
+ this.ctx.fillStyle = '#2a2a2a';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ if (!this.gameState) return;
+
+ // 相機偏移(跟隨玩家)
+ const localPlayer = this.gameState.players[this.localPlayerId];
+ let cameraX = 0, cameraY = 0;
+
+ if (localPlayer && localPlayer.alive) {
+ cameraX = this.canvas.width / 2 - localPlayer.x;
+ cameraY = this.canvas.height / 2 - localPlayer.y;
+ }
+
+ this.ctx.save();
+ this.ctx.translate(cameraX, cameraY);
+
+ // 繪製地圖
+ this.renderMap();
+
+ // 繪製炸彈點
+ this.renderBombSites();
+
+ // 繪製炸彈
+ this.renderBomb();
+
+ // 繪製玩家
+ this.renderPlayers();
+
+ // 繪製子彈
+ this.renderBullets();
+
+ this.ctx.restore();
+
+ // 繪製準心(不受相機影響)
+ if (localPlayer && localPlayer.alive) {
+ this.renderCrosshair();
+ }
+ }
+
+ renderMap() {
+ if (!this.gameState.map) return;
+
+ // 繪製牆壁
+ this.ctx.fillStyle = '#444';
+ this.gameState.map.walls.forEach(wall => {
+ this.ctx.fillRect(wall.x, wall.y, wall.width, wall.height);
+ });
+ }
+
+ renderBombSites() {
+ if (!this.gameState.map) return;
+
+ this.ctx.strokeStyle = '#ffaa00';
+ this.ctx.lineWidth = 2;
+ this.ctx.setLineDash([5, 5]);
+
+ this.gameState.map.bomb_sites.forEach(site => {
+ this.ctx.beginPath();
+ this.ctx.arc(site.x, site.y, site.radius, 0, Math.PI * 2);
+ this.ctx.stroke();
+
+ this.ctx.fillStyle = '#ffaa00';
+ this.ctx.font = '24px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(site.name, site.x, site.y);
+ });
+
+ this.ctx.setLineDash([]);
+ }
+
+ renderBomb() {
+ if (!this.gameState.bomb.planted) return;
+
+ const pos = this.gameState.bomb.bomb_position;
+ if (!pos) return;
+
+ // 炸彈圖示
+ this.ctx.fillStyle = '#ff0000';
+ this.ctx.beginPath();
+ this.ctx.arc(pos.x, pos.y, 10, 0, Math.PI * 2);
+ this.ctx.fill();
+
+ // 閃爍效果
+ if (this.gameState.bomb.bomb_timer < 10) {
+ const flash = Math.sin(Date.now() * 0.01) > 0;
+ if (flash) {
+ this.ctx.strokeStyle = '#ff0000';
+ this.ctx.lineWidth = 3;
+ this.ctx.beginPath();
+ this.ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
+ this.ctx.stroke();
+ }
+ }
+ }
+
+ renderPlayers() {
+ Object.values(this.gameState.players).forEach(player => {
+ if (!player.alive) return;
+
+ // 玩家身體
+ this.ctx.fillStyle = player.team === 'ct' ? '#4488ff' : '#ff8844';
+ this.ctx.beginPath();
+ this.ctx.arc(player.x, player.y, 15, 0, Math.PI * 2);
+ this.ctx.fill();
+
+ // 方向指示
+ this.ctx.strokeStyle = '#ffffff';
+ this.ctx.lineWidth = 3;
+ this.ctx.beginPath();
+ this.ctx.moveTo(player.x, player.y);
+ this.ctx.lineTo(
+ player.x + Math.cos(player.angle || 0) * 20,
+ player.y + Math.sin(player.angle || 0) * 20
+ );
+ this.ctx.stroke();
+
+ // 名稱
+ this.ctx.fillStyle = 'white';
+ this.ctx.font = '12px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(player.name, player.x, player.y - 25);
+
+ // 血量條
+ const barWidth = 30;
+ const barHeight = 4;
+ this.ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth, barHeight);
+
+ const healthColor = player.health > 50 ? '#00ff00' : player.health > 25 ? '#ffaa00' : '#ff0000';
+ this.ctx.fillStyle = healthColor;
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth * (player.health/100), barHeight);
+ });
+ }
+
+ renderBullets() {
+ this.ctx.fillStyle = '#ffff00';
+ this.ctx.shadowBlur = 5;
+ this.ctx.shadowColor = '#ffff00';
+
+ this.gameState.bullets.forEach(bullet => {
+ this.ctx.beginPath();
+ this.ctx.arc(bullet.x, bullet.y, 3, 0, Math.PI * 2);
+ this.ctx.fill();
+ });
+
+ this.ctx.shadowBlur = 0;
+ }
+
+ renderCrosshair() {
+ const centerX = this.canvas.width / 2;
+ const centerY = this.canvas.height / 2;
+
+ const aimX = centerX + Math.cos(this.aimAngle) * this.aimDistance;
+ const aimY = centerY + Math.sin(this.aimAngle) * this.aimDistance;
+
+ // 瞄準線
+ this.ctx.strokeStyle = this.autoAim ? 'rgba(255,0,0,0.3)' : 'rgba(0,255,0,0.3)';
+ this.ctx.lineWidth = 1;
+ this.ctx.setLineDash([5, 5]);
+ this.ctx.beginPath();
+ this.ctx.moveTo(centerX, centerY);
+ this.ctx.lineTo(aimX, aimY);
+ this.ctx.stroke();
+ this.ctx.setLineDash([]);
+
+ // 準心
+ this.ctx.strokeStyle = this.autoAim ? '#ff4444' : '#00ff00';
+ this.ctx.lineWidth = 2;
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(aimX - 15, aimY);
+ this.ctx.lineTo(aimX - 5, aimY);
+ this.ctx.moveTo(aimX + 5, aimY);
+ this.ctx.lineTo(aimX + 15, aimY);
+ this.ctx.moveTo(aimX, aimY - 15);
+ this.ctx.lineTo(aimX, aimY - 5);
+ this.ctx.moveTo(aimX, aimY + 5);
+ this.ctx.lineTo(aimX, aimY + 15);
+ this.ctx.stroke();
+ }
+
+ toggleBuyMenu() {
+ const menu = document.getElementById('buy-menu');
+ if (menu) {
+ menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
+ }
+ }
+
+ quickBuy(key) {
+ const weapons = {
+ '1': 'ak47',
+ '2': 'm4a1',
+ '3': 'awp',
+ '4': 'deagle',
+ '5': 'kevlar'
+ };
+
+ if (weapons[key]) {
+ this.live.push({
+ type: 'buy_weapon',
+ weapon: weapons[key]
+ });
+ }
+ }
+
+ showNotification(text) {
+ const div = document.createElement('div');
+ div.style.cssText = `
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(0,0,0,0.9);
+ color: #ffaa00;
+ padding: 20px;
+ border-radius: 5px;
+ font-size: 18px;
+ z-index: 10000;
+ `;
+ div.textContent = text;
+ document.body.appendChild(div);
+ setTimeout(() => div.remove(), 1500);
+ }
+
+ gameLoop() {
+ const now = Date.now();
+ const delta = (now - this.lastUpdate) / 1000;
+ this.lastUpdate = now;
+
+ // 處理輸入
+ this.handleMovement();
+
+ // 自動瞄準
+ if (this.autoAim && this.gameState) {
+ this.applyAutoAim();
+ }
+
+ // 渲染
+ this.render();
+
+ requestAnimationFrame(() => this.gameLoop());
+ }
+
+ applyAutoAim() {
+ const localPlayer = this.gameState.players[this.localPlayerId];
+ if (!localPlayer || !localPlayer.alive) return;
+
+ let closestEnemy = null;
+ let closestDistance = Infinity;
+
+ Object.values(this.gameState.players).forEach(player => {
+ if (player.id === this.localPlayerId) return;
+ if (player.team === localPlayer.team) return;
+ if (!player.alive) return;
+
+ const distance = Math.sqrt(
+ Math.pow(player.x - localPlayer.x, 2) +
+ Math.pow(player.y - localPlayer.y, 2)
+ );
+
+ if (distance < closestDistance && distance < 400) {
+ closestDistance = distance;
+ closestEnemy = player;
+ }
+ });
+
+ if (closestEnemy) {
+ const targetAngle = Math.atan2(
+ closestEnemy.y - localPlayer.y,
+ closestEnemy.x - localPlayer.x
+ );
+
+ const diff = targetAngle - this.aimAngle;
+ this.aimAngle += diff * 0.1;
+ }
+ }
+}
+
+// 啟動遊戲
+window.addEventListener('DOMContentLoaded', () => {
+ // 設置購買選單事件
+ document.querySelectorAll('.buy-item').forEach(button => {
+ button.addEventListener('click', (e) => {
+ const weapon = e.target.dataset.weapon;
+ Live.current?.push({
+ type: 'buy_weapon',
+ weapon: weapon
+ });
+ document.getElementById('buy-menu').style.display = 'none';
+ });
+ });
+
+ // 啟動遊戲
+ new CS16MVP();
+});
\ No newline at end of file
diff --git a/examples/cs2d/public/_static/style.css b/examples/cs2d/public/_static/style.css
new file mode 100644
index 0000000..c00a524
--- /dev/null
+++ b/examples/cs2d/public/_static/style.css
@@ -0,0 +1,328 @@
+/* CS2D Game Styles */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Courier New', monospace;
+ background-color: #1a1a1a;
+ color: white;
+ overflow: hidden;
+ user-select: none;
+}
+
+#cs2d-container {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+}
+
+#game-canvas {
+ cursor: crosshair;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
+
+/* HUD Styles */
+#hud {
+ background: linear-gradient(to right, rgba(0,0,0,0.8), transparent);
+ padding: 15px;
+ border-radius: 5px;
+ min-width: 200px;
+}
+
+#hud div {
+ margin: 5px 0;
+ font-size: 16px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
+}
+
+#health {
+ color: #ff4444;
+ font-weight: bold;
+}
+
+#armor {
+ color: #4444ff;
+ font-weight: bold;
+}
+
+#ammo {
+ color: #ffaa00;
+}
+
+#money {
+ color: #44ff44;
+}
+
+/* Scoreboard */
+#scoreboard {
+ background: rgba(0,0,0,0.9);
+ border: 2px solid #444;
+ border-radius: 5px;
+ min-width: 400px;
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+#scoreboard h3 {
+ text-align: center;
+ padding: 10px;
+ background: #222;
+ margin: 0;
+}
+
+#team-ct, #team-t {
+ padding: 10px;
+}
+
+#team-ct {
+ background: rgba(68,68,255,0.1);
+ border-top: 3px solid #4444ff;
+}
+
+#team-t {
+ background: rgba(255,68,68,0.1);
+ border-top: 3px solid #ff4444;
+}
+
+.player-score {
+ display: flex;
+ justify-content: space-between;
+ padding: 5px;
+ margin: 2px 0;
+ background: rgba(255,255,255,0.05);
+}
+
+/* Buy Menu */
+#buy-menu {
+ background: rgba(0,0,0,0.95);
+ border: 2px solid #666;
+ border-radius: 10px;
+ min-width: 500px;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+#buy-menu h2 {
+ text-align: center;
+ color: #ffaa00;
+ margin-bottom: 20px;
+}
+
+.weapon-categories {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ padding: 20px;
+}
+
+.weapon-categories button {
+ padding: 15px;
+ background: rgba(255,255,255,0.1);
+ border: 1px solid #444;
+ color: white;
+ cursor: pointer;
+ transition: all 0.3s;
+ font-size: 14px;
+ text-align: left;
+}
+
+.weapon-categories button:hover {
+ background: rgba(255,255,255,0.2);
+ border-color: #ffaa00;
+ transform: scale(1.05);
+}
+
+.weapon-categories button:active {
+ transform: scale(0.95);
+}
+
+/* Chat */
+#chat {
+ pointer-events: none;
+}
+
+#chat-messages {
+ border-radius: 5px;
+ font-size: 12px;
+ line-height: 1.4;
+ pointer-events: none;
+}
+
+.chat-message {
+ margin: 2px 0;
+ padding: 2px 5px;
+}
+
+.chat-message.team-ct {
+ color: #8888ff;
+}
+
+.chat-message.team-t {
+ color: #ff8888;
+}
+
+#chat-input {
+ background: rgba(0,0,0,0.8);
+ border: 1px solid #444;
+ color: white;
+ padding: 5px;
+ font-size: 12px;
+ pointer-events: all;
+}
+
+#chat-input:focus {
+ outline: none;
+ border-color: #ffaa00;
+}
+
+/* Kill Feed */
+#kill-feed {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ width: 300px;
+}
+
+.kill-entry {
+ background: rgba(0,0,0,0.7);
+ padding: 5px 10px;
+ margin: 2px 0;
+ border-left: 3px solid #ff4444;
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Round Timer */
+#round-timer {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 24px;
+ font-weight: bold;
+ background: rgba(0,0,0,0.8);
+ padding: 10px 20px;
+ border-radius: 5px;
+ border: 2px solid #444;
+}
+
+#round-timer.warning {
+ color: #ffaa00;
+ border-color: #ffaa00;
+}
+
+#round-timer.critical {
+ color: #ff4444;
+ border-color: #ff4444;
+ animation: pulse 1s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ transform: translateX(-50%) scale(1);
+ }
+ 50% {
+ transform: translateX(-50%) scale(1.05);
+ }
+}
+
+/* Spectator Mode */
+.spectator-mode {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ background: rgba(0,0,0,0.8);
+ padding: 20px;
+ border-radius: 10px;
+}
+
+.spectator-mode h2 {
+ color: #ff4444;
+ margin-bottom: 10px;
+}
+
+/* Loading Screen */
+.loading-screen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: #1a1a1a;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+}
+
+.loading-content {
+ text-align: center;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid #444;
+ border-top: 5px solid #ffaa00;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+@keyframes fadeOut {
+ 0% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 70% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-10px);
+ }
+}
+
+/* Damage Indicator */
+.damage-indicator {
+ position: absolute;
+ color: #ff4444;
+ font-weight: bold;
+ font-size: 18px;
+ pointer-events: none;
+ animation: damageFloat 1s ease-out forwards;
+}
+
+@keyframes damageFloat {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-50px);
+ }
+}
\ No newline at end of file
diff --git a/examples/hello-world/.ruby-version b/examples/hello-world/.ruby-version
new file mode 100644
index 0000000..9c25013
--- /dev/null
+++ b/examples/hello-world/.ruby-version
@@ -0,0 +1 @@
+3.3.6
diff --git a/examples/hello-world/.tool-versions b/examples/hello-world/.tool-versions
new file mode 100644
index 0000000..974865f
--- /dev/null
+++ b/examples/hello-world/.tool-versions
@@ -0,0 +1 @@
+ruby 2.7.6
diff --git a/examples/hello-world/cs2d_app.rb b/examples/hello-world/cs2d_app.rb
new file mode 100644
index 0000000..b13d312
--- /dev/null
+++ b/examples/hello-world/cs2d_app.rb
@@ -0,0 +1,596 @@
+#!/usr/bin/env lively
+# frozen_string_literal: true
+
+require_relative "game/game_room"
+require_relative "game/player"
+require_relative "game/game_state"
+
+class CS2DView < Live::View
+ def initialize(...)
+ super
+ @game_room = GameRoom.new
+ end
+
+ def bind(page)
+ super
+ @page = page
+ @game_room.add_player(page.id)
+ self.update!
+ end
+
+ def close
+ @game_room.remove_player(@page.id)
+ super
+ end
+
+ def handle(event)
+ case event[:type]
+ when "player_move"
+ @game_room.update_player_position(@page.id, event[:x], event[:y])
+ when "player_shoot"
+ @game_room.player_shoot(@page.id, event[:angle])
+ when "player_reload"
+ @game_room.player_reload(@page.id)
+ when "change_team"
+ @game_room.change_team(@page.id, event[:team])
+ when "buy_weapon"
+ @game_room.buy_weapon(@page.id, event[:weapon])
+ when "chat_message"
+ @game_room.broadcast_chat(@page.id, event[:message])
+ end
+
+ broadcast_game_state
+ end
+
+ def broadcast_game_state
+ @game_room.players.each do |player_id, player|
+ if page = Live::Page.pages[player_id]
+ page.live.push(game_state_json)
+ end
+ end
+ end
+
+ def game_state_json
+ {
+ type: "game_update",
+ players: @game_room.players_data,
+ bullets: @game_room.bullets_data,
+ round_time: @game_room.round_time,
+ scores: @game_room.scores
+ }.to_json
+ end
+
+ def render(builder)
+ builder.tag(:div, id: "cs2d-container", style: "width: 100%; height: 100vh; margin: 0; padding: 0; overflow: hidden;") do
+ builder.tag(:canvas, id: "game-canvas", style: "display: block;")
+
+ builder.tag(:div, id: "game-ui", style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%;") do
+ render_hud(builder)
+ render_scoreboard(builder)
+ render_buy_menu(builder)
+ render_chat(builder)
+ end
+ end
+
+ builder.tag(:script, type: "module") do
+ builder.text(client_game_script)
+ end
+ end
+
+ private
+
+ def render_hud(builder)
+ builder.tag(:div, id: "hud", style: "position: absolute; bottom: 20px; left: 20px; color: white; font-family: monospace;") do
+ builder.tag(:div, id: "health") { builder.text("HP: 100") }
+ builder.tag(:div, id: "armor") { builder.text("Armor: 0") }
+ builder.tag(:div, id: "ammo") { builder.text("Ammo: 30/90") }
+ builder.tag(:div, id: "money") { builder.text("$800") }
+ end
+ end
+
+ def render_scoreboard(builder)
+ builder.tag(:div, id: "scoreboard", style: "position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: white; padding: 10px; display: none;") do
+ builder.tag(:h3) { builder.text("Scoreboard") }
+ builder.tag(:div, id: "team-ct") { builder.text("Counter-Terrorists") }
+ builder.tag(:div, id: "team-t") { builder.text("Terrorists") }
+ end
+ end
+
+ def render_buy_menu(builder)
+ builder.tag(:div, id: "buy-menu", style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.9); color: white; padding: 20px; display: none;") do
+ builder.tag(:h2) { builder.text("Buy Menu") }
+ builder.tag(:div, class: "weapon-categories") do
+ builder.tag(:button, onclick: "buyWeapon('ak47')") { builder.text("AK-47 - $2700") }
+ builder.tag(:button, onclick: "buyWeapon('m4a1')") { builder.text("M4A1 - $3100") }
+ builder.tag(:button, onclick: "buyWeapon('awp')") { builder.text("AWP - $4750") }
+ end
+ end
+ end
+
+ def render_chat(builder)
+ builder.tag(:div, id: "chat", style: "position: absolute; bottom: 100px; left: 20px; width: 300px; height: 150px;") do
+ builder.tag(:div, id: "chat-messages", style: "background: rgba(0,0,0,0.5); color: white; padding: 5px; height: 120px; overflow-y: auto;")
+ builder.tag(:input, id: "chat-input", type: "text", placeholder: "Press T to chat...",
+ style: "width: 100%; display: none;")
+ end
+ end
+
+ def client_game_script
+ <<~JAVASCRIPT
+ import Live from "/_components/@socketry/live/Live.js";
+
+ class CS2DGame {
+ constructor() {
+ this.canvas = document.getElementById('game-canvas');
+ this.ctx = this.canvas.getContext('2d');
+ this.setupCanvas();
+ this.setupInput();
+ this.players = {};
+ this.bullets = [];
+ this.localPlayer = null;
+ this.live = Live.connect();
+ this.setupNetworking();
+
+ // Mac 優化:瞄準系統
+ this.aimAngle = 0;
+ this.aimDistance = 100;
+ this.autoAimEnabled = true;
+ this.aimSensitivity = 0.15;
+ this.lastShootTime = 0;
+ this.shootCooldown = 100; // ms
+
+ this.gameLoop();
+ this.showControls();
+ }
+
+ setupCanvas() {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ window.addEventListener('resize', () => {
+ this.canvas.width = window.innerWidth;
+ this.canvas.height = window.innerHeight;
+ });
+ }
+
+ setupInput() {
+ this.keys = {};
+
+ // 鍵盤控制
+ document.addEventListener('keydown', (e) => {
+ this.keys[e.key.toLowerCase()] = true;
+
+ // 空白鍵射擊
+ if (e.key === ' ') {
+ e.preventDefault();
+ this.shoot();
+ }
+
+ // 方向鍵或 IJKL 控制瞄準
+ if (e.key === 'ArrowLeft' || e.key === 'j') {
+ this.aimAngle -= this.aimSensitivity;
+ }
+ if (e.key === 'ArrowRight' || e.key === 'l') {
+ this.aimAngle += this.aimSensitivity;
+ }
+ if (e.key === 'ArrowUp' || e.key === 'i') {
+ this.aimDistance = Math.min(this.aimDistance + 10, 200);
+ }
+ if (e.key === 'ArrowDown' || e.key === 'k') {
+ this.aimDistance = Math.max(this.aimDistance - 10, 50);
+ }
+
+ // 快速 180 度轉身
+ if (e.key === 'q' || e.key === 'Q') {
+ this.aimAngle += Math.PI;
+ }
+
+ // 切換自動瞄準
+ if (e.key === 'v' || e.key === 'V') {
+ this.autoAimEnabled = !this.autoAimEnabled;
+ this.showNotification(this.autoAimEnabled ? '自動瞄準:開啟' : '自動瞄準:關閉');
+ }
+
+ if (e.key === 'b' || e.key === 'B') {
+ this.toggleBuyMenu();
+ }
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ this.toggleScoreboard();
+ }
+ if (e.key === 't' || e.key === 'T') {
+ e.preventDefault();
+ this.toggleChat();
+ }
+ if (e.key === 'r' || e.key === 'R') {
+ this.reload();
+ }
+
+ // 數字鍵快速購買
+ if (e.key >= '1' && e.key <= '5') {
+ this.quickBuy(e.key);
+ }
+ });
+
+ document.addEventListener('keyup', (e) => {
+ this.keys[e.key.toLowerCase()] = false;
+ });
+
+ // 觸控板支援
+ this.setupTouchpad();
+ }
+
+ setupTouchpad() {
+ let touchStartX = 0;
+ let touchStartY = 0;
+
+ // 雙指滑動控制瞄準
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ // 水平滾動改變瞄準角度
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ this.aimAngle += e.deltaX * 0.01;
+ }
+ // 垂直滾動改變瞄準距離
+ else {
+ this.aimDistance = Math.max(50, Math.min(200, this.aimDistance - e.deltaY));
+ }
+ });
+
+ // 雙指點擊射擊
+ this.canvas.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this.shoot();
+ });
+
+ // 單指點擊也可射擊
+ this.canvas.addEventListener('click', (e) => {
+ // 如果點擊的是遊戲區域,則射擊
+ if (e.target === this.canvas) {
+ this.shoot();
+ }
+ });
+ }
+
+ setupNetworking() {
+ this.live.addEventListener('message', (event) => {
+ const data = JSON.parse(event.data);
+ if (data.type === 'game_update') {
+ this.updateGameState(data);
+ }
+ });
+ }
+
+ updateGameState(data) {
+ this.players = data.players || {};
+ this.bullets = data.bullets || [];
+ this.updateUI(data);
+
+ // 自動瞄準輔助
+ if (this.autoAimEnabled && this.localPlayer) {
+ this.applyAutoAim();
+ }
+ }
+
+ applyAutoAim() {
+ const player = this.players[this.localPlayer];
+ if (!player) return;
+
+ let closestEnemy = null;
+ let closestDistance = Infinity;
+
+ // 找最近的敵人
+ Object.values(this.players).forEach(enemy => {
+ if (enemy.id === this.localPlayer || enemy.team === player.team || enemy.dead) return;
+
+ const distance = Math.sqrt(
+ Math.pow(enemy.x - player.x, 2) +
+ Math.pow(enemy.y - player.y, 2)
+ );
+
+ if (distance < closestDistance && distance < 300) {
+ closestDistance = distance;
+ closestEnemy = enemy;
+ }
+ });
+
+ // 緩慢調整瞄準角度朝向最近的敵人
+ if (closestEnemy) {
+ const targetAngle = Math.atan2(
+ closestEnemy.y - player.y,
+ closestEnemy.x - player.x
+ );
+
+ // 平滑過渡
+ const angleDiff = targetAngle - this.aimAngle;
+ this.aimAngle += angleDiff * 0.1;
+ }
+ }
+
+ updateUI(data) {
+ if (this.localPlayer) {
+ const player = this.players[this.localPlayer];
+ if (player) {
+ document.getElementById('health').textContent = `HP: ${player.health}`;
+ document.getElementById('armor').textContent = `Armor: ${player.armor}`;
+ document.getElementById('money').textContent = `$${player.money}`;
+ document.getElementById('ammo').textContent = `Ammo: ${player.ammo || '30/90'}`;
+ }
+ }
+ }
+
+ handleMovement() {
+ let dx = 0, dy = 0;
+ if (this.keys['w']) dy -= 1;
+ if (this.keys['s']) dy += 1;
+ if (this.keys['a']) dx -= 1;
+ if (this.keys['d']) dx += 1;
+
+ // Shift 加速跑
+ const speed = this.keys['shift'] ? 7 : 5;
+
+ if (dx !== 0 || dy !== 0) {
+ const angle = Math.atan2(dy, dx);
+ const vx = Math.cos(angle) * speed;
+ const vy = Math.sin(angle) * speed;
+
+ this.live.push({
+ type: 'player_move',
+ x: vx,
+ y: vy
+ });
+ }
+ }
+
+ shoot() {
+ const now = Date.now();
+ if (now - this.lastShootTime < this.shootCooldown) return;
+
+ this.lastShootTime = now;
+ this.live.push({
+ type: 'player_shoot',
+ angle: this.aimAngle
+ });
+ }
+
+ reload() {
+ this.live.push({
+ type: 'player_reload'
+ });
+ }
+
+ quickBuy(key) {
+ const weapons = {
+ '1': 'ak47',
+ '2': 'm4a1',
+ '3': 'awp',
+ '4': 'deagle',
+ '5': 'armor'
+ };
+
+ if (weapons[key]) {
+ this.live.push({
+ type: 'buy_weapon',
+ weapon: weapons[key]
+ });
+ this.showNotification(`購買:${weapons[key].toUpperCase()}`);
+ }
+ }
+
+ showNotification(text) {
+ const notification = document.createElement('div');
+ notification.style.cssText = `
+ position: fixed;
+ top: 100px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0,0,0,0.8);
+ color: #ffaa00;
+ padding: 10px 20px;
+ border-radius: 5px;
+ font-size: 16px;
+ z-index: 10000;
+ animation: fadeOut 2s forwards;
+ `;
+ notification.textContent = text;
+ document.body.appendChild(notification);
+ setTimeout(() => notification.remove(), 2000);
+ }
+
+ showControls() {
+ const controls = document.createElement('div');
+ controls.id = 'controls-help';
+ controls.style.cssText = `
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ background: rgba(0,0,0,0.7);
+ color: white;
+ padding: 10px;
+ border-radius: 5px;
+ font-size: 12px;
+ font-family: monospace;
+ z-index: 1000;
+ `;
+ controls.innerHTML = `
+ Mac 優化控制
+ 移動:WASD (Shift 加速)
+ 瞄準:方向鍵 或 IJKL
+ 射擊:空白鍵 或 點擊
+ 換彈:R
+ 快速轉身:Q
+ 自動瞄準:V
+ 購買:B 或 數字鍵1-5
+
+ 觸控板手勢
+ 雙指橫滑:旋轉瞄準
+ 雙指縱滑:調整距離
+ 雙指點擊:射擊
+ `;
+ document.body.appendChild(controls);
+
+ // 5秒後自動隱藏,按 H 可再次顯示
+ setTimeout(() => {
+ controls.style.opacity = '0.3';
+ }, 5000);
+ }
+
+ render() {
+ this.ctx.fillStyle = '#2a2a2a';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // 繪製地圖格線
+ this.ctx.strokeStyle = '#333';
+ this.ctx.lineWidth = 0.5;
+ for (let x = 0; x < this.canvas.width; x += 50) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, 0);
+ this.ctx.lineTo(x, this.canvas.height);
+ this.ctx.stroke();
+ }
+ for (let y = 0; y < this.canvas.height; y += 50) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, y);
+ this.ctx.lineTo(this.canvas.width, y);
+ this.ctx.stroke();
+ }
+
+ const centerX = this.canvas.width / 2;
+ const centerY = this.canvas.height / 2;
+
+ // 繪製玩家
+ Object.values(this.players).forEach(player => {
+ // 玩家身體
+ this.ctx.fillStyle = player.team === 'ct' ? '#4444ff' : '#ff4444';
+ this.ctx.beginPath();
+ this.ctx.arc(player.x, player.y, 15, 0, Math.PI * 2);
+ this.ctx.fill();
+
+ // 玩家方向指示
+ if (player.id === this.localPlayer) {
+ this.ctx.strokeStyle = '#ffffff';
+ this.ctx.lineWidth = 3;
+ this.ctx.beginPath();
+ this.ctx.moveTo(player.x, player.y);
+ this.ctx.lineTo(
+ player.x + Math.cos(this.aimAngle) * 25,
+ player.y + Math.sin(this.aimAngle) * 25
+ );
+ this.ctx.stroke();
+ }
+
+ // 玩家名稱與血量條
+ this.ctx.fillStyle = 'white';
+ this.ctx.font = '12px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText(player.name, player.x, player.y - 25);
+
+ // 血量條
+ const barWidth = 30;
+ const barHeight = 4;
+ this.ctx.fillStyle = 'rgba(0,0,0,0.5)';
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth, barHeight);
+ this.ctx.fillStyle = player.health > 50 ? '#00ff00' : player.health > 25 ? '#ffaa00' : '#ff0000';
+ this.ctx.fillRect(player.x - barWidth/2, player.y - 20, barWidth * (player.health/100), barHeight);
+ });
+
+ // 繪製子彈
+ this.ctx.shadowBlur = 5;
+ this.ctx.shadowColor = '#ffff00';
+ this.ctx.fillStyle = '#ffff00';
+ this.bullets.forEach(bullet => {
+ this.ctx.beginPath();
+ this.ctx.arc(bullet.x, bullet.y, 3, 0, Math.PI * 2);
+ this.ctx.fill();
+ });
+ this.ctx.shadowBlur = 0;
+
+ // 繪製瞄準系統 (針對本地玩家)
+ if (this.localPlayer && this.players[this.localPlayer]) {
+ const player = this.players[this.localPlayer];
+ const aimX = player.x + Math.cos(this.aimAngle) * this.aimDistance;
+ const aimY = player.y + Math.sin(this.aimAngle) * this.aimDistance;
+
+ // 瞄準線
+ this.ctx.strokeStyle = this.autoAimEnabled ? 'rgba(255,0,0,0.3)' : 'rgba(0,255,0,0.3)';
+ this.ctx.lineWidth = 1;
+ this.ctx.setLineDash([5, 5]);
+ this.ctx.beginPath();
+ this.ctx.moveTo(player.x, player.y);
+ this.ctx.lineTo(aimX, aimY);
+ this.ctx.stroke();
+ this.ctx.setLineDash([]);
+
+ // 準心
+ this.ctx.strokeStyle = this.autoAimEnabled ? '#ff4444' : '#00ff00';
+ this.ctx.lineWidth = 2;
+
+ // 圓形準心
+ this.ctx.beginPath();
+ this.ctx.arc(aimX, aimY, 15, 0, Math.PI * 2);
+ this.ctx.stroke();
+
+ // 十字準心
+ this.ctx.beginPath();
+ this.ctx.moveTo(aimX - 20, aimY);
+ this.ctx.lineTo(aimX - 8, aimY);
+ this.ctx.moveTo(aimX + 8, aimY);
+ this.ctx.lineTo(aimX + 20, aimY);
+ this.ctx.moveTo(aimX, aimY - 20);
+ this.ctx.lineTo(aimX, aimY - 8);
+ this.ctx.moveTo(aimX, aimY + 8);
+ this.ctx.lineTo(aimX, aimY + 20);
+ this.ctx.stroke();
+
+ // 自動瞄準指示器
+ if (this.autoAimEnabled) {
+ this.ctx.fillStyle = 'rgba(255,0,0,0.5)';
+ this.ctx.font = '10px Arial';
+ this.ctx.textAlign = 'center';
+ this.ctx.fillText('AUTO', aimX, aimY - 25);
+ }
+ }
+ }
+
+ toggleBuyMenu() {
+ const menu = document.getElementById('buy-menu');
+ menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
+ }
+
+ toggleScoreboard() {
+ const scoreboard = document.getElementById('scoreboard');
+ scoreboard.style.display = scoreboard.style.display === 'none' ? 'block' : 'none';
+ }
+
+ toggleChat() {
+ const input = document.getElementById('chat-input');
+ input.style.display = input.style.display === 'none' ? 'block' : 'none';
+ if (input.style.display === 'block') {
+ input.focus();
+ }
+ }
+
+ gameLoop() {
+ this.handleMovement();
+ this.render();
+ requestAnimationFrame(() => this.gameLoop());
+ }
+ }
+
+ // 啟動遊戲
+ window.addEventListener('DOMContentLoaded', () => {
+ new CS2DGame();
+ });
+
+ // 購買武器函數
+ window.buyWeapon = function(weapon) {
+ Live.current?.push({
+ type: 'buy_weapon',
+ weapon: weapon
+ });
+ document.getElementById('buy-menu').style.display = 'none';
+ };
+ JAVASCRIPT
+ end
+end
+
+Application = Lively::Application[CS2DView]
\ No newline at end of file
diff --git a/examples/hello-world/game/bullet.rb b/examples/hello-world/game/bullet.rb
new file mode 100644
index 0000000..18b926d
--- /dev/null
+++ b/examples/hello-world/game/bullet.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Bullet
+ attr_accessor :x, :y, :hit
+ attr_reader :owner_id, :angle, :damage, :speed, :penetration
+
+ def initialize(owner_id:, x:, y:, angle:, damage:, speed:, penetration:)
+ @owner_id = owner_id
+ @x = x
+ @y = y
+ @angle = angle
+ @damage = damage
+ @speed = speed
+ @penetration = penetration
+ @hit = false
+ @distance_traveled = 0
+ @max_distance = 1000
+ end
+
+ def update
+ # 更新子彈位置
+ @x += Math.cos(@angle) * @speed
+ @y += Math.sin(@angle) * @speed
+ @distance_traveled += @speed
+ end
+
+ def hits?(target_x, target_y, radius)
+ distance = Math.sqrt((target_x - @x) ** 2 + (target_y - @y) ** 2)
+ distance <= radius
+ end
+
+ def out_of_bounds?
+ @x < 0 || @x > 1280 || @y < 0 || @y > 720 || @distance_traveled > @max_distance
+ end
+end
\ No newline at end of file
diff --git a/examples/hello-world/game/game_room.rb b/examples/hello-world/game/game_room.rb
new file mode 100644
index 0000000..f359710
--- /dev/null
+++ b/examples/hello-world/game/game_room.rb
@@ -0,0 +1,243 @@
+# frozen_string_literal: true
+
+require_relative "player"
+require_relative "bullet"
+require_relative "game_state"
+
+class GameRoom
+ attr_reader :players, :bullets, :game_state, :round_time, :scores
+
+ def initialize
+ @players = {}
+ @bullets = []
+ @game_state = GameState.new
+ @round_time = 120 # 秒
+ @scores = { ct: 0, t: 0 }
+ @round_start_time = Time.now
+ @map = load_map("de_dust2")
+ end
+
+ def add_player(player_id)
+ @players[player_id] = Player.new(
+ id: player_id,
+ name: "Player#{player_id[0..4]}",
+ team: @players.size.even? ? :ct : :t,
+ x: rand(100..700),
+ y: rand(100..500)
+ )
+ end
+
+ def remove_player(player_id)
+ @players.delete(player_id)
+ end
+
+ def update_player_position(player_id, dx, dy)
+ return unless player = @players[player_id]
+ return if player.dead?
+
+ # 計算新位置
+ new_x = player.x + dx
+ new_y = player.y + dy
+
+ # 檢查碰撞
+ unless check_collision(new_x, new_y)
+ player.x = new_x.clamp(20, 1260)
+ player.y = new_y.clamp(20, 700)
+ end
+
+ player.last_update = Time.now
+ end
+
+ def player_shoot(player_id, angle)
+ return unless player = @players[player_id]
+ return if player.dead?
+ return unless player.can_shoot?
+
+ weapon = player.current_weapon
+
+ # 創建子彈
+ @bullets << Bullet.new(
+ owner_id: player_id,
+ x: player.x,
+ y: player.y,
+ angle: angle,
+ damage: weapon[:damage],
+ speed: weapon[:bullet_speed],
+ penetration: weapon[:penetration]
+ )
+
+ player.shoot!
+ end
+
+ def player_reload(player_id)
+ return unless player = @players[player_id]
+ return if player.dead?
+
+ player.reload!
+ end
+
+ def change_team(player_id, team)
+ return unless player = @players[player_id]
+ return unless [:ct, :t].include?(team.to_sym)
+
+ player.team = team.to_sym
+ player.reset_for_new_round
+ end
+
+ def buy_weapon(player_id, weapon_name)
+ return unless player = @players[player_id]
+ return if player.dead?
+ return unless @game_state.buy_time?
+
+ weapon_price = WEAPONS[weapon_name.to_sym][:price]
+
+ if player.money >= weapon_price
+ player.money -= weapon_price
+ player.add_weapon(weapon_name.to_sym)
+ true
+ else
+ false
+ end
+ end
+
+ def broadcast_chat(player_id, message)
+ return unless player = @players[player_id]
+
+ {
+ type: "chat",
+ player_name: player.name,
+ team: player.team,
+ message: message[0..100] # 限制訊息長度
+ }
+ end
+
+ def update_bullets
+ @bullets.each do |bullet|
+ bullet.update
+
+ # 檢查子彈是否擊中玩家
+ @players.each do |id, player|
+ next if id == bullet.owner_id
+ next if player.dead?
+
+ if bullet.hits?(player.x, player.y, 15)
+ player.take_damage(bullet.damage)
+
+ # 擊殺獎勵
+ if player.dead?
+ killer = @players[bullet.owner_id]
+ killer.money += 300 if killer
+ killer.kills += 1 if killer
+ end
+
+ bullet.hit = true
+ end
+ end
+ end
+
+ # 移除已擊中或超出範圍的子彈
+ @bullets.reject! { |b| b.hit || b.out_of_bounds? }
+ end
+
+ def update_round
+ current_time = Time.now
+ elapsed = current_time - @round_start_time
+ @round_time = [120 - elapsed.to_i, 0].max
+
+ # 檢查回合結束條件
+ if @round_time <= 0 || team_eliminated?(:ct) || team_eliminated?(:t)
+ end_round
+ end
+ end
+
+ def team_eliminated?(team)
+ @players.values.select { |p| p.team == team && !p.dead? }.empty?
+ end
+
+ def end_round
+ # 計算獲勝隊伍
+ if team_eliminated?(:t)
+ @scores[:ct] += 1
+ award_team_money(:ct, 3250)
+ award_team_money(:t, 1400)
+ elsif team_eliminated?(:ct)
+ @scores[:t] += 1
+ award_team_money(:t, 3250)
+ award_team_money(:ct, 1400)
+ else
+ # 時間結束,CT 獲勝
+ @scores[:ct] += 1
+ award_team_money(:ct, 3250)
+ award_team_money(:t, 1400)
+ end
+
+ start_new_round
+ end
+
+ def start_new_round
+ @round_start_time = Time.now
+ @bullets.clear
+
+ @players.each do |_, player|
+ player.reset_for_new_round
+ end
+
+ @game_state.start_buy_phase
+ end
+
+ def award_team_money(team, amount)
+ @players.values.select { |p| p.team == team }.each do |player|
+ player.money = [player.money + amount, 16000].min
+ end
+ end
+
+ def players_data
+ @players.transform_values do |player|
+ {
+ id: player.id,
+ name: player.name,
+ team: player.team,
+ x: player.x,
+ y: player.y,
+ health: player.health,
+ armor: player.armor,
+ money: player.money,
+ dead: player.dead?,
+ weapon: player.current_weapon[:name]
+ }
+ end
+ end
+
+ def bullets_data
+ @bullets.map do |bullet|
+ {
+ x: bullet.x,
+ y: bullet.y,
+ angle: bullet.angle
+ }
+ end
+ end
+
+ private
+
+ def check_collision(x, y)
+ # 簡單的邊界檢查,之後可以加入地圖牆壁碰撞
+ false
+ end
+
+ def load_map(map_name)
+ # 載入地圖資料
+ {}
+ end
+
+ WEAPONS = {
+ glock: { name: "Glock-18", price: 400, damage: 28, rate: 0.15, magazine: 20, bullet_speed: 20, penetration: 1 },
+ usp: { name: "USP-S", price: 500, damage: 35, rate: 0.17, magazine: 12, bullet_speed: 20, penetration: 1 },
+ deagle: { name: "Desert Eagle", price: 700, damage: 48, rate: 0.225, magazine: 7, bullet_speed: 25, penetration: 2 },
+ ak47: { name: "AK-47", price: 2700, damage: 36, rate: 0.1, magazine: 30, bullet_speed: 22, penetration: 2 },
+ m4a1: { name: "M4A1", price: 3100, damage: 33, rate: 0.09, magazine: 30, bullet_speed: 23, penetration: 2 },
+ awp: { name: "AWP", price: 4750, damage: 115, rate: 1.45, magazine: 10, bullet_speed: 30, penetration: 3 },
+ mp5: { name: "MP5", price: 1500, damage: 26, rate: 0.075, magazine: 30, bullet_speed: 20, penetration: 1 },
+ p90: { name: "P90", price: 2350, damage: 26, rate: 0.07, magazine: 50, bullet_speed: 21, penetration: 1 }
+ }.freeze
+end
\ No newline at end of file
diff --git a/examples/hello-world/game/game_state.rb b/examples/hello-world/game/game_state.rb
new file mode 100644
index 0000000..a26e153
--- /dev/null
+++ b/examples/hello-world/game/game_state.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+class GameState
+ attr_reader :phase, :round_number, :max_rounds
+
+ PHASES = {
+ waiting: :waiting, # 等待玩家
+ buy_time: :buy_time, # 購買時間
+ playing: :playing, # 遊戲進行中
+ round_end: :round_end, # 回合結束
+ game_over: :game_over # 遊戲結束
+ }.freeze
+
+ def initialize
+ @phase = PHASES[:waiting]
+ @round_number = 1
+ @max_rounds = 30
+ @phase_start_time = Time.now
+ @buy_time_duration = 15 # 秒
+ @round_end_duration = 5 # 秒
+ end
+
+ def waiting?
+ @phase == PHASES[:waiting]
+ end
+
+ def buy_time?
+ @phase == PHASES[:buy_time]
+ end
+
+ def playing?
+ @phase == PHASES[:playing]
+ end
+
+ def round_ended?
+ @phase == PHASES[:round_end]
+ end
+
+ def game_over?
+ @phase == PHASES[:game_over]
+ end
+
+ def start_buy_phase
+ @phase = PHASES[:buy_time]
+ @phase_start_time = Time.now
+
+ # 自動過渡到遊戲階段
+ Thread.new do
+ sleep(@buy_time_duration)
+ start_playing_phase
+ end
+ end
+
+ def start_playing_phase
+ @phase = PHASES[:playing]
+ @phase_start_time = Time.now
+ end
+
+ def end_round
+ @phase = PHASES[:round_end]
+ @phase_start_time = Time.now
+ @round_number += 1
+
+ if @round_number > @max_rounds
+ end_game
+ else
+ # 自動開始新回合
+ Thread.new do
+ sleep(@round_end_duration)
+ start_buy_phase
+ end
+ end
+ end
+
+ def end_game
+ @phase = PHASES[:game_over]
+ end
+
+ def time_remaining_in_phase
+ case @phase
+ when PHASES[:buy_time]
+ [@buy_time_duration - (Time.now - @phase_start_time), 0].max
+ when PHASES[:round_end]
+ [@round_end_duration - (Time.now - @phase_start_time), 0].max
+ else
+ 0
+ end
+ end
+
+ def can_buy?
+ buy_time? && time_remaining_in_phase > 0
+ end
+
+ def to_h
+ {
+ phase: @phase,
+ round_number: @round_number,
+ max_rounds: @max_rounds,
+ time_in_phase: time_remaining_in_phase
+ }
+ end
+end
\ No newline at end of file
diff --git a/examples/hello-world/game/player.rb b/examples/hello-world/game/player.rb
new file mode 100644
index 0000000..cb6cd04
--- /dev/null
+++ b/examples/hello-world/game/player.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+class Player
+ attr_accessor :id, :name, :team, :x, :y, :health, :armor, :money
+ attr_accessor :kills, :deaths, :last_update
+ attr_reader :weapons, :current_weapon_index
+
+ def initialize(id:, name:, team:, x: 0, y: 0)
+ @id = id
+ @name = name
+ @team = team
+ @x = x
+ @y = y
+ @health = 100
+ @armor = 0
+ @money = 800
+ @kills = 0
+ @deaths = 0
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @last_shot_time = Time.now
+ @ammo = { magazine: 30, reserve: 90 }
+ @is_reloading = false
+ @reload_start_time = nil
+ @last_update = Time.now
+ end
+
+ def current_weapon
+ @weapons[@current_weapon_index] || default_weapon
+ end
+
+ def default_weapon
+ @team == :ct ? usp_weapon : glock_weapon
+ end
+
+ def glock_weapon
+ {
+ name: "Glock-18",
+ damage: 28,
+ rate: 0.15,
+ magazine: 20,
+ magazine_size: 20,
+ reserve: 120,
+ bullet_speed: 20,
+ penetration: 1,
+ reload_time: 2.2
+ }
+ end
+
+ def usp_weapon
+ {
+ name: "USP-S",
+ damage: 35,
+ rate: 0.17,
+ magazine: 12,
+ magazine_size: 12,
+ reserve: 72,
+ bullet_speed: 20,
+ penetration: 1,
+ reload_time: 2.2
+ }
+ end
+
+ def add_weapon(weapon_type)
+ weapon_data = GameRoom::WEAPONS[weapon_type].dup
+ weapon_data[:magazine] = weapon_data[:magazine]
+ weapon_data[:magazine_size] = weapon_data[:magazine]
+ weapon_data[:reserve] = weapon_data[:magazine] * 3
+ weapon_data[:reload_time] = calculate_reload_time(weapon_type)
+
+ # 替換主武器或副武器
+ if primary_weapon?(weapon_type)
+ @weapons[1] = weapon_data
+ @current_weapon_index = 1
+ else
+ @weapons[0] = weapon_data
+ @current_weapon_index = 0
+ end
+
+ @ammo = {
+ magazine: weapon_data[:magazine],
+ reserve: weapon_data[:reserve]
+ }
+ end
+
+ def primary_weapon?(weapon_type)
+ [:ak47, :m4a1, :awp, :mp5, :p90].include?(weapon_type)
+ end
+
+ def calculate_reload_time(weapon_type)
+ case weapon_type
+ when :awp then 3.7
+ when :ak47, :m4a1 then 2.5
+ when :mp5, :p90 then 2.3
+ when :deagle then 2.2
+ else 2.0
+ end
+ end
+
+ def can_shoot?
+ return false if dead?
+ return false if @is_reloading
+ return false if @ammo[:magazine] <= 0
+
+ time_since_last_shot = Time.now - @last_shot_time
+ time_since_last_shot >= current_weapon[:rate]
+ end
+
+ def shoot!
+ return unless can_shoot?
+
+ @ammo[:magazine] -= 1
+ @last_shot_time = Time.now
+
+ # 自動換彈
+ reload! if @ammo[:magazine] <= 0 && @ammo[:reserve] > 0
+ end
+
+ def reload!
+ return if @is_reloading
+ return if @ammo[:magazine] >= current_weapon[:magazine_size]
+ return if @ammo[:reserve] <= 0
+
+ @is_reloading = true
+ @reload_start_time = Time.now
+
+ # 這裡應該要設定計時器,暫時簡化處理
+ Thread.new do
+ sleep(current_weapon[:reload_time])
+ finish_reload
+ end
+ end
+
+ def finish_reload
+ return unless @is_reloading
+
+ needed = current_weapon[:magazine_size] - @ammo[:magazine]
+ available = [@ammo[:reserve], needed].min
+
+ @ammo[:magazine] += available
+ @ammo[:reserve] -= available
+ @is_reloading = false
+ @reload_start_time = nil
+ end
+
+ def take_damage(damage)
+ # 護甲減傷
+ if @armor > 0
+ armor_absorbed = [damage * 0.5, @armor].min
+ @armor -= armor_absorbed
+ actual_damage = damage - armor_absorbed * 0.5
+ else
+ actual_damage = damage
+ end
+
+ @health = [@health - actual_damage, 0].max
+
+ if dead?
+ @deaths += 1
+ end
+ end
+
+ def dead?
+ @health <= 0
+ end
+
+ def reset_for_new_round
+ @health = 100
+ @armor = 0
+ @weapons = [default_weapon]
+ @current_weapon_index = 0
+ @ammo = { magazine: 30, reserve: 90 }
+ @is_reloading = false
+ @reload_start_time = nil
+
+ # 重生位置
+ spawn_points = @team == :ct ? ct_spawn_points : t_spawn_points
+ spawn = spawn_points.sample
+ @x = spawn[:x]
+ @y = spawn[:y]
+ end
+
+ def switch_weapon(index)
+ return if @is_reloading
+ return unless @weapons[index]
+
+ @current_weapon_index = index
+ end
+
+ def buy_armor
+ return false if @money < 650
+ return false if @armor >= 100
+
+ @money -= 650
+ @armor = 100
+ true
+ end
+
+ def buy_helmet
+ return false if @money < 1000
+ return false if @armor >= 100
+
+ @money -= 1000
+ @armor = 100
+ true
+ end
+
+ private
+
+ def ct_spawn_points
+ [
+ { x: 100, y: 100 },
+ { x: 150, y: 100 },
+ { x: 100, y: 150 },
+ { x: 150, y: 150 },
+ { x: 125, y: 125 }
+ ]
+ end
+
+ def t_spawn_points
+ [
+ { x: 1100, y: 600 },
+ { x: 1150, y: 600 },
+ { x: 1100, y: 650 },
+ { x: 1150, y: 650 },
+ { x: 1125, y: 625 }
+ ]
+ end
+end
\ No newline at end of file
diff --git a/examples/hello-world/public/_static/style.css b/examples/hello-world/public/_static/style.css
new file mode 100644
index 0000000..c00a524
--- /dev/null
+++ b/examples/hello-world/public/_static/style.css
@@ -0,0 +1,328 @@
+/* CS2D Game Styles */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Courier New', monospace;
+ background-color: #1a1a1a;
+ color: white;
+ overflow: hidden;
+ user-select: none;
+}
+
+#cs2d-container {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+}
+
+#game-canvas {
+ cursor: crosshair;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
+
+/* HUD Styles */
+#hud {
+ background: linear-gradient(to right, rgba(0,0,0,0.8), transparent);
+ padding: 15px;
+ border-radius: 5px;
+ min-width: 200px;
+}
+
+#hud div {
+ margin: 5px 0;
+ font-size: 16px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
+}
+
+#health {
+ color: #ff4444;
+ font-weight: bold;
+}
+
+#armor {
+ color: #4444ff;
+ font-weight: bold;
+}
+
+#ammo {
+ color: #ffaa00;
+}
+
+#money {
+ color: #44ff44;
+}
+
+/* Scoreboard */
+#scoreboard {
+ background: rgba(0,0,0,0.9);
+ border: 2px solid #444;
+ border-radius: 5px;
+ min-width: 400px;
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+#scoreboard h3 {
+ text-align: center;
+ padding: 10px;
+ background: #222;
+ margin: 0;
+}
+
+#team-ct, #team-t {
+ padding: 10px;
+}
+
+#team-ct {
+ background: rgba(68,68,255,0.1);
+ border-top: 3px solid #4444ff;
+}
+
+#team-t {
+ background: rgba(255,68,68,0.1);
+ border-top: 3px solid #ff4444;
+}
+
+.player-score {
+ display: flex;
+ justify-content: space-between;
+ padding: 5px;
+ margin: 2px 0;
+ background: rgba(255,255,255,0.05);
+}
+
+/* Buy Menu */
+#buy-menu {
+ background: rgba(0,0,0,0.95);
+ border: 2px solid #666;
+ border-radius: 10px;
+ min-width: 500px;
+ max-height: 600px;
+ overflow-y: auto;
+}
+
+#buy-menu h2 {
+ text-align: center;
+ color: #ffaa00;
+ margin-bottom: 20px;
+}
+
+.weapon-categories {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ padding: 20px;
+}
+
+.weapon-categories button {
+ padding: 15px;
+ background: rgba(255,255,255,0.1);
+ border: 1px solid #444;
+ color: white;
+ cursor: pointer;
+ transition: all 0.3s;
+ font-size: 14px;
+ text-align: left;
+}
+
+.weapon-categories button:hover {
+ background: rgba(255,255,255,0.2);
+ border-color: #ffaa00;
+ transform: scale(1.05);
+}
+
+.weapon-categories button:active {
+ transform: scale(0.95);
+}
+
+/* Chat */
+#chat {
+ pointer-events: none;
+}
+
+#chat-messages {
+ border-radius: 5px;
+ font-size: 12px;
+ line-height: 1.4;
+ pointer-events: none;
+}
+
+.chat-message {
+ margin: 2px 0;
+ padding: 2px 5px;
+}
+
+.chat-message.team-ct {
+ color: #8888ff;
+}
+
+.chat-message.team-t {
+ color: #ff8888;
+}
+
+#chat-input {
+ background: rgba(0,0,0,0.8);
+ border: 1px solid #444;
+ color: white;
+ padding: 5px;
+ font-size: 12px;
+ pointer-events: all;
+}
+
+#chat-input:focus {
+ outline: none;
+ border-color: #ffaa00;
+}
+
+/* Kill Feed */
+#kill-feed {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ width: 300px;
+}
+
+.kill-entry {
+ background: rgba(0,0,0,0.7);
+ padding: 5px 10px;
+ margin: 2px 0;
+ border-left: 3px solid #ff4444;
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Round Timer */
+#round-timer {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 24px;
+ font-weight: bold;
+ background: rgba(0,0,0,0.8);
+ padding: 10px 20px;
+ border-radius: 5px;
+ border: 2px solid #444;
+}
+
+#round-timer.warning {
+ color: #ffaa00;
+ border-color: #ffaa00;
+}
+
+#round-timer.critical {
+ color: #ff4444;
+ border-color: #ff4444;
+ animation: pulse 1s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ transform: translateX(-50%) scale(1);
+ }
+ 50% {
+ transform: translateX(-50%) scale(1.05);
+ }
+}
+
+/* Spectator Mode */
+.spectator-mode {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ background: rgba(0,0,0,0.8);
+ padding: 20px;
+ border-radius: 10px;
+}
+
+.spectator-mode h2 {
+ color: #ff4444;
+ margin-bottom: 10px;
+}
+
+/* Loading Screen */
+.loading-screen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: #1a1a1a;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+}
+
+.loading-content {
+ text-align: center;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid #444;
+ border-top: 5px solid #ffaa00;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+@keyframes fadeOut {
+ 0% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 70% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-10px);
+ }
+}
+
+/* Damage Indicator */
+.damage-indicator {
+ position: absolute;
+ color: #ff4444;
+ font-weight: bold;
+ font-size: 18px;
+ pointer-events: none;
+ animation: damageFloat 1s ease-out forwards;
+}
+
+@keyframes damageFloat {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-50px);
+ }
+}
\ No newline at end of file
diff --git a/examples/hello-world/simple_server.rb b/examples/hello-world/simple_server.rb
new file mode 100644
index 0000000..a6d5dc1
--- /dev/null
+++ b/examples/hello-world/simple_server.rb
@@ -0,0 +1,348 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'webrick'
+require 'json'
+require 'erb'
+
+class CS2DServer < WEBrick::HTTPServlet::AbstractServlet
+ def do_GET(request, response)
+ response.status = 200
+ response['Content-Type'] = 'text/html'
+ response.body = <<~HTML
+
+
+
+ CS2D - Mac Optimized
+
+
+
+
+
+ CS2D - Mac 觸控板優化版
+ 移動: WASD (Shift 加速)
+ 瞄準: 方向鍵/IJKL
+ 射擊: 空白鍵
+ 轉身: Q (180°)
+ 自動瞄準: V
+ 觸控板:
+ 雙指橫滑 - 旋轉
+ 雙指縱滑 - 距離
+ 雙指點擊 - 射擊
+
+
+
HP: 100
+
Ammo: 30/90
+
Score: 0
+
+
+
+
+
+ HTML
+ end
+end
+
+server = WEBrick::HTTPServer.new(Port: 9292)
+server.mount '/', CS2DServer
+trap('INT') { server.shutdown }
+
+puts "🎮 CS2D Server started!"
+puts "📱 Open http://localhost:9292 in your browser"
+puts "🎯 Mac touchpad optimized!"
+puts "Press Ctrl+C to stop"
+
+server.start
\ No newline at end of file
From 78f706da64124b49977ab3a7500d15b0902d6ae5 Mon Sep 17 00:00:00 2001
From: jimmy2822
Date: Sat, 9 Aug 2025 20:37:56 +0800
Subject: [PATCH 003/175] Improve CS2D touchpad controls and add implementation
plan
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Enhanced Mac touchpad controls with better precision and smoothing
- Made auto-aim optional (toggle with V key) instead of default
- Added comprehensive implementation plan for CS 1.6 features
- Included standalone HTML version and Python server for testing
- Updated control instructions for better Mac touchpad experience
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
.tool-versions | 1 +
examples/cs2d/PLAN.md | 453 +++++++++++++
examples/cs2d/cs16_game.html | 1229 ++++++++++++++++++++++++++++++++++
examples/cs2d/cs16_server.rb | 74 +-
examples/cs2d/serve.py | 25 +
5 files changed, 1757 insertions(+), 25 deletions(-)
create mode 100644 .tool-versions
create mode 100644 examples/cs2d/PLAN.md
create mode 100644 examples/cs2d/cs16_game.html
create mode 100644 examples/cs2d/serve.py
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..974865f
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+ruby 2.7.6
diff --git a/examples/cs2d/PLAN.md b/examples/cs2d/PLAN.md
new file mode 100644
index 0000000..b1e4012
--- /dev/null
+++ b/examples/cs2d/PLAN.md
@@ -0,0 +1,453 @@
+# CS2D 實作計劃 (Implementation Plan)
+
+## 📋 專案現況 (Current Status)
+
+### ✅ 已完成功能 (Completed Features)
+1. **基礎遊戲架構** - 專案結構與檔案組織
+2. **玩家移動與物理系統** - WASD 控制、碰撞檢測
+3. **Mac 觸控板優化** - 雙指手勢、精準控制
+4. **基礎多人架構** - WebSocket 準備、房間系統設計
+5. **觸控板精準控制** - 移除強制自動瞄準,改為選擇性輔助
+
+### 🚧 進行中 (In Progress)
+- 單機 AI 對戰模式(用於測試)
+
+### 📝 待實作 (To Be Implemented)
+以下功能需要依序完成以達成完整的 CS 1.6 體驗
+
+---
+
+## 🎯 Phase 1: 核心遊戲機制 (Core Mechanics)
+**預計時間**: 3-4 天
+**優先級**: 🔴 Critical
+
+### 1.1 炸彈系統 (Bomb System)
+```ruby
+# 需要實作的類別
+class BombSystem
+ - plant_bomb() # 3秒安裝時間
+ - defuse_bomb() # 10秒拆除(5秒with kit)
+ - bomb_countdown() # 45秒倒數
+ - explosion() # 爆炸效果與傷害
+end
+```
+
+**實作細節**:
+- [ ] 炸彈點 A/B 區域判定
+- [ ] 安裝/拆除進度條 UI
+- [ ] 炸彈倒數計時器顯示
+- [ ] 爆炸範圍傷害計算
+- [ ] 音效提示(滴答聲)
+
+### 1.2 回合制系統 (Round System)
+```ruby
+class RoundManager
+ - freeze_time # 5秒凍結
+ - buy_time # 15秒購買
+ - round_time # 1:55戰鬥
+ - round_end # 結算與重置
+end
+```
+
+**實作細節**:
+- [ ] 階段轉換邏輯
+- [ ] 玩家重生機制
+- [ ] 回合勝負判定
+- [ ] 15回合換邊
+- [ ] 最多30回合限制
+
+### 1.3 死亡與觀戰 (Death & Spectator)
+```javascript
+class SpectatorSystem {
+ - death_cam // 死亡視角
+ - free_look // 自由觀戰
+ - player_follow // 跟隨隊友
+ - info_display // 顯示資訊
+}
+```
+
+**實作細節**:
+- [ ] 死亡後切換觀戰模式
+- [ ] 觀戰視角切換(數字鍵1-5)
+- [ ] 顯示被觀戰玩家資訊
+- [ ] 禁止死亡玩家影響遊戲
+
+---
+
+## 💰 Phase 2: 經濟系統 (Economy System)
+**預計時間**: 2-3 天
+**優先級**: 🟠 High
+
+### 2.1 金錢管理 (Money Management)
+```ruby
+class Economy
+ START_MONEY = 800
+ MAX_MONEY = 16000
+
+ - calculate_kill_reward()
+ - calculate_round_bonus()
+ - handle_loss_bonus()
+ - track_team_economy()
+end
+```
+
+**實作細節**:
+- [ ] 擊殺獎勵系統(依武器類型)
+- [ ] 回合獎勵(勝$3250/敗$1400+)
+- [ ] 連敗獎勵遞增(最高$3400)
+- [ ] 團隊經濟顯示
+
+### 2.2 購買系統優化 (Buy System Enhancement)
+```javascript
+class BuyMenu {
+ - category_tabs // 分類標籤
+ - quick_buy // 快速購買
+ - team_buy // 團隊購買建議
+ - rebuy // 重複上次購買
+}
+```
+
+**實作細節**:
+- [ ] 購買選單 UI 重製
+- [ ] 武器分類(手槍/步槍/裝備)
+- [ ] 金額不足提示
+- [ ] 購買歷史記錄
+
+### 2.3 武器掉落 (Weapon Drop)
+```ruby
+class WeaponDrop
+ - drop_on_death()
+ - pickup_weapon()
+ - swap_weapons()
+ - weapon_persistence()
+end
+```
+
+**實作細節**:
+- [ ] 死亡時掉落武器
+- [ ] G 鍵丟棄武器
+- [ ] E 鍵撿起武器
+- [ ] 武器保存到下回合
+
+---
+
+## 🗺️ Phase 3: 地圖系統 (Map System)
+**預計時間**: 3-4 天
+**優先級**: 🟡 Medium
+
+### 3.1 地圖結構 (Map Structure)
+```javascript
+class MapSystem {
+ maps: {
+ "de_dust2_mini": {
+ layout: [...],
+ spawns: { ct: [...], t: [...] },
+ bombsites: { A: {...}, B: {...} },
+ walls: [...],
+ cover: [...]
+ }
+ }
+}
+```
+
+**實作細節**:
+- [ ] 簡化版 dust2 地圖
+- [ ] 碰撞網格系統
+- [ ] 視線遮擋計算
+- [ ] 小地圖顯示
+
+### 3.2 戰術點位 (Tactical Positions)
+```ruby
+class TacticalMap
+ - choke_points # 關鍵點位
+ - sniper_spots # 狙擊點
+ - cover_spots # 掩體位置
+ - rotation_paths # 轉點路線
+end
+```
+
+**實作細節**:
+- [ ] 預設戰術點位
+- [ ] AI 路徑規劃
+- [ ] 煙霧彈點位標記
+- [ ] 聲音傳播範圍
+
+---
+
+## 🔫 Phase 4: 武器系統完善 (Weapon System)
+**預計時間**: 2-3 天
+**優先級**: 🟡 Medium
+
+### 4.1 武器特性 (Weapon Properties)
+```javascript
+const WeaponStats = {
+ ak47: {
+ damage: { close: 36, medium: 30, far: 24 },
+ recoil: { pattern: [...], recovery: 0.5 },
+ penetration: 2,
+ accuracy: { standing: 0.7, moving: 0.3 }
+ }
+}
+```
+
+**實作細節**:
+- [ ] 距離傷害衰減
+- [ ] 後座力模式
+- [ ] 穿透力系統
+- [ ] 精準度影響
+
+### 4.2 彈道系統 (Ballistics)
+```ruby
+class Ballistics
+ - calculate_spread()
+ - apply_recoil()
+ - handle_penetration()
+ - trace_bullet_path()
+end
+```
+
+**實作細節**:
+- [ ] 子彈散布計算
+- [ ] 連發後座力累積
+- [ ] 牆壁穿透傷害
+- [ ] 曳光彈顯示
+
+---
+
+## 🌐 Phase 5: 多人連線 (Multiplayer)
+**預計時間**: 4-5 天
+**優先級**: 🔵 Important
+
+### 5.1 網路同步 (Network Sync)
+```ruby
+class NetworkManager
+ - client_prediction()
+ - server_reconciliation()
+ - lag_compensation()
+ - interpolation()
+end
+```
+
+**實作細節**:
+- [ ] 客戶端預測
+- [ ] 伺服器調和
+- [ ] 延遲補償(回溯)
+- [ ] 實體插值
+
+### 5.2 房間管理 (Room Management)
+```javascript
+class RoomManager {
+ - create_room()
+ - join_room()
+ - room_settings()
+ - kick_vote()
+}
+```
+
+**實作細節**:
+- [ ] 創建/加入房間
+- [ ] 房間設定(地圖、人數)
+- [ ] 踢人投票系統
+- [ ] 自動平衡隊伍
+
+### 5.3 狀態同步 (State Sync)
+```ruby
+class StateSync
+ UPDATE_RATE = 30 # 30 ticks/sec
+
+ - compress_state()
+ - delta_compression()
+ - priority_updates()
+ - packet_loss_handling()
+end
+```
+
+**實作細節**:
+- [ ] 狀態壓縮
+- [ ] 差異更新
+- [ ] 優先級系統
+- [ ] 丟包處理
+
+---
+
+## 🎨 Phase 6: 視覺與音效 (Visual & Audio)
+**預計時間**: 2-3 天
+**優先級**: 🟢 Nice to Have
+
+### 6.1 視覺效果 (Visual Effects)
+```javascript
+class VisualEffects {
+ - muzzle_flash()
+ - bullet_impacts()
+ - blood_effects()
+ - explosion_particles()
+}
+```
+
+**實作細節**:
+- [ ] 槍口火焰
+- [ ] 彈孔效果
+- [ ] 血液噴濺
+- [ ] 爆炸粒子
+
+### 6.2 音效系統 (Audio System)
+```javascript
+class AudioManager {
+ - positional_audio()
+ - footsteps()
+ - weapon_sounds()
+ - voice_lines()
+}
+```
+
+**實作細節**:
+- [ ] 3D 音效定位
+- [ ] 腳步聲(材質區分)
+- [ ] 武器音效
+- [ ] 語音提示
+
+### 6.3 UI 改進 (UI Enhancement)
+```javascript
+class UIEnhancement {
+ - kill_feed()
+ - damage_numbers()
+ - hitmarkers()
+ - minimap()
+}
+```
+
+**實作細節**:
+- [ ] 擊殺提示
+- [ ] 傷害數字
+- [ ] 命中標記
+- [ ] 小地圖雷達
+
+---
+
+## 🔧 Phase 7: 優化與測試 (Optimization & Testing)
+**預計時間**: 2-3 天
+**優先級**: 🟢 Final
+
+### 7.1 性能優化 (Performance)
+- [ ] Canvas 渲染優化
+- [ ] 物件池(Object Pooling)
+- [ ] 視錐裁剪(Frustum Culling)
+- [ ] LOD 系統
+
+### 7.2 平衡調整 (Balance)
+- [ ] 武器傷害平衡
+- [ ] 經濟系統調整
+- [ ] 地圖平衡
+- [ ] 移動速度調整
+
+### 7.3 測試項目 (Testing)
+- [ ] 單元測試
+- [ ] 整合測試
+- [ ] 壓力測試(10人同時)
+- [ ] 跨瀏覽器測試
+
+---
+
+## 📅 時程規劃 (Timeline)
+
+### Week 1 (第一週)
+- Day 1-2: 炸彈系統
+- Day 3-4: 回合制系統
+- Day 5: 死亡與觀戰
+- Weekend: 測試與修復
+
+### Week 2 (第二週)
+- Day 1-2: 經濟系統
+- Day 3: 購買系統優化
+- Day 4-5: 地圖系統
+- Weekend: 武器系統完善
+
+### Week 3 (第三週)
+- Day 1-3: 多人連線
+- Day 4: 視覺與音效
+- Day 5: 優化與測試
+- Weekend: 發布準備
+
+---
+
+## 🚀 MVP 里程碑 (MVP Milestones)
+
+### Milestone 1: 可玩原型 ✅
+- [x] 基本移動與射擊
+- [x] 簡單 AI 敵人
+- [x] 基礎 UI
+
+### Milestone 2: 核心玩法 🚧
+- [ ] 完整回合制
+- [ ] 炸彈機制
+- [ ] 經濟系統
+
+### Milestone 3: 多人對戰 📅
+- [ ] 2v2 連線對戰
+- [ ] 狀態同步
+- [ ] 房間系統
+
+### Milestone 4: 完整體驗 📅
+- [ ] 5v5 對戰
+- [ ] 完整地圖
+- [ ] 音效系統
+
+---
+
+## 🐛 已知問題 (Known Issues)
+
+1. **觸控板靈敏度** - 需要提供設定選項
+2. **AI 路徑** - 有時會卡在牆角
+3. **子彈穿透** - 尚未實作
+4. **音效缺失** - 需要加入音效檔案
+
+---
+
+## 📝 開發筆記 (Dev Notes)
+
+### 技術債務 (Technical Debt)
+- 需要重構 GameRoom 類別(太大)
+- 網路協議需要優化(現在用 JSON)
+- 物理系統可以改用 Matter.js
+
+### 效能考量 (Performance Considerations)
+- 考慮使用 WebGL 替代 Canvas 2D
+- 實作 WebRTC 點對點連線
+- 使用 Protocol Buffers 替代 JSON
+
+### 未來功能 (Future Features)
+- 排名系統
+- 自訂地圖編輯器
+- 重播系統
+- 觀戰模式直播
+
+---
+
+## 📊 成功指標 (Success Metrics)
+
+### 技術指標
+- [ ] 60 FPS 穩定運行
+- [ ] 延遲 < 50ms(本地)
+- [ ] 支援 10 人同時遊戲
+- [ ] 跨瀏覽器相容
+
+### 遊戲性指標
+- [ ] 回合時間合理(2-3分鐘)
+- [ ] 經濟系統平衡
+- [ ] 武器使用率平均
+- [ ] 雙方勝率接近 50%
+
+---
+
+## 🔗 相關文件 (Related Documents)
+
+- [CS16_MVP_PLAN.md](./CS16_MVP_PLAN.md) - 原始 MVP 計劃
+- [GAMEPLAY_GUIDE.md](./docs/GAMEPLAY_GUIDE.md) - 遊戲指南
+- [TECHNICAL.md](./docs/TECHNICAL.md) - 技術文件
+- [README.md](./README.md) - 專案說明
+
+---
+
+**最後更新**: 2025-08-09
+**負責人**: Development Team
+**狀態**: 🚧 Active Development
\ No newline at end of file
diff --git a/examples/cs2d/cs16_game.html b/examples/cs2d/cs16_game.html
new file mode 100644
index 0000000..9dcfb71
--- /dev/null
+++ b/examples/cs2d/cs16_game.html
@@ -0,0 +1,1229 @@
+
+
+
+
+ CS 1.6 2D - MVP
+
+
+
+
+
+
+
+
+
+
+
$800
+
+
+ 30 / 90
+
+
Glock-18
+
+
+
+
+
🎮 Game Controls
+ Mouse Controls:
+ 🖱️ Move mouse - Aim
+ 🖱️ Left click - Shoot
+
+ Keyboard:
+ Move: WASD (Shift to run)
+ Shoot: Space or Left Click
+ Reload: R
+ Interact: E
+ Buy Menu: B
+ Quick Buy: 1-5
+
+ 💡 Tip: Press V to toggle auto-aim
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/cs2d/cs16_server.rb b/examples/cs2d/cs16_server.rb
index e09c67a..afd3e02 100644
--- a/examples/cs2d/cs16_server.rb
+++ b/examples/cs2d/cs16_server.rb
@@ -312,20 +312,22 @@ def do_GET(request, response)
-
🎮 Mac 優化控制
- 移動: WASD (Shift 加速)
- 瞄準: 方向鍵 或 IJKL
- 射擊: 空白鍵 或 點擊
- 換彈: R
- 互動: E (安裝/拆彈)
- 購買: B 或 數字鍵 1-5
- 轉身: Q (180°)
- 自動瞄準: V
+ 🎮 Mac 觸控板優化
+ 主要控制 (推薦):
+ 🖱️ 雙指橫滑 - 精準旋轉瞄準
+ 🖱️ 雙指縱滑 - 調整瞄準距離
+ 🖱️ 單指點擊 - 射擊
+ 🖱️ 雙指點擊 - 快速射擊
- 觸控板手勢:
- 雙指橫滑 - 旋轉瞄準
- 雙指縱滑 - 調整距離
- 雙指點擊 - 射擊
+ 鍵盤控制:
+ 移動: WASD (Shift 跑)
+ 瞄準: 方向鍵/IJKL
+ 射擊: 空白鍵
+ 換彈: R | 互動: E
+ 購買: B 或 數字鍵 1-5
+ 快速轉身: Q (180°)
+
+ 💡 提示: V 鍵可開啟輔助瞄準
+ CS2D Client-Side Test Suite
+
+
+
Browser Compatibility Tests
+
+
+
+
+
+
Performance Tests
+
+
+
+
+
+
+
Game Logic Tests
+
+
+
+
+
+
Network Simulation Tests
+
+
+
+
+
+
Stress Tests
+
+
+
+
+
+
diff --git a/examples/cs2d/game/bullet.rb b/examples/cs2d/game/bullet.rb
index 18b926d..67c7992 100644
--- a/examples/cs2d/game/bullet.rb
+++ b/examples/cs2d/game/bullet.rb
@@ -2,9 +2,10 @@
class Bullet
attr_accessor :x, :y, :hit
- attr_reader :owner_id, :angle, :damage, :speed, :penetration
+ attr_reader :id, :owner_id, :angle, :damage, :speed, :penetration
- def initialize(owner_id:, x:, y:, angle:, damage:, speed:, penetration:)
+ def initialize(id: nil, owner_id:, x:, y:, angle:, damage:, speed:, penetration:)
+ @id = id || "#{Time.now.to_i}_#{owner_id}_#{rand(1000)}"
@owner_id = owner_id
@x = x
@y = y
diff --git a/examples/cs2d/game/game_state.rb b/examples/cs2d/game/game_state.rb
index a26e153..9fac1bf 100644
--- a/examples/cs2d/game/game_state.rb
+++ b/examples/cs2d/game/game_state.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class GameState
- attr_reader :phase, :round_number, :max_rounds
+ attr_reader :phase, :round_number, :max_rounds, :scores, :bomb_info, :recent_events
PHASES = {
waiting: :waiting, # 等待玩家
@@ -16,8 +16,30 @@ def initialize
@round_number = 1
@max_rounds = 30
@phase_start_time = Time.now
- @buy_time_duration = 15 # 秒
+ @buy_time_duration = 20 # 秒
@round_end_duration = 5 # 秒
+ @round_time = 115 # 1:55 round time
+ @round_start_time = Time.now
+
+ # Scoring
+ @scores = { ct: 0, t: 0 }
+
+ # Bomb system
+ @bomb_info = {
+ planted: false,
+ defused: false,
+ exploded: false,
+ plant_time: nil,
+ defuse_time: nil,
+ site: nil,
+ planter_id: nil,
+ defuser_id: nil,
+ timer: 40.0 # 40 seconds until explosion
+ }
+
+ # Events for broadcasting
+ @recent_events = []
+ @event_counter = 0
end
def waiting?
@@ -43,37 +65,47 @@ def game_over?
def start_buy_phase
@phase = PHASES[:buy_time]
@phase_start_time = Time.now
-
- # 自動過渡到遊戲階段
- Thread.new do
- sleep(@buy_time_duration)
- start_playing_phase
- end
end
def start_playing_phase
@phase = PHASES[:playing]
@phase_start_time = Time.now
+ @round_start_time = Time.now
end
- def end_round
+ def end_round(winning_team, reason)
@phase = PHASES[:round_end]
@phase_start_time = Time.now
+
+ # Update scores
+ @scores[winning_team] += 1
+
+ # Add event
+ add_event({
+ type: "round_ended",
+ winning_team: winning_team,
+ reason: reason,
+ round_number: @round_number,
+ scores: @scores.dup
+ })
+
@round_number += 1
- if @round_number > @max_rounds
+ # Check for game end
+ if game_should_end?
end_game
- else
- # 自動開始新回合
- Thread.new do
- sleep(@round_end_duration)
- start_buy_phase
- end
end
end
def end_game
@phase = PHASES[:game_over]
+
+ winner = @scores[:ct] > @scores[:t] ? :ct : :t
+ add_event({
+ type: "game_ended",
+ winner: winner,
+ final_scores: @scores.dup
+ })
end
def time_remaining_in_phase
@@ -87,16 +119,173 @@ def time_remaining_in_phase
end
end
+ def round_time_left
+ return 0 unless playing?
+ [@round_time - (Time.now - @round_start_time), 0].max
+ end
+
+ def round_active?
+ playing? && round_time_left > 0
+ end
+
+ def current_phase
+ @phase
+ end
+
+ def buy_time
+ @buy_time_duration
+ end
+
def can_buy?
buy_time? && time_remaining_in_phase > 0
end
+ # Bomb system methods
+ def start_bomb_plant(player_id, site)
+ @bomb_info[:planted] = false # Still planting
+ @bomb_info[:plant_time] = Time.now
+ @bomb_info[:site] = site
+ @bomb_info[:planter_id] = player_id
+
+ add_event({
+ type: "bomb_plant_started",
+ player_id: player_id,
+ site: site
+ })
+ end
+
+ def complete_bomb_plant
+ @bomb_info[:planted] = true
+ @bomb_info[:plant_time] = Time.now
+
+ add_event({
+ type: "bomb_planted",
+ site: @bomb_info[:site],
+ timer: @bomb_info[:timer]
+ })
+ end
+
+ def start_bomb_defuse(player_id, defuse_time)
+ @bomb_info[:defuse_time] = defuse_time
+ @bomb_info[:defuser_id] = player_id
+
+ add_event({
+ type: "bomb_defuse_started",
+ player_id: player_id,
+ defuse_time: defuse_time
+ })
+ end
+
+ def complete_bomb_defuse
+ @bomb_info[:defused] = true
+
+ add_event({
+ type: "bomb_defused",
+ defuser_id: @bomb_info[:defuser_id]
+ })
+ end
+
+ def explode_bomb
+ @bomb_info[:exploded] = true
+
+ add_event({
+ type: "bomb_exploded",
+ site: @bomb_info[:site]
+ })
+ end
+
+ def bomb_planted?
+ @bomb_info[:planted]
+ end
+
+ def bomb_defused?
+ @bomb_info[:defused]
+ end
+
+ def bomb_exploded?
+ @bomb_info[:exploded]
+ end
+
+ def bomb_time_remaining
+ return 0 unless bomb_planted?
+ return 0 if bomb_defused? || bomb_exploded?
+
+ elapsed = Time.now - @bomb_info[:plant_time]
+ [@bomb_info[:timer] - elapsed, 0].max
+ end
+
+ # Game state management
+ def update(delta_time)
+ # Update bomb timer if planted
+ if bomb_planted? && !bomb_defused? && !bomb_exploded?
+ if bomb_time_remaining <= 0
+ explode_bomb
+ end
+ end
+
+ # Clear old events
+ clear_old_events
+ end
+
+ def start_new_round
+ @phase = PHASES[:buy_time]
+ @phase_start_time = Time.now
+
+ # Reset bomb
+ @bomb_info = {
+ planted: false,
+ defused: false,
+ exploded: false,
+ plant_time: nil,
+ defuse_time: nil,
+ site: nil,
+ planter_id: nil,
+ defuser_id: nil,
+ timer: 40.0
+ }
+
+ add_event({
+ type: "new_round_started",
+ round_number: @round_number,
+ buy_time: @buy_time_duration
+ })
+ end
+
+ def add_event(event)
+ @event_counter += 1
+ event[:id] = @event_counter
+ event[:timestamp] = Time.now.to_f * 1000
+ @recent_events << event
+ end
+
+ def clear_old_events
+ # Keep only events from the last 5 seconds
+ cutoff_time = (Time.now.to_f * 1000) - 5000
+ @recent_events.reject! { |event| event[:timestamp] < cutoff_time }
+ end
+
+ def game_should_end?
+ # Game ends when one team reaches 16 rounds or after 30 rounds total
+ @scores[:ct] >= 16 || @scores[:t] >= 16 || @round_number > @max_rounds
+ end
+
def to_h
{
phase: @phase,
round_number: @round_number,
max_rounds: @max_rounds,
- time_in_phase: time_remaining_in_phase
+ time_in_phase: time_remaining_in_phase,
+ round_time_left: round_time_left,
+ scores: @scores,
+ bomb_info: @bomb_info,
+ recent_events: @recent_events
}
end
+
+ private
+
+ def reset_for_new_round
+ @phase = PHASES[:waiting]
+ @recent_events.clear
+ end
end
\ No newline at end of file
diff --git a/examples/cs2d/game/multiplayer_game_room.rb b/examples/cs2d/game/multiplayer_game_room.rb
new file mode 100644
index 0000000..e1618e7
--- /dev/null
+++ b/examples/cs2d/game/multiplayer_game_room.rb
@@ -0,0 +1,869 @@
+# frozen_string_literal: true
+
+require_relative "player"
+require_relative "bullet"
+require_relative "game_state"
+require_relative "lag_compensation"
+
+class MultiplayerGameRoom
+ attr_reader :room_id, :players, :bullets, :game_state, :room_settings
+
+ TICK_RATE = 30 # 30 updates per second
+ TICK_INTERVAL = 1.0 / TICK_RATE
+ MAX_PLAYERS = 10
+
+ def initialize(room_id, settings = {})
+ @room_id = room_id
+ @players = {}
+ @player_views = {} # Store view references for direct communication
+ @bullets = []
+ @game_state = GameState.new
+ @room_settings = default_room_settings.merge(settings)
+
+ # Network optimization
+ @last_state_snapshot = {}
+ @state_history = [] # For lag compensation
+ @tick_count = 0
+ @last_tick_time = Time.now
+
+ # Authoritative game state
+ @authoritative_state = {
+ tick: 0,
+ timestamp: Time.now.to_f * 1000,
+ players: {},
+ bullets: [],
+ round_info: {},
+ game_events: []
+ }
+
+ # Room management
+ @vote_kicks = {}
+ @team_balance_enabled = true
+
+ # Start the game loop
+ start_game_loop
+ end
+
+ def add_player(player_id, view)
+ return false if @players.size >= MAX_PLAYERS
+
+ # Auto-balance teams
+ team = determine_team_for_new_player
+
+ player = Player.new(
+ id: player_id,
+ name: generate_player_name(player_id),
+ team: team,
+ x: get_spawn_position(team)[:x],
+ y: get_spawn_position(team)[:y]
+ )
+
+ @players[player_id] = player
+ @player_views[player_id] = view
+
+ broadcast_to_all_players({
+ type: "player_joined",
+ player: player_data(player),
+ room_info: get_room_info,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ # Send full state to new player
+ view.send_message({
+ type: "full_game_state",
+ state: get_full_state,
+ your_player_id: player_id,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ true
+ end
+
+ def remove_player(player_id)
+ player = @players.delete(player_id)
+ @player_views.delete(player_id)
+ @vote_kicks.delete(player_id)
+
+ if player
+ broadcast_to_all_players({
+ type: "player_left",
+ player_id: player_id,
+ player_name: player.name,
+ timestamp: Time.now.to_f * 1000
+ })
+ end
+
+ check_round_end_conditions
+ end
+
+ def get_player(player_id)
+ @players[player_id]
+ end
+
+ def get_player_view(player_id)
+ @player_views[player_id]
+ end
+
+ def process_movement(player_id, input)
+ player = @players[player_id]
+ return { success: false } unless player && !player.dead?
+
+ # Extract movement input
+ dx = input[:dx] || 0
+ dy = input[:dy] || 0
+
+ # Apply movement with collision detection
+ old_position = { x: player.x, y: player.y }
+ new_x = player.x + dx
+ new_y = player.y + dy
+
+ # Validate movement (bounds checking, collision detection)
+ if valid_position?(new_x, new_y, player_id)
+ player.x = new_x.clamp(20, 1260)
+ player.y = new_y.clamp(20, 700)
+ player.last_update = Time.now
+
+ # Update authoritative state
+ update_player_in_authoritative_state(player_id, player)
+
+ return {
+ success: true,
+ position: { x: player.x, y: player.y },
+ old_position: old_position
+ }
+ else
+ return {
+ success: false,
+ position: old_position,
+ reason: "invalid_position"
+ }
+ end
+ end
+
+ def process_shoot(player_id, angle, timestamp)
+ player = @players[player_id]
+ return { success: false } unless player && !player.dead? && player.can_shoot?
+
+ weapon = player.current_weapon
+
+ # Create bullet with server-authoritative ID
+ bullet_id = "#{@tick_count}_#{player_id}_#{Time.now.to_i}"
+ bullet = Bullet.new(
+ id: bullet_id,
+ owner_id: player_id,
+ x: player.x,
+ y: player.y,
+ angle: angle,
+ damage: weapon[:damage],
+ speed: weapon[:bullet_speed],
+ penetration: weapon[:penetration]
+ )
+
+ @bullets << bullet
+ player.shoot!
+
+ # Check for immediate hits with lag compensation
+ hits = check_bullet_hits(bullet, timestamp)
+
+ {
+ success: true,
+ bullet_id: bullet_id,
+ position: { x: player.x, y: player.y },
+ hits: hits
+ }
+ end
+
+ def process_reload(player_id)
+ player = @players[player_id]
+ return { success: false } unless player && !player.dead?
+
+ if player.can_reload?
+ player.reload!
+ {
+ success: true,
+ reload_time: player.current_weapon[:reload_time]
+ }
+ else
+ {
+ success: false,
+ reason: "cannot_reload"
+ }
+ end
+ end
+
+ def change_team(player_id, new_team)
+ player = @players[player_id]
+ return unless player
+ return unless [:ct, :t].include?(new_team.to_sym)
+
+ old_team = player.team
+ player.team = new_team.to_sym
+ player.reset_for_new_round
+
+ broadcast_to_all_players({
+ type: "team_changed",
+ player_id: player_id,
+ old_team: old_team,
+ new_team: new_team,
+ timestamp: Time.now.to_f * 1000
+ })
+ end
+
+ def buy_weapon(player_id, weapon_name)
+ player = @players[player_id]
+ return false unless player && !player.dead? && @game_state.buy_time?
+
+ weapon_info = WEAPONS[weapon_name.to_sym]
+ return false unless weapon_info
+
+ if player.money >= weapon_info[:price]
+ player.money -= weapon_info[:price]
+ player.add_weapon(weapon_name.to_sym)
+
+ broadcast_to_all_players({
+ type: "weapon_purchased",
+ player_id: player_id,
+ weapon: weapon_name,
+ player_money: player.money,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ true
+ else
+ false
+ end
+ end
+
+ def plant_bomb(player_id)
+ player = @players[player_id]
+ return false unless player && player.team == :t && !player.dead?
+
+ # Check if player is in bomb site
+ bomb_site = get_bomb_site_at_position(player.x, player.y)
+ return false unless bomb_site
+
+ @game_state.start_bomb_plant(player_id, bomb_site)
+
+ broadcast_to_all_players({
+ type: "bomb_plant_started",
+ player_id: player_id,
+ bomb_site: bomb_site,
+ plant_time: 3.0,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ true
+ end
+
+ def defuse_bomb(player_id)
+ player = @players[player_id]
+ return false unless player && player.team == :ct && !player.dead?
+ return false unless @game_state.bomb_planted?
+
+ defuse_time = player.has_defuse_kit ? 5.0 : 10.0
+ @game_state.start_bomb_defuse(player_id, defuse_time)
+
+ broadcast_to_all_players({
+ type: "bomb_defuse_started",
+ player_id: player_id,
+ defuse_time: defuse_time,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ true
+ end
+
+ def vote_kick(voter_id, target_id)
+ return false unless @players[voter_id] && @players[target_id]
+
+ @vote_kicks[target_id] ||= []
+ @vote_kicks[target_id] << voter_id unless @vote_kicks[target_id].include?(voter_id)
+
+ required_votes = (@players.size / 2.0).ceil
+
+ if @vote_kicks[target_id].size >= required_votes
+ kick_player(target_id)
+ @vote_kicks.delete(target_id)
+ true
+ else
+ broadcast_to_all_players({
+ type: "vote_kick_update",
+ target_id: target_id,
+ votes: @vote_kicks[target_id].size,
+ required: required_votes,
+ timestamp: Time.now.to_f * 1000
+ })
+ false
+ end
+ end
+
+ def broadcast_to_all_players(message)
+ @player_views.each do |player_id, view|
+ view.send_message(message)
+ end
+ end
+
+ def get_full_state
+ {
+ tick: @tick_count,
+ timestamp: Time.now.to_f * 1000,
+ players: players_data,
+ bullets: bullets_data,
+ round_info: {
+ time_left: @game_state.round_time_left,
+ phase: @game_state.current_phase,
+ scores: @game_state.scores,
+ round_number: @game_state.round_number
+ },
+ bomb_info: @game_state.bomb_info,
+ room_settings: @room_settings
+ }
+ end
+
+ def get_room_info
+ {
+ room_id: @room_id,
+ player_count: @players.size,
+ max_players: MAX_PLAYERS,
+ map: @room_settings[:map],
+ game_mode: @room_settings[:game_mode],
+ round_time: @room_settings[:round_time],
+ players: @players.values.map { |p| { id: p.id, name: p.name, team: p.team } }
+ }
+ end
+
+ def apply_lag_compensation(target_time)
+ # Store current state
+ @pre_rollback_state = deep_copy_state
+
+ # Find the appropriate historical state
+ target_state = find_state_at_time(target_time)
+ return unless target_state
+
+ # Rollback to that state
+ restore_state(target_state)
+ end
+
+ def restore_current_state
+ return unless @pre_rollback_state
+
+ restore_state(@pre_rollback_state)
+ @pre_rollback_state = nil
+ end
+
+ def reconcile_client_state(player_id, predicted_state, sequence)
+ player = @players[player_id]
+ return unless player
+
+ # Compare client prediction with server state
+ server_position = { x: player.x, y: player.y }
+ client_position = predicted_state[:position]
+
+ # If there's a significant difference, send correction
+ distance = Math.sqrt(
+ (server_position[:x] - client_position[:x]) ** 2 +
+ (server_position[:y] - client_position[:y]) ** 2
+ )
+
+ if distance > 10 # Tolerance threshold
+ get_player_view(player_id)&.send_message({
+ type: "position_correction",
+ sequence: sequence,
+ authoritative_position: server_position,
+ timestamp: Time.now.to_f * 1000
+ })
+ end
+ end
+
+ def empty?
+ @players.empty?
+ end
+
+ def cleanup
+ @game_loop_thread&.kill
+ end
+
+ private
+
+ def start_game_loop
+ @game_loop_thread = Thread.new do
+ loop do
+ start_time = Time.now
+
+ update_game_state
+ broadcast_state_updates
+
+ # Maintain consistent tick rate
+ elapsed = Time.now - start_time
+ sleep_time = TICK_INTERVAL - elapsed
+ sleep(sleep_time) if sleep_time > 0
+
+ @tick_count += 1
+ @last_tick_time = Time.now
+ end
+ rescue => e
+ puts "Game loop error: #{e.message}"
+ puts e.backtrace
+ end
+ end
+
+ def update_game_state
+ # Update bullets
+ update_bullets
+
+ # Update game state (round timer, bomb timer, etc.)
+ @game_state.update(TICK_INTERVAL)
+
+ # Check win conditions
+ check_round_end_conditions
+
+ # Store state snapshot for lag compensation
+ store_state_snapshot if (@tick_count % 5) == 0 # Store every 5 ticks
+
+ # Clean up old state history (keep last 2 seconds)
+ cleanup_old_state_history
+ end
+
+ def update_bullets
+ @bullets.each do |bullet|
+ bullet.update
+
+ # Check for hits
+ @players.each do |id, player|
+ next if id == bullet.owner_id
+ next if player.dead?
+
+ if bullet.hits?(player.x, player.y, 15)
+ process_bullet_hit(bullet, player)
+ end
+ end
+ end
+
+ # Remove expired bullets
+ @bullets.reject! { |b| b.hit || b.out_of_bounds? }
+ end
+
+ def process_bullet_hit(bullet, player)
+ return if bullet.hit
+
+ damage = calculate_damage(bullet, player)
+ player.take_damage(damage)
+ bullet.hit = true
+
+ # Award kill money and update stats
+ if player.dead?
+ killer = @players[bullet.owner_id]
+ if killer
+ killer.money += 300
+ killer.kills += 1
+ end
+
+ broadcast_to_all_players({
+ type: "player_killed",
+ victim_id: player.id,
+ killer_id: bullet.owner_id,
+ weapon: killer&.current_weapon&.[](:name),
+ timestamp: Time.now.to_f * 1000
+ })
+ else
+ broadcast_to_all_players({
+ type: "player_hit",
+ victim_id: player.id,
+ damage: damage,
+ health_remaining: player.health,
+ timestamp: Time.now.to_f * 1000
+ })
+ end
+ end
+
+ def broadcast_state_updates
+ # Create delta update (only send changes)
+ current_state = create_state_snapshot
+ delta_update = calculate_delta_update(@last_state_snapshot, current_state)
+
+ if delta_update[:has_changes]
+ message = {
+ type: "game_state_delta",
+ tick: @tick_count,
+ timestamp: Time.now.to_f * 1000,
+ delta: delta_update[:delta]
+ }
+
+ broadcast_to_all_players(message)
+ end
+
+ @last_state_snapshot = current_state
+ end
+
+ def create_state_snapshot
+ {
+ players: @players.transform_values { |p| player_data(p) },
+ bullets: @bullets.map { |b| bullet_data(b) },
+ round_time: @game_state.round_time_left,
+ scores: @game_state.scores,
+ game_events: @game_state.recent_events
+ }
+ end
+
+ def calculate_delta_update(old_state, new_state)
+ delta = {}
+ has_changes = false
+
+ # Check for player changes
+ if old_state[:players] != new_state[:players]
+ delta[:players] = new_state[:players]
+ has_changes = true
+ end
+
+ # Check for bullet changes
+ if old_state[:bullets] != new_state[:bullets]
+ delta[:bullets] = new_state[:bullets]
+ has_changes = true
+ end
+
+ # Check for round time changes
+ if old_state[:round_time] != new_state[:round_time]
+ delta[:round_time] = new_state[:round_time]
+ has_changes = true
+ end
+
+ # Check for score changes
+ if old_state[:scores] != new_state[:scores]
+ delta[:scores] = new_state[:scores]
+ has_changes = true
+ end
+
+ # Always include new game events
+ if new_state[:game_events] && !new_state[:game_events].empty?
+ delta[:game_events] = new_state[:game_events]
+ has_changes = true
+ end
+
+ { has_changes: has_changes, delta: delta }
+ end
+
+ def check_round_end_conditions
+ return unless @game_state.round_active?
+
+ ct_alive = @players.values.count { |p| p.team == :ct && !p.dead? }
+ t_alive = @players.values.count { |p| p.team == :t && !p.dead? }
+
+ if ct_alive == 0 && t_alive > 0
+ end_round(:t, "elimination")
+ elsif t_alive == 0 && ct_alive > 0
+ end_round(:ct, "elimination")
+ elsif @game_state.round_time_left <= 0
+ end_round(:ct, "time")
+ elsif @game_state.bomb_exploded?
+ end_round(:t, "bomb_explosion")
+ elsif @game_state.bomb_defused?
+ end_round(:ct, "bomb_defused")
+ end
+ end
+
+ def end_round(winning_team, reason)
+ @game_state.end_round(winning_team, reason)
+
+ # Award money
+ award_round_money(winning_team, reason)
+
+ broadcast_to_all_players({
+ type: "round_ended",
+ winning_team: winning_team,
+ reason: reason,
+ scores: @game_state.scores,
+ round_number: @game_state.round_number,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ # Start new round after delay
+ Thread.new do
+ sleep(5) # 5 second delay
+ start_new_round
+ end
+ end
+
+ def start_new_round
+ @bullets.clear
+
+ @players.each do |_, player|
+ player.reset_for_new_round
+ spawn_pos = get_spawn_position(player.team)
+ player.x = spawn_pos[:x]
+ player.y = spawn_pos[:y]
+ end
+
+ @game_state.start_new_round
+
+ broadcast_to_all_players({
+ type: "round_started",
+ round_number: @game_state.round_number,
+ buy_time: @game_state.buy_time,
+ timestamp: Time.now.to_f * 1000
+ })
+ end
+
+ def award_round_money(winning_team, reason)
+ case reason
+ when "elimination", "bomb_defused"
+ award_team_money(winning_team, 3250)
+ award_team_money(winning_team == :ct ? :t : :ct, 1400)
+ when "time"
+ award_team_money(:ct, 3250)
+ award_team_money(:t, 1400)
+ when "bomb_explosion"
+ award_team_money(:t, 3500)
+ award_team_money(:ct, 1400)
+ end
+ end
+
+ def award_team_money(team, amount)
+ @players.values.select { |p| p.team == team }.each do |player|
+ player.money = [player.money + amount, 16000].min
+ end
+ end
+
+ def player_data(player)
+ {
+ id: player.id,
+ name: player.name,
+ team: player.team,
+ x: player.x,
+ y: player.y,
+ health: player.health,
+ armor: player.armor,
+ money: player.money,
+ kills: player.kills,
+ deaths: player.deaths,
+ dead: player.dead?,
+ weapon: player.current_weapon[:name],
+ ammo: player.ammo_info
+ }
+ end
+
+ def bullet_data(bullet)
+ {
+ id: bullet.id,
+ x: bullet.x,
+ y: bullet.y,
+ angle: bullet.angle,
+ owner_id: bullet.owner_id
+ }
+ end
+
+ def bullets_data
+ @bullets.map { |bullet| bullet_data(bullet) }
+ end
+
+ def players_data
+ @players.transform_values { |player| player_data(player) }
+ end
+
+ def determine_team_for_new_player
+ return :ct if @team_balance_enabled.nil?
+ return :ct if @players.empty?
+
+ ct_count = @players.values.count { |p| p.team == :ct }
+ t_count = @players.values.count { |p| p.team == :t }
+
+ ct_count <= t_count ? :ct : :t
+ end
+
+ def generate_player_name(player_id)
+ "Player#{player_id[0..4]}"
+ end
+
+ def get_spawn_position(team)
+ spawn_points = team == :ct ? ct_spawn_points : t_spawn_points
+ spawn_points.sample
+ end
+
+ def ct_spawn_points
+ [
+ { x: 100, y: 100 },
+ { x: 150, y: 100 },
+ { x: 100, y: 150 },
+ { x: 150, y: 150 },
+ { x: 125, y: 125 }
+ ]
+ end
+
+ def t_spawn_points
+ [
+ { x: 1100, y: 600 },
+ { x: 1150, y: 600 },
+ { x: 1100, y: 650 },
+ { x: 1150, y: 650 },
+ { x: 1125, y: 625 }
+ ]
+ end
+
+ def valid_position?(x, y, player_id)
+ # Basic bounds checking
+ return false if x < 20 || x > 1260 || y < 20 || y > 700
+
+ # Check collision with other players (simple)
+ @players.each do |id, player|
+ next if id == player_id || player.dead?
+
+ distance = Math.sqrt((x - player.x) ** 2 + (y - player.y) ** 2)
+ return false if distance < 25 # Players can't overlap
+ end
+
+ true
+ end
+
+ def get_bomb_site_at_position(x, y)
+ bomb_sites = [
+ { id: :a, x: 200, y: 200, radius: 50 },
+ { id: :b, x: 1000, y: 500, radius: 50 }
+ ]
+
+ bomb_sites.each do |site|
+ distance = Math.sqrt((x - site[:x]) ** 2 + (y - site[:y]) ** 2)
+ return site[:id] if distance <= site[:radius]
+ end
+
+ nil
+ end
+
+ def kick_player(player_id)
+ player = @players[player_id]
+ return unless player
+
+ broadcast_to_all_players({
+ type: "player_kicked",
+ player_id: player_id,
+ player_name: player.name,
+ timestamp: Time.now.to_f * 1000
+ })
+
+ remove_player(player_id)
+ end
+
+ def calculate_damage(bullet, player)
+ # Base damage
+ damage = bullet.damage
+
+ # Distance falloff (simplified)
+ distance = Math.sqrt((bullet.x - player.x) ** 2 + (bullet.y - player.y) ** 2)
+ if distance > 500
+ damage *= 0.8
+ elsif distance > 1000
+ damage *= 0.6
+ end
+
+ # Armor reduction
+ if player.armor > 0
+ damage *= 0.5
+ end
+
+ damage.to_i
+ end
+
+ def store_state_snapshot
+ snapshot = {
+ timestamp: Time.now.to_f * 1000,
+ tick: @tick_count,
+ players: @players.transform_values { |p| { x: p.x, y: p.y, health: p.health } },
+ bullets: @bullets.map { |b| { x: b.x, y: b.y, angle: b.angle } }
+ }
+
+ @state_history << snapshot
+ end
+
+ def cleanup_old_state_history
+ cutoff_time = (Time.now.to_f * 1000) - 2000 # 2 seconds
+ @state_history.reject! { |state| state[:timestamp] < cutoff_time }
+ end
+
+ def find_state_at_time(target_time)
+ @state_history.reverse.find { |state| state[:timestamp] <= target_time }
+ end
+
+ def deep_copy_state
+ {
+ players: @players.transform_values { |p| { x: p.x, y: p.y, health: p.health } },
+ bullets: @bullets.map { |b| { x: b.x, y: b.y, angle: b.angle, hit: b.hit } }
+ }
+ end
+
+ def restore_state(state)
+ # Restore player positions
+ state[:players].each do |id, player_state|
+ next unless @players[id]
+
+ @players[id].x = player_state[:x]
+ @players[id].y = player_state[:y]
+ @players[id].health = player_state[:health] if player_state[:health]
+ end
+
+ # Restore bullet positions (if needed)
+ if state[:bullets]
+ @bullets.each_with_index do |bullet, index|
+ if bullet_state = state[:bullets][index]
+ bullet.x = bullet_state[:x]
+ bullet.y = bullet_state[:y]
+ bullet.angle = bullet_state[:angle]
+ bullet.hit = bullet_state[:hit] if bullet_state.key?(:hit)
+ end
+ end
+ end
+ end
+
+ def check_bullet_hits(bullet, timestamp)
+ hits = []
+
+ # Use lag compensation for hit detection
+ apply_lag_compensation(timestamp)
+
+ @players.each do |id, player|
+ next if id == bullet.owner_id || player.dead?
+
+ if bullet.hits?(player.x, player.y, 15)
+ hits << {
+ player_id: id,
+ damage: calculate_damage(bullet, player),
+ position: { x: player.x, y: player.y }
+ }
+ end
+ end
+
+ restore_current_state
+ hits
+ end
+
+ def update_player_in_authoritative_state(player_id, player)
+ @authoritative_state[:players][player_id] = {
+ x: player.x,
+ y: player.y,
+ health: player.health,
+ armor: player.armor,
+ weapon: player.current_weapon[:name],
+ last_update: Time.now.to_f * 1000
+ }
+ end
+
+ def default_room_settings
+ {
+ map: "de_dust2",
+ max_rounds: 30,
+ round_time: 115, # seconds
+ buy_time: 20, # seconds
+ game_mode: "competitive",
+ friendly_fire: false,
+ auto_balance: true
+ }
+ end
+
+ WEAPONS = {
+ glock: { name: "Glock-18", price: 400, damage: 28, rate: 0.15, magazine: 20, bullet_speed: 20, penetration: 1 },
+ usp: { name: "USP-S", price: 500, damage: 35, rate: 0.17, magazine: 12, bullet_speed: 20, penetration: 1 },
+ deagle: { name: "Desert Eagle", price: 700, damage: 48, rate: 0.225, magazine: 7, bullet_speed: 25, penetration: 2 },
+ ak47: { name: "AK-47", price: 2700, damage: 36, rate: 0.1, magazine: 30, bullet_speed: 22, penetration: 2 },
+ m4a1: { name: "M4A1", price: 3100, damage: 33, rate: 0.09, magazine: 30, bullet_speed: 23, penetration: 2 },
+ awp: { name: "AWP", price: 4750, damage: 115, rate: 1.45, magazine: 10, bullet_speed: 30, penetration: 3 },
+ mp5: { name: "MP5", price: 1500, damage: 26, rate: 0.075, magazine: 30, bullet_speed: 20, penetration: 1 },
+ p90: { name: "P90", price: 2350, damage: 26, rate: 0.07, magazine: 50, bullet_speed: 21, penetration: 1 }
+ }.freeze
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/network_manager.rb b/examples/cs2d/game/network_manager.rb
new file mode 100644
index 0000000..2409082
--- /dev/null
+++ b/examples/cs2d/game/network_manager.rb
@@ -0,0 +1,292 @@
+# frozen_string_literal: true
+
+class NetworkManager
+ attr_reader :latency, :packet_loss, :jitter
+
+ def initialize
+ @latency = 0
+ @packet_loss = 0.0
+ @jitter = 0
+ @last_ping_time = Time.now
+ @ping_samples = []
+ @packet_stats = { sent: 0, received: 0, lost: 0 }
+
+ # Network optimization settings
+ @compression_enabled = true
+ @delta_compression = true
+ @priority_system_enabled = true
+
+ # Message queuing
+ @outgoing_messages = []
+ @incoming_message_buffer = []
+ @message_priorities = {}
+ end
+
+ def calculate_latency(server_timestamp, client_timestamp)
+ return 0 unless server_timestamp && client_timestamp
+
+ rtt = (client_timestamp - server_timestamp).abs
+ @ping_samples << rtt
+
+ # Keep only last 10 samples
+ @ping_samples = @ping_samples.last(10)
+
+ # Calculate average latency
+ @latency = @ping_samples.sum / @ping_samples.size
+
+ # Calculate jitter (variance in latency)
+ if @ping_samples.size > 1
+ avg = @latency
+ variance = @ping_samples.map { |sample| (sample - avg) ** 2 }.sum / @ping_samples.size
+ @jitter = Math.sqrt(variance)
+ end
+
+ @latency
+ end
+
+ def update_packet_stats(packets_sent, packets_received)
+ @packet_stats[:sent] = packets_sent
+ @packet_stats[:received] = packets_received
+ @packet_stats[:lost] = packets_sent - packets_received
+
+ @packet_loss = @packet_stats[:sent] > 0 ?
+ (@packet_stats[:lost].to_f / @packet_stats[:sent]) * 100 : 0
+ end
+
+ def compress_message(message)
+ return message unless @compression_enabled
+
+ # Simple JSON compression - remove null/empty values
+ compressed = {}
+
+ message.each do |key, value|
+ case value
+ when Hash
+ compressed_hash = compress_hash(value)
+ compressed[key] = compressed_hash unless compressed_hash.empty?
+ when Array
+ compressed_array = value.reject(&:nil?).compact
+ compressed[key] = compressed_array unless compressed_array.empty?
+ when nil, "", 0, false
+ # Skip empty values
+ else
+ compressed[key] = value
+ end
+ end
+
+ compressed
+ end
+
+ def decompress_message(message)
+ # Currently just returns the message as-is
+ # Could implement proper decompression if needed
+ message
+ end
+
+ def prioritize_message(message)
+ return message unless @priority_system_enabled
+
+ priority = calculate_message_priority(message)
+ message[:priority] = priority
+ message
+ end
+
+ def queue_outgoing_message(message)
+ prioritized_message = prioritize_message(message)
+ compressed_message = compress_message(prioritized_message)
+
+ @outgoing_messages << {
+ message: compressed_message,
+ priority: compressed_message[:priority] || 0,
+ timestamp: Time.now.to_f * 1000,
+ attempts: 0
+ }
+
+ # Sort by priority (higher priority first)
+ @outgoing_messages.sort_by! { |msg| -msg[:priority] }
+ end
+
+ def get_next_outgoing_message
+ return nil if @outgoing_messages.empty?
+
+ # Get highest priority message
+ message_data = @outgoing_messages.shift
+ message_data[:attempts] += 1
+
+ # If this is a critical message and it fails, retry it
+ if message_data[:priority] >= 8 && message_data[:attempts] < 3
+ # Re-queue for retry
+ @outgoing_messages.unshift(message_data)
+ end
+
+ message_data[:message]
+ end
+
+ def process_incoming_message(raw_message)
+ decompressed = decompress_message(raw_message)
+
+ # Add to processing buffer
+ @incoming_message_buffer << {
+ message: decompressed,
+ received_at: Time.now.to_f * 1000,
+ processed: false
+ }
+
+ # Process messages in order
+ process_message_buffer
+ end
+
+ def get_network_stats
+ {
+ latency: @latency.round(2),
+ jitter: @jitter.round(2),
+ packet_loss: @packet_loss.round(2),
+ packets_sent: @packet_stats[:sent],
+ packets_received: @packet_stats[:received],
+ packets_lost: @packet_stats[:lost],
+ outgoing_queue_size: @outgoing_messages.size,
+ incoming_buffer_size: @incoming_message_buffer.size
+ }
+ end
+
+ def should_use_lag_compensation?
+ @latency > 50 # Use lag compensation if latency > 50ms
+ end
+
+ def should_use_prediction?
+ @latency > 30 || @packet_loss > 2.0
+ end
+
+ def should_use_interpolation?
+ @jitter > 10 || @packet_loss > 1.0
+ end
+
+ def get_prediction_time
+ # How far ahead to predict based on network conditions
+ base_prediction = @latency / 1000.0 # Convert to seconds
+ jitter_factor = @jitter / 1000.0
+
+ # Add some buffer for packet loss
+ packet_loss_factor = @packet_loss / 100.0 * 0.1
+
+ base_prediction + jitter_factor + packet_loss_factor
+ end
+
+ def get_interpolation_delay
+ # How much to delay interpolation to smooth out jitter
+ [@jitter / 1000.0 * 2, 0.1].min # Maximum 100ms delay
+ end
+
+ def adaptive_quality_settings
+ settings = {
+ update_rate: 30, # Default 30 Hz
+ compression_level: 1,
+ delta_updates: true,
+ position_smoothing: true
+ }
+
+ # Adjust based on network conditions
+ if @latency > 100
+ settings[:update_rate] = 20 # Reduce update rate for high latency
+ settings[:compression_level] = 2 # Increase compression
+ end
+
+ if @packet_loss > 5.0
+ settings[:delta_updates] = false # Use full updates for packet loss
+ settings[:compression_level] = 3 # Maximum compression
+ end
+
+ if @jitter > 20
+ settings[:position_smoothing] = true # Enable extra smoothing
+ settings[:update_rate] = 25 # Slightly reduce update rate
+ end
+
+ settings
+ end
+
+ def estimate_server_time(local_time)
+ # Estimate what the server time is right now
+ local_time + (@latency / 2000.0) # Half RTT in seconds
+ end
+
+ def clear_message_queues
+ @outgoing_messages.clear
+ @incoming_message_buffer.clear
+ end
+
+ def reset_stats
+ @ping_samples.clear
+ @packet_stats = { sent: 0, received: 0, lost: 0 }
+ @latency = 0
+ @packet_loss = 0.0
+ @jitter = 0
+ end
+
+ private
+
+ def compress_hash(hash)
+ compressed = {}
+ hash.each do |key, value|
+ case value
+ when nil, "", 0, false
+ # Skip empty values
+ when Hash
+ sub_compressed = compress_hash(value)
+ compressed[key] = sub_compressed unless sub_compressed.empty?
+ when Array
+ compressed_array = value.reject(&:nil?).compact
+ compressed[key] = compressed_array unless compressed_array.empty?
+ else
+ compressed[key] = value
+ end
+ end
+ compressed
+ end
+
+ def calculate_message_priority(message)
+ case message[:type]
+ when "player_shot", "player_killed", "player_hit"
+ 10 # Highest priority - combat events
+ when "movement_result", "position_correction"
+ 9 # High priority - movement validation
+ when "game_state_delta", "round_started", "round_ended"
+ 8 # Important game state
+ when "player_joined", "player_left"
+ 7 # Player management
+ when "weapon_purchased", "team_changed"
+ 6 # Game actions
+ when "chat_message", "vote_kick_update"
+ 5 # Social features
+ when "bomb_plant_started", "bomb_defuse_started"
+ 10 # Critical game events
+ when "full_game_state"
+ 4 # Large state updates (lower priority)
+ when "network_ping"
+ 3 # Network diagnostics
+ else
+ 5 # Default priority
+ end
+ end
+
+ def process_message_buffer
+ # Process messages in chronological order
+ @incoming_message_buffer.sort_by! { |msg| msg[:received_at] }
+
+ processed_messages = []
+
+ @incoming_message_buffer.each do |msg_data|
+ next if msg_data[:processed]
+
+ # Process the message
+ yield msg_data[:message] if block_given?
+
+ msg_data[:processed] = true
+ processed_messages << msg_data
+ end
+
+ # Clean up processed messages
+ @incoming_message_buffer.reject! { |msg| msg[:processed] }
+
+ processed_messages
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/player.rb b/examples/cs2d/game/player.rb
index cb6cd04..59e4af0 100644
--- a/examples/cs2d/game/player.rb
+++ b/examples/cs2d/game/player.rb
@@ -205,6 +205,23 @@ def buy_helmet
true
end
+ def ammo_info
+ {
+ magazine: @ammo[:magazine],
+ reserve: @ammo[:reserve],
+ magazine_size: current_weapon[:magazine_size] || current_weapon[:magazine]
+ }
+ end
+
+ def can_reload?
+ !@is_reloading && @ammo[:magazine] < current_weapon[:magazine_size] && @ammo[:reserve] > 0
+ end
+
+ def has_defuse_kit
+ # For now, CT players have 50% chance of having defuse kit
+ @team == :ct && rand < 0.5
+ end
+
private
def ct_spawn_points
diff --git a/examples/cs2d/game/room_manager.rb b/examples/cs2d/game/room_manager.rb
new file mode 100644
index 0000000..6f0cac0
--- /dev/null
+++ b/examples/cs2d/game/room_manager.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require_relative "multiplayer_game_room"
+
+class RoomManager
+ def initialize
+ @rooms = {}
+ @player_to_room = {}
+ @room_counter = 0
+ end
+
+ def find_or_create_room(player_id)
+ # Check if player is already in a room
+ existing_room_id = @player_to_room[player_id]
+ return existing_room_id if existing_room_id && @rooms[existing_room_id]
+
+ # Try to find a room with space
+ available_room = find_available_room
+
+ if available_room
+ @player_to_room[player_id] = available_room.room_id
+ return available_room.room_id
+ else
+ # Create a new room
+ return create_room(player_id)
+ end
+ end
+
+ def create_room(creator_id, settings = {})
+ @room_counter += 1
+ room_id = "room_#{@room_counter}"
+
+ room = MultiplayerGameRoom.new(room_id, settings)
+ @rooms[room_id] = room
+ @player_to_room[creator_id] = room_id
+
+ puts "Created new room: #{room_id}"
+ room_id
+ end
+
+ def join_room(player_id, room_id)
+ room = @rooms[room_id]
+ return false unless room
+
+ # Remove from current room if any
+ leave_room(player_id)
+
+ @player_to_room[player_id] = room_id
+ true
+ end
+
+ def leave_room(player_id)
+ room_id = @player_to_room[player_id]
+ return unless room_id
+
+ room = @rooms[room_id]
+ if room
+ room.remove_player(player_id)
+ end
+
+ @player_to_room.delete(player_id)
+ end
+
+ def get_room(room_id)
+ @rooms[room_id]
+ end
+
+ def get_player_room(player_id)
+ room_id = @player_to_room[player_id]
+ return nil unless room_id
+
+ @rooms[room_id]
+ end
+
+ def cleanup_empty_room(room_id)
+ room = @rooms[room_id]
+ return unless room
+
+ if room.empty?
+ room.cleanup
+ @rooms.delete(room_id)
+
+ # Clean up player mappings
+ @player_to_room.delete_if { |_, rid| rid == room_id }
+
+ puts "Cleaned up empty room: #{room_id}"
+ end
+ end
+
+ def get_room_list
+ @rooms.map do |room_id, room|
+ {
+ room_id: room_id,
+ player_count: room.players.size,
+ max_players: MultiplayerGameRoom::MAX_PLAYERS,
+ map: room.room_settings[:map],
+ game_mode: room.room_settings[:game_mode]
+ }
+ end
+ end
+
+ def get_stats
+ {
+ total_rooms: @rooms.size,
+ total_players: @player_to_room.size,
+ rooms: get_room_list
+ }
+ end
+
+ def force_cleanup_player(player_id)
+ # Force remove player from all systems
+ leave_room(player_id)
+ end
+
+ private
+
+ def find_available_room
+ @rooms.values.find do |room|
+ room.players.size < MultiplayerGameRoom::MAX_PLAYERS
+ end
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/game/weapon_config.rb b/examples/cs2d/game/weapon_config.rb
new file mode 100644
index 0000000..3760e73
--- /dev/null
+++ b/examples/cs2d/game/weapon_config.rb
@@ -0,0 +1,243 @@
+# frozen_string_literal: true
+
+# Balanced weapon configuration for CS2D
+# Values optimized for 60 FPS gameplay with 10 players
+class WeaponConfig
+ # Weapon definitions with balanced stats
+ WEAPONS = {
+ # Pistols
+ 'glock' => {
+ name: 'Glock-18',
+ type: 'pistol',
+ cost: 200,
+ damage: { base: 28, headshot_multiplier: 2.5, armor_reduction: 0.75 },
+ firerate: 400, # ms between shots (higher = slower)
+ accuracy: { standing: 0.85, moving: 0.65, crouching: 0.95 },
+ recoil: { pattern: [0.2, 0.3, 0.4, 0.5], recovery: 0.8 },
+ ammo: { magazine: 20, reserve: 120, reload_time: 2200 },
+ range: { effective: 300, max: 500 },
+ movement_speed_multiplier: 1.0,
+ penetration_power: 1,
+ kill_reward: 600
+ },
+
+ 'usp' => {
+ name: 'USP-S',
+ type: 'pistol',
+ cost: 200,
+ damage: { base: 34, headshot_multiplier: 2.5, armor_reduction: 0.75 },
+ firerate: 350,
+ accuracy: { standing: 0.90, moving: 0.70, crouching: 0.98 },
+ recoil: { pattern: [0.15, 0.25, 0.35, 0.45], recovery: 0.9 },
+ ammo: { magazine: 12, reserve: 100, reload_time: 2500 },
+ range: { effective: 350, max: 550 },
+ movement_speed_multiplier: 1.0,
+ penetration_power: 1,
+ kill_reward: 600
+ },
+
+ 'deagle' => {
+ name: 'Desert Eagle',
+ type: 'pistol',
+ cost: 700,
+ damage: { base: 63, headshot_multiplier: 2.5, armor_reduction: 0.85 },
+ firerate: 267,
+ accuracy: { standing: 0.75, moving: 0.40, crouching: 0.85 },
+ recoil: { pattern: [0.8, 1.2, 1.5, 1.8], recovery: 0.6 },
+ ammo: { magazine: 7, reserve: 35, reload_time: 2200 },
+ range: { effective: 400, max: 600 },
+ movement_speed_multiplier: 0.95,
+ penetration_power: 2,
+ kill_reward: 300
+ },
+
+ # SMGs
+ 'mp5' => {
+ name: 'MP5-SD',
+ type: 'smg',
+ cost: 1500,
+ damage: { base: 26, headshot_multiplier: 2.0, armor_reduction: 0.6 },
+ firerate: 80,
+ accuracy: { standing: 0.70, moving: 0.85, crouching: 0.80 },
+ recoil: { pattern: [0.2, 0.25, 0.3, 0.35, 0.4], recovery: 0.85 },
+ ammo: { magazine: 30, reserve: 120, reload_time: 2600 },
+ range: { effective: 200, max: 400 },
+ movement_speed_multiplier: 1.15,
+ penetration_power: 1,
+ kill_reward: 600
+ },
+
+ # Rifles
+ 'ak47' => {
+ name: 'AK-47',
+ type: 'rifle',
+ cost: 2700,
+ damage: { base: 36, headshot_multiplier: 2.5, armor_reduction: 0.9 },
+ firerate: 100,
+ accuracy: { standing: 0.75, moving: 0.45, crouching: 0.85 },
+ recoil: { pattern: [0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], recovery: 0.7 },
+ ammo: { magazine: 30, reserve: 90, reload_time: 2500 },
+ range: { effective: 500, max: 800 },
+ movement_speed_multiplier: 0.85,
+ penetration_power: 2.5,
+ kill_reward: 300
+ },
+
+ 'm4a1' => {
+ name: 'M4A1-S',
+ type: 'rifle',
+ cost: 3100,
+ damage: { base: 33, headshot_multiplier: 2.5, armor_reduction: 0.9 },
+ firerate: 90,
+ accuracy: { standing: 0.80, moving: 0.50, crouching: 0.90 },
+ recoil: { pattern: [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9], recovery: 0.75 },
+ ammo: { magazine: 25, reserve: 75, reload_time: 3100 },
+ range: { effective: 550, max: 850 },
+ movement_speed_multiplier: 0.85,
+ penetration_power: 2.5,
+ kill_reward: 300
+ },
+
+ # Sniper Rifles
+ 'awp' => {
+ name: 'AWP',
+ type: 'sniper',
+ cost: 4750,
+ damage: { base: 115, headshot_multiplier: 1.0, armor_reduction: 0.95 },
+ firerate: 1470,
+ accuracy: { standing: 0.99, moving: 0.20, crouching: 0.99 },
+ recoil: { pattern: [1.5], recovery: 0.5 },
+ ammo: { magazine: 10, reserve: 30, reload_time: 3700 },
+ range: { effective: 1000, max: 1200 },
+ movement_speed_multiplier: 0.65,
+ penetration_power: 3,
+ kill_reward: 100
+ },
+
+ # Equipment
+ 'armor' => {
+ name: 'Kevlar Vest',
+ type: 'equipment',
+ cost: 650,
+ armor_value: 100,
+ damage_reduction: 0.5
+ },
+
+ 'armor_helmet' => {
+ name: 'Kevlar + Helmet',
+ type: 'equipment',
+ cost: 1000,
+ armor_value: 100,
+ damage_reduction: 0.5,
+ headshot_protection: true
+ },
+
+ 'defuse_kit' => {
+ name: 'Defuse Kit',
+ type: 'equipment',
+ cost: 400,
+ defuse_time_reduction: 5000 # 5 seconds faster
+ },
+
+ # Grenades
+ 'he_grenade' => {
+ name: 'HE Grenade',
+ type: 'grenade',
+ cost: 300,
+ damage: { base: 99, max_range: 100, min_range: 300 },
+ throw_velocity: 15
+ },
+
+ 'smoke_grenade' => {
+ name: 'Smoke Grenade',
+ type: 'grenade',
+ cost: 300,
+ duration: 18000, # 18 seconds
+ radius: 150
+ },
+
+ 'flashbang' => {
+ name: 'Flashbang',
+ type: 'grenade',
+ cost: 200,
+ duration: 5000, # 5 seconds max blind
+ max_range: 200
+ }
+ }.freeze
+
+ # Economy configuration
+ ECONOMY = {
+ starting_money: 800,
+ max_money: 16000,
+ round_loss_bonus: [1400, 1900, 2400, 2900, 3400],
+ round_win_bonus: {
+ elimination: 3250,
+ time_expire: 3250,
+ bomb_explosion: 3500,
+ bomb_defused: 3500
+ },
+ consecutive_loss_bonus_cap: 3400
+ }.freeze
+
+ # Movement speed configuration (pixels per frame at 60fps)
+ MOVEMENT_SPEEDS = {
+ base_speed: 5.0,
+ crouch_multiplier: 0.34,
+ walk_multiplier: 0.52,
+ weapon_speed_multipliers: {
+ 'knife' => 1.15,
+ 'pistol' => 1.0,
+ 'smg' => 0.95,
+ 'rifle' => 0.85,
+ 'sniper' => 0.65,
+ 'grenade' => 0.9
+ }
+ }.freeze
+
+ def self.get_weapon(weapon_name)
+ WEAPONS[weapon_name&.downcase] || WEAPONS['glock']
+ end
+
+ def self.calculate_damage(weapon, distance, armor = 0, headshot = false)
+ weapon_stats = get_weapon(weapon)
+ base_damage = weapon_stats[:damage][:base]
+
+ # Distance falloff
+ effective_range = weapon_stats[:range][:effective]
+ max_range = weapon_stats[:range][:max]
+
+ damage_multiplier = if distance <= effective_range
+ 1.0
+ elsif distance >= max_range
+ 0.5
+ else
+ 1.0 - (0.5 * (distance - effective_range) / (max_range - effective_range))
+ end
+
+ damage = base_damage * damage_multiplier
+
+ # Armor reduction
+ if armor > 0
+ armor_multiplier = weapon_stats[:damage][:armor_reduction]
+ damage = damage * armor_multiplier
+ end
+
+ # Headshot multiplier
+ if headshot
+ headshot_multiplier = weapon_stats[:damage][:headshot_multiplier]
+ damage = damage * headshot_multiplier
+ end
+
+ damage.round
+ end
+
+ def self.get_movement_speed(weapon_type, base_speed = MOVEMENT_SPEEDS[:base_speed])
+ multiplier = MOVEMENT_SPEEDS[:weapon_speed_multipliers][weapon_type] || 1.0
+ base_speed * multiplier
+ end
+
+ def self.get_kill_reward(weapon_name)
+ weapon_stats = get_weapon(weapon_name)
+ weapon_stats[:kill_reward] || 300
+ end
+end
\ No newline at end of file
diff --git a/examples/cs2d/test_client.html b/examples/cs2d/test_client.html
new file mode 100644
index 0000000..5e60504
--- /dev/null
+++ b/examples/cs2d/test_client.html
@@ -0,0 +1,621 @@
+
+
+