first commit
This commit is contained in:
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
10
.idea/.gitignore
generated
vendored
Normal 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
9
.idea/a301_game_server.iml
generated
Normal 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
11
.idea/go.imports.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
60
Makefile
Normal 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
865
UNITY_INTEGRATION.md
Normal 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
75
cmd/server/main.go
Normal 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
650
cmd/testclient/main.go
Normal 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
124
config/config.go
Normal 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
32
config/config.yaml
Normal 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
42
gen_proto.ps1
Normal 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
18
go.mod
Normal 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
31
go.sum
Normal 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
184
internal/ai/behavior.go
Normal 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
|
||||
}
|
||||
221
internal/ai/behavior_test.go
Normal file
221
internal/ai/behavior_test.go
Normal 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
137
internal/ai/mob.go
Normal 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
130
internal/ai/registry.go
Normal 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
152
internal/ai/spawner.go
Normal 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
68
internal/auth/auth.go
Normal 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
86
internal/combat/buff.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
368
internal/combat/combat_manager.go
Normal file
368
internal/combat/combat_manager.go
Normal 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)
|
||||
}
|
||||
239
internal/combat/combat_test.go
Normal file
239
internal/combat/combat_test.go
Normal 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
51
internal/combat/damage.go
Normal 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
148
internal/combat/skill.go
Normal 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
47
internal/db/migrations.go
Normal 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
51
internal/db/postgres.go
Normal 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
|
||||
}
|
||||
166
internal/db/repository/character.go
Normal file
166
internal/db/repository/character.go
Normal 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
26
internal/entity/entity.go
Normal 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
|
||||
}
|
||||
462
internal/game/game_server.go
Normal file
462
internal/game/game_server.go
Normal 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
94
internal/game/world.go
Normal 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
665
internal/game/zone.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
174
internal/network/connection.go
Normal file
174
internal/network/connection.go
Normal 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
121
internal/network/packet.go
Normal 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
|
||||
}
|
||||
89
internal/network/server.go
Normal file
89
internal/network/server.go
Normal 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
249
internal/player/player.go
Normal 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,
|
||||
}
|
||||
}
|
||||
90
internal/player/session.go
Normal file
90
internal/player/session.go
Normal 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
14
internal/player/stats.go
Normal 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
70
internal/world/aoi.go
Normal 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
150
internal/world/aoi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
179
internal/world/spatial_grid.go
Normal file
179
internal/world/spatial_grid.go
Normal 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
|
||||
}
|
||||
20
internal/world/zone_transfer.go
Normal file
20
internal/world/zone_transfer.go
Normal 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
43
pkg/logger/logger.go
Normal 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
56
pkg/mathutil/vec3.go
Normal 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
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
170
proto/messages.proto
Normal 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
BIN
testclient.exe
Normal file
Binary file not shown.
6479
unity/Assets/Scripts/Proto/Messages.cs
Normal file
6479
unity/Assets/Scripts/Proto/Messages.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user