first commit

This commit is contained in:
2026-02-26 17:52:48 +09:00
commit dabf1f3ba9
49 changed files with 14883 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(protoc:*)",
"Bash(go install:*)",
"Bash(winget install:*)",
"Bash(export:*)",
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(go:*)",
"Bash(\"C:/Users/SSAFY/AppData/Local/Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe\":*)"
]
}
}

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 쿼리 파일을 포함한 무시된 디폴트 폴더
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/

9
.idea/a301_game_server.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/a301_game_server.iml" filepath="$PROJECT_DIR$/.idea/a301_game_server.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

60
Makefile Normal file
View File

@@ -0,0 +1,60 @@
PROTOC := C:/Users/SSAFY/AppData/Local/Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe
GOPATH_BIN := $(shell go env GOPATH)/bin
.PHONY: build run proto proto-unity proto-all clean test testclient
# Build the server binary.
build:
go build -o bin/server.exe ./cmd/server
# Run the server.
run:
go run ./cmd/server -config config/config.yaml
# Generate Go server code from protobuf.
proto:
PATH="$(GOPATH_BIN):$(PATH)" $(PROTOC) \
--go_out=proto/gen/pb \
--go_opt=paths=source_relative \
--proto_path=proto \
proto/messages.proto
# Generate C# Unity client code from protobuf.
proto-unity:
$(PROTOC) \
--csharp_out=./unity/Assets/Scripts/Proto \
--proto_path=./proto \
proto/messages.proto
# Generate both Go and C# at once.
proto-all: proto proto-unity
# Clean build artifacts.
clean:
rm -rf bin/
# Run tests.
test:
go test ./... -v -race
# Run with AOI disabled for comparison.
run-no-aoi:
AOI_ENABLED=false go run ./cmd/server -config config/config.yaml
# Run the WebSocket test client (server must be running).
testclient:
go run ./cmd/testclient
# Run test client with specific scenario (auth|move|combat|metrics|all).
testclient-auth:
go run ./cmd/testclient -scenario auth
testclient-combat:
go run ./cmd/testclient -scenario combat
testclient-metrics:
go run ./cmd/testclient -scenario metrics
# Load test: 10 clients for 15 seconds.
loadtest:
go run ./cmd/testclient -clients 10 -duration 15s

865
UNITY_INTEGRATION.md Normal file
View File

