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