@@ -0,0 +1,865 @@
# Unity 클라이언트 연동 가이드
## 목차
1. [패키지 설치](#1-패키지-설치)
2. [Protobuf C# 코드 생성](#2-protobuf-c-코드-생성)
3. [네트워크 레이어 구현](#3-네트워크-레이어-구현)
4. [메시지 타입 정의](#4-메시지-타입-정의)
5. [인증 흐름](#5-인증-흐름)
6. [이동 동기화](#6-이동-동기화)
7. [엔티티 관리 (AOI)](#7-엔티티-관리-aoi)
8. [전투 시스템](#8-전투-시스템)
9. [존 전환](#9-존-전환)
10. [디버그 도구](#10-디버그-도구)
11. [전체 GameManager 예시](#11-전체-gamemanager-예시)
---
## 1. 패키지 설치
### Unity Package Manager에서 설치
**Window → Package Manager → Add package by name**
```
com.unity.nuget.newtonsoft-json
```
### NuGet 또는 DLL 직접 추가
아래 두 패키지가 필요합니다.
| 패키지 | 용도 |
|--------|------|
| `NativeWebSocket` | WebSocket 통신 |
| `Google.Protobuf` | 메시지 직렬화 |
**NativeWebSocket** (무료, WebGL 지원)
- [https://github.com/endel/NativeWebSocket](https://github.com/endel/NativeWebSocket)
- `Assets/Plugins/` 폴더에 복사
**Google.Protobuf**
- NuGet에서 `Google.Protobuf` 다운로드
- `Google.Protobuf.dll``Assets/Plugins/` 폴더에 복사
---
## 2. Protobuf C# 코드 생성
서버의 `proto/messages.proto` 파일로부터 C# 코드를 생성합니다.
### protoc 설치 (Windows)
```
winget install Google.Protobuf
```
### C# 코드 생성
프로젝트 루트에서 실행:
```bash
protoc --csharp_out=./unity/Assets/Scripts/Proto \
--proto_path=./proto \
proto/messages.proto
```
생성된 `Messages.cs`를 Unity 프로젝트의 `Assets/Scripts/Proto/` 폴더에 배치합니다.
---
## 3. 네트워크 레이어 구현
### 와이어 프로토콜
서버와의 통신 형식은 다음과 같습니다.
```
[2바이트: 메시지 타입 ID (Big Endian)] [Protobuf 직렬화 페이로드]
```
### NetworkClient.cs
```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
using NativeWebSocket;
using Google.Protobuf;
public class NetworkClient : MonoBehaviour
{
public static NetworkClient Instance { get; private set; }
[Header("Server")]
[SerializeField] private string serverUrl = "ws://localhost:8080/ws";
private WebSocket _ws;
private readonly Queue<Action> _mainThreadQueue = new();
// 메시지 핸들러 테이블
private readonly Dictionary<ushort, Action<byte[]>> _handlers = new();
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
RegisterHandlers();
}
void Update()
{
#if !UNITY_WEBGL || UNITY_EDITOR
_ws?.DispatchMessageQueue();
#endif
// 메인 스레드에서 Unity API 호출
while (_mainThreadQueue.Count > 0)
_mainThreadQueue.Dequeue()?.Invoke();
}
public async void Connect()
{
_ws = new WebSocket(serverUrl);
_ws.OnOpen += () => Debug.Log("[Net] Connected");
_ws.OnClose += (e) => Debug.Log($"[Net] Disconnected: {e}");
_ws.OnError += (e) => Debug.LogError($"[Net] Error: {e}");
_ws.OnMessage += OnRawMessage;
await _ws.Connect();
}
public async void Disconnect()
{
if (_ws != null)
await _ws.Close();
}
// ─── 송신 ──────────────────────────────────────────────────
public async void Send(ushort msgType, IMessage payload)
{
if (_ws?.State != WebSocketState.Open) return;
byte[] body = payload.ToByteArray();
byte[] packet = new byte[2 + body.Length];
// Big Endian 2바이트 타입 ID
packet[0] = (byte)(msgType >> 8);
packet[1] = (byte)(msgType & 0xFF);
Buffer.BlockCopy(body, 0, packet, 2, body.Length);
await _ws.Send(packet);
}
// ─── 수신 ──────────────────────────────────────────────────
private void OnRawMessage(byte[] data)
{
if (data.Length < 2) return;
ushort msgType = (ushort)((data[0] << 8) | data[1]);
byte[] payload = new byte[data.Length - 2];
Buffer.BlockCopy(data, 2, payload, 0, payload.Length);
_mainThreadQueue.Enqueue(() =>
{
if (_handlers.TryGetValue(msgType, out var handler))
handler(payload);
else
Debug.LogWarning($"[Net] Unhandled message type: 0x{msgType:X4}");
});
}
public void Register(ushort msgType, Action<byte[]> handler)
{
_handlers[msgType] = handler;
}
// 핸들러 등록은 GameManager에서 수행 (아래 참조)
private void RegisterHandlers() { }
}
```
---
## 4. 메시지 타입 정의
```csharp
public static class MsgType
{
// Auth
public const ushort LoginRequest = 0x0001;
public const ushort LoginResponse = 0x0002;
public const ushort EnterWorldRequest = 0x0003;
public const ushort EnterWorldResponse = 0x0004;
// Movement
public const ushort MoveRequest = 0x0010;
public const ushort StateUpdate = 0x0011;
public const ushort SpawnEntity = 0x0012;
public const ushort DespawnEntity = 0x0013;
public const ushort ZoneTransferNotify = 0x0014;
// System
public const ushort Ping = 0x0020;
public const ushort Pong = 0x0021;
// Combat
public const ushort UseSkillRequest = 0x0040;
public const ushort UseSkillResponse = 0x0041;
public const ushort CombatEvent = 0x0042;
public const ushort BuffApplied = 0x0043;
public const ushort BuffRemoved = 0x0044;
public const ushort RespawnRequest = 0x0045;
public const ushort RespawnResponse = 0x0046;
// Debug
public const ushort AOIToggleRequest = 0x0030;
public const ushort AOIToggleResponse = 0x0031;
public const ushort MetricsRequest = 0x0032;
public const ushort ServerMetrics = 0x0033;
}
```
---
## 5. 인증 흐름
```
클라이언트 서버
│ │
│── LoginRequest ───────────────→│ username + password
│← LoginResponse ────────────────│ success, session_token, player_id
│ │
│── EnterWorldRequest ──────────→│ session_token
│← EnterWorldResponse ───────────│ self(EntityState), zone_id
│ │
│ [게임 진행] │
```
### AuthManager.cs
```csharp
public class AuthManager : MonoBehaviour
{
public static AuthManager Instance { get; private set; }
public string SessionToken { get; private set; }
public ulong PlayerId { get; private set; }
public event Action<bool, string> OnLoginResult;
public event Action<EntityState> OnEnterWorldResult;
void Awake()
{
Instance = this;
NetworkClient.Instance.Register(MsgType.LoginResponse, OnLoginResponse);
NetworkClient.Instance.Register(MsgType.EnterWorldResponse, OnEnterWorldResponse);
}
public void Login(string username, string password)
{
NetworkClient.Instance.Send(MsgType.LoginRequest, new LoginRequest
{
Username = username,
Password = password
});
}
public void EnterWorld()
{
NetworkClient.Instance.Send(MsgType.EnterWorldRequest, new EnterWorldRequest
{
SessionToken = SessionToken
});
}
private void OnLoginResponse(byte[] data)
{
var resp = LoginResponse.Parser.ParseFrom(data);
if (resp.Success)
{
SessionToken = resp.SessionToken;
PlayerId = resp.PlayerId;
}
OnLoginResult?.Invoke(resp.Success, resp.ErrorMessage);
}
private void OnEnterWorldResponse(byte[] data)
{
var resp = EnterWorldResponse.Parser.ParseFrom(data);
if (resp.Success)
OnEnterWorldResult?.Invoke(resp.Self);
}
}
```
---
## 6. 이동 동기화
### 전략: 클라이언트 예측 + 서버 권위
- 클라이언트는 **로컬에서 즉시 이동**을 적용합니다 (반응성 유지).
- 매 50ms(서버 틱)마다 현재 위치/속도를 서버에 전송합니다.
- 서버에서 받은 `StateUpdate`로 다른 엔티티의 위치를 보간합니다.
### MovementSender.cs (로컬 플레이어)
```csharp
public class MovementSender : MonoBehaviour
{
[SerializeField] private float sendInterval = 0.05f; // 50ms = 20 tick/s
private CharacterController _controller;
private float _timer;
private Vector3 _lastSentPos;
void Awake() => _controller = GetComponent<CharacterController>();
void Update()
{
// 로컬 이동 입력 처리 (CharacterController 등)
HandleInput();
_timer += Time.deltaTime;
if (_timer >= sendInterval)
{
_timer = 0f;
SendMoveIfChanged();
}
}
void HandleInput()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
Vector3 dir = new Vector3(h, 0, v).normalized;
_controller.Move(dir * 5f * Time.deltaTime);
}
void SendMoveIfChanged()
{
if (Vector3.Distance(transform.position, _lastSentPos) < 0.01f) return;
_lastSentPos = transform.position;
var vel = _controller.velocity;
NetworkClient.Instance.Send(MsgType.MoveRequest, new MoveRequest
{
Position = ToProtoVec3(transform.position),
Rotation = transform.eulerAngles.y,
Velocity = ToProtoVec3(vel)
});
}
static Vector3Proto ToProtoVec3(Vector3 v) => new() { X = v.x, Y = v.y, Z = v.z };
}
```
### RemoteEntityView.cs (다른 플레이어/몬스터)
```csharp
// 서버에서 받은 위치로 부드럽게 보간
public class RemoteEntityView : MonoBehaviour
{
private Vector3 _targetPos;
private float _targetRot;
private const float LerpSpeed = 15f;
public void ApplyState(EntityState state)
{
_targetPos = new Vector3(state.Position.X, state.Position.Y, state.Position.Z);
_targetRot = state.Rotation;
}
void Update()
{
transform.position = Vector3.Lerp(transform.position, _targetPos,
LerpSpeed * Time.deltaTime);
transform.rotation = Quaternion.Lerp(transform.rotation,
Quaternion.Euler(0, _targetRot, 0),
LerpSpeed * Time.deltaTime);
}
}
```
---
## 7. 엔티티 관리 (AOI)
서버 AOI가 관리하는 엔티티 생성/삭제를 클라이언트에서 처리합니다.
### EntityManager.cs
```csharp
public class EntityManager : MonoBehaviour
{
public static EntityManager Instance { get; private set; }
[SerializeField] private GameObject playerPrefab;
[SerializeField] private GameObject mobPrefab;
private readonly Dictionary<ulong, GameObject> _entities = new();
public ulong LocalPlayerId { get; set; }
void Awake()
{
Instance = this;
NetworkClient.Instance.Register(MsgType.StateUpdate, OnStateUpdate);
NetworkClient.Instance.Register(MsgType.SpawnEntity, OnSpawnEntity);
NetworkClient.Instance.Register(MsgType.DespawnEntity, OnDespawnEntity);
}
// ─── 수신 핸들러 ───────────────────────────────────────────
private void OnStateUpdate(byte[] data)
{
var update = StateUpdate.Parser.ParseFrom(data);
foreach (var state in update.Entities)
UpdateEntity(state);
}
private void OnSpawnEntity(byte[] data)
{
var msg = SpawnEntity_.Parser.ParseFrom(data); // 네임스페이스 주의
SpawnEntity(msg.Entity);
}
private void OnDespawnEntity(byte[] data)
{
var msg = DespawnEntity_.Parser.ParseFrom(data);
DespawnEntity(msg.EntityId);
}
// ─── 엔티티 수명 관리 ─────────────────────────────────────
void SpawnEntity(EntityState state)
{
if (state.EntityId == LocalPlayerId) return;
if (_entities.ContainsKey(state.EntityId)) return;
var prefab = state.EntityType == EntityType.EntityTypeMob ? mobPrefab : playerPrefab;
var go = Instantiate(prefab,
new Vector3(state.Position.X, state.Position.Y, state.Position.Z),
Quaternion.identity);
go.name = $"{state.Name}_{state.EntityId}";
go.GetComponent<RemoteEntityView>()?.ApplyState(state);
go.GetComponent<EntityUI>()?.Setup(state.Name, state.Hp, state.MaxHp);
_entities[state.EntityId] = go;
}
void UpdateEntity(EntityState state)
{
if (state.EntityId == LocalPlayerId) return;
if (!_entities.TryGetValue(state.EntityId, out var go))
{
SpawnEntity(state);
return;
}
go.GetComponent<RemoteEntityView>()?.ApplyState(state);
go.GetComponent<EntityUI>()?.UpdateHP(state.Hp, state.MaxHp);
}
void DespawnEntity(ulong entityId)
{
if (_entities.TryGetValue(entityId, out var go))
{
Destroy(go);
_entities.Remove(entityId);
}
}
public void ClearAll()
{
foreach (var go in _entities.Values)
if (go != null) Destroy(go);
_entities.Clear();
}
}
```
---
## 8. 전투 시스템
### CombatManager.cs
```csharp
public class CombatManager : MonoBehaviour
{
public static CombatManager Instance { get; private set; }
public event Action<CombatEvent> OnCombatEvent;
public event Action<BuffApplied> OnBuffApplied;
public event Action<BuffRemoved> OnBuffRemoved;
void Awake()
{
Instance = this;
NetworkClient.Instance.Register(MsgType.CombatEvent, OnCombatEventMsg);
NetworkClient.Instance.Register(MsgType.BuffApplied, OnBuffAppliedMsg);
NetworkClient.Instance.Register(MsgType.BuffRemoved, OnBuffRemovedMsg);
NetworkClient.Instance.Register(MsgType.UseSkillResponse, OnUseSkillResponse);
NetworkClient.Instance.Register(MsgType.RespawnResponse, OnRespawnResponse);
}
// ─── 스킬 사용 ────────────────────────────────────────────
// 단일 타겟 (Basic Attack, Fireball, Poison 등)
public void UseSkill(uint skillId, ulong targetEntityId)
{
NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest
{
SkillId = skillId,
TargetId = targetEntityId
});
}
// AoE 지면 타겟 (Flame Strike 등)
public void UseSkillAoE(uint skillId, Vector3 groundPos)
{
NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest
{
SkillId = skillId,
TargetPos = new Vector3Proto { X = groundPos.x, Y = groundPos.y, Z = groundPos.z }
});
}
// 셀프 타겟 (Heal, Power Up 등)
public void UseSkillSelf(uint skillId)
{
NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest
{
SkillId = skillId
});
}
// 리스폰 요청
public void RequestRespawn()
{
NetworkClient.Instance.Send(MsgType.RespawnRequest, new RespawnRequest());
}
// ─── 수신 핸들러 ──────────────────────────────────────────
private void OnCombatEventMsg(byte[] data)
{
var evt = CombatEvent.Parser.ParseFrom(data);
OnCombatEvent?.Invoke(evt);
switch (evt.EventType)
{
case CombatEventType.CombatEventDamage:
ShowDamageNumber(evt.TargetId, evt.Damage, evt.IsCritical);
UpdateEntityHP(evt.TargetId, evt.TargetHp, evt.TargetMaxHp);
if (evt.TargetDied)
OnEntityDied(evt.TargetId);
break;
case CombatEventType.CombatEventHeal:
ShowHealNumber(evt.TargetId, evt.Heal);
UpdateEntityHP(evt.TargetId, evt.TargetHp, evt.TargetMaxHp);
break;
case CombatEventType.CombatEventDeath:
OnEntityDied(evt.TargetId);
break;
case CombatEventType.CombatEventRespawn:
OnEntityRespawned(evt.TargetId, evt.TargetHp, evt.TargetMaxHp);
break;
}
}
private void OnBuffAppliedMsg(byte[] data)
{
var buff = BuffApplied.Parser.ParseFrom(data);
OnBuffApplied?.Invoke(buff);
// UI: 버프 아이콘 추가
BuffUI.Instance?.AddBuff(buff.TargetId, buff.BuffId, buff.BuffName,
buff.Duration, buff.IsDebuff);
}
private void OnBuffRemovedMsg(byte[] data)
{
var buff = BuffRemoved.Parser.ParseFrom(data);
OnBuffRemoved?.Invoke(buff);
BuffUI.Instance?.RemoveBuff(buff.TargetId, buff.BuffId);
}
private void OnUseSkillResponse(byte[] data)
{
var resp = UseSkillResponse.Parser.ParseFrom(data);
if (!resp.Success)
UIManager.Instance?.ShowError(resp.ErrorMessage); // "skill on cooldown" 등
}
private void OnRespawnResponse(byte[] data)
{
var resp = RespawnResponse.Parser.ParseFrom(data);
// 로컬 플레이어 상태 복구
LocalPlayer.Instance?.OnRespawned(resp.Self);
}
// ─── 헬퍼 ─────────────────────────────────────────────────
void ShowDamageNumber(ulong entityId, int damage, bool isCrit)
{
// 데미지 폰트 팝업
Debug.Log($"DMG {damage}{(isCrit ? " CRIT!" : "")} on {entityId}");
}
void ShowHealNumber(ulong entityId, int heal)
{
Debug.Log($"HEAL +{heal} on {entityId}");
}
void UpdateEntityHP(ulong entityId, int hp, int maxHp)
{
// EntityUI HP 바 업데이트
}
void OnEntityDied(ulong entityId)
{
if (entityId == AuthManager.Instance.PlayerId)
{
// 로컬 플레이어 사망 → 리스폰 UI 표시
UIManager.Instance?.ShowRespawnPanel();
}
// 사망 애니메이션 등
}
void OnEntityRespawned(ulong entityId, int hp, int maxHp) { }
}
```
### 스킬 ID 참조
| ID | 이름 | 타입 | 설명 |
|----|------|------|------|
| 1 | Basic Attack | 단일 적 | 근접 물리 공격 |
| 2 | Fireball | 단일 적 | 원거리 마법 |
| 3 | Heal | 자신 | HP 회복 |
| 4 | Flame Strike | 지면 AoE | 범위 화염 공격 |
| 5 | Poison | 단일 적 | 독 DoT (10초) |
| 6 | Power Up | 자신 | STR 버프 (10초) |
---
## 9. 존 전환
포탈 위치에 접근하면 서버가 `ZoneTransferNotify`를 보냅니다.
```csharp
public class ZoneManager : MonoBehaviour
{
public static ZoneManager Instance { get; private set; }
public uint CurrentZoneId { get; private set; }
void Awake()
{
Instance = this;
NetworkClient.Instance.Register(MsgType.ZoneTransferNotify, OnZoneTransfer);
}
private void OnZoneTransfer(byte[] data)
{
var notify = ZoneTransferNotify.Parser.ParseFrom(data);
// 1. 현재 존의 모든 엔티티 제거
EntityManager.Instance.ClearAll();
// 2. 로딩 화면 표시 (선택)
UIManager.Instance?.ShowLoading(true);
// 3. 새 존 정보 적용
CurrentZoneId = notify.NewZoneId;
// 4. 로컬 플레이어 위치 이동
var pos = notify.Self.Position;
LocalPlayer.Instance?.Teleport(new Vector3(pos.X, pos.Y, pos.Z));
// 5. 주변 엔티티 스폰
foreach (var entity in notify.NearbyEntities)
EntityManager.Instance.SpawnEntityPublic(entity);
UIManager.Instance?.ShowLoading(false);
Debug.Log($"[Zone] Transferred to zone {notify.NewZoneId}");
}
}
```
---
## 10. 디버그 도구
게임 중 AOI 토글과 서버 메트릭을 확인할 수 있습니다.
```csharp
public class DebugPanel : MonoBehaviour
{
void Start()
{
NetworkClient.Instance.Register(MsgType.AOIToggleResponse, OnAOIToggle);
NetworkClient.Instance.Register(MsgType.ServerMetrics, OnMetrics);
}
// AOI 켜기/끄기 (성능 비교용)
public void ToggleAOI(bool enabled)
{
NetworkClient.Instance.Send(MsgType.AOIToggleRequest, new AOIToggleRequest
{
Enabled = enabled
});
}
// 서버 메트릭 요청
public void RequestMetrics()
{
NetworkClient.Instance.Send(MsgType.MetricsRequest, new MetricsRequest());
}
private void OnAOIToggle(byte[] data)
{
var resp = AOIToggleResponse.Parser.ParseFrom(data);
Debug.Log($"[Debug] AOI: {resp.Message}");
}
private void OnMetrics(byte[] data)
{
var m = ServerMetrics.Parser.ParseFrom(data);
Debug.Log($"[Metrics] Players={m.OnlinePlayers} " +
$"Entities={m.TotalEntities} " +
$"Tick={m.TickDurationUs}us " +
$"AOI={m.AoiEnabled}");
}
}
```
---
## 11. 전체 GameManager 예시
모든 시스템을 하나의 씬에서 연결하는 예시입니다.
```csharp
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
[Header("UI")]
[SerializeField] private LoginPanel loginPanel;
[SerializeField] private HUDPanel hudPanel;
void Awake() => Instance = this;
void Start()
{
// 1. 서버 접속
NetworkClient.Instance.Connect();
// 2. 인증 이벤트 구독
AuthManager.Instance.OnLoginResult += HandleLoginResult;
AuthManager.Instance.OnEnterWorldResult += HandleEnterWorld;
// 3. 로그인 UI 표시
loginPanel.gameObject.SetActive(true);
}
// 로그인 버튼 → AuthManager.Login() 호출
public void OnLoginButtonClick(string user, string pass)
{
AuthManager.Instance.Login(user, pass);
}
private void HandleLoginResult(bool success, string error)
{
if (!success) { loginPanel.ShowError(error); return; }
loginPanel.gameObject.SetActive(false);
AuthManager.Instance.EnterWorld();
}
private void HandleEnterWorld(EntityState self)
{
// 로컬 플레이어 생성
EntityManager.Instance.LocalPlayerId = self.EntityId;
LocalPlayer.Instance?.Initialize(self);
// HUD 표시
hudPanel.gameObject.SetActive(true);
hudPanel.UpdateHP(self.Hp, self.MaxHp);
}
void OnApplicationQuit()
{
NetworkClient.Instance.Disconnect();
}
}
```
---
## 흐름 다이어그램
```
게임 시작
NetworkClient.Connect() ← ws://서버IP:8080/ws
AuthManager.Login() ← LoginRequest
│ LoginResponse (session_token)
AuthManager.EnterWorld() ← EnterWorldRequest
│ EnterWorldResponse (self, zone_id)
EntityManager 초기화 ← SpawnEntity (주변 플레이어/몬스터)
게임 루프 (매 프레임)
├─ MovementSender → MoveRequest (50ms마다)
├─ CombatManager → UseSkillRequest (스킬 버튼)
│ ← StateUpdate (20tick/s, 주변 엔티티 위치)
│ ← CombatEvent (피해/힐/사망)
│ ← BuffApplied / BuffRemoved
│ ← SpawnEntity / DespawnEntity (AOI 진입/이탈)
└─ ZoneManager ← ZoneTransferNotify (포탈 진입 시)
```
---
## 주의사항
### Protobuf 네임스페이스
생성된 C# 클래스 이름이 Unity 내장 타입과 충돌할 수 있습니다.
```csharp
// Vector3는 UnityEngine.Vector3와 충돌 → proto 타입 명시
var v = new Proto.Vector3 { X = 1, Y = 0, Z = 0 };
// 또는 별칭 사용
using Vector3Proto = Proto.Vector3;
```
### WebGL 빌드
WebGL에서는 `NativeWebSocket`의 JavaScript 백엔드가 사용됩니다.
`_ws.DispatchMessageQueue()``#if !UNITY_WEBGL || UNITY_EDITOR`로 감싸야 합니다.
### 스레드 안전
WebSocket 콜백은 백그라운드 스레드에서 호출됩니다.
Unity API (`Instantiate`, `Destroy` 등)는 반드시 **메인 스레드**에서 호출해야 합니다.
`NetworkClient``_mainThreadQueue` 패턴을 사용하세요.
### 핑 측정
```csharp
long sentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
NetworkClient.Instance.Send(MsgType.Ping, new Ping { ClientTime = sentTime });
// Pong 수신 시:
// latency = (pong.ServerTime - pong.ClientTime) / 2 (단방향 추정)
```

75
cmd/server/main.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
"a301_game_server/config"
"a301_game_server/internal/db"
"a301_game_server/internal/game"
"a301_game_server/internal/network"
"a301_game_server/pkg/logger"
)
func main() {
configPath := flag.String("config", "config/config.yaml", "path to configuration file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
panic("failed to load config: " + err.Error())
}
if err := logger.Init(cfg.Log.Level); err != nil {
panic("failed to init logger: " + err.Error())
}
defer logger.Sync()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Connect to PostgreSQL.
dbPool, err := db.NewPool(ctx, &cfg.Database)
if err != nil {
logger.Fatal("failed to connect to database", "error", err)
}
defer dbPool.Close()
// Run migrations.
if err := dbPool.RunMigrations(ctx); err != nil {
logger.Fatal("failed to run migrations", "error", err)
}
// Create game server.
gs := game.NewGameServer(cfg, dbPool)
gs.Start()
defer gs.Stop()
// Create and start network server.
srv := network.NewServer(cfg, gs)
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
logger.Info("received signal, shutting down", "signal", sig)
cancel()
}()
logger.Info("game server starting",
"address", cfg.Server.Address(),
"tickRate", cfg.World.TickRate,
"aoiEnabled", cfg.World.AOI.Enabled,
)
if err := srv.Start(ctx); err != nil {
logger.Fatal("server error", "error", err)
}
logger.Info("server shutdown complete")
}

650
cmd/testclient/main.go Normal file
View File

@@ -0,0 +1,650 @@
// testclient: 서버와 WebSocket 연결해서 전체 게임 흐름을 테스트하는 CLI 클라이언트.
//
// 사용법:
//
// go run ./cmd/testclient # 기본 (ws://localhost:8080/ws, user1/pass1)
// go run ./cmd/testclient -url ws://host:8080/ws # 서버 주소 지정
// go run ./cmd/testclient -user alice -pass secret
// go run ./cmd/testclient -clients 5 # 5명 동시 접속 부하 테스트
// go run ./cmd/testclient -scenario combat # 전투 시나리오만 실행
package main
import (
"encoding/binary"
"flag"
"fmt"
"log"
"math/rand"
"net/url"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
pb "a301_game_server/proto/gen/pb"
"google.golang.org/protobuf/proto"
)
// ─── 메시지 타입 ID ────────────────────────────────────────────────
const (
MsgLoginRequest uint16 = 0x0001
MsgLoginResponse uint16 = 0x0002
MsgEnterWorldRequest uint16 = 0x0003
MsgEnterWorldResponse uint16 = 0x0004
MsgMoveRequest uint16 = 0x0010
MsgStateUpdate uint16 = 0x0011
MsgSpawnEntity uint16 = 0x0012
MsgDespawnEntity uint16 = 0x0013
MsgZoneTransferNotify uint16 = 0x0014
MsgPing uint16 = 0x0020
MsgPong uint16 = 0x0021
MsgUseSkillRequest uint16 = 0x0040
MsgUseSkillResponse uint16 = 0x0041
MsgCombatEvent uint16 = 0x0042
MsgBuffApplied uint16 = 0x0043
MsgBuffRemoved uint16 = 0x0044
MsgRespawnRequest uint16 = 0x0045
MsgRespawnResponse uint16 = 0x0046
MsgAOIToggleRequest uint16 = 0x0030
MsgAOIToggleResponse uint16 = 0x0031
MsgMetricsRequest uint16 = 0x0032
MsgServerMetrics uint16 = 0x0033
)
var msgTypeNames = map[uint16]string{
MsgLoginRequest: "LoginRequest", MsgLoginResponse: "LoginResponse",
MsgEnterWorldRequest: "EnterWorldRequest", MsgEnterWorldResponse: "EnterWorldResponse",
MsgMoveRequest: "MoveRequest", MsgStateUpdate: "StateUpdate",
MsgSpawnEntity: "SpawnEntity", MsgDespawnEntity: "DespawnEntity",
MsgZoneTransferNotify: "ZoneTransferNotify",
MsgPing: "Ping", MsgPong: "Pong",
MsgUseSkillRequest: "UseSkillRequest", MsgUseSkillResponse: "UseSkillResponse",
MsgCombatEvent: "CombatEvent", MsgBuffApplied: "BuffApplied",
MsgBuffRemoved: "BuffRemoved",
MsgRespawnRequest: "RespawnRequest", MsgRespawnResponse: "RespawnResponse",
MsgAOIToggleRequest: "AOIToggleRequest", MsgAOIToggleResponse: "AOIToggleResponse",
MsgMetricsRequest: "MetricsRequest", MsgServerMetrics: "ServerMetrics",
}
// ─── 클라이언트 ────────────────────────────────────────────────────
type Client struct {
id int
username string
password string
conn *websocket.Conn
logger *log.Logger
sessionToken string
playerID uint64
zoneID uint32
done chan struct{}
recvCh chan recvMsg
mu sync.Mutex
entities map[uint64]*pb.EntityState
}
type recvMsg struct {
msgType uint16
data []byte
}
func newClient(id int, username, password string) *Client {
return &Client{
id: id,
username: username,
password: password,
logger: log.New(os.Stdout, fmt.Sprintf("[client-%d] ", id), log.Ltime|log.Lmsgprefix),
done: make(chan struct{}),
recvCh: make(chan recvMsg, 64),
entities: make(map[uint64]*pb.EntityState),
}
}
func (c *Client) connect(serverURL string) error {
u, err := url.Parse(serverURL)
if err != nil {
return err
}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.conn = conn
c.logger.Printf("연결 성공: %s", serverURL)
// 수신 루프
go c.readLoop()
return nil
}
func (c *Client) readLoop() {
defer close(c.done)
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
c.logger.Printf("연결 종료: %v", err)
return
}
if len(data) < 2 {
c.logger.Printf("패킷 너무 짧음 (%d bytes)", len(data))
continue
}
msgType := binary.BigEndian.Uint16(data[:2])
payload := data[2:]
// Ping에는 자동으로 Pong 응답
if msgType == MsgPing {
var ping pb.Ping
if err := proto.Unmarshal(payload, &ping); err == nil {
_ = c.send(MsgPong, &pb.Pong{ClientTime: ping.ClientTime})
}
continue
}
select {
case c.recvCh <- recvMsg{msgType, payload}:
default:
c.logger.Printf("recvCh 버퍼 풀 - 메시지 드롭: 0x%04X", msgType)
}
}
}
func (c *Client) send(msgType uint16, msg proto.Message) error {
body, err := proto.Marshal(msg)
if err != nil {
return err
}
packet := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(packet[:2], msgType)
copy(packet[2:], body)
return c.conn.WriteMessage(websocket.BinaryMessage, packet)
}
// waitFor: 특정 메시지 타입이 올 때까지 대기 (timeout 적용)
func (c *Client) waitFor(msgType uint16, timeout time.Duration) ([]byte, error) {
deadline := time.After(timeout)
for {
select {
case msg := <-c.recvCh:
name, ok := msgTypeNames[msg.msgType]
if !ok {
name = fmt.Sprintf("0x%04X", msg.msgType)
}
if msg.msgType == msgType {
c.logger.Printf(" <- %s (받음)", name)
return msg.data, nil
}
// 기다리는 타입이 아닌 것은 즉시 처리
c.handleAsync(msg)
case <-deadline:
return nil, fmt.Errorf("타임아웃: 0x%04X 메시지 대기 중 %v 초과", msgType, timeout)
case <-c.done:
return nil, fmt.Errorf("연결 끊김")
}
}
}
// handleAsync: waitFor 중 받은 다른 메시지를 처리
func (c *Client) handleAsync(msg recvMsg) {
name, ok := msgTypeNames[msg.msgType]
if !ok {
name = fmt.Sprintf("0x%04X", msg.msgType)
}
switch msg.msgType {
case MsgStateUpdate:
var upd pb.StateUpdate
if err := proto.Unmarshal(msg.data, &upd); err == nil {
c.mu.Lock()
for _, e := range upd.Entities {
c.entities[e.EntityId] = e
}
c.mu.Unlock()
}
case MsgSpawnEntity:
var sp pb.SpawnEntity
if err := proto.Unmarshal(msg.data, &sp); err == nil {
c.mu.Lock()
c.entities[sp.Entity.EntityId] = sp.Entity
c.mu.Unlock()
c.logger.Printf(" <- SpawnEntity: [%d] %s (HP:%d/%d) @ (%.1f,%.1f)",
sp.Entity.EntityId, sp.Entity.Name, sp.Entity.Hp, sp.Entity.MaxHp,
sp.Entity.Position.GetX(), sp.Entity.Position.GetZ())
}
case MsgDespawnEntity:
var dp pb.DespawnEntity
if err := proto.Unmarshal(msg.data, &dp); err == nil {
c.mu.Lock()
delete(c.entities, dp.EntityId)
c.mu.Unlock()
c.logger.Printf(" <- DespawnEntity: [%d]", dp.EntityId)
}
case MsgCombatEvent:
var evt pb.CombatEvent
if err := proto.Unmarshal(msg.data, &evt); err == nil {
switch evt.EventType {
case pb.CombatEventType_COMBAT_EVENT_DAMAGE:
crit := ""
if evt.IsCritical {
crit = " [CRIT]"
}
c.logger.Printf(" <- CombatEvent: [%d] -> [%d] 데미지 %d%s (남은HP: %d/%d)",
evt.CasterId, evt.TargetId, evt.Damage, crit, evt.TargetHp, evt.TargetMaxHp)
case pb.CombatEventType_COMBAT_EVENT_HEAL:
c.logger.Printf(" <- CombatEvent: [%d] 힐 +%d (HP: %d/%d)",
evt.TargetId, evt.Heal, evt.TargetHp, evt.TargetMaxHp)
case pb.CombatEventType_COMBAT_EVENT_DEATH:
c.logger.Printf(" <- CombatEvent: [%d] 사망", evt.TargetId)
case pb.CombatEventType_COMBAT_EVENT_RESPAWN:
c.logger.Printf(" <- CombatEvent: [%d] 리스폰 (HP: %d/%d)",
evt.TargetId, evt.TargetHp, evt.TargetMaxHp)
}
}
case MsgBuffApplied:
var b pb.BuffApplied
if err := proto.Unmarshal(msg.data, &b); err == nil {
debuff := ""
if b.IsDebuff {
debuff = "[디버프]"
}
c.logger.Printf(" <- BuffApplied%s: [%d] %s (%.1fs)", debuff, b.TargetId, b.BuffName, b.Duration)
}
case MsgBuffRemoved:
var b pb.BuffRemoved
if err := proto.Unmarshal(msg.data, &b); err == nil {
c.logger.Printf(" <- BuffRemoved: [%d] buff#%d", b.TargetId, b.BuffId)
}
case MsgZoneTransferNotify:
var z pb.ZoneTransferNotify
if err := proto.Unmarshal(msg.data, &z); err == nil {
c.zoneID = z.NewZoneId
c.logger.Printf(" <- ZoneTransfer: 새 존 %d (주변 엔티티 %d개)", z.NewZoneId, len(z.NearbyEntities))
}
default:
c.logger.Printf(" <- %s (비동기 수신)", name)
}
}
// ─── 시나리오 ──────────────────────────────────────────────────────
// stepAuth: 로그인 → 월드 입장
func (c *Client) stepAuth() error {
// 1. 로그인
c.logger.Printf("→ LoginRequest (user=%s)", c.username)
if err := c.send(MsgLoginRequest, &pb.LoginRequest{
Username: c.username,
Password: c.password,
}); err != nil {
return err
}
data, err := c.waitFor(MsgLoginResponse, 5*time.Second)
if err != nil {
return err
}
var resp pb.LoginResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("로그인 실패: %s", resp.ErrorMessage)
}
c.sessionToken = resp.SessionToken
c.playerID = resp.PlayerId
c.logger.Printf(" 로그인 성공: playerID=%d, token=%s…", resp.PlayerId, resp.SessionToken[:8])
// 2. 월드 입장
c.logger.Printf("→ EnterWorldRequest")
if err := c.send(MsgEnterWorldRequest, &pb.EnterWorldRequest{
SessionToken: c.sessionToken,
}); err != nil {
return err
}
data, err = c.waitFor(MsgEnterWorldResponse, 5*time.Second)
if err != nil {
return err
}
var ewResp pb.EnterWorldResponse
if err := proto.Unmarshal(data, &ewResp); err != nil {
return err
}
if !ewResp.Success {
return fmt.Errorf("월드 입장 실패: %s", ewResp.ErrorMessage)
}
c.zoneID = ewResp.ZoneId
c.logger.Printf(" 월드 입장 성공: zone=%d, pos=(%.1f, %.1f, %.1f)",
ewResp.ZoneId,
ewResp.Self.Position.GetX(),
ewResp.Self.Position.GetY(),
ewResp.Self.Position.GetZ())
c.logger.Printf(" 플레이어 정보: name=%s, HP=%d/%d, level=%d",
ewResp.Self.Name, ewResp.Self.Hp, ewResp.Self.MaxHp, ewResp.Self.Level)
return nil
}
// stepMove: 위치 이동 전송 후 StateUpdate 수신 확인
func (c *Client) stepMove() error {
c.logger.Printf("─── 이동 테스트 ───")
positions := [][2]float32{{5, 5}, {10, 10}, {15, 5}, {10, 0}}
for _, pos := range positions {
c.logger.Printf("→ MoveRequest (%.1f, 0, %.1f)", pos[0], pos[1])
if err := c.send(MsgMoveRequest, &pb.MoveRequest{
Position: &pb.Vector3{X: pos[0], Y: 0, Z: pos[1]},
Velocity: &pb.Vector3{X: 1, Y: 0, Z: 0},
Rotation: 0,
}); err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
}
// StateUpdate 수신 대기 (이동 후 서버 틱 안에 옴)
c.logger.Printf("StateUpdate 대기 중…")
_, err := c.waitFor(MsgStateUpdate, 3*time.Second)
if err != nil {
c.logger.Printf(" 경고: StateUpdate 없음 (혼자 접속 중이면 정상)")
return nil
}
c.mu.Lock()
c.logger.Printf(" 현재 시야 내 엔티티 수: %d", len(c.entities))
c.mu.Unlock()
return nil
}
// stepCombat: 주변 몹 타겟팅 후 스킬 사용
func (c *Client) stepCombat() error {
c.logger.Printf("─── 전투 테스트 ───")
// 주변 몹 찾기 (SpawnEntity로 받은 것들 중 mob 타입)
var mobID uint64
c.mu.Lock()
for id, e := range c.entities {
if e.EntityType == pb.EntityType_ENTITY_TYPE_MOB {
mobID = id
c.logger.Printf(" 타겟 몹 발견: [%d] %s (HP:%d/%d)", id, e.Name, e.Hp, e.MaxHp)
break
}
}
c.mu.Unlock()
if mobID == 0 {
c.logger.Printf(" 주변에 몹이 없음 - 스킬 테스트 스킵")
return nil
}
skills := []struct {
id uint32
name string
}{
{1, "Basic Attack"},
{2, "Fireball"},
}
for _, skill := range skills {
c.logger.Printf("→ UseSkillRequest: %s (skillID=%d) → 타겟 [%d]", skill.name, skill.id, mobID)
if err := c.send(MsgUseSkillRequest, &pb.UseSkillRequest{
SkillId: skill.id,
TargetId: mobID,
}); err != nil {
return err
}
data, err := c.waitFor(MsgUseSkillResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.UseSkillResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Success {
c.logger.Printf(" 스킬 성공")
} else {
c.logger.Printf(" 스킬 실패: %s", resp.ErrorMessage)
}
time.Sleep(200 * time.Millisecond) // 쿨다운 대기
}
return nil
}
// stepHeal: 자신 힐 스킬
func (c *Client) stepHeal() error {
c.logger.Printf("─── 힐 테스트 ───")
c.logger.Printf("→ UseSkillRequest: Heal (skillID=3, 셀프)")
if err := c.send(MsgUseSkillRequest, &pb.UseSkillRequest{SkillId: 3}); err != nil {
return err
}
data, err := c.waitFor(MsgUseSkillResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.UseSkillResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Success {
c.logger.Printf(" 힐 성공")
} else {
c.logger.Printf(" 힐 실패: %s", resp.ErrorMessage)
}
return nil
}
// stepMetrics: 서버 메트릭 요청
func (c *Client) stepMetrics() error {
c.logger.Printf("─── 서버 메트릭 ───")
c.logger.Printf("→ MetricsRequest")
if err := c.send(MsgMetricsRequest, &pb.MetricsRequest{}); err != nil {
return err
}
data, err := c.waitFor(MsgServerMetrics, 3*time.Second)
if err != nil {
return err
}
var m pb.ServerMetrics
if err := proto.Unmarshal(data, &m); err != nil {
return err
}
c.logger.Printf(" 온라인 플레이어: %d", m.OnlinePlayers)
c.logger.Printf(" 총 엔티티: %d", m.TotalEntities)
c.logger.Printf(" 틱 시간: %d µs", m.TickDurationUs)
c.logger.Printf(" AOI 활성화: %v", m.AoiEnabled)
return nil
}
// stepAOIToggle: AOI on/off 토글 테스트
func (c *Client) stepAOIToggle(enabled bool) error {
c.logger.Printf("─── AOI 토글 (enabled=%v) ───", enabled)
if err := c.send(MsgAOIToggleRequest, &pb.AOIToggleRequest{Enabled: enabled}); err != nil {
return err
}
data, err := c.waitFor(MsgAOIToggleResponse, 3*time.Second)
if err != nil {
return err
}
var resp pb.AOIToggleResponse
if err := proto.Unmarshal(data, &resp); err != nil {
return err
}
c.logger.Printf(" AOI 토글 결과: %s", resp.Message)
return nil
}
// runScenario: 전체 또는 특정 시나리오 실행
func (c *Client) runScenario(scenario string) {
// 공통: 인증
if err := c.stepAuth(); err != nil {
c.logger.Printf("[FAIL] 인증: %v", err)
return
}
switch scenario {
case "auth":
c.logger.Printf("[OK] 인증 시나리오 완료")
case "move":
if err := c.stepMove(); err != nil {
c.logger.Printf("[FAIL] 이동: %v", err)
return
}
c.logger.Printf("[OK] 이동 시나리오 완료")
case "combat":
// 이동해서 몹 시야 안으로
time.Sleep(500 * time.Millisecond) // SpawnEntity 수신 대기
if err := c.stepCombat(); err != nil {
c.logger.Printf("[FAIL] 전투: %v", err)
return
}
if err := c.stepHeal(); err != nil {
c.logger.Printf("[FAIL] 힐: %v", err)
return
}
c.logger.Printf("[OK] 전투 시나리오 완료")
case "metrics":
if err := c.stepMetrics(); err != nil {
c.logger.Printf("[FAIL] 메트릭: %v", err)
return
}
if err := c.stepAOIToggle(false); err != nil {
c.logger.Printf("[FAIL] AOI 토글: %v", err)
return
}
if err := c.stepAOIToggle(true); err != nil {
c.logger.Printf("[FAIL] AOI 토글: %v", err)
return
}
c.logger.Printf("[OK] 메트릭 시나리오 완료")
default: // "all"
time.Sleep(500 * time.Millisecond) // SpawnEntity 수신 대기
if err := c.stepMove(); err != nil {
c.logger.Printf("[FAIL] 이동: %v", err)
}
if err := c.stepCombat(); err != nil {
c.logger.Printf("[FAIL] 전투: %v", err)
}
if err := c.stepHeal(); err != nil {
c.logger.Printf("[FAIL] 힐: %v", err)
}
if err := c.stepMetrics(); err != nil {
c.logger.Printf("[FAIL] 메트릭: %v", err)
}
c.logger.Printf("[OK] 전체 시나리오 완료")
}
}
// ─── 부하 테스트 ──────────────────────────────────────────────────
func loadTest(serverURL string, numClients int, duration time.Duration) {
fmt.Printf("부하 테스트: %d 클라이언트, %v 동안\n", numClients, duration)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < numClients; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
username := fmt.Sprintf("loadtest_%d_%d", id, rand.Intn(9999))
c := newClient(id, username, "testpass")
if err := c.connect(serverURL); err != nil {
c.logger.Printf("[FAIL] 연결: %v", err)
return
}
defer c.conn.Close()
if err := c.stepAuth(); err != nil {
c.logger.Printf("[FAIL] 인증: %v", err)
return
}
// duration 동안 이동 패킷 전송
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
timeout := time.After(duration)
x, z := float32(0), float32(0)
for {
select {
case <-ticker.C:
x += rand.Float32()*2 - 1
z += rand.Float32()*2 - 1
_ = c.send(MsgMoveRequest, &pb.MoveRequest{
Position: &pb.Vector3{X: x, Y: 0, Z: z},
Velocity: &pb.Vector3{X: 1, Y: 0, Z: 0},
})
case <-timeout:
c.logger.Printf("완료 (%.1fs)", time.Since(start).Seconds())
return
case <-c.done:
return
}
}
}(i)
time.Sleep(10 * time.Millisecond) // 동시 접속 분산
}
wg.Wait()
fmt.Printf("부하 테스트 완료: %.1fs\n", time.Since(start).Seconds())
}
// ─── main ─────────────────────────────────────────────────────────
func main() {
serverURL := flag.String("url", "ws://localhost:8080/ws", "WebSocket 서버 주소")
username := flag.String("user", "testuser1", "사용자 이름")
password := flag.String("pass", "password123", "비밀번호")
scenario := flag.String("scenario", "all", "실행할 시나리오: all | auth | move | combat | metrics")
numClients := flag.Int("clients", 1, "동시 접속 클라이언트 수 (부하 테스트)")
loadDuration := flag.Duration("duration", 10*time.Second, "부하 테스트 지속 시간")
flag.Parse()
// 부하 테스트 모드
if *numClients > 1 {
loadTest(*serverURL, *numClients, *loadDuration)
return
}
// 단일 클라이언트 시나리오
c := newClient(1, *username, *password)
if err := c.connect(*serverURL); err != nil {
log.Fatalf("서버 연결 실패: %v\n서버가 실행 중인지 확인하세요: make run", err)
}
defer c.conn.Close()
c.runScenario(*scenario)
// Ctrl+C 대기 (실시간 메시지 관찰용)
if *scenario == "all" {
fmt.Println("\n[Ctrl+C로 종료] 서버 메시지 수신 대기 중...")
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
loop:
for {
select {
case <-sig:
break loop
case <-c.done:
break loop
case msg := <-c.recvCh:
c.handleAsync(msg)
case <-ticker.C:
}
}
}
}

124
config/config.go Normal file
View File

@@ -0,0 +1,124 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
World WorldConfig `yaml:"world"`
Network NetworkConfig `yaml:"network"`
Log LogConfig `yaml:"log"`
}
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
DBName string `yaml:"dbname"`
MaxConns int32 `yaml:"max_conns"`
MinConns int32 `yaml:"min_conns"`
SaveInterval time.Duration `yaml:"save_interval"`
}
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
d.User, d.Password, d.Host, d.Port, d.DBName)
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
type WorldConfig struct {
TickRate int `yaml:"tick_rate"`
AOI AOIConfig `yaml:"aoi"`
MaxPlayers int `yaml:"max_players"`
}
type AOIConfig struct {
Enabled bool `yaml:"enabled"`
CellSize float32 `yaml:"cell_size"`
ViewRange int `yaml:"view_range"`
}
type NetworkConfig struct {
WriteBufferSize int `yaml:"write_buffer_size"`
ReadBufferSize int `yaml:"read_buffer_size"`
SendChannelSize int `yaml:"send_channel_size"`
HeartbeatInterval time.Duration `yaml:"heartbeat_interval"`
HeartbeatTimeout time.Duration `yaml:"heartbeat_timeout"`
MaxMessageSize int64 `yaml:"max_message_size"`
}
type LogConfig struct {
Level string `yaml:"level"`
}
func (c *Config) TickInterval() time.Duration {
return time.Second / time.Duration(c.World.TickRate)
}
func (c *ServerConfig) Address() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
cfg := defaultConfig()
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config file: %w", err)
}
return cfg, nil
}
func defaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 8080,
},
World: WorldConfig{
TickRate: 20,
MaxPlayers: 5000,
AOI: AOIConfig{
Enabled: true,
CellSize: 50.0,
ViewRange: 2,
},
},
Database: DatabaseConfig{
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "postgres",
DBName: "mmorpg",
MaxConns: 50,
MinConns: 5,
SaveInterval: 60 * time.Second,
},
Network: NetworkConfig{
WriteBufferSize: 4096,
ReadBufferSize: 4096,
SendChannelSize: 256,
HeartbeatInterval: 5 * time.Second,
HeartbeatTimeout: 15 * time.Second,
MaxMessageSize: 8192,
},
Log: LogConfig{
Level: "info",
},
}
}

32
config/config.yaml Normal file
View File

@@ -0,0 +1,32 @@
server:
host: "0.0.0.0"
port: 8080
database:
host: "localhost"
port: 5432
user: "postgres"
password: "postgres"
dbname: "mmorpg"
max_conns: 50
min_conns: 5
save_interval: 60s
world:
tick_rate: 20
max_players: 5000
aoi:
enabled: true
cell_size: 50.0
view_range: 2
network:
write_buffer_size: 4096
read_buffer_size: 4096
send_channel_size: 256
heartbeat_interval: 5s
heartbeat_timeout: 15s
max_message_size: 8192
log:
level: "info"

42
gen_proto.ps1 Normal file
View File

@@ -0,0 +1,42 @@
# proto 코드 생성 스크립트 (PowerShell)
# 사용법: .\gen_proto.ps1
$PROTOC = "C:\Users\SSAFY\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe"
$GOPATH_BIN = (go env GOPATH) + "\bin"
$env:PATH = "$GOPATH_BIN;$env:PATH"
$ROOT = $PSScriptRoot
# Go 서버 코드 생성
Write-Host "[1/2] Generating Go server code..." -ForegroundColor Cyan
& $PROTOC `
--go_out="$ROOT\proto\gen\pb" `
--go_opt=paths=source_relative `
--proto_path="$ROOT\proto" `
"$ROOT\proto\messages.proto"
if ($LASTEXITCODE -ne 0) {
Write-Host "Go proto generation failed." -ForegroundColor Red
exit 1
}
Write-Host " -> proto/gen/pb/messages.pb.go" -ForegroundColor Green
# Unity C# 코드 생성
Write-Host "[2/2] Generating C# Unity code..." -ForegroundColor Cyan
$unityOutDir = "$ROOT\unity\Assets\Scripts\Proto"
if (!(Test-Path $unityOutDir)) {
New-Item -ItemType Directory -Path $unityOutDir | Out-Null
}
& $PROTOC `
--csharp_out="$unityOutDir" `
--proto_path="$ROOT\proto" `
"$ROOT\proto\messages.proto"
if ($LASTEXITCODE -ne 0) {
Write-Host "C# proto generation failed." -ForegroundColor Red
exit 1
}
Write-Host " -> unity/Assets/Scripts/Proto/Messages.cs" -ForegroundColor Green
Write-Host "`nDone!" -ForegroundColor Green

18
go.mod Normal file
View File

@@ -0,0 +1,18 @@
module a301_game_server
go 1.25
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

31
go.sum Normal file
View File

@@ -0,0 +1,31 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

184
internal/ai/behavior.go Normal file
View File

@@ -0,0 +1,184 @@
package ai
import (
"math"
"time"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
)
// AIState represents the mob's behavioral state.
type AIState int
const (
StateIdle AIState = iota // Standing at spawn, doing nothing
StatePatrol // Wandering near spawn
StateChase // Moving toward a target
StateAttack // In attack range, using skills
StateReturn // Walking back to spawn (leash)
StateDead // Dead, waiting for respawn
)
// EntityProvider gives the AI access to the game world.
type EntityProvider interface {
GetEntity(id uint64) entity.Entity
GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity
}
// SkillUser allows the AI to use combat skills.
type SkillUser interface {
UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string)
}
// UpdateMob advances one mob's AI by one tick.
func UpdateMob(m *Mob, dt time.Duration, provider EntityProvider, skills SkillUser) {
if !m.IsAlive() {
m.SetState(StateDead)
return
}
switch m.State() {
case StateIdle:
updateIdle(m, provider)
case StateChase:
updateChase(m, dt, provider)
case StateAttack:
updateAttack(m, provider, skills)
case StateReturn:
updateReturn(m, dt)
case StateDead:
// handled by spawner
}
}
func updateIdle(m *Mob, provider EntityProvider) {
// Scan for players in aggro range.
target := findNearestPlayer(m, provider, m.Def().AggroRange)
if target != nil {
m.SetTargetID(target.EntityID())
m.SetState(StateChase)
}
}
func updateChase(m *Mob, dt time.Duration, provider EntityProvider) {
target := provider.GetEntity(m.TargetID())
if target == nil || !isAlive(target) {
m.SetTargetID(0)
m.SetState(StateReturn)
return
}
// Leash check: too far from spawn?
if m.Position().DistanceXZ(m.SpawnPos()) > m.Def().LeashRange {
m.SetTargetID(0)
m.SetState(StateReturn)
return
}
dist := m.Position().DistanceXZ(target.Position())
// Close enough to attack?
if dist <= m.Def().AttackRange {
m.SetState(StateAttack)
return
}
// Move toward target.
moveToward(m, target.Position(), dt)
}
func updateAttack(m *Mob, provider EntityProvider, skills SkillUser) {
target := provider.GetEntity(m.TargetID())
if target == nil || !isAlive(target) {
m.SetTargetID(0)
m.SetState(StateReturn)
return
}
dist := m.Position().DistanceXZ(target.Position())
// Target moved out of attack range? Chase again.
if dist > m.Def().AttackRange*1.2 { // 20% buffer to prevent flickering
m.SetState(StateChase)
return
}
// Leash check.
if m.Position().DistanceXZ(m.SpawnPos()) > m.Def().LeashRange {
m.SetTargetID(0)
m.SetState(StateReturn)
return
}
// Face target.
dir := target.Position().Sub(m.Position())
m.SetRotation(float32(math.Atan2(float64(dir.X), float64(dir.Z))))
// Use attack skill.
skills.UseSkill(m.EntityID(), m.Def().AttackSkill, target.EntityID(), mathutil.Vec3{})
}
func updateReturn(m *Mob, dt time.Duration) {
dist := m.Position().DistanceXZ(m.SpawnPos())
if dist < 0.5 {
m.SetPosition(m.SpawnPos())
m.SetState(StateIdle)
// Heal to full when returning.
m.SetHP(m.MaxHP())
return
}
moveToward(m, m.SpawnPos(), dt)
}
// moveToward moves the mob toward a target position at its move speed.
func moveToward(m *Mob, target mathutil.Vec3, dt time.Duration) {
dir := target.Sub(m.Position())
dir.Y = 0
dist := dir.Length()
if dist < 0.01 {
return
}
step := m.Def().MoveSpeed * float32(dt.Seconds())
if step > dist {
step = dist
}
move := dir.Normalize().Scale(step)
m.SetPosition(m.Position().Add(move))
// Face movement direction.
m.SetRotation(float32(math.Atan2(float64(dir.X), float64(dir.Z))))
}
func findNearestPlayer(m *Mob, provider EntityProvider, radius float32) entity.Entity {
players := provider.GetPlayersInRange(m.Position(), radius)
if len(players) == 0 {
return nil
}
var nearest entity.Entity
minDist := float32(math.MaxFloat32)
for _, p := range players {
if !isAlive(p) {
continue
}
d := m.Position().DistanceXZ(p.Position())
if d < minDist {
minDist = d
nearest = p
}
}
return nearest
}
func isAlive(e entity.Entity) bool {
type aliveChecker interface {
IsAlive() bool
}
if a, ok := e.(aliveChecker); ok {
return a.IsAlive()
}
return true
}

View File

@@ -0,0 +1,221 @@
package ai
import (
"testing"
"time"
"a301_game_server/internal/combat"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
type mockPlayer struct {
id uint64
pos mathutil.Vec3
hp int32
maxHP int32
alive bool
}
func (m *mockPlayer) EntityID() uint64 { return m.id }
func (m *mockPlayer) EntityType() entity.Type { return entity.TypePlayer }
func (m *mockPlayer) Position() mathutil.Vec3 { return m.pos }
func (m *mockPlayer) SetPosition(p mathutil.Vec3) { m.pos = p }
func (m *mockPlayer) Rotation() float32 { return 0 }
func (m *mockPlayer) SetRotation(float32) {}
func (m *mockPlayer) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
func (m *mockPlayer) HP() int32 { return m.hp }
func (m *mockPlayer) MaxHP() int32 { return m.maxHP }
func (m *mockPlayer) SetHP(hp int32) { m.hp = hp; m.alive = hp > 0 }
func (m *mockPlayer) MP() int32 { return 100 }
func (m *mockPlayer) SetMP(int32) {}
func (m *mockPlayer) IsAlive() bool { return m.alive }
func (m *mockPlayer) Stats() combat.CombatStats { return combat.CombatStats{Str: 10, Dex: 10, Int: 10, Level: 1} }
type mockProvider struct {
entities map[uint64]entity.Entity
}
func (p *mockProvider) GetEntity(id uint64) entity.Entity {
return p.entities[id]
}
func (p *mockProvider) GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity {
radiusSq := radius * radius
var result []entity.Entity
for _, e := range p.entities {
if e.EntityType() == entity.TypePlayer {
if e.Position().DistanceSqTo(center) <= radiusSq {
result = append(result, e)
}
}
}
return result
}
type mockSkillUser struct {
called bool
}
func (s *mockSkillUser) UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string) {
s.called = true
return true, ""
}
func newTestMob() *Mob {
def := &MobDef{
ID: 1,
Name: "TestMob",
Level: 1,
HP: 100,
MP: 0,
Str: 10,
Dex: 5,
Int: 3,
MoveSpeed: 5.0,
AggroRange: 10.0,
AttackRange: 2.5,
AttackSkill: 1,
LeashRange: 30.0,
}
return NewMob(1000, def, mathutil.NewVec3(50, 0, 50))
}
func TestIdleToChase(t *testing.T) {
mob := newTestMob()
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(55, 0, 50), hp: 100, maxHP: 100, alive: true}
provider := &mockProvider{
entities: map[uint64]entity.Entity{1: player, 1000: mob},
}
skills := &mockSkillUser{}
// Player is within aggro range (5 units < 10 aggro range).
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if mob.State() != StateChase {
t.Errorf("expected StateChase, got %d", mob.State())
}
if mob.TargetID() != 1 {
t.Errorf("expected target 1, got %d", mob.TargetID())
}
}
func TestChaseToAttack(t *testing.T) {
mob := newTestMob()
mob.SetState(StateChase)
mob.SetTargetID(1)
// Place player within attack range.
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(52, 0, 50), hp: 100, maxHP: 100, alive: true}
provider := &mockProvider{
entities: map[uint64]entity.Entity{1: player, 1000: mob},
}
skills := &mockSkillUser{}
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if mob.State() != StateAttack {
t.Errorf("expected StateAttack, got %d", mob.State())
}
}
func TestAttackUsesSkill(t *testing.T) {
mob := newTestMob()
mob.SetState(StateAttack)
mob.SetTargetID(1)
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(52, 0, 50), hp: 100, maxHP: 100, alive: true}
provider := &mockProvider{
entities: map[uint64]entity.Entity{1: player, 1000: mob},
}
skills := &mockSkillUser{}
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if !skills.called {
t.Error("expected skill to be used")
}
}
func TestLeashReturn(t *testing.T) {
mob := newTestMob()
mob.SetState(StateChase)
mob.SetTargetID(1)
// Move mob far from spawn.
mob.SetPosition(mathutil.NewVec3(100, 0, 100)) // >30 units from spawn(50,0,50)
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(110, 0, 110), hp: 100, maxHP: 100, alive: true}
provider := &mockProvider{
entities: map[uint64]entity.Entity{1: player, 1000: mob},
}
skills := &mockSkillUser{}
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if mob.State() != StateReturn {
t.Errorf("expected StateReturn (leash), got %d", mob.State())
}
}
func TestReturnToIdle(t *testing.T) {
mob := newTestMob()
mob.SetState(StateReturn)
mob.SetPosition(mathutil.NewVec3(50.1, 0, 50.1)) // very close to spawn
skills := &mockSkillUser{}
provider := &mockProvider{entities: map[uint64]entity.Entity{1000: mob}}
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if mob.State() != StateIdle {
t.Errorf("expected StateIdle after return, got %d", mob.State())
}
if mob.HP() != mob.MaxHP() {
t.Error("mob should heal to full on return")
}
}
func TestTargetDiesReturnToSpawn(t *testing.T) {
mob := newTestMob()
mob.SetState(StateChase)
mob.SetTargetID(1)
// Target is dead.
player := &mockPlayer{id: 1, pos: mathutil.NewVec3(55, 0, 50), hp: 0, maxHP: 100, alive: false}
provider := &mockProvider{
entities: map[uint64]entity.Entity{1: player, 1000: mob},
}
skills := &mockSkillUser{}
UpdateMob(mob, 50*time.Millisecond, provider, skills)
if mob.State() != StateReturn {
t.Errorf("expected StateReturn when target dies, got %d", mob.State())
}
}
func TestMobReset(t *testing.T) {
mob := newTestMob()
mob.SetHP(0)
mob.SetPosition(mathutil.NewVec3(100, 0, 100))
mob.SetState(StateDead)
mob.Reset()
if mob.HP() != mob.MaxHP() {
t.Error("HP should be full after reset")
}
if mob.Position() != mob.SpawnPos() {
t.Error("position should be spawn pos after reset")
}
if mob.State() != StateIdle {
t.Error("state should be Idle after reset")
}
}

137
internal/ai/mob.go Normal file
View File

@@ -0,0 +1,137 @@
package ai
import (
"a301_game_server/internal/combat"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// MobDef defines a mob template loaded from data.
type MobDef struct {
ID uint32
Name string
Level int32
HP int32
MP int32
Str int32
Dex int32
Int int32
MoveSpeed float32 // units per second
AggroRange float32 // distance to start chasing
AttackRange float32
AttackSkill uint32 // skill ID used for auto-attack
LeashRange float32 // max distance from spawn before returning
ExpReward int64
LootTable []LootEntry
}
// LootEntry defines a possible drop.
type LootEntry struct {
ItemID uint32
Quantity int32
Chance float32 // 0.0 - 1.0
}
// Mob is a server-controlled enemy entity.
type Mob struct {
id uint64
def *MobDef
position mathutil.Vec3
rotation float32
hp int32
maxHP int32
mp int32
maxMP int32
spawnPos mathutil.Vec3
alive bool
// AI state
state AIState
targetID uint64 // entity being chased/attacked
}
// NewMob creates a mob from a definition at the given position.
func NewMob(id uint64, def *MobDef, spawnPos mathutil.Vec3) *Mob {
return &Mob{
id: id,
def: def,
position: spawnPos,
spawnPos: spawnPos,
hp: def.HP,
maxHP: def.HP,
mp: def.MP,
maxMP: def.MP,
alive: true,
state: StateIdle,
}
}
// Entity interface
func (m *Mob) EntityID() uint64 { return m.id }
func (m *Mob) EntityType() entity.Type { return entity.TypeMob }
func (m *Mob) Position() mathutil.Vec3 { return m.position }
func (m *Mob) SetPosition(p mathutil.Vec3) { m.position = p }
func (m *Mob) Rotation() float32 { return m.rotation }
func (m *Mob) SetRotation(r float32) { m.rotation = r }
// Combatant interface
func (m *Mob) HP() int32 { return m.hp }
func (m *Mob) MaxHP() int32 { return m.maxHP }
func (m *Mob) SetHP(hp int32) {
if hp < 0 {
hp = 0
}
if hp > m.maxHP {
hp = m.maxHP
}
m.hp = hp
m.alive = hp > 0
}
func (m *Mob) MP() int32 { return m.mp }
func (m *Mob) SetMP(mp int32) {
if mp < 0 {
mp = 0
}
m.mp = mp
}
func (m *Mob) IsAlive() bool { return m.alive }
func (m *Mob) Stats() combat.CombatStats {
return combat.CombatStats{
Str: m.def.Str,
Dex: m.def.Dex,
Int: m.def.Int,
Level: m.def.Level,
}
}
func (m *Mob) Def() *MobDef { return m.def }
func (m *Mob) SpawnPos() mathutil.Vec3 { return m.spawnPos }
func (m *Mob) State() AIState { return m.state }
func (m *Mob) SetState(s AIState) { m.state = s }
func (m *Mob) TargetID() uint64 { return m.targetID }
func (m *Mob) SetTargetID(id uint64) { m.targetID = id }
func (m *Mob) ToProto() *pb.EntityState {
return &pb.EntityState{
EntityId: m.id,
Name: m.def.Name,
Position: &pb.Vector3{X: m.position.X, Y: m.position.Y, Z: m.position.Z},
Rotation: m.rotation,
Hp: m.hp,
MaxHp: m.maxHP,
Level: m.def.Level,
EntityType: pb.EntityType_ENTITY_TYPE_MOB,
}
}
// Reset restores the mob to full health at its spawn position.
func (m *Mob) Reset() {
m.hp = m.maxHP
m.mp = m.maxMP
m.position = m.spawnPos
m.alive = true
m.state = StateIdle
m.targetID = 0
}

130
internal/ai/registry.go Normal file
View File

@@ -0,0 +1,130 @@
package ai
import "time"
// MobRegistry holds all mob definitions.
type MobRegistry struct {
defs map[uint32]*MobDef
}
// NewMobRegistry creates a registry with default mob definitions.
func NewMobRegistry() *MobRegistry {
r := &MobRegistry{defs: make(map[uint32]*MobDef)}
r.registerDefaults()
return r
}
// Get returns a mob definition by ID.
func (r *MobRegistry) Get(id uint32) *MobDef {
return r.defs[id]
}
func (r *MobRegistry) registerDefaults() {
r.defs[1] = &MobDef{
ID: 1,
Name: "Goblin",
Level: 1,
HP: 60,
MP: 0,
Str: 8,
Dex: 6,
Int: 3,
MoveSpeed: 4.0,
AggroRange: 10.0,
AttackRange: 2.5,
AttackSkill: 1, // basic attack
LeashRange: 30.0,
ExpReward: 20,
LootTable: []LootEntry{
{ItemID: 101, Quantity: 1, Chance: 0.5},
},
}
r.defs[2] = &MobDef{
ID: 2,
Name: "Wolf",
Level: 2,
HP: 80,
MP: 0,
Str: 12,
Dex: 10,
Int: 2,
MoveSpeed: 6.0,
AggroRange: 12.0,
AttackRange: 2.0,
AttackSkill: 1,
LeashRange: 35.0,
ExpReward: 35,
LootTable: []LootEntry{
{ItemID: 102, Quantity: 1, Chance: 0.4},
{ItemID: 103, Quantity: 1, Chance: 0.1},
},
}
r.defs[3] = &MobDef{
ID: 3,
Name: "Forest Troll",
Level: 5,
HP: 200,
MP: 30,
Str: 25,
Dex: 8,
Int: 5,
MoveSpeed: 3.0,
AggroRange: 8.0,
AttackRange: 3.0,
AttackSkill: 1,
LeashRange: 25.0,
ExpReward: 80,
LootTable: []LootEntry{
{ItemID: 104, Quantity: 1, Chance: 0.6},
{ItemID: 105, Quantity: 1, Chance: 0.15},
},
}
r.defs[4] = &MobDef{
ID: 4,
Name: "Fire Elemental",
Level: 8,
HP: 350,
MP: 100,
Str: 15,
Dex: 12,
Int: 30,
MoveSpeed: 3.5,
AggroRange: 15.0,
AttackRange: 10.0,
AttackSkill: 2, // fireball
LeashRange: 40.0,
ExpReward: 150,
LootTable: []LootEntry{
{ItemID: 106, Quantity: 1, Chance: 0.3},
{ItemID: 107, Quantity: 1, Chance: 0.05},
},
}
r.defs[5] = &MobDef{
ID: 5,
Name: "Dragon Whelp",
Level: 12,
HP: 800,
MP: 200,
Str: 35,
Dex: 20,
Int: 25,
MoveSpeed: 5.0,
AggroRange: 20.0,
AttackRange: 4.0,
AttackSkill: 2,
LeashRange: 50.0,
ExpReward: 350,
LootTable: []LootEntry{
{ItemID: 108, Quantity: 1, Chance: 0.4},
{ItemID: 109, Quantity: 1, Chance: 0.02},
},
}
// Adjust respawn-related values (these go in SpawnPoints, not MobDef, but
// set sensible AttackSkill cooldowns via the existing combat skill system)
_ = time.Second // reference for documentation
}

152
internal/ai/spawner.go Normal file
View File

@@ -0,0 +1,152 @@
package ai
import (
"sync/atomic"
"time"
"a301_game_server/pkg/logger"
"a301_game_server/pkg/mathutil"
)
// SpawnPoint defines where and what to spawn.
type SpawnPoint struct {
MobDef *MobDef
Position mathutil.Vec3
RespawnDelay time.Duration
MaxCount int // max alive at this point
}
// spawnEntry tracks a single spawned mob.
type spawnEntry struct {
mob *Mob
alive bool
diedAt time.Time
}
// Spawner manages mob spawning and respawning for a zone.
type Spawner struct {
points []SpawnPoint
mobs map[uint64]*spawnEntry // mobID -> entry
pointMobs map[int][]*spawnEntry // spawnPointIndex -> entries
nextID *atomic.Uint64
// Callbacks
onSpawn func(m *Mob)
onRemove func(mobID uint64)
}
// NewSpawner creates a mob spawner.
func NewSpawner(nextID *atomic.Uint64, onSpawn func(*Mob), onRemove func(uint64)) *Spawner {
return &Spawner{
mobs: make(map[uint64]*spawnEntry),
pointMobs: make(map[int][]*spawnEntry),
nextID: nextID,
onSpawn: onSpawn,
onRemove: onRemove,
}
}
// AddSpawnPoint registers a spawn point.
func (s *Spawner) AddSpawnPoint(sp SpawnPoint) {
s.points = append(s.points, sp)
}
// InitialSpawn spawns all mobs at startup.
func (s *Spawner) InitialSpawn() {
for i, sp := range s.points {
for j := 0; j < sp.MaxCount; j++ {
s.spawnMob(i, &sp)
}
}
logger.Info("initial spawn complete", "totalMobs", len(s.mobs))
}
// Update checks for mobs that need respawning.
func (s *Spawner) Update(now time.Time) {
for i, sp := range s.points {
entries := s.pointMobs[i]
aliveCount := 0
for _, e := range entries {
if e.alive {
aliveCount++
continue
}
// Check if it's time to respawn.
if !e.diedAt.IsZero() && now.Sub(e.diedAt) >= sp.RespawnDelay {
s.respawnMob(e)
aliveCount++
}
}
// Spawn new if below max count.
for aliveCount < sp.MaxCount {
s.spawnMob(i, &sp)
aliveCount++
}
}
}
// NotifyDeath marks a mob as dead and starts the respawn timer.
func (s *Spawner) NotifyDeath(mobID uint64) {
entry, ok := s.mobs[mobID]
if !ok {
return
}
entry.alive = false
entry.diedAt = time.Now()
// Remove from zone (despawn).
if s.onRemove != nil {
s.onRemove(mobID)
}
}
// GetMob returns a mob by ID.
func (s *Spawner) GetMob(id uint64) *Mob {
if e, ok := s.mobs[id]; ok {
return e.mob
}
return nil
}
// AllMobs returns all tracked mobs.
func (s *Spawner) AllMobs() []*Mob {
result := make([]*Mob, 0, len(s.mobs))
for _, e := range s.mobs {
result = append(result, e.mob)
}
return result
}
// AliveMobs returns all alive mobs.
func (s *Spawner) AliveMobs() []*Mob {
var result []*Mob
for _, e := range s.mobs {
if e.alive {
result = append(result, e.mob)
}
}
return result
}
func (s *Spawner) spawnMob(pointIdx int, sp *SpawnPoint) {
id := s.nextID.Add(1) + 100000 // offset to avoid collision with player IDs
mob := NewMob(id, sp.MobDef, sp.Position)
entry := &spawnEntry{mob: mob, alive: true}
s.mobs[id] = entry
s.pointMobs[pointIdx] = append(s.pointMobs[pointIdx], entry)
if s.onSpawn != nil {
s.onSpawn(mob)
}
}
func (s *Spawner) respawnMob(entry *spawnEntry) {
entry.mob.Reset()
entry.alive = true
entry.diedAt = time.Time{}
if s.onSpawn != nil {
s.onSpawn(entry.mob)
}
}

68
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,68 @@
package auth
import (
"context"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
"a301_game_server/internal/db"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUsernameTaken = errors.New("username already taken")
)
// Service handles account registration and authentication.
type Service struct {
pool *db.Pool
}
// NewService creates a new auth service.
func NewService(pool *db.Pool) *Service {
return &Service{pool: pool}
}
// Register creates a new account. Returns the account ID.
func (s *Service) Register(ctx context.Context, username, password string) (int64, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return 0, fmt.Errorf("hash password: %w", err)
}
var id int64
err = s.pool.QueryRow(ctx,
`INSERT INTO accounts (username, password) VALUES ($1, $2)
ON CONFLICT (username) DO NOTHING
RETURNING id`,
username, string(hash),
).Scan(&id)
if err != nil {
return 0, ErrUsernameTaken
}
return id, nil
}
// Login validates credentials and returns the account ID.
func (s *Service) Login(ctx context.Context, username, password string) (int64, error) {
var id int64
var hash string
err := s.pool.QueryRow(ctx,
`SELECT id, password FROM accounts WHERE username = $1`, username,
).Scan(&id, &hash)
if err != nil {
return 0, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return 0, ErrInvalidCredentials
}
return id, nil
}

86
internal/combat/buff.go Normal file
View File

@@ -0,0 +1,86 @@
package combat
import "time"
// BuffDef defines a buff/debuff type.
type BuffDef struct {
ID uint32
Name string
IsDebuff bool
DamagePerTick int32 // for DoTs (debuff); heal per tick for HoTs (buff)
StatModifier StatMod
}
// StatMod is a temporary stat modification from a buff.
type StatMod struct {
StrBonus int32
DexBonus int32
IntBonus int32
}
// ActiveBuff is an active buff/debuff on an entity.
type ActiveBuff struct {
Def *BuffDef
CasterID uint64
Remaining time.Duration
TickInterval time.Duration
NextTick time.Duration // time until next tick
}
// Tick advances the buff by dt. Returns damage/heal to apply this tick (0 if no tick).
func (b *ActiveBuff) Tick(dt time.Duration) int32 {
b.Remaining -= dt
var tickValue int32
if b.TickInterval > 0 {
b.NextTick -= dt
if b.NextTick <= 0 {
tickValue = b.Def.DamagePerTick
b.NextTick += b.TickInterval
}
}
return tickValue
}
// IsExpired returns true if the buff has no remaining duration.
func (b *ActiveBuff) IsExpired() bool {
return b.Remaining <= 0
}
// BuffRegistry holds all buff/debuff definitions.
type BuffRegistry struct {
buffs map[uint32]*BuffDef
}
// NewBuffRegistry creates a registry with default buffs.
func NewBuffRegistry() *BuffRegistry {
r := &BuffRegistry{buffs: make(map[uint32]*BuffDef)}
r.registerDefaults()
return r
}
// Get returns a buff definition.
func (r *BuffRegistry) Get(id uint32) *BuffDef {
return r.buffs[id]
}
func (r *BuffRegistry) registerDefaults() {
// Poison DoT (referenced by skill ID 5, effect Value=1)
r.buffs[1] = &BuffDef{
ID: 1,
Name: "Poison",
IsDebuff: true,
DamagePerTick: 8,
}
// Power Up buff (referenced by skill ID 6, effect Value=2)
r.buffs[2] = &BuffDef{
ID: 2,
Name: "Power Up",
IsDebuff: false,
StatModifier: StatMod{
StrBonus: 20,
},
}
}

View File

@@ -0,0 +1,368 @@
package combat
import (
"time"
"a301_game_server/internal/entity"
"a301_game_server/internal/network"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// Combatant is an entity that can participate in combat.
type Combatant interface {
entity.Entity
HP() int32
SetHP(int32)
MaxHP() int32
MP() int32
SetMP(int32)
IsAlive() bool
Stats() CombatStats
}
// CombatStats provides stat access for damage calculation.
type CombatStats struct {
Str int32
Dex int32
Int int32
Level int32
}
// CombatantWithConn is a combatant that can receive messages (player).
type CombatantWithConn interface {
Combatant
Connection() *network.Connection
}
// Manager handles all combat logic for a zone.
type Manager struct {
skills *SkillRegistry
buffs *BuffRegistry
// Per-entity state
cooldowns map[uint64]map[uint32]time.Time // entityID -> skillID -> ready time
activeBuffs map[uint64][]*ActiveBuff // entityID -> active buffs
// Broadcast function (set by zone to send to AOI)
broadcastToNearby func(ent entity.Entity, msgType uint16, msg interface{})
sendToEntity func(entityID uint64, msgType uint16, msg interface{})
}
// NewManager creates a combat manager.
func NewManager() *Manager {
return &Manager{
skills: NewSkillRegistry(),
buffs: NewBuffRegistry(),
cooldowns: make(map[uint64]map[uint32]time.Time),
activeBuffs: make(map[uint64][]*ActiveBuff),
}
}
// Skills returns the skill registry.
func (m *Manager) Skills() *SkillRegistry { return m.skills }
// SetBroadcast configures the broadcast callback.
func (m *Manager) SetBroadcast(
broadcast func(ent entity.Entity, msgType uint16, msg interface{}),
send func(entityID uint64, msgType uint16, msg interface{}),
) {
m.broadcastToNearby = broadcast
m.sendToEntity = send
}
// UseSkill attempts to execute a skill.
func (m *Manager) UseSkill(
caster Combatant,
skillID uint32,
targetID uint64,
targetPos mathutil.Vec3,
getEntity func(uint64) entity.Entity,
getEntitiesInRadius func(center mathutil.Vec3, radius float32) []entity.Entity,
) (bool, string) {
if !caster.IsAlive() {
return false, "you are dead"
}
skill := m.skills.Get(skillID)
if skill == nil {
return false, "unknown skill"
}
// Cooldown check.
if cd, ok := m.cooldowns[caster.EntityID()]; ok {
if readyAt, ok := cd[skillID]; ok && time.Now().Before(readyAt) {
return false, "skill on cooldown"
}
}
// Mana check.
if caster.MP() < skill.ManaCost {
return false, "not enough mana"
}
// Resolve targets.
var targets []Combatant
switch skill.TargetType {
case TargetSelf:
targets = []Combatant{caster}
case TargetSingleEnemy:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
target, ok := ent.(Combatant)
if !ok || !target.IsAlive() {
return false, "invalid target"
}
if caster.Position().DistanceXZ(target.Position()) > skill.Range {
return false, "target out of range"
}
targets = []Combatant{target}
case TargetSingleAlly:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
target, ok := ent.(Combatant)
if !ok || !target.IsAlive() {
return false, "invalid target"
}
if caster.Position().DistanceXZ(target.Position()) > skill.Range {
return false, "target out of range"
}
targets = []Combatant{target}
case TargetAoEGround:
if caster.Position().DistanceXZ(targetPos) > skill.Range {
return false, "target position out of range"
}
entities := getEntitiesInRadius(targetPos, skill.AoERadius)
for _, e := range entities {
if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() {
targets = append(targets, c)
}
}
case TargetAoETarget:
ent := getEntity(targetID)
if ent == nil {
return false, "target not found"
}
if caster.Position().DistanceXZ(ent.Position()) > skill.Range {
return false, "target out of range"
}
entities := getEntitiesInRadius(ent.Position(), skill.AoERadius)
for _, e := range entities {
if c, ok := e.(Combatant); ok && c.IsAlive() && c.EntityID() != caster.EntityID() {
targets = append(targets, c)
}
}
}
// Consume mana.
caster.SetMP(caster.MP() - skill.ManaCost)
// Set cooldown.
if m.cooldowns[caster.EntityID()] == nil {
m.cooldowns[caster.EntityID()] = make(map[uint32]time.Time)
}
m.cooldowns[caster.EntityID()][skillID] = time.Now().Add(skill.Cooldown)
// Calculate effective stats (base + buff modifiers).
casterStats := m.effectiveStats(caster)
// Apply effects to each target.
for _, target := range targets {
m.applyEffects(caster, target, skill, casterStats)
}
return true, ""
}
func (m *Manager) applyEffects(caster, target Combatant, skill *SkillDef, casterStats CombatStats) {
for _, effect := range skill.Effects {
switch effect.Type {
case EffectDamage:
targetStats := m.effectiveStats(target)
result := CalcDamage(effect.Value, casterStats.Str, targetStats.Dex)
target.SetHP(target.HP() - result.FinalDamage)
died := !target.IsAlive()
evt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
SkillId: skill.ID,
Damage: result.FinalDamage,
IsCritical: result.IsCritical,
TargetDied: died,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
if died {
deathEvt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
EventType: pb.CombatEventType_COMBAT_EVENT_DEATH,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, deathEvt)
}
}
case EffectHeal:
heal := CalcHeal(effect.Value, casterStats.Int)
target.SetHP(target.HP() + heal)
evt := &pb.CombatEvent{
CasterId: caster.EntityID(),
TargetId: target.EntityID(),
SkillId: skill.ID,
Heal: heal,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_HEAL,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
case EffectBuff, EffectDebuff:
buffDef := m.buffs.Get(uint32(effect.Value))
if buffDef == nil {
continue
}
ab := &ActiveBuff{
Def: buffDef,
CasterID: caster.EntityID(),
Remaining: effect.Duration,
TickInterval: effect.TickInterval,
NextTick: effect.TickInterval,
}
m.activeBuffs[target.EntityID()] = append(m.activeBuffs[target.EntityID()], ab)
evt := &pb.BuffApplied{
TargetId: target.EntityID(),
BuffId: buffDef.ID,
BuffName: buffDef.Name,
Duration: float32(effect.Duration.Seconds()),
IsDebuff: buffDef.IsDebuff,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgBuffApplied, evt)
}
}
}
}
// UpdateBuffs processes active buffs each tick. Call once per zone tick.
func (m *Manager) UpdateBuffs(dt time.Duration, getEntity func(uint64) Combatant) {
for entityID, buffs := range m.activeBuffs {
target := getEntity(entityID)
if target == nil {
delete(m.activeBuffs, entityID)
continue
}
var remaining []*ActiveBuff
for _, b := range buffs {
tickValue := b.Tick(dt)
if tickValue != 0 && target.IsAlive() {
if b.Def.IsDebuff {
// DoT damage.
target.SetHP(target.HP() - tickValue)
evt := &pb.CombatEvent{
CasterId: b.CasterID,
TargetId: entityID,
Damage: tickValue,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_DAMAGE,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
} else {
// HoT heal.
target.SetHP(target.HP() + tickValue)
evt := &pb.CombatEvent{
CasterId: b.CasterID,
TargetId: entityID,
Heal: tickValue,
TargetHp: target.HP(),
TargetMaxHp: target.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_HEAL,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgCombatEvent, evt)
}
}
}
if !b.IsExpired() {
remaining = append(remaining, b)
} else {
// Notify buff removed.
evt := &pb.BuffRemoved{
TargetId: entityID,
BuffId: b.Def.ID,
}
if m.broadcastToNearby != nil {
m.broadcastToNearby(target, network.MsgBuffRemoved, evt)
}
}
}
if len(remaining) == 0 {
delete(m.activeBuffs, entityID)
} else {
m.activeBuffs[entityID] = remaining
}
}
}
// Respawn resets a dead entity to full HP at a spawn position.
func (m *Manager) Respawn(ent Combatant, spawnPos mathutil.Vec3) {
ent.SetHP(ent.MaxHP())
ent.SetPosition(spawnPos)
m.clearCooldowns(ent.EntityID())
m.clearBuffs(ent.EntityID())
}
// RemoveEntity cleans up combat state for a removed entity.
func (m *Manager) RemoveEntity(entityID uint64) {
m.clearCooldowns(entityID)
m.clearBuffs(entityID)
}
// effectiveStats returns stats with buff modifiers applied.
func (m *Manager) effectiveStats(c Combatant) CombatStats {
base := c.Stats()
buffs := m.activeBuffs[c.EntityID()]
for _, b := range buffs {
base.Str += b.Def.StatModifier.StrBonus
base.Dex += b.Def.StatModifier.DexBonus
base.Int += b.Def.StatModifier.IntBonus
}
return base
}
func (m *Manager) clearCooldowns(entityID uint64) {
delete(m.cooldowns, entityID)
}
func (m *Manager) clearBuffs(entityID uint64) {
delete(m.activeBuffs, entityID)
}

View File

@@ -0,0 +1,239 @@
package combat
import (
"testing"
"time"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// mockCombatant implements Combatant for testing.
type mockCombatant struct {
id uint64
pos mathutil.Vec3
hp, maxHP int32
mp, maxMP int32
str, dex, intStat int32
}
func (m *mockCombatant) EntityID() uint64 { return m.id }
func (m *mockCombatant) EntityType() entity.Type { return entity.TypePlayer }
func (m *mockCombatant) Position() mathutil.Vec3 { return m.pos }
func (m *mockCombatant) SetPosition(p mathutil.Vec3) { m.pos = p }
func (m *mockCombatant) Rotation() float32 { return 0 }
func (m *mockCombatant) SetRotation(float32) {}
func (m *mockCombatant) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
func (m *mockCombatant) HP() int32 { return m.hp }
func (m *mockCombatant) SetHP(hp int32) {
if hp < 0 { hp = 0 }
if hp > m.maxHP { hp = m.maxHP }
m.hp = hp
}
func (m *mockCombatant) MaxHP() int32 { return m.maxHP }
func (m *mockCombatant) MP() int32 { return m.mp }
func (m *mockCombatant) SetMP(mp int32) {
if mp < 0 { mp = 0 }
m.mp = mp
}
func (m *mockCombatant) IsAlive() bool { return m.hp > 0 }
func (m *mockCombatant) Stats() CombatStats {
return CombatStats{Str: m.str, Dex: m.dex, Int: m.intStat, Level: 1}
}
func newMock(id uint64, x, z float32) *mockCombatant {
return &mockCombatant{
id: id, pos: mathutil.NewVec3(x, 0, z),
hp: 100, maxHP: 100, mp: 100, maxMP: 100,
str: 10, dex: 10, intStat: 10,
}
}
func TestBasicAttack(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0) // within range (3.0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatalf("expected success, got error: %s", errMsg)
}
if target.HP() >= 100 {
t.Error("target should have taken damage")
}
}
func TestOutOfRange(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 100, 0) // far away
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected failure due to range")
}
}
func TestCooldown(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 2, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
getEnt := func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
}
// First use should succeed.
ok, _ := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if !ok {
t.Fatal("first use should succeed")
}
// Immediate second use should fail (cooldown).
ok, errMsg := mgr.UseSkill(attacker, 1, 2, mathutil.Vec3{}, getEnt, nil)
if ok {
t.Error("expected cooldown failure")
}
if errMsg != "skill on cooldown" {
t.Errorf("expected 'skill on cooldown', got '%s'", errMsg)
}
}
func TestManaConsumption(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
target := newMock(2, 5, 0)
caster.mp = 10 // not enough for Fireball (20 mana)
entities := map[uint64]*mockCombatant{1: caster, 2: target}
ok, errMsg := mgr.UseSkill(caster, 2, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if ok {
t.Error("expected mana failure")
}
if errMsg != "not enough mana" {
t.Errorf("expected 'not enough mana', got '%s'", errMsg)
}
}
func TestHealSelf(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
caster := newMock(1, 0, 0)
caster.hp = 30
ok, _ := mgr.UseSkill(caster, 3, 0, mathutil.Vec3{},
func(id uint64) entity.Entity { return nil },
nil,
)
if !ok {
t.Fatal("heal should succeed")
}
if caster.HP() <= 30 {
t.Error("HP should have increased")
}
}
func TestPoisonDoT(t *testing.T) {
mgr := NewManager()
mgr.SetBroadcast(func(entity.Entity, uint16, interface{}) {}, func(uint64, uint16, interface{}) {})
attacker := newMock(1, 0, 0)
target := newMock(2, 5, 0)
entities := map[uint64]*mockCombatant{1: attacker, 2: target}
// Apply poison (skill 5).
ok, _ := mgr.UseSkill(attacker, 5, 2, mathutil.Vec3{},
func(id uint64) entity.Entity {
if e, ok := entities[id]; ok { return e }
return nil
},
nil,
)
if !ok {
t.Fatal("poison should succeed")
}
initialHP := target.HP()
// Simulate ticks until first DoT tick (2s at 50ms/tick = 40 ticks).
for i := 0; i < 40; i++ {
mgr.UpdateBuffs(50*time.Millisecond, func(id uint64) Combatant {
if e, ok := entities[id]; ok { return e }
return nil
})
}
if target.HP() >= initialHP {
t.Error("poison DoT should have dealt damage")
}
}
func TestDamageFormula(t *testing.T) {
// Base 15, attacker STR 10 (1.1x), defender DEX 10 (0.95x)
// Expected ~15.675 (without crit).
result := CalcDamage(15, 10, 10)
if result.FinalDamage < 1 {
t.Error("damage should be at least 1")
}
}
func TestRespawn(t *testing.T) {
mgr := NewManager()
ent := newMock(1, 50, 50)
ent.hp = 0
spawn := mathutil.NewVec3(0, 0, 0)
mgr.Respawn(ent, spawn)
if !ent.IsAlive() {
t.Error("should be alive after respawn")
}
if ent.HP() != ent.MaxHP() {
t.Error("should have full HP after respawn")
}
if ent.Position() != spawn {
t.Error("should be at spawn position")
}
}

51
internal/combat/damage.go Normal file
View File

@@ -0,0 +1,51 @@
package combat
import (
"math/rand"
)
const (
critChance = 0.15 // 15% crit chance
critMultiplier = 1.5
)
// DamageResult holds the outcome of a damage calculation.
type DamageResult struct {
FinalDamage int32
IsCritical bool
}
// CalcDamage computes final damage from base damage and attacker/defender stats.
// Formula: base * (1 + attackerStr/100) * (1 - defenderDex/200)
// Then roll for crit.
func CalcDamage(baseDamage int32, attackerStr, defenderDex int32) DamageResult {
attack := float64(baseDamage) * (1.0 + float64(attackerStr)/100.0)
defense := 1.0 - float64(defenderDex)/200.0
if defense < 0.1 {
defense = 0.1 // minimum 10% damage
}
dmg := attack * defense
isCrit := rand.Float64() < critChance
if isCrit {
dmg *= critMultiplier
}
final := int32(dmg)
if final < 1 {
final = 1
}
return DamageResult{FinalDamage: final, IsCritical: isCrit}
}
// CalcHeal computes final healing.
// Formula: base * (1 + casterInt/100)
func CalcHeal(baseHeal int32, casterInt int32) int32 {
heal := float64(baseHeal) * (1.0 + float64(casterInt)/100.0)
result := int32(heal)
if result < 1 {
result = 1
}
return result
}

148
internal/combat/skill.go Normal file
View File

@@ -0,0 +1,148 @@
package combat
import "time"
// TargetType determines how a skill selects its target.
type TargetType int
const (
TargetSelf TargetType = iota
TargetSingleEnemy // requires a valid enemy entity ID
TargetSingleAlly // requires a valid ally entity ID
TargetAoEGround // requires a ground position
TargetAoETarget // AoE centered on target entity
)
// EffectType determines what a skill effect does.
type EffectType int
const (
EffectDamage EffectType = iota
EffectHeal
EffectBuff
EffectDebuff
)
// Effect is a single outcome of a skill.
type Effect struct {
Type EffectType
Value int32 // damage amount, heal amount, or buff/debuff ID
Duration time.Duration // 0 for instant effects
TickInterval time.Duration // for DoT/HoT; 0 means apply once
}
// SkillDef defines a skill's properties (loaded from data).
type SkillDef struct {
ID uint32
Name string
Cooldown time.Duration
ManaCost int32
Range float32 // max distance to target (0 = self only)
TargetType TargetType
AoERadius float32 // for AoE skills
CastTime time.Duration
Effects []Effect
}
// SkillRegistry holds all skill definitions.
type SkillRegistry struct {
skills map[uint32]*SkillDef
}
// NewSkillRegistry creates a registry with default skills.
func NewSkillRegistry() *SkillRegistry {
r := &SkillRegistry{skills: make(map[uint32]*SkillDef)}
r.registerDefaults()
return r
}
// Get returns a skill definition by ID.
func (r *SkillRegistry) Get(id uint32) *SkillDef {
return r.skills[id]
}
// Register adds a skill definition.
func (r *SkillRegistry) Register(s *SkillDef) {
r.skills[s.ID] = s
}
func (r *SkillRegistry) registerDefaults() {
// Basic attack
r.Register(&SkillDef{
ID: 1,
Name: "Basic Attack",
Cooldown: 1 * time.Second,
ManaCost: 0,
Range: 3.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDamage, Value: 15},
},
})
// Fireball - ranged damage
r.Register(&SkillDef{
ID: 2,
Name: "Fireball",
Cooldown: 3 * time.Second,
ManaCost: 20,
Range: 15.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDamage, Value: 40},
},
})
// Heal
r.Register(&SkillDef{
ID: 3,
Name: "Heal",
Cooldown: 5 * time.Second,
ManaCost: 30,
Range: 0,
TargetType: TargetSelf,
Effects: []Effect{
{Type: EffectHeal, Value: 50},
},
})
// AoE - Flame Strike
r.Register(&SkillDef{
ID: 4,
Name: "Flame Strike",
Cooldown: 8 * time.Second,
ManaCost: 40,
Range: 12.0,
TargetType: TargetAoEGround,
AoERadius: 5.0,
Effects: []Effect{
{Type: EffectDamage, Value: 30},
},
})
// Poison (DoT debuff)
r.Register(&SkillDef{
ID: 5,
Name: "Poison",
Cooldown: 6 * time.Second,
ManaCost: 15,
Range: 8.0,
TargetType: TargetSingleEnemy,
Effects: []Effect{
{Type: EffectDebuff, Value: 1, Duration: 10 * time.Second, TickInterval: 2 * time.Second},
},
})
// Power Buff (self buff - increases damage)
r.Register(&SkillDef{
ID: 6,
Name: "Power Up",
Cooldown: 15 * time.Second,
ManaCost: 25,
Range: 0,
TargetType: TargetSelf,
Effects: []Effect{
{Type: EffectBuff, Value: 2, Duration: 10 * time.Second},
},
})
}

47
internal/db/migrations.go Normal file
View File

@@ -0,0 +1,47 @@
package db
var migrations = []string{
// 0: accounts table
`CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(32) UNIQUE NOT NULL,
password VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
// 1: characters table
`CREATE TABLE IF NOT EXISTS characters (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
name VARCHAR(32) UNIQUE NOT NULL,
level INT NOT NULL DEFAULT 1,
exp BIGINT NOT NULL DEFAULT 0,
hp INT NOT NULL DEFAULT 100,
max_hp INT NOT NULL DEFAULT 100,
mp INT NOT NULL DEFAULT 50,
max_mp INT NOT NULL DEFAULT 50,
str INT NOT NULL DEFAULT 10,
dex INT NOT NULL DEFAULT 10,
int_stat INT NOT NULL DEFAULT 10,
zone_id INT NOT NULL DEFAULT 1,
pos_x REAL NOT NULL DEFAULT 0,
pos_y REAL NOT NULL DEFAULT 0,
pos_z REAL NOT NULL DEFAULT 0,
rotation REAL NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
// 2: inventory table
`CREATE TABLE IF NOT EXISTS inventory (
id BIGSERIAL PRIMARY KEY,
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
slot INT NOT NULL,
item_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
UNIQUE(character_id, slot)
)`,
// 3: index for character lookups by account
`CREATE INDEX IF NOT EXISTS idx_characters_account_id ON characters(account_id)`,
}

51
internal/db/postgres.go Normal file
View File

@@ -0,0 +1,51 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"a301_game_server/config"
"a301_game_server/pkg/logger"
)
// Pool wraps a pgx connection pool.
type Pool struct {
*pgxpool.Pool
}
// NewPool creates a connection pool to PostgreSQL.
func NewPool(ctx context.Context, cfg *config.DatabaseConfig) (*Pool, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.DSN())
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MinConns = cfg.MinConns
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
logger.Info("database connected", "host", cfg.Host, "db", cfg.DBName)
return &Pool{pool}, nil
}
// RunMigrations executes all schema migrations.
func (p *Pool) RunMigrations(ctx context.Context) error {
for i, m := range migrations {
if _, err := p.Exec(ctx, m); err != nil {
return fmt.Errorf("migration %d failed: %w", i, err)
}
}
logger.Info("database migrations completed", "count", len(migrations))
return nil
}

View File

@@ -0,0 +1,166 @@
package repository
import (
"context"
"fmt"
"a301_game_server/internal/db"
)
// CharacterData holds persisted character state.
type CharacterData struct {
ID int64
AccountID int64
Name string
Level int32
Exp int64
HP int32
MaxHP int32
MP int32
MaxMP int32
Str int32
Dex int32
IntStat int32
ZoneID int32
PosX float32
PosY float32
PosZ float32
Rotation float32
}
// CharacterRepo handles character persistence.
type CharacterRepo struct {
pool *db.Pool
}
// NewCharacterRepo creates a new character repository.
func NewCharacterRepo(pool *db.Pool) *CharacterRepo {
return &CharacterRepo{pool: pool}
}
// Create inserts a new character.
func (r *CharacterRepo) Create(ctx context.Context, accountID int64, name string) (*CharacterData, error) {
c := &CharacterData{
AccountID: accountID,
Name: name,
Level: 1,
HP: 100,
MaxHP: 100,
MP: 50,
MaxMP: 50,
Str: 10,
Dex: 10,
IntStat: 10,
ZoneID: 1,
}
err := r.pool.QueryRow(ctx,
`INSERT INTO characters (account_id, name, level, hp, max_hp, mp, max_mp, str, dex, int_stat, zone_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id`,
c.AccountID, c.Name, c.Level, c.HP, c.MaxHP, c.MP, c.MaxMP, c.Str, c.Dex, c.IntStat, c.ZoneID,
).Scan(&c.ID)
if err != nil {
return nil, fmt.Errorf("create character: %w", err)
}
return c, nil
}
// GetByAccountID returns all characters for an account.
func (r *CharacterRepo) GetByAccountID(ctx context.Context, accountID int64) ([]*CharacterData, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, account_id, name, level, exp, hp, max_hp, mp, max_mp, str, dex, int_stat,
zone_id, pos_x, pos_y, pos_z, rotation
FROM characters WHERE account_id = $1`, accountID,
)
if err != nil {
return nil, fmt.Errorf("query characters: %w", err)
}
defer rows.Close()
var chars []*CharacterData
for rows.Next() {
c := &CharacterData{}
if err := rows.Scan(
&c.ID, &c.AccountID, &c.Name, &c.Level, &c.Exp,
&c.HP, &c.MaxHP, &c.MP, &c.MaxMP,
&c.Str, &c.Dex, &c.IntStat,
&c.ZoneID, &c.PosX, &c.PosY, &c.PosZ, &c.Rotation,
); err != nil {
return nil, fmt.Errorf("scan character: %w", err)
}
chars = append(chars, c)
}
return chars, nil
}
// GetByID loads a single character.
func (r *CharacterRepo) GetByID(ctx context.Context, id int64) (*CharacterData, error) {
c := &CharacterData{}
err := r.pool.QueryRow(ctx,
`SELECT id, account_id, name, level, exp, hp, max_hp, mp, max_mp, str, dex, int_stat,
zone_id, pos_x, pos_y, pos_z, rotation
FROM characters WHERE id = $1`, id,
).Scan(
&c.ID, &c.AccountID, &c.Name, &c.Level, &c.Exp,
&c.HP, &c.MaxHP, &c.MP, &c.MaxMP,
&c.Str, &c.Dex, &c.IntStat,
&c.ZoneID, &c.PosX, &c.PosY, &c.PosZ, &c.Rotation,
)
if err != nil {
return nil, fmt.Errorf("get character %d: %w", id, err)
}
return c, nil
}
// Save persists the current character state.
func (r *CharacterRepo) Save(ctx context.Context, c *CharacterData) error {
_, err := r.pool.Exec(ctx,
`UPDATE characters SET
level = $2, exp = $3, hp = $4, max_hp = $5, mp = $6, max_mp = $7,
str = $8, dex = $9, int_stat = $10,
zone_id = $11, pos_x = $12, pos_y = $13, pos_z = $14, rotation = $15,
updated_at = NOW()
WHERE id = $1`,
c.ID, c.Level, c.Exp, c.HP, c.MaxHP, c.MP, c.MaxMP,
c.Str, c.Dex, c.IntStat,
c.ZoneID, c.PosX, c.PosY, c.PosZ, c.Rotation,
)
if err != nil {
return fmt.Errorf("save character %d: %w", c.ID, err)
}
return nil
}
// SaveBatch saves multiple characters in a single transaction.
func (r *CharacterRepo) SaveBatch(ctx context.Context, chars []*CharacterData) error {
if len(chars) == 0 {
return nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
for _, c := range chars {
_, err := tx.Exec(ctx,
`UPDATE characters SET
level = $2, exp = $3, hp = $4, max_hp = $5, mp = $6, max_mp = $7,
str = $8, dex = $9, int_stat = $10,
zone_id = $11, pos_x = $12, pos_y = $13, pos_z = $14, rotation = $15,
updated_at = NOW()
WHERE id = $1`,
c.ID, c.Level, c.Exp, c.HP, c.MaxHP, c.MP, c.MaxMP,
c.Str, c.Dex, c.IntStat,
c.ZoneID, c.PosX, c.PosY, c.PosZ, c.Rotation,
)
if err != nil {
return fmt.Errorf("save character %d in batch: %w", c.ID, err)
}
}
return tx.Commit(ctx)
}

26
internal/entity/entity.go Normal file
View File

@@ -0,0 +1,26 @@
package entity
import (
pb "a301_game_server/proto/gen/pb"
"a301_game_server/pkg/mathutil"
)
// Type identifies the kind of entity.
type Type int
const (
TypePlayer Type = iota
TypeMob
TypeNPC
)
// Entity is anything that exists in the game world.
type Entity interface {
EntityID() uint64
EntityType() Type
Position() mathutil.Vec3
SetPosition(pos mathutil.Vec3)
Rotation() float32
SetRotation(rot float32)
ToProto() *pb.EntityState
}

View File

@@ -0,0 +1,462 @@
package game
import (
"context"
"sync"
"sync/atomic"
"time"
"a301_game_server/config"
"a301_game_server/internal/ai"
"a301_game_server/internal/auth"
"a301_game_server/internal/db"
"a301_game_server/internal/db/repository"
"a301_game_server/internal/network"
"a301_game_server/internal/player"
"a301_game_server/internal/world"
"a301_game_server/pkg/logger"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
const defaultZoneID uint32 = 1
// GameServer is the top-level orchestrator that connects networking with game logic.
type GameServer struct {
cfg *config.Config
world *World
sessions *player.SessionManager
dbPool *db.Pool
authSvc *auth.Service
charRepo *repository.CharacterRepo
mu sync.RWMutex
connPlayer map[uint64]*player.Player // connID -> player
playerConn map[uint64]uint64 // playerID -> connID
nextPlayerID atomic.Uint64
cancelSave context.CancelFunc
}
// NewGameServer creates the game server.
func NewGameServer(cfg *config.Config, dbPool *db.Pool) *GameServer {
gs := &GameServer{
cfg: cfg,
world: NewWorld(cfg),
sessions: player.NewSessionManager(),
dbPool: dbPool,
authSvc: auth.NewService(dbPool),
charRepo: repository.NewCharacterRepo(dbPool),
connPlayer: make(map[uint64]*player.Player),
playerConn: make(map[uint64]uint64),
}
// Create zones, portals, and mobs.
gs.setupWorld()
return gs
}
// World returns the game world.
func (gs *GameServer) World() *World { return gs.world }
// Start launches all zone game loops and periodic save.
func (gs *GameServer) Start() {
gs.world.mu.RLock()
for _, zone := range gs.world.zones {
zone.SetMessageHandler(gs)
zone.SetZoneTransferCallback(gs.handleZoneTransfer)
}
gs.world.mu.RUnlock()
gs.world.StartAll()
// Start periodic character save.
ctx, cancel := context.WithCancel(context.Background())
gs.cancelSave = cancel
go gs.periodicSave(ctx)
}
// Stop shuts down all zone game loops and saves all players.
func (gs *GameServer) Stop() {
if gs.cancelSave != nil {
gs.cancelSave()
}
// Final save of all online players.
gs.saveAllPlayers()
gs.world.StopAll()
}
// OnPacket handles incoming packets from a connection.
func (gs *GameServer) OnPacket(conn *network.Connection, pkt *network.Packet) {
switch pkt.Type {
case network.MsgLoginRequest:
gs.handleLogin(conn, pkt)
case network.MsgEnterWorldRequest:
gs.handleEnterWorld(conn, pkt)
default:
gs.mu.RLock()
p, ok := gs.connPlayer[conn.ID()]
gs.mu.RUnlock()
if !ok {
return
}
zone, err := gs.world.GetZone(p.ZoneID())
if err != nil {
return
}
zone.EnqueueMessage(PlayerMessage{PlayerID: p.EntityID(), Packet: pkt})
}
}
// OnDisconnect handles a connection closing.
func (gs *GameServer) OnDisconnect(conn *network.Connection) {
gs.mu.Lock()
p, ok := gs.connPlayer[conn.ID()]
if !ok {
gs.mu.Unlock()
return
}
delete(gs.connPlayer, conn.ID())
delete(gs.playerConn, p.EntityID())
gs.mu.Unlock()
// Save character to DB on disconnect.
if p.CharID() != 0 {
if err := gs.charRepo.Save(context.Background(), p.ToCharacterData()); err != nil {
logger.Error("failed to save player on disconnect", "playerID", p.EntityID(), "error", err)
}
}
zone, err := gs.world.GetZone(p.ZoneID())
if err == nil {
zone.EnqueueMessage(PlayerMessage{
PlayerID: p.EntityID(),
Packet: &network.Packet{Type: msgPlayerDisconnect},
})
}
logger.Info("player disconnected", "connID", conn.ID(), "playerID", p.EntityID())
}
// Internal message types.
const (
msgPlayerDisconnect uint16 = 0xFFFF
msgPlayerEnterWorld uint16 = 0xFFFE
)
// HandleZoneMessage implements ZoneMessageHandler.
func (gs *GameServer) HandleZoneMessage(zone *Zone, msg PlayerMessage) bool {
switch msg.Packet.Type {
case msgPlayerDisconnect:
zone.RemovePlayer(msg.PlayerID)
return true
case msgPlayerEnterWorld:
gs.mu.RLock()
var found *player.Player
for _, p := range gs.connPlayer {
if p.EntityID() == msg.PlayerID {
found = p
break
}
}
gs.mu.RUnlock()
if found != nil {
zone.AddPlayer(found)
}
return true
default:
return false
}
}
func (gs *GameServer) handleLogin(conn *network.Connection, pkt *network.Packet) {
req := pkt.Payload.(*pb.LoginRequest)
ctx := context.Background()
// Try login first.
accountID, err := gs.authSvc.Login(ctx, req.Username, req.Password)
if err != nil {
// Auto-register if account doesn't exist.
accountID, err = gs.authSvc.Register(ctx, req.Username, req.Password)
if err != nil {
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
Success: false,
ErrorMessage: "login failed: " + err.Error(),
})
return
}
// Create default character on first registration.
_, charErr := gs.charRepo.Create(ctx, accountID, req.Username)
if charErr != nil {
logger.Error("failed to create default character", "accountID", accountID, "error", charErr)
}
}
session := gs.sessions.Create(uint64(accountID), req.Username)
conn.Send(network.MsgLoginResponse, &pb.LoginResponse{
Success: true,
SessionToken: session.Token,
PlayerId: uint64(accountID),
})
logger.Info("player logged in", "username", req.Username, "accountID", accountID)
}
func (gs *GameServer) handleEnterWorld(conn *network.Connection, pkt *network.Packet) {
req := pkt.Payload.(*pb.EnterWorldRequest)
session := gs.sessions.Get(req.SessionToken)
if session == nil {
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
Success: false,
ErrorMessage: "invalid session",
})
return
}
ctx := context.Background()
// Load character from DB.
chars, err := gs.charRepo.GetByAccountID(ctx, int64(session.PlayerID))
if err != nil || len(chars) == 0 {
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
Success: false,
ErrorMessage: "no character found",
})
return
}
charData := chars[0] // Use first character for now.
p := player.NewPlayerFromDB(charData, conn)
// Register connection-player mapping.
gs.mu.Lock()
gs.connPlayer[conn.ID()] = p
gs.playerConn[p.EntityID()] = conn.ID()
gs.mu.Unlock()
zoneID := p.ZoneID()
zone, err := gs.world.GetZone(zoneID)
if err != nil {
// Fall back to default zone.
zoneID = defaultZoneID
p.SetZoneID(defaultZoneID)
zone, _ = gs.world.GetZone(defaultZoneID)
}
zone.EnqueueMessage(PlayerMessage{
PlayerID: p.EntityID(),
Packet: &network.Packet{Type: msgPlayerEnterWorld},
})
conn.Send(network.MsgEnterWorldResponse, &pb.EnterWorldResponse{
Success: true,
Self: p.ToProto(),
ZoneId: zoneID,
})
logger.Info("player entered world", "playerID", p.EntityID(), "charID", charData.ID, "zone", zoneID)
}
// periodicSave saves all dirty player data to DB at configured intervals.
func (gs *GameServer) periodicSave(ctx context.Context) {
ticker := time.NewTicker(gs.cfg.Database.SaveInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
gs.saveAllPlayers()
case <-ctx.Done():
return
}
}
}
func (gs *GameServer) saveAllPlayers() {
gs.mu.RLock()
var dirty []*player.Player
for _, p := range gs.connPlayer {
if p.IsDirty() && p.CharID() != 0 {
dirty = append(dirty, p)
}
}
gs.mu.RUnlock()
if len(dirty) == 0 {
return
}
chars := make([]*repository.CharacterData, 0, len(dirty))
for _, p := range dirty {
chars = append(chars, p.ToCharacterData())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := gs.charRepo.SaveBatch(ctx, chars); err != nil {
logger.Error("periodic save failed", "count", len(chars), "error", err)
return
}
for _, p := range dirty {
p.ClearDirty()
}
logger.Debug("periodic save completed", "count", len(chars))
}
// setupWorld creates all zones, portals, and mob spawn points.
func (gs *GameServer) setupWorld() {
zone1 := gs.world.CreateZone(1) // Starting zone - plains
zone2 := gs.world.CreateZone(2) // Forest zone - medium difficulty
zone3 := gs.world.CreateZone(3) // Volcano zone - hard
// Set spawn positions.
zone1.spawnPos = mathutil.NewVec3(0, 0, 0)
zone2.spawnPos = mathutil.NewVec3(5, 0, 5)
zone3.spawnPos = mathutil.NewVec3(10, 0, 10)
// Portals: Zone 1 <-> Zone 2
zone1.AddPortal(world.ZonePortal{
SourceZoneID: 1,
TriggerPos: mathutil.NewVec3(300, 0, 150),
TriggerRadius: 5.0,
TargetZoneID: 2,
TargetPos: mathutil.NewVec3(5, 0, 5),
})
zone2.AddPortal(world.ZonePortal{
SourceZoneID: 2,
TriggerPos: mathutil.NewVec3(0, 0, 0),
TriggerRadius: 5.0,
TargetZoneID: 1,
TargetPos: mathutil.NewVec3(295, 0, 150),
})
// Portals: Zone 2 <-> Zone 3
zone2.AddPortal(world.ZonePortal{
SourceZoneID: 2,
TriggerPos: mathutil.NewVec3(300, 0, 300),
TriggerRadius: 5.0,
TargetZoneID: 3,
TargetPos: mathutil.NewVec3(10, 0, 10),
})
zone3.AddPortal(world.ZonePortal{
SourceZoneID: 3,
TriggerPos: mathutil.NewVec3(0, 0, 0),
TriggerRadius: 5.0,
TargetZoneID: 2,
TargetPos: mathutil.NewVec3(295, 0, 295),
})
// Populate zones with mobs.
gs.setupZoneMobs(zone1, []mobSpawnConfig{
{mobID: 1, count: 3, baseX: 20, baseZ: 30, spacing: 15}, // Goblins
{mobID: 2, count: 2, baseX: 80, baseZ: 80, spacing: 12}, // Wolves
})
gs.setupZoneMobs(zone2, []mobSpawnConfig{
{mobID: 2, count: 4, baseX: 50, baseZ: 50, spacing: 15}, // Wolves
{mobID: 3, count: 2, baseX: 150, baseZ: 150, spacing: 20}, // Trolls
{mobID: 4, count: 1, baseX: 200, baseZ: 50, spacing: 0}, // Fire Elemental
})
gs.setupZoneMobs(zone3, []mobSpawnConfig{
{mobID: 4, count: 3, baseX: 80, baseZ: 80, spacing: 25}, // Fire Elementals
{mobID: 5, count: 1, baseX: 200, baseZ: 200, spacing: 0}, // Dragon Whelp
})
}
type mobSpawnConfig struct {
mobID uint32
count int
baseX float32
baseZ float32
spacing float32
}
// setupZoneMobs configures mob spawn points for a zone.
func (gs *GameServer) setupZoneMobs(zone *Zone, configs []mobSpawnConfig) {
registry := ai.NewMobRegistry()
spawner := zone.Spawner()
for _, cfg := range configs {
def := registry.Get(cfg.mobID)
if def == nil {
continue
}
respawn := time.Duration(15+def.Level*3) * time.Second
for i := 0; i < cfg.count; i++ {
spawner.AddSpawnPoint(ai.SpawnPoint{
MobDef: def,
Position: mathutil.NewVec3(cfg.baseX+float32(i)*cfg.spacing, 0, cfg.baseZ+float32(i)*cfg.spacing),
RespawnDelay: respawn,
MaxCount: 1,
})
}
}
spawner.InitialSpawn()
}
// handleZoneTransfer moves a player between zones.
func (gs *GameServer) handleZoneTransfer(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3) {
gs.mu.RLock()
var p *player.Player
var connID uint64
for cid, pl := range gs.connPlayer {
if pl.EntityID() == playerID {
p = pl
connID = cid
break
}
}
gs.mu.RUnlock()
if p == nil {
return
}
_ = connID
sourceZone, err := gs.world.GetZone(p.ZoneID())
if err != nil {
return
}
targetZone, err := gs.world.GetZone(targetZoneID)
if err != nil {
logger.Warn("zone transfer target not found", "targetZone", targetZoneID)
return
}
// Remove from source zone.
sourceZone.RemovePlayer(playerID)
// Update player state.
p.SetPosition(targetPos)
p.SetZoneID(targetZoneID)
// Add to target zone via message queue.
targetZone.EnqueueMessage(PlayerMessage{
PlayerID: playerID,
Packet: &network.Packet{Type: msgPlayerEnterWorld},
})
// Notify client of zone change.
p.Connection().Send(network.MsgZoneTransferNotify, &pb.ZoneTransferNotify{
NewZoneId: targetZoneID,
Self: p.ToProto(),
})
logger.Info("zone transfer",
"playerID", playerID,
"from", sourceZone.ID(),
"to", targetZoneID,
)
}

94
internal/game/world.go Normal file
View File

@@ -0,0 +1,94 @@
package game
import (
"fmt"
"sync"
"a301_game_server/config"
"a301_game_server/pkg/logger"
)
// World manages all zones.
type World struct {
mu sync.RWMutex
zones map[uint32]*Zone
cfg *config.Config
}
// NewWorld creates a new world.
func NewWorld(cfg *config.Config) *World {
return &World{
zones: make(map[uint32]*Zone),
cfg: cfg,
}
}
// CreateZone creates and registers a new zone.
func (w *World) CreateZone(id uint32) *Zone {
zone := NewZone(id, w.cfg)
w.mu.Lock()
w.zones[id] = zone
w.mu.Unlock()
logger.Info("zone created", "zoneID", id)
return zone
}
// GetZone returns a zone by ID.
func (w *World) GetZone(id uint32) (*Zone, error) {
w.mu.RLock()
defer w.mu.RUnlock()
zone, ok := w.zones[id]
if !ok {
return nil, fmt.Errorf("zone %d not found", id)
}
return zone, nil
}
// StartAll launches all zone game loops.
func (w *World) StartAll() {
w.mu.RLock()
defer w.mu.RUnlock()
for _, zone := range w.zones {
go zone.Run()
}
logger.Info("all zones started", "count", len(w.zones))
}
// StopAll stops all zone game loops.
func (w *World) StopAll() {
w.mu.RLock()
defer w.mu.RUnlock()
for _, zone := range w.zones {
zone.Stop()
}
logger.Info("all zones stopped")
}
// TotalPlayers returns the total number of online players across all zones.
func (w *World) TotalPlayers() int {
w.mu.RLock()
defer w.mu.RUnlock()
total := 0
for _, zone := range w.zones {
total += zone.PlayerCount()
}
return total
}
// TotalEntities returns the total number of entities across all zones.
func (w *World) TotalEntities() int {
w.mu.RLock()
defer w.mu.RUnlock()
total := 0
for _, zone := range w.zones {
total += zone.EntityCount()
}
return total
}

665
internal/game/zone.go Normal file
View File

@@ -0,0 +1,665 @@
package game
import (
"sync/atomic"
"time"
"a301_game_server/config"
"a301_game_server/internal/ai"
"a301_game_server/internal/combat"
"a301_game_server/internal/entity"
"a301_game_server/internal/network"
"a301_game_server/internal/world"
"a301_game_server/pkg/logger"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
"google.golang.org/protobuf/proto"
)
const (
maxMoveSpeed float32 = 10.0 // units per second
)
// PlayerMessage is a message from a player connection queued for zone processing.
type PlayerMessage struct {
PlayerID uint64
Packet *network.Packet
}
// PlayerEntity wraps an entity.Entity that is also a connected player.
type PlayerEntity interface {
entity.Entity
Connection() *network.Connection
Velocity() mathutil.Vec3
SetVelocity(vel mathutil.Vec3)
}
// ZoneMessageHandler provides an extension point for handling custom message types in a zone.
type ZoneMessageHandler interface {
HandleZoneMessage(zone *Zone, msg PlayerMessage) bool // returns true if handled
}
// Zone is a self-contained game area with its own game loop.
type Zone struct {
id uint32
cfg *config.Config
entities map[uint64]entity.Entity
players map[uint64]PlayerEntity
aoi world.AOIManager
incoming chan PlayerMessage
stopCh chan struct{}
tick int64
// Metrics
lastTickDuration atomic.Int64
// AOI toggle support
aoiEnabled bool
gridAOI *world.GridAOI
broadcastAOI *world.BroadcastAllAOI
// Combat
combatMgr *combat.Manager
// AI / Mobs
spawner *ai.Spawner
nextEntityID atomic.Uint64
// External message handler for custom/internal messages.
extHandler ZoneMessageHandler
// Respawn position
spawnPos mathutil.Vec3
// Zone portals
portals []world.ZonePortal
// Zone transfer callback (set by GameServer)
onZoneTransfer func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)
}
// NewZone creates a new zone with the given configuration.
func NewZone(id uint32, cfg *config.Config) *Zone {
gridAOI := world.NewGridAOI(cfg.World.AOI.CellSize, cfg.World.AOI.ViewRange)
broadcastAOI := world.NewBroadcastAllAOI()
var activeAOI world.AOIManager
if cfg.World.AOI.Enabled {
activeAOI = gridAOI
} else {
activeAOI = broadcastAOI
}
cm := combat.NewManager()
z := &Zone{
id: id,
cfg: cfg,
entities: make(map[uint64]entity.Entity),
players: make(map[uint64]PlayerEntity),
aoi: activeAOI,
incoming: make(chan PlayerMessage, 4096),
stopCh: make(chan struct{}),
aoiEnabled: cfg.World.AOI.Enabled,
gridAOI: gridAOI,
broadcastAOI: broadcastAOI,
combatMgr: cm,
spawnPos: mathutil.NewVec3(0, 0, 0),
}
// Wire combat manager broadcast to zone AOI.
cm.SetBroadcast(z.broadcastCombatEvent, z.sendToEntity)
// Create mob spawner.
z.spawner = ai.NewSpawner(
&z.nextEntityID,
func(m *ai.Mob) { z.addMob(m) },
func(mobID uint64) { z.removeMob(mobID) },
)
return z
}
// ID returns the zone identifier.
func (z *Zone) ID() uint32 { return z.id }
// AddPortal registers a zone portal.
func (z *Zone) AddPortal(portal world.ZonePortal) {
z.portals = append(z.portals, portal)
}
// SetZoneTransferCallback sets the function called when a player enters a portal.
func (z *Zone) SetZoneTransferCallback(fn func(playerID uint64, targetZoneID uint32, targetPos mathutil.Vec3)) {
z.onZoneTransfer = fn
}
// PlayerCount returns the current number of players in this zone.
func (z *Zone) PlayerCount() int { return len(z.players) }
// EntityCount returns the current number of entities in this zone.
func (z *Zone) EntityCount() int { return len(z.entities) }
// LastTickDuration returns the duration of the last tick in microseconds.
func (z *Zone) LastTickDuration() int64 { return z.lastTickDuration.Load() }
// AOIEnabled returns whether grid-based AOI is currently active.
func (z *Zone) AOIEnabled() bool { return z.aoiEnabled }
// EnqueueMessage queues a player message for processing in the next tick.
func (z *Zone) EnqueueMessage(msg PlayerMessage) {
select {
case z.incoming <- msg:
default:
logger.Warn("zone message queue full, dropping", "zoneID", z.id, "playerID", msg.PlayerID)
}
}
// AddPlayer adds a player to the zone.
// Must be called from the zone's goroutine or before Run() starts.
func (z *Zone) AddPlayer(p PlayerEntity) {
z.entities[p.EntityID()] = p
z.players[p.EntityID()] = p
z.aoi.Add(p)
// Notify existing nearby players about the new player.
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: p.ToProto()})
for _, nearby := range z.aoi.GetNearby(p) {
if np, ok := z.players[nearby.EntityID()]; ok {
np.Connection().SendRaw(spawnData)
}
}
logger.Info("player added to zone", "zoneID", z.id, "playerID", p.EntityID(), "players", len(z.players))
}
// RemovePlayer removes a player from the zone.
func (z *Zone) RemovePlayer(playerID uint64) {
entity, ok := z.entities[playerID]
if !ok {
return
}
events := z.aoi.Remove(entity)
z.handleAOIEvents(events)
z.combatMgr.RemoveEntity(playerID)
delete(z.entities, playerID)
delete(z.players, playerID)
logger.Info("player removed from zone", "zoneID", z.id, "playerID", playerID, "players", len(z.players))
}
// ToggleAOI switches between grid-based and broadcast-all AOI at runtime.
func (z *Zone) ToggleAOI(enabled bool) {
if z.aoiEnabled == enabled {
return
}
z.aoiEnabled = enabled
// Rebuild the target AOI manager with current entities.
var newAOI world.AOIManager
if enabled {
g := world.NewGridAOI(z.cfg.World.AOI.CellSize, z.cfg.World.AOI.ViewRange)
for _, e := range z.entities {
g.Add(e)
}
z.gridAOI = g
newAOI = g
} else {
b := world.NewBroadcastAllAOI()
for _, e := range z.entities {
b.Add(e)
}
z.broadcastAOI = b
newAOI = b
}
z.aoi = newAOI
// After toggle, send full spawn list to all players so they see the correct set.
for _, p := range z.players {
z.sendNearbySnapshot(p)
}
logger.Info("AOI toggled", "zoneID", z.id, "enabled", enabled)
}
// Run starts the zone's game loop. Blocks until Stop() is called.
func (z *Zone) Run() {
interval := z.cfg.TickInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
logger.Info("zone started", "zoneID", z.id, "tickInterval", interval)
for {
select {
case <-ticker.C:
start := time.Now()
z.processTick()
z.lastTickDuration.Store(time.Since(start).Microseconds())
case <-z.stopCh:
logger.Info("zone stopped", "zoneID", z.id)
return
}
}
}
// Stop signals the zone's game loop to exit.
func (z *Zone) Stop() {
close(z.stopCh)
}
func (z *Zone) processTick() {
z.tick++
z.processInputQueue()
z.updateMovement()
z.updateAI()
z.updateCombat()
z.checkDeaths()
z.spawner.Update(time.Now())
z.broadcastState()
}
func (z *Zone) updateCombat() {
dt := z.cfg.TickInterval()
z.combatMgr.UpdateBuffs(dt, func(id uint64) combat.Combatant {
if p, ok := z.players[id]; ok {
if c, ok := p.(combat.Combatant); ok {
return c
}
}
return nil
})
}
func (z *Zone) processInputQueue() {
for {
select {
case msg := <-z.incoming:
z.handleMessage(msg)
default:
return
}
}
}
// SetMessageHandler sets an external handler for custom message types.
func (z *Zone) SetMessageHandler(h ZoneMessageHandler) {
z.extHandler = h
}
func (z *Zone) handleMessage(msg PlayerMessage) {
// Try external handler first (for internal messages like disconnect/enter).
if z.extHandler != nil && z.extHandler.HandleZoneMessage(z, msg) {
return
}
switch msg.Packet.Type {
case network.MsgMoveRequest:
z.handleMoveRequest(msg)
case network.MsgUseSkillRequest:
z.handleUseSkill(msg)
case network.MsgRespawnRequest:
z.handleRespawn(msg)
case network.MsgPing:
z.handlePing(msg)
case network.MsgAOIToggleRequest:
z.handleAOIToggle(msg)
case network.MsgMetricsRequest:
z.handleMetrics(msg)
}
}
func (z *Zone) handleMoveRequest(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.MoveRequest)
newPos := mathutil.NewVec3(req.Position.X, req.Position.Y, req.Position.Z)
vel := mathutil.NewVec3(req.Velocity.X, req.Velocity.Y, req.Velocity.Z)
// Server-side speed validation.
if vel.Length() > maxMoveSpeed*1.1 { // 10% tolerance
vel = vel.Normalize().Scale(maxMoveSpeed)
}
oldPos := p.Position()
p.SetPosition(newPos)
p.SetRotation(req.Rotation)
p.SetVelocity(vel)
// Update AOI and handle events.
events := z.aoi.UpdatePosition(p, oldPos, newPos)
z.handleAOIEvents(events)
// Check portal triggers.
z.checkPortals(p, newPos)
}
func (z *Zone) handlePing(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
ping := msg.Packet.Payload.(*pb.Ping)
p.Connection().Send(network.MsgPong, &pb.Pong{
ClientTime: ping.ClientTime,
ServerTime: time.Now().UnixMilli(),
})
}
func (z *Zone) handleAOIToggle(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.AOIToggleRequest)
z.ToggleAOI(req.Enabled)
status := "disabled"
if req.Enabled {
status = "enabled"
}
p.Connection().Send(network.MsgAOIToggleResponse, &pb.AOIToggleResponse{
Enabled: req.Enabled,
Message: "AOI " + status,
})
}
func (z *Zone) handleMetrics(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
p.Connection().Send(network.MsgServerMetrics, &pb.ServerMetrics{
OnlinePlayers: int32(len(z.players)),
TotalEntities: int32(len(z.entities)),
TickDurationUs: z.lastTickDuration.Load(),
AoiEnabled: z.aoiEnabled,
})
}
func (z *Zone) updateMovement() {
// Movement is applied immediately in handleMoveRequest (client-authoritative position
// with server validation). Future: add server-side physics/collision here.
}
func (z *Zone) broadcastState() {
if len(z.players) == 0 {
return
}
// For each player, send state updates of nearby entities.
for _, p := range z.players {
nearby := z.aoi.GetNearby(p)
if len(nearby) == 0 {
continue
}
states := make([]*pb.EntityState, 0, len(nearby))
for _, e := range nearby {
states = append(states, e.ToProto())
}
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
Entities: states,
ServerTick: z.tick,
})
}
}
func (z *Zone) handleAOIEvents(events []world.AOIEvent) {
for _, evt := range events {
observerPlayer, ok := z.players[evt.Observer.EntityID()]
if !ok {
continue
}
switch evt.Type {
case world.AOIEnter:
observerPlayer.Connection().Send(network.MsgSpawnEntity, &pb.SpawnEntity{
Entity: evt.Target.ToProto(),
})
case world.AOILeave:
observerPlayer.Connection().Send(network.MsgDespawnEntity, &pb.DespawnEntity{
EntityId: evt.Target.EntityID(),
})
}
}
}
func (z *Zone) sendNearbySnapshot(p PlayerEntity) {
nearby := z.aoi.GetNearby(p)
states := make([]*pb.EntityState, 0, len(nearby))
for _, e := range nearby {
states = append(states, e.ToProto())
}
p.Connection().Send(network.MsgStateUpdate, &pb.StateUpdate{
Entities: states,
ServerTick: z.tick,
})
}
// ─── Combat Handlers ────────────────────────────────────────
func (z *Zone) handleUseSkill(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
req := msg.Packet.Payload.(*pb.UseSkillRequest)
var targetPos mathutil.Vec3
if req.TargetPos != nil {
targetPos = mathutil.NewVec3(req.TargetPos.X, req.TargetPos.Y, req.TargetPos.Z)
}
caster, ok := p.(combat.Combatant)
if !ok {
return
}
success, errMsg := z.combatMgr.UseSkill(
caster,
req.SkillId,
req.TargetId,
targetPos,
func(id uint64) entity.Entity { return z.entities[id] },
z.getEntitiesInRadius,
)
p.Connection().Send(network.MsgUseSkillResponse, &pb.UseSkillResponse{
Success: success,
ErrorMessage: errMsg,
})
}
func (z *Zone) handleRespawn(msg PlayerMessage) {
p, ok := z.players[msg.PlayerID]
if !ok {
return
}
c, ok := p.(combat.Combatant)
if !ok || c.IsAlive() {
return
}
oldPos := p.Position()
z.combatMgr.Respawn(c, z.spawnPos)
// Update AOI for the new position.
events := z.aoi.UpdatePosition(p, oldPos, z.spawnPos)
z.handleAOIEvents(events)
// Notify respawn.
respawnEvt := &pb.CombatEvent{
TargetId: p.EntityID(),
TargetHp: c.HP(),
TargetMaxHp: c.MaxHP(),
EventType: pb.CombatEventType_COMBAT_EVENT_RESPAWN,
}
z.broadcastCombatEvent(p, network.MsgCombatEvent, respawnEvt)
p.Connection().Send(network.MsgRespawnResponse, &pb.RespawnResponse{
Self: p.ToProto(),
})
}
// broadcastCombatEvent sends a combat event to all players who can see the entity.
func (z *Zone) broadcastCombatEvent(ent entity.Entity, msgType uint16, msg interface{}) {
protoMsg, ok := msg.(proto.Message)
if !ok {
return
}
data, err := network.Encode(msgType, protoMsg)
if err != nil {
return
}
// Send to the entity itself if it's a player.
if p, ok := z.players[ent.EntityID()]; ok {
p.Connection().SendRaw(data)
}
// Send to nearby players.
for _, nearby := range z.aoi.GetNearby(ent) {
if p, ok := z.players[nearby.EntityID()]; ok {
p.Connection().SendRaw(data)
}
}
}
// sendToEntity sends a message to a specific entity (if it's a player).
func (z *Zone) sendToEntity(entityID uint64, msgType uint16, msg interface{}) {
p, ok := z.players[entityID]
if !ok {
return
}
protoMsg, ok := msg.(proto.Message)
if !ok {
return
}
p.Connection().Send(msgType, protoMsg)
}
// getEntitiesInRadius returns all entities within a radius of a point.
func (z *Zone) getEntitiesInRadius(center mathutil.Vec3, radius float32) []entity.Entity {
radiusSq := radius * radius
var result []entity.Entity
for _, e := range z.entities {
if e.Position().DistanceSqTo(center) <= radiusSq {
result = append(result, e)
}
}
return result
}
// ─── AI / Mob Management ────────────────────────────────────
// Spawner returns the zone's mob spawner for external configuration.
func (z *Zone) Spawner() *ai.Spawner { return z.spawner }
func (z *Zone) addMob(m *ai.Mob) {
z.entities[m.EntityID()] = m
z.aoi.Add(m)
// Notify nearby players about the new mob.
spawnData, _ := network.Encode(network.MsgSpawnEntity, &pb.SpawnEntity{Entity: m.ToProto()})
for _, nearby := range z.aoi.GetNearby(m) {
if p, ok := z.players[nearby.EntityID()]; ok {
p.Connection().SendRaw(spawnData)
}
}
}
func (z *Zone) removeMob(mobID uint64) {
ent, ok := z.entities[mobID]
if !ok {
return
}
events := z.aoi.Remove(ent)
z.handleAOIEvents(events)
z.combatMgr.RemoveEntity(mobID)
delete(z.entities, mobID)
}
func (z *Zone) updateAI() {
dt := z.cfg.TickInterval()
for _, m := range z.spawner.AliveMobs() {
oldPos := m.Position()
ai.UpdateMob(m, dt, z, z)
newPos := m.Position()
// Update AOI if mob moved.
if oldPos != newPos {
events := z.aoi.UpdatePosition(m, oldPos, newPos)
z.handleAOIEvents(events)
}
}
}
func (z *Zone) checkDeaths() {
for _, m := range z.spawner.AliveMobs() {
if !m.IsAlive() {
z.spawner.NotifyDeath(m.EntityID())
}
}
}
// ─── ai.EntityProvider implementation ───────────────────────
func (z *Zone) GetEntity(id uint64) entity.Entity {
return z.entities[id]
}
func (z *Zone) GetPlayersInRange(center mathutil.Vec3, radius float32) []entity.Entity {
radiusSq := radius * radius
var result []entity.Entity
for _, p := range z.players {
if p.Position().DistanceSqTo(center) <= radiusSq {
result = append(result, p)
}
}
return result
}
// ─── ai.SkillUser implementation ────────────────────────────
func (z *Zone) UseSkill(casterID uint64, skillID uint32, targetID uint64, targetPos mathutil.Vec3) (bool, string) {
ent := z.entities[casterID]
if ent == nil {
return false, "caster not found"
}
caster, ok := ent.(combat.Combatant)
if !ok {
return false, "caster cannot fight"
}
return z.combatMgr.UseSkill(
caster, skillID, targetID, targetPos,
func(id uint64) entity.Entity { return z.entities[id] },
z.getEntitiesInRadius,
)
}
// ─── Zone Portals ───────────────────────────────────────────
func (z *Zone) checkPortals(p PlayerEntity, pos mathutil.Vec3) {
if z.onZoneTransfer == nil || len(z.portals) == 0 {
return
}
for _, portal := range z.portals {
if portal.IsInRange(pos) {
z.onZoneTransfer(p.EntityID(), portal.TargetZoneID, portal.TargetPos)
return
}
}
}

View File

@@ -0,0 +1,174 @@
package network
import (
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
"a301_game_server/pkg/logger"
)
// ConnState represents the lifecycle state of a connection.
type ConnState int32
const (
ConnStateActive ConnState = iota
ConnStateClosed
)
// Connection wraps a WebSocket connection with send buffering and lifecycle management.
type Connection struct {
id uint64
ws *websocket.Conn
sendCh chan []byte
handler PacketHandler
state atomic.Int32
closeOnce sync.Once
maxMessageSize int64
heartbeatInterval time.Duration
heartbeatTimeout time.Duration
}
// PacketHandler processes incoming packets from a connection.
type PacketHandler interface {
OnPacket(conn *Connection, pkt *Packet)
OnDisconnect(conn *Connection)
}
// NewConnection creates a new Connection wrapping the given WebSocket.
func NewConnection(id uint64, ws *websocket.Conn, handler PacketHandler, sendChSize int, maxMsgSize int64, hbInterval, hbTimeout time.Duration) *Connection {
c := &Connection{
id: id,
ws: ws,
sendCh: make(chan []byte, sendChSize),
handler: handler,
maxMessageSize: maxMsgSize,
heartbeatInterval: hbInterval,
heartbeatTimeout: hbTimeout,
}
c.state.Store(int32(ConnStateActive))
return c
}
// ID returns the connection's unique identifier.
func (c *Connection) ID() uint64 { return c.id }
// Start launches the read and write goroutines.
func (c *Connection) Start() {
go c.readLoop()
go c.writeLoop()
}
// Send encodes and queues a message for sending. Non-blocking: drops if buffer is full.
func (c *Connection) Send(msgType uint16, msg proto.Message) {
if c.IsClosed() {
return
}
data, err := Encode(msgType, msg)
if err != nil {
logger.Error("encode failed", "connID", c.id, "msgType", msgType, "error", err)
return
}
select {
case c.sendCh <- data:
default:
logger.Warn("send buffer full, dropping message", "connID", c.id, "msgType", msgType)
}
}
// SendRaw queues pre-encoded data for sending. Non-blocking.
func (c *Connection) SendRaw(data []byte) {
if c.IsClosed() {
return
}
select {
case c.sendCh <- data:
default:
logger.Warn("send buffer full, dropping raw message", "connID", c.id)
}
}
// Close terminates the connection.
func (c *Connection) Close() {
c.closeOnce.Do(func() {
c.state.Store(int32(ConnStateClosed))
close(c.sendCh)
_ = c.ws.Close()
})
}
// IsClosed returns true if the connection has been closed.
func (c *Connection) IsClosed() bool {
return ConnState(c.state.Load()) == ConnStateClosed
}
func (c *Connection) readLoop() {
defer func() {
c.handler.OnDisconnect(c)
c.Close()
}()
c.ws.SetReadLimit(c.maxMessageSize)
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
c.ws.SetPongHandler(func(string) error {
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
return nil
})
for {
msgType, data, err := c.ws.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logger.Debug("read error", "connID", c.id, "error", err)
}
return
}
if msgType != websocket.BinaryMessage {
continue
}
pkt, err := Decode(data)
if err != nil {
logger.Warn("decode error", "connID", c.id, "error", err)
continue
}
c.handler.OnPacket(c, pkt)
}
}
func (c *Connection) writeLoop() {
ticker := time.NewTicker(c.heartbeatInterval)
defer ticker.Stop()
for {
select {
case data, ok := <-c.sendCh:
if !ok {
_ = c.ws.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.ws.WriteMessage(websocket.BinaryMessage, data); err != nil {
logger.Debug("write error", "connID", c.id, "error", err)
return
}
case <-ticker.C:
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

121
internal/network/packet.go Normal file
View File

@@ -0,0 +1,121 @@
package network
import (
"encoding/binary"
"errors"
"fmt"
"google.golang.org/protobuf/proto"
pb "a301_game_server/proto/gen/pb"
)
// Message type IDs — the wire protocol uses 2-byte type prefixes.
const (
// Auth
MsgLoginRequest uint16 = 0x0001
MsgLoginResponse uint16 = 0x0002
MsgEnterWorldRequest uint16 = 0x0003
MsgEnterWorldResponse uint16 = 0x0004
// Movement
MsgMoveRequest uint16 = 0x0010
MsgStateUpdate uint16 = 0x0011
MsgSpawnEntity uint16 = 0x0012
MsgDespawnEntity uint16 = 0x0013
// Zone Transfer
MsgZoneTransferNotify uint16 = 0x0014
// System
MsgPing uint16 = 0x0020
MsgPong uint16 = 0x0021
// Combat
MsgUseSkillRequest uint16 = 0x0040
MsgUseSkillResponse uint16 = 0x0041
MsgCombatEvent uint16 = 0x0042
MsgBuffApplied uint16 = 0x0043
MsgBuffRemoved uint16 = 0x0044
MsgRespawnRequest uint16 = 0x0045
MsgRespawnResponse uint16 = 0x0046
// Admin / Debug
MsgAOIToggleRequest uint16 = 0x0030
MsgAOIToggleResponse uint16 = 0x0031
MsgMetricsRequest uint16 = 0x0032
MsgServerMetrics uint16 = 0x0033
)
var (
ErrUnknownMessageType = errors.New("unknown message type")
ErrMessageTooShort = errors.New("message too short")
)
// Packet is a decoded network message.
type Packet struct {
Type uint16
Payload proto.Message
}
// messageFactory maps type IDs to protobuf message constructors.
var messageFactory = map[uint16]func() proto.Message{
MsgLoginRequest: func() proto.Message { return &pb.LoginRequest{} },
MsgLoginResponse: func() proto.Message { return &pb.LoginResponse{} },
MsgEnterWorldRequest: func() proto.Message { return &pb.EnterWorldRequest{} },
MsgEnterWorldResponse: func() proto.Message { return &pb.EnterWorldResponse{} },
MsgMoveRequest: func() proto.Message { return &pb.MoveRequest{} },
MsgStateUpdate: func() proto.Message { return &pb.StateUpdate{} },
MsgSpawnEntity: func() proto.Message { return &pb.SpawnEntity{} },
MsgDespawnEntity: func() proto.Message { return &pb.DespawnEntity{} },
MsgPing: func() proto.Message { return &pb.Ping{} },
MsgPong: func() proto.Message { return &pb.Pong{} },
MsgZoneTransferNotify: func() proto.Message { return &pb.ZoneTransferNotify{} },
MsgUseSkillRequest: func() proto.Message { return &pb.UseSkillRequest{} },
MsgUseSkillResponse: func() proto.Message { return &pb.UseSkillResponse{} },
MsgCombatEvent: func() proto.Message { return &pb.CombatEvent{} },
MsgBuffApplied: func() proto.Message { return &pb.BuffApplied{} },
MsgBuffRemoved: func() proto.Message { return &pb.BuffRemoved{} },
MsgRespawnRequest: func() proto.Message { return &pb.RespawnRequest{} },
MsgRespawnResponse: func() proto.Message { return &pb.RespawnResponse{} },
MsgAOIToggleRequest: func() proto.Message { return &pb.AOIToggleRequest{} },
MsgAOIToggleResponse: func() proto.Message { return &pb.AOIToggleResponse{} },
MsgMetricsRequest: func() proto.Message { return &pb.MetricsRequest{} },
MsgServerMetrics: func() proto.Message { return &pb.ServerMetrics{} },
}
// Encode serializes a packet into a wire-format byte slice: [2-byte type][protobuf payload].
func Encode(msgType uint16, msg proto.Message) ([]byte, error) {
payload, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal message 0x%04X: %w", msgType, err)
}
buf := make([]byte, 2+len(payload))
binary.BigEndian.PutUint16(buf[:2], msgType)
copy(buf[2:], payload)
return buf, nil
}
// Decode parses a wire-format byte slice into a Packet.
func Decode(data []byte) (*Packet, error) {
if len(data) < 2 {
return nil, ErrMessageTooShort
}
msgType := binary.BigEndian.Uint16(data[:2])
factory, ok := messageFactory[msgType]
if !ok {
return nil, fmt.Errorf("%w: 0x%04X", ErrUnknownMessageType, msgType)
}
msg := factory()
if len(data) > 2 {
if err := proto.Unmarshal(data[2:], msg); err != nil {
return nil, fmt.Errorf("unmarshal message 0x%04X: %w", msgType, err)
}
}
return &Packet{Type: msgType, Payload: msg}, nil
}

View File

@@ -0,0 +1,89 @@
package network
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"github.com/gorilla/websocket"
"a301_game_server/config"
"a301_game_server/pkg/logger"
)
// Server listens for WebSocket connections and creates Connection objects.
type Server struct {
cfg *config.Config
upgrader websocket.Upgrader
handler PacketHandler
nextID atomic.Uint64
srv *http.Server
}
// NewServer creates a new WebSocket server.
func NewServer(cfg *config.Config, handler PacketHandler) *Server {
return &Server{
cfg: cfg,
handler: handler,
upgrader: websocket.Upgrader{
ReadBufferSize: cfg.Network.ReadBufferSize,
WriteBufferSize: cfg.Network.WriteBufferSize,
CheckOrigin: func(r *http.Request) bool { return true },
},
}
}
// Start begins listening for connections. Blocks until the context is cancelled.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/ws", s.handleWebSocket)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
addr := s.cfg.Server.Address()
s.srv = &http.Server{
Addr: addr,
Handler: mux,
}
errCh := make(chan error, 1)
go func() {
logger.Info("websocket server starting", "address", addr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("listen: %w", err)
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down websocket server")
return s.srv.Shutdown(context.Background())
}
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("websocket upgrade failed", "error", err, "remote", r.RemoteAddr)
return
}
connID := s.nextID.Add(1)
conn := NewConnection(
connID,
ws,
s.handler,
s.cfg.Network.SendChannelSize,
s.cfg.Network.MaxMessageSize,
s.cfg.Network.HeartbeatInterval,
s.cfg.Network.HeartbeatTimeout,
)
logger.Info("client connected", "connID", connID, "remote", r.RemoteAddr)
conn.Start()
}

249
internal/player/player.go Normal file
View File

@@ -0,0 +1,249 @@
package player
import (
"sync"
"a301_game_server/internal/combat"
"a301_game_server/internal/db/repository"
"a301_game_server/internal/entity"
"a301_game_server/internal/network"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// Player represents an online player in the game world.
type Player struct {
mu sync.RWMutex
id uint64
charID int64 // database character ID
acctID int64 // database account ID
name string
position mathutil.Vec3
rotation float32
velocity mathutil.Vec3
stats Stats
conn *network.Connection
zoneID uint32
session *Session
dirty bool // true if state changed since last DB save
}
// NewPlayer creates a new player.
func NewPlayer(id uint64, name string, conn *network.Connection) *Player {
return &Player{
id: id,
name: name,
conn: conn,
stats: Stats{
HP: 100, MaxHP: 100,
MP: 50, MaxMP: 50,
Str: 10, Dex: 10, Int: 10,
Level: 1,
},
}
}
// NewPlayerFromDB creates a player from persisted character data.
func NewPlayerFromDB(data *repository.CharacterData, conn *network.Connection) *Player {
return &Player{
id: uint64(data.ID),
charID: data.ID,
acctID: data.AccountID,
name: data.Name,
position: mathutil.NewVec3(data.PosX, data.PosY, data.PosZ),
rotation: data.Rotation,
stats: Stats{
HP: data.HP, MaxHP: data.MaxHP,
MP: data.MP, MaxMP: data.MaxMP,
Str: data.Str, Dex: data.Dex, Int: data.IntStat,
Level: data.Level, Exp: data.Exp,
},
conn: conn,
zoneID: uint32(data.ZoneID),
}
}
// ToCharacterData converts current state to a persistable format.
func (p *Player) ToCharacterData() *repository.CharacterData {
p.mu.RLock()
defer p.mu.RUnlock()
return &repository.CharacterData{
ID: p.charID,
AccountID: p.acctID,
Name: p.name,
Level: p.stats.Level,
Exp: p.stats.Exp,
HP: p.stats.HP,
MaxHP: p.stats.MaxHP,
MP: p.stats.MP,
MaxMP: p.stats.MaxMP,
Str: p.stats.Str,
Dex: p.stats.Dex,
IntStat: p.stats.Int,
ZoneID: int32(p.zoneID),
PosX: p.position.X,
PosY: p.position.Y,
PosZ: p.position.Z,
Rotation: p.rotation,
}
}
func (p *Player) EntityID() uint64 { return p.id }
func (p *Player) EntityType() entity.Type { return entity.TypePlayer }
func (p *Player) Position() mathutil.Vec3 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.position
}
func (p *Player) SetPosition(pos mathutil.Vec3) {
p.mu.Lock()
defer p.mu.Unlock()
p.position = pos
p.dirty = true
}
func (p *Player) Rotation() float32 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.rotation
}
func (p *Player) SetRotation(rot float32) {
p.mu.Lock()
defer p.mu.Unlock()
p.rotation = rot
p.dirty = true
}
func (p *Player) Velocity() mathutil.Vec3 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.velocity
}
func (p *Player) SetVelocity(vel mathutil.Vec3) {
p.mu.Lock()
defer p.mu.Unlock()
p.velocity = vel
}
func (p *Player) Name() string { return p.name }
func (p *Player) CharID() int64 { return p.charID }
func (p *Player) AccountID() int64 { return p.acctID }
func (p *Player) Connection() *network.Connection { return p.conn }
func (p *Player) ZoneID() uint32 { return p.zoneID }
func (p *Player) SetZoneID(id uint32) { p.zoneID = id }
func (p *Player) Stats() combat.CombatStats {
p.mu.RLock()
defer p.mu.RUnlock()
return combat.CombatStats{
Str: p.stats.Str,
Dex: p.stats.Dex,
Int: p.stats.Int,
Level: p.stats.Level,
}
}
func (p *Player) RawStats() Stats {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats
}
func (p *Player) SetStats(s Stats) {
p.mu.Lock()
defer p.mu.Unlock()
p.stats = s
p.dirty = true
}
func (p *Player) HP() int32 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats.HP
}
func (p *Player) SetHP(hp int32) {
p.mu.Lock()
defer p.mu.Unlock()
if hp < 0 {
hp = 0
}
if hp > p.stats.MaxHP {
hp = p.stats.MaxHP
}
p.stats.HP = hp
p.dirty = true
}
func (p *Player) MaxHP() int32 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats.MaxHP
}
func (p *Player) MP() int32 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats.MP
}
func (p *Player) SetMP(mp int32) {
p.mu.Lock()
defer p.mu.Unlock()
if mp < 0 {
mp = 0
}
if mp > p.stats.MaxMP {
mp = p.stats.MaxMP
}
p.stats.MP = mp
p.dirty = true
}
func (p *Player) Level() int32 {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats.Level
}
func (p *Player) IsAlive() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.stats.HP > 0
}
// IsDirty returns true if state has changed since last save.
func (p *Player) IsDirty() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.dirty
}
// ClearDirty resets the dirty flag after a successful save.
func (p *Player) ClearDirty() {
p.mu.Lock()
defer p.mu.Unlock()
p.dirty = false
}
func (p *Player) ToProto() *pb.EntityState {
p.mu.RLock()
defer p.mu.RUnlock()
return &pb.EntityState{
EntityId: p.id,
Name: p.name,
Position: &pb.Vector3{X: p.position.X, Y: p.position.Y, Z: p.position.Z},
Rotation: p.rotation,
Hp: p.stats.HP,
MaxHp: p.stats.MaxHP,
Level: p.stats.Level,
EntityType: pb.EntityType_ENTITY_TYPE_PLAYER,
}
}

View File

@@ -0,0 +1,90 @@
package player
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// Session holds an authenticated player's session state.
type Session struct {
Token string
PlayerID uint64
PlayerName string
CreatedAt time.Time
LastActive time.Time
}
// SessionManager manages active sessions.
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*Session // token -> session
}
// NewSessionManager creates a new session manager.
func NewSessionManager() *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
}
}
// Create generates a new session for the given player.
func (sm *SessionManager) Create(playerID uint64, playerName string) *Session {
token := generateToken()
now := time.Now()
s := &Session{
Token: token,
PlayerID: playerID,
PlayerName: playerName,
CreatedAt: now,
LastActive: now,
}
sm.mu.Lock()
sm.sessions[token] = s
sm.mu.Unlock()
return s
}
// Get retrieves a session by token. Returns nil if not found.
func (sm *SessionManager) Get(token string) *Session {
sm.mu.RLock()
defer sm.mu.RUnlock()
s := sm.sessions[token]
if s != nil {
s.LastActive = time.Now()
}
return s
}
// Remove deletes a session.
func (sm *SessionManager) Remove(token string) {
sm.mu.Lock()
delete(sm.sessions, token)
sm.mu.Unlock()
}
// CleanupExpired removes sessions inactive for longer than the given duration.
func (sm *SessionManager) CleanupExpired(maxIdle time.Duration) int {
sm.mu.Lock()
defer sm.mu.Unlock()
cutoff := time.Now().Add(-maxIdle)
removed := 0
for token, s := range sm.sessions {
if s.LastActive.Before(cutoff) {
delete(sm.sessions, token)
removed++
}
}
return removed
}
func generateToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

14
internal/player/stats.go Normal file
View File

@@ -0,0 +1,14 @@
package player
// Stats holds combat-related attributes.
type Stats struct {
HP int32
MaxHP int32
MP int32
MaxMP int32
Str int32
Dex int32
Int int32
Level int32
Exp int64
}

70
internal/world/aoi.go Normal file
View File

@@ -0,0 +1,70 @@
package world
import (
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
)
// AOIEvent represents an entity entering or leaving another entity's area of interest.
type AOIEvent struct {
Observer entity.Entity
Target entity.Entity
Type AOIEventType
}
type AOIEventType int
const (
AOIEnter AOIEventType = iota
AOILeave
)
// AOIManager determines which entities can see each other.
type AOIManager interface {
Add(ent entity.Entity)
Remove(ent entity.Entity) []AOIEvent
UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent
GetNearby(ent entity.Entity) []entity.Entity
}
// BroadcastAllAOI is a trivial AOI that treats all entities as visible to each other.
// Used when AOI is disabled for debugging/comparison.
type BroadcastAllAOI struct {
entities map[uint64]entity.Entity
}
func NewBroadcastAllAOI() *BroadcastAllAOI {
return &BroadcastAllAOI{
entities: make(map[uint64]entity.Entity),
}
}
func (b *BroadcastAllAOI) Add(ent entity.Entity) {
b.entities[ent.EntityID()] = ent
}
func (b *BroadcastAllAOI) Remove(ent entity.Entity) []AOIEvent {
delete(b.entities, ent.EntityID())
var events []AOIEvent
for _, other := range b.entities {
if other.EntityID() == ent.EntityID() {
continue
}
events = append(events, AOIEvent{Observer: other, Target: ent, Type: AOILeave})
}
return events
}
func (b *BroadcastAllAOI) UpdatePosition(_ entity.Entity, _, _ mathutil.Vec3) []AOIEvent {
return nil
}
func (b *BroadcastAllAOI) GetNearby(ent entity.Entity) []entity.Entity {
result := make([]entity.Entity, 0, len(b.entities)-1)
for _, e := range b.entities {
if e.EntityID() != ent.EntityID() {
result = append(result, e)
}
}
return result
}

150
internal/world/aoi_test.go Normal file
View File

@@ -0,0 +1,150 @@
package world
import (
"testing"
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
pb "a301_game_server/proto/gen/pb"
)
// mockEntity is a minimal entity for testing.
type mockEntity struct {
id uint64
pos mathutil.Vec3
}
func (m *mockEntity) EntityID() uint64 { return m.id }
func (m *mockEntity) EntityType() entity.Type { return entity.TypePlayer }
func (m *mockEntity) Position() mathutil.Vec3 { return m.pos }
func (m *mockEntity) SetPosition(p mathutil.Vec3) { m.pos = p }
func (m *mockEntity) Rotation() float32 { return 0 }
func (m *mockEntity) SetRotation(float32) {}
func (m *mockEntity) ToProto() *pb.EntityState { return &pb.EntityState{EntityId: m.id} }
func TestBroadcastAllAOI_GetNearby(t *testing.T) {
aoi := NewBroadcastAllAOI()
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(100, 0, 100)}
e3 := &mockEntity{id: 3, pos: mathutil.NewVec3(999, 0, 999)}
aoi.Add(e1)
aoi.Add(e2)
aoi.Add(e3)
// With broadcast-all, everyone sees everyone.
nearby := aoi.GetNearby(e1)
if len(nearby) != 2 {
t.Errorf("expected 2 nearby, got %d", len(nearby))
}
}
func TestBroadcastAllAOI_Remove(t *testing.T) {
aoi := NewBroadcastAllAOI()
e1 := &mockEntity{id: 1}
e2 := &mockEntity{id: 2}
aoi.Add(e1)
aoi.Add(e2)
events := aoi.Remove(e1)
if len(events) != 1 {
t.Errorf("expected 1 leave event, got %d", len(events))
}
if events[0].Type != AOILeave {
t.Errorf("expected AOILeave event")
}
nearby := aoi.GetNearby(e2)
if len(nearby) != 0 {
t.Errorf("expected 0 nearby after removal, got %d", len(nearby))
}
}
func TestGridAOI_NearbyInSameCell(t *testing.T) {
aoi := NewGridAOI(50, 2)
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(10, 0, 10)}
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(20, 0, 20)}
aoi.Add(e1)
aoi.Add(e2)
nearby := aoi.GetNearby(e1)
if len(nearby) != 1 {
t.Errorf("expected 1 nearby, got %d", len(nearby))
}
if nearby[0].EntityID() != 2 {
t.Errorf("expected entity 2, got %d", nearby[0].EntityID())
}
}
func TestGridAOI_FarAwayNotVisible(t *testing.T) {
aoi := NewGridAOI(50, 1) // viewRange=1 means 3x3 grid = 150 units visibility
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)} // far away
aoi.Add(e1)
aoi.Add(e2)
nearby := aoi.GetNearby(e1)
if len(nearby) != 0 {
t.Errorf("expected 0 nearby for far entity, got %d", len(nearby))
}
}
func TestGridAOI_MoveGeneratesEvents(t *testing.T) {
aoi := NewGridAOI(50, 1)
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(200, 0, 200)}
aoi.Add(e1)
aoi.Add(e2)
// Initially not visible to each other.
nearby := aoi.GetNearby(e1)
if len(nearby) != 0 {
t.Fatalf("expected not visible initially, got %d", len(nearby))
}
// Move e2 close to e1.
oldPos := e2.pos
e2.pos = mathutil.NewVec3(10, 0, 10)
events := aoi.UpdatePosition(e2, oldPos, e2.pos)
// Should generate enter events (e1 sees e2, e2 sees e1).
enterCount := 0
for _, evt := range events {
if evt.Type == AOIEnter {
enterCount++
}
}
if enterCount != 2 {
t.Errorf("expected 2 enter events, got %d", enterCount)
}
}
func TestGridAOI_ToggleComparison(t *testing.T) {
// Demonstrates the difference between BroadcastAll and Grid AOI.
e1 := &mockEntity{id: 1, pos: mathutil.NewVec3(0, 0, 0)}
e2 := &mockEntity{id: 2, pos: mathutil.NewVec3(500, 0, 500)}
// BroadcastAll: both visible
broadcast := NewBroadcastAllAOI()
broadcast.Add(e1)
broadcast.Add(e2)
if len(broadcast.GetNearby(e1)) != 1 {
t.Error("broadcast-all should see all entities")
}
// Grid: e2 not visible from e1 (too far)
grid := NewGridAOI(50, 1)
grid.Add(e1)
grid.Add(e2)
if len(grid.GetNearby(e1)) != 0 {
t.Error("grid AOI should NOT see distant entities")
}
}

View File

@@ -0,0 +1,179 @@
package world
import (
"a301_game_server/internal/entity"
"a301_game_server/pkg/mathutil"
)
// cellKey uniquely identifies a grid cell.
type cellKey struct {
cx, cz int
}
// GridAOI implements AOI using a spatial grid. Entities in nearby cells are considered visible.
type GridAOI struct {
cellSize float32
viewRange int
cells map[cellKey]map[uint64]entity.Entity
entityCell map[uint64]cellKey
}
func NewGridAOI(cellSize float32, viewRange int) *GridAOI {
return &GridAOI{
cellSize: cellSize,
viewRange: viewRange,
cells: make(map[cellKey]map[uint64]entity.Entity),
entityCell: make(map[uint64]cellKey),
}
}
func (g *GridAOI) posToCell(pos mathutil.Vec3) cellKey {
cx := int(pos.X / g.cellSize)
cz := int(pos.Z / g.cellSize)
if pos.X < 0 {
cx--
}
if pos.Z < 0 {
cz--
}
return cellKey{cx, cz}
}
func (g *GridAOI) Add(ent entity.Entity) {
cell := g.posToCell(ent.Position())
g.addToCell(cell, ent)
g.entityCell[ent.EntityID()] = cell
}
func (g *GridAOI) Remove(ent entity.Entity) []AOIEvent {
eid := ent.EntityID()
cell, ok := g.entityCell[eid]
if !ok {
return nil
}
nearby := g.getNearbyFromCell(cell, eid)
events := make([]AOIEvent, 0, len(nearby))
for _, observer := range nearby {
events = append(events, AOIEvent{Observer: observer, Target: ent, Type: AOILeave})
}
g.removeFromCell(cell, eid)
delete(g.entityCell, eid)
return events
}
func (g *GridAOI) UpdatePosition(ent entity.Entity, oldPos, newPos mathutil.Vec3) []AOIEvent {
eid := ent.EntityID()
oldCell := g.posToCell(oldPos)
newCell := g.posToCell(newPos)
if oldCell == newCell {
return nil
}
oldVisible := g.visibleCells(oldCell)
newVisible := g.visibleCells(newCell)
leaving := cellDifference(oldVisible, newVisible)
entering := cellDifference(newVisible, oldVisible)
var events []AOIEvent
for _, c := range leaving {
if cellEntities, ok := g.cells[c]; ok {
for _, other := range cellEntities {
if other.EntityID() == eid {
continue
}
events = append(events,
AOIEvent{Observer: other, Target: ent, Type: AOILeave},
AOIEvent{Observer: ent, Target: other, Type: AOILeave},
)
}
}
}
for _, c := range entering {
if cellEntities, ok := g.cells[c]; ok {
for _, other := range cellEntities {
if other.EntityID() == eid {
continue
}
events = append(events,
AOIEvent{Observer: other, Target: ent, Type: AOIEnter},
AOIEvent{Observer: ent, Target: other, Type: AOIEnter},
)
}
}
}
g.removeFromCell(oldCell, eid)
g.addToCell(newCell, ent)
g.entityCell[eid] = newCell
return events
}
func (g *GridAOI) GetNearby(ent entity.Entity) []entity.Entity {
cell, ok := g.entityCell[ent.EntityID()]
if !ok {
return nil
}
return g.getNearbyFromCell(cell, ent.EntityID())
}
func (g *GridAOI) getNearbyFromCell(cell cellKey, excludeID uint64) []entity.Entity {
var result []entity.Entity
for _, c := range g.visibleCells(cell) {
if cellEntities, ok := g.cells[c]; ok {
for _, e := range cellEntities {
if e.EntityID() != excludeID {
result = append(result, e)
}
}
}
}
return result
}
func (g *GridAOI) visibleCells(center cellKey) []cellKey {
size := (2*g.viewRange + 1) * (2*g.viewRange + 1)
cells := make([]cellKey, 0, size)
for dx := -g.viewRange; dx <= g.viewRange; dx++ {
for dz := -g.viewRange; dz <= g.viewRange; dz++ {
cells = append(cells, cellKey{center.cx + dx, center.cz + dz})
}
}
return cells
}
func (g *GridAOI) addToCell(cell cellKey, ent entity.Entity) {
if g.cells[cell] == nil {
g.cells[cell] = make(map[uint64]entity.Entity)
}
g.cells[cell][ent.EntityID()] = ent
}
func (g *GridAOI) removeFromCell(cell cellKey, eid uint64) {
if m, ok := g.cells[cell]; ok {
delete(m, eid)
if len(m) == 0 {
delete(g.cells, cell)
}
}
}
func cellDifference(a, b []cellKey) []cellKey {
set := make(map[cellKey]struct{}, len(b))
for _, c := range b {
set[c] = struct{}{}
}
var diff []cellKey
for _, c := range a {
if _, ok := set[c]; !ok {
diff = append(diff, c)
}
}
return diff
}

View File

@@ -0,0 +1,20 @@
package world
import "a301_game_server/pkg/mathutil"
// ZonePortal defines a connection between two zones.
type ZonePortal struct {
// Trigger area in source zone.
SourceZoneID uint32
TriggerPos mathutil.Vec3
TriggerRadius float32
// Destination in target zone.
TargetZoneID uint32
TargetPos mathutil.Vec3
}
// IsInRange returns true if the given position is within the portal's trigger area.
func (p *ZonePortal) IsInRange(pos mathutil.Vec3) bool {
return pos.DistanceXZ(p.TriggerPos) <= p.TriggerRadius
}

43
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,43 @@
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var log *zap.SugaredLogger
func Init(level string) error {
var zapLevel zapcore.Level
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
zapLevel = zapcore.InfoLevel
}
cfg := zap.Config{
Level: zap.NewAtomicLevelAt(zapLevel),
Encoding: "console",
EncoderConfig: zap.NewDevelopmentEncoderConfig(),
OutputPaths: []string{"stdout"},
ErrorOutputPaths: []string{"stderr"},
}
l, err := cfg.Build(zap.AddCallerSkip(1))
if err != nil {
return err
}
log = l.Sugar()
return nil
}
func Info(msg string, keysAndValues ...interface{}) { log.Infow(msg, keysAndValues...) }
func Warn(msg string, keysAndValues ...interface{}) { log.Warnw(msg, keysAndValues...) }
func Error(msg string, keysAndValues ...interface{}) { log.Errorw(msg, keysAndValues...) }
func Debug(msg string, keysAndValues ...interface{}) { log.Debugw(msg, keysAndValues...) }
func Fatal(msg string, keysAndValues ...interface{}) { log.Fatalw(msg, keysAndValues...) }
func Sync() {
if log != nil {
_ = log.Sync()
}
}

56
pkg/mathutil/vec3.go Normal file
View File

@@ -0,0 +1,56 @@
package mathutil
import "math"
type Vec3 struct {
X float32
Y float32
Z float32
}
func NewVec3(x, y, z float32) Vec3 {
return Vec3{X: x, Y: y, Z: z}
}
func (v Vec3) Add(other Vec3) Vec3 {
return Vec3{X: v.X + other.X, Y: v.Y + other.Y, Z: v.Z + other.Z}
}
func (v Vec3) Sub(other Vec3) Vec3 {
return Vec3{X: v.X - other.X, Y: v.Y - other.Y, Z: v.Z - other.Z}
}
func (v Vec3) Scale(s float32) Vec3 {
return Vec3{X: v.X * s, Y: v.Y * s, Z: v.Z * s}
}
func (v Vec3) Length() float32 {
return float32(math.Sqrt(float64(v.X*v.X + v.Y*v.Y + v.Z*v.Z)))
}
func (v Vec3) LengthSq() float32 {
return v.X*v.X + v.Y*v.Y + v.Z*v.Z
}
func (v Vec3) Normalize() Vec3 {
l := v.Length()
if l < 1e-8 {
return Vec3{}
}
return v.Scale(1.0 / l)
}
func (v Vec3) DistanceTo(other Vec3) float32 {
return v.Sub(other).Length()
}
func (v Vec3) DistanceSqTo(other Vec3) float32 {
return v.Sub(other).LengthSq()
}
// DistanceXZ returns distance ignoring Y axis (for ground-plane calculations).
func (v Vec3) DistanceXZ(other Vec3) float32 {
dx := v.X - other.X
dz := v.Z - other.Z
return float32(math.Sqrt(float64(dx*dx + dz*dz)))
}

1728
proto/gen/pb/messages.pb.go Normal file

File diff suppressed because it is too large Load Diff

170
proto/messages.proto Normal file
View File

@@ -0,0 +1,170 @@
syntax = "proto3";
package proto;
option go_package = "a301_game_server/proto/gen/pb";
// ─── Authentication ────────────────────────────────────────
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
bool success = 1;
string session_token = 2;
string error_message = 3;
uint64 player_id = 4;
}
message EnterWorldRequest {
string session_token = 1;
}
message EnterWorldResponse {
bool success = 1;
string error_message = 2;
EntityState self = 3;
repeated EntityState nearby_entities = 4;
uint32 zone_id = 5;
}
// ─── Entity State ──────────────────────────────────────────
message Vector3 {
float x = 1;
float y = 2;
float z = 3;
}
message EntityState {
uint64 entity_id = 1;
string name = 2;
Vector3 position = 3;
float rotation = 4;
int32 hp = 5;
int32 max_hp = 6;
int32 level = 7;
EntityType entity_type = 8;
}
enum EntityType {
ENTITY_TYPE_PLAYER = 0;
ENTITY_TYPE_MOB = 1;
ENTITY_TYPE_NPC = 2;
}
// ─── Movement ──────────────────────────────────────────────
message MoveRequest {
Vector3 position = 1;
float rotation = 2;
Vector3 velocity = 3;
}
message StateUpdate {
repeated EntityState entities = 1;
int64 server_tick = 2;
}
message SpawnEntity {
EntityState entity = 1;
}
message DespawnEntity {
uint64 entity_id = 1;
}
// ─── System ────────────────────────────────────────────────
message Ping {
int64 client_time = 1;
}
message Pong {
int64 client_time = 1;
int64 server_time = 2;
}
// ─── Zone Transfer ─────────────────────────────────────────
message ZoneTransferNotify {
uint32 new_zone_id = 1;
EntityState self = 2;
repeated EntityState nearby_entities = 3;
}
// ─── Combat ────────────────────────────────────────────────
message UseSkillRequest {
uint32 skill_id = 1;
uint64 target_id = 2;
Vector3 target_pos = 3; // for ground-targeted AoE
}
message UseSkillResponse {
bool success = 1;
string error_message = 2;
}
message CombatEvent {
uint64 caster_id = 1;
uint64 target_id = 2;
uint32 skill_id = 3;
int32 damage = 4;
int32 heal = 5;
bool is_critical = 6;
bool target_died = 7;
int32 target_hp = 8;
int32 target_max_hp = 9;
CombatEventType event_type = 10;
}
enum CombatEventType {
COMBAT_EVENT_DAMAGE = 0;
COMBAT_EVENT_HEAL = 1;
COMBAT_EVENT_BUFF = 2;
COMBAT_EVENT_DEBUFF = 3;
COMBAT_EVENT_DEATH = 4;
COMBAT_EVENT_RESPAWN = 5;
}
message BuffApplied {
uint64 target_id = 1;
uint32 buff_id = 2;
string buff_name = 3;
float duration = 4;
bool is_debuff = 5;
}
message BuffRemoved {
uint64 target_id = 1;
uint32 buff_id = 2;
}
message RespawnRequest {}
message RespawnResponse {
EntityState self = 1;
}
// ─── Admin / Debug ─────────────────────────────────────────
message AOIToggleRequest {
bool enabled = 1;
}
message AOIToggleResponse {
bool enabled = 1;
string message = 2;
}
message ServerMetrics {
int32 online_players = 1;
int32 total_entities = 2;
int64 tick_duration_us = 3;
bool aoi_enabled = 4;
}
message MetricsRequest {}

BIN
testclient.exe Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff