Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24b17f5e389343acb22f8e1af4f67e55
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
# RockPaperScissors(石头剪刀布)
|
||||
|
||||
本目录是石头剪刀布小游戏的**客户端源文件根目录**,遵循统一的小游戏目录规范(见
|
||||
`docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md` §2.4):
|
||||
|
||||
```
|
||||
MiniGames/RockPaperScissors/
|
||||
scripts/ # 客户端脚本(C# 热更源码:薄层 IGameClient + 链接的共享 Core)
|
||||
res/ # 美术 / UI / 音效(打成 AssetBundle)
|
||||
```
|
||||
|
||||
打热更包时,`scripts/` 编出的热更 DLL(以 `.bytes` 形式)与 `res/` 的美术资源**一起打成 AB 包**,
|
||||
上传到服务器对应的下载目录(见设计文档 §3.1)。
|
||||
|
||||
## 当前状态(C# 客户端已落地)
|
||||
|
||||
- `res/UI/Texture/`:三张出招贴图(rock / paper / scissors),将随 AB 打包。
|
||||
- `scripts/Client/`:客户端薄层 C# 实现 `RpsGameClient`(`IGameClient`),由 Unity/HybridCLR 编译为热更 DLL;
|
||||
另有 `Client/HotUpdateGames/RPS.Client/RPS.Client.csproj` 链接同一份源做 csc 编译验证。
|
||||
- 共享核心 `RPS.Core`(规则/状态/编解码)与服务端薄层 `RPS.Server`(`IGameServerRoom`)的 C# 源码在
|
||||
`Server/games-src/RPS/`(旧 Lua 版已删除)。
|
||||
- 待办(独立改造,须 Unity):将 `RPS.Core` 收敛为 `scripts/Core` 单源、服务端 `<Compile Include>` 链接(设计 §10.1)。
|
||||
|
||||
## AI 兜底(匹配超时)
|
||||
|
||||
- 本游戏为 2 人对战。匹配超过 **15 秒**仍未凑齐真人时,服务器用本游戏自带的 AI 补位,真人与 AI 继续对局。
|
||||
- AI 逻辑在服务端薄层 `RPS.Server`(C#)中实现,框架不实现具体玩法 AI。
|
||||
- AI 出招为等概率随机,且不读取真人本回合的选择,保证公平。
|
||||
- 结算时 AI 玩家以 `isAI = true` 标记,客户端显示对手为 “AI”。
|
||||
|
||||
通用规则详见 `Doc/独立游戏系统设计文档.md` 第 13 节。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b595fe0c98674881a2daf9e584e2f42f
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"gameId": "rps",
|
||||
"serverSrcDir": "RPS",
|
||||
"serverProject": "RPS.Server/RPS.Server.csproj",
|
||||
"serverAssembly": "RPS.Server.dll",
|
||||
"serverEntryType": "RPS.Server.RpsServerRoom",
|
||||
"clientProject": "Client/HotUpdateGames/RPS.Client/RPS.Client.csproj",
|
||||
"coreDll": "RPS.Core.dll",
|
||||
"clientDll": "RPS.Client.dll",
|
||||
"clientEntryType": "RPS.Client.RpsGameClient",
|
||||
"minFrameworkVersion": 1,
|
||||
"playerCount": 2,
|
||||
"tickRateHz": 10
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7ef337db5578d14ab57bfefc8bafd89
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d96d7a72447b08d43b22b4f38723c309
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a68044edd6288d4f8c4450f0282ad91
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8437e8251903f7745b4d1a64ebfdfd23
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09f1f504868ec894ab9ad0a5acb3a939
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1885a29a8a948094197bc9e63ef0fc5f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,153 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc3c3e342d4db8f478747831c79baeff
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: WebGL
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,100 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31f2a411d6da4521bfdf13b8451dd401
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 0
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
nameFileIdTable: {}
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
+153
@@ -0,0 +1,153 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df4376a0ea6bc734898349e55a49a9ea
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: WebGL
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb09db0faed155643b37e19c0b5a7813
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f7e1f179b770fe4683ac2a65bb75759
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 672a7190847d49e4cb83cd0d779d8f9c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,326 @@
|
||||
using RPS.Core;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace RPS.Client
|
||||
{
|
||||
public sealed class RpsGameClient : IGameClient
|
||||
{
|
||||
private const ushort SnapshotOpcode = 1;
|
||||
private const ushort ChoiceOpcode = 1;
|
||||
private const string UiPrefabPath =
|
||||
"Assets/MiniGames/RockPaperScissors/res/UI/Prefab/UI_RockPaperScissors.prefab";
|
||||
|
||||
private readonly RpsLogic _logic = new RpsLogic();
|
||||
private IGameClientCtx _ctx;
|
||||
private RpsView _view;
|
||||
private RpsState _state;
|
||||
private Choice _localChoice = Choice.None;
|
||||
private string _status = "waiting for match...";
|
||||
private float _elapsed;
|
||||
|
||||
public void OnEnter(IGameClientCtx ctx)
|
||||
{
|
||||
_ctx = ctx;
|
||||
EnsureEventSystem();
|
||||
_ctx.Assets.Load(UiPrefabPath, OnPrefabLoaded);
|
||||
_ctx.Logger.Info("rps client entered");
|
||||
}
|
||||
|
||||
public void OnNetMessage(NetMessage msg)
|
||||
{
|
||||
if (msg.Opcode != SnapshotOpcode) return;
|
||||
|
||||
RpsState previous = _state;
|
||||
_state = _logic.Decode(msg.Payload);
|
||||
if (_state.Phase == RpsPhase.Choosing && previous != null && previous.Round != _state.Round)
|
||||
_localChoice = Choice.None;
|
||||
|
||||
_status = _state.Phase == RpsPhase.Finished
|
||||
? (_state.Winner == 2 ? "finished: draw" : "finished: seat " + _state.Winner + " wins")
|
||||
: "round " + _state.Round + " / " + _state.Phase;
|
||||
Render();
|
||||
}
|
||||
|
||||
public void OnUpdate(float dt)
|
||||
{
|
||||
_elapsed += dt;
|
||||
_view?.SetElapsed(_elapsed);
|
||||
_view?.SetProgress(_state);
|
||||
}
|
||||
|
||||
public void OnExit()
|
||||
{
|
||||
_ctx?.Logger.Info("rps client exited");
|
||||
_view?.Destroy();
|
||||
_view = null;
|
||||
_ctx = null;
|
||||
_state = null;
|
||||
_localChoice = Choice.None;
|
||||
_status = "waiting for match...";
|
||||
_elapsed = 0f;
|
||||
}
|
||||
|
||||
public void SubmitChoice(Choice choice)
|
||||
{
|
||||
if (_ctx == null || _state == null) return;
|
||||
if (_state.Phase != RpsPhase.Choosing) return;
|
||||
if (_localChoice != Choice.None) return;
|
||||
|
||||
_localChoice = choice;
|
||||
var w = new PacketWriter();
|
||||
w.WriteByte((byte)choice);
|
||||
_ctx.Send(new NetMessage(ChoiceOpcode, w.ToArray()));
|
||||
_status = "locked: " + choice;
|
||||
Render();
|
||||
}
|
||||
|
||||
private void OnPrefabLoaded(object loaded)
|
||||
{
|
||||
if (_ctx == null) return;
|
||||
|
||||
var prefab = loaded as GameObject;
|
||||
if (prefab == null)
|
||||
{
|
||||
_ctx.Logger.Error("rps ui prefab failed to load: " + UiPrefabPath);
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject instance = Object.Instantiate(prefab);
|
||||
instance.name = "UI_RockPaperScissors";
|
||||
Object.DontDestroyOnLoad(instance);
|
||||
_view = new RpsView(instance, SubmitChoice);
|
||||
Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
_view?.Render(_state, _localChoice, _status, _elapsed);
|
||||
}
|
||||
|
||||
private static void EnsureEventSystem()
|
||||
{
|
||||
if (EventSystem.current != null) return;
|
||||
|
||||
var eventSystem = new GameObject("EventSystem");
|
||||
Object.DontDestroyOnLoad(eventSystem);
|
||||
eventSystem.AddComponent<EventSystem>();
|
||||
eventSystem.AddComponent<StandaloneInputModule>();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RpsView
|
||||
{
|
||||
private const float ChooseSeconds = 5f;
|
||||
private const float RevealSeconds = 5f;
|
||||
private const float TotalSeconds = 60f;
|
||||
|
||||
private readonly GameObject _root;
|
||||
private readonly Button _rockButton;
|
||||
private readonly Button _paperButton;
|
||||
private readonly Button _scissorsButton;
|
||||
private readonly Button _confirmButton;
|
||||
private readonly TextMeshProUGUI _phaseText;
|
||||
private readonly TextMeshProUGUI _messageText;
|
||||
private readonly TextMeshProUGUI _roundText;
|
||||
private readonly TextMeshProUGUI _lockedText;
|
||||
private readonly TextMeshProUGUI _leftScoreText;
|
||||
private readonly TextMeshProUGUI _rightScoreText;
|
||||
private readonly TextMeshProUGUI _leftRevealText;
|
||||
private readonly TextMeshProUGUI _rightRevealText;
|
||||
private readonly TextMeshProUGUI _resultText;
|
||||
private readonly TextMeshProUGUI _totalTimeText;
|
||||
private readonly TextMeshProUGUI _finalRowsText;
|
||||
private readonly TextMeshProUGUI _leftNameText;
|
||||
private readonly TextMeshProUGUI _rightNameText;
|
||||
private readonly Image _totalProgressFill;
|
||||
private readonly Image _phaseProgressFill;
|
||||
private readonly Image _leftChoiceIcon;
|
||||
private readonly Image _rightChoiceIcon;
|
||||
private readonly Sprite _rockSprite;
|
||||
private readonly Sprite _paperSprite;
|
||||
private readonly Sprite _scissorsSprite;
|
||||
|
||||
public RpsView(GameObject root, System.Action<Choice> choose)
|
||||
{
|
||||
_root = root;
|
||||
_rockButton = Find<Button>("Btn_Rock");
|
||||
_paperButton = Find<Button>("Btn_Paper");
|
||||
_scissorsButton = Find<Button>("Btn_Scissors");
|
||||
_confirmButton = Find<Button>("Btn_Confirm");
|
||||
_phaseText = Find<TextMeshProUGUI>("Txt_Phase");
|
||||
_messageText = Find<TextMeshProUGUI>("Txt_Message");
|
||||
_roundText = Find<TextMeshProUGUI>("Txt_Round");
|
||||
_lockedText = Find<TextMeshProUGUI>("Txt_Locked");
|
||||
_leftScoreText = Find<TextMeshProUGUI>("Txt_LeftScore");
|
||||
_rightScoreText = Find<TextMeshProUGUI>("Txt_RightScore");
|
||||
_leftRevealText = Find<TextMeshProUGUI>("Txt_LeftReveal");
|
||||
_rightRevealText = Find<TextMeshProUGUI>("Txt_RightReveal");
|
||||
_resultText = Find<TextMeshProUGUI>("Txt_Result");
|
||||
_totalTimeText = Find<TextMeshProUGUI>("Txt_TotalTime");
|
||||
_finalRowsText = Find<TextMeshProUGUI>("Txt_FinalRows");
|
||||
_leftNameText = Find<TextMeshProUGUI>("Txt_LeftName");
|
||||
_rightNameText = Find<TextMeshProUGUI>("Txt_RightName");
|
||||
_totalProgressFill = Find<Image>("Img_TotalProgressFill");
|
||||
_phaseProgressFill = Find<Image>("Img_PhaseProgressFill");
|
||||
_leftChoiceIcon = Find<Image>("Img_LeftChoiceIcon");
|
||||
_rightChoiceIcon = Find<Image>("Img_RightChoiceIcon");
|
||||
_rockSprite = Find<Image>("Img_RockIcon")?.sprite;
|
||||
_paperSprite = Find<Image>("Img_PaperIcon")?.sprite;
|
||||
_scissorsSprite = Find<Image>("Img_ScissorsIcon")?.sprite;
|
||||
|
||||
if (_rockButton != null) _rockButton.onClick.AddListener(() => choose(Choice.Rock));
|
||||
if (_paperButton != null) _paperButton.onClick.AddListener(() => choose(Choice.Paper));
|
||||
if (_scissorsButton != null) _scissorsButton.onClick.AddListener(() => choose(Choice.Scissors));
|
||||
if (_confirmButton != null) _confirmButton.gameObject.SetActive(false);
|
||||
|
||||
SetText(_leftNameText, "You");
|
||||
SetText(_rightNameText, "Opponent");
|
||||
}
|
||||
|
||||
public void Render(RpsState state, Choice localChoice, string status, float elapsed)
|
||||
{
|
||||
SetText(_messageText, status);
|
||||
SetElapsed(elapsed);
|
||||
|
||||
if (state == null)
|
||||
{
|
||||
SetText(_phaseText, "Waiting");
|
||||
SetText(_roundText, "Round -");
|
||||
SetText(_lockedText, "Locked 0/2");
|
||||
SetText(_leftScoreText, "0");
|
||||
SetText(_rightScoreText, "0");
|
||||
SetText(_leftRevealText, "Hidden");
|
||||
SetText(_rightRevealText, "Hidden");
|
||||
SetText(_resultText, "");
|
||||
SetText(_finalRowsText, "");
|
||||
SetIcon(_leftChoiceIcon, Choice.None);
|
||||
SetIcon(_rightChoiceIcon, Choice.None);
|
||||
SetButtons(false);
|
||||
SetProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
SetText(_phaseText, PhaseName(state.Phase));
|
||||
SetText(_roundText, "Round " + state.Round);
|
||||
SetText(_leftScoreText, state.Scores[0].ToString());
|
||||
SetText(_rightScoreText, state.Scores[1].ToString());
|
||||
SetText(_lockedText, "Locked " + LockedCount(state) + "/2");
|
||||
SetText(_leftRevealText, ChoiceDisplay(state.Choices[0], state.Phase));
|
||||
SetText(_rightRevealText, ChoiceDisplay(state.Choices[1], state.Phase));
|
||||
SetText(_resultText, ResultText(state));
|
||||
SetText(_finalRowsText, FinalRows(state));
|
||||
SetText(_rightNameText, state.IsAi[1] ? "AI" : "Opponent");
|
||||
SetIcon(_leftChoiceIcon, state.Choices[0]);
|
||||
SetIcon(_rightChoiceIcon, state.Choices[1]);
|
||||
SetButtons(state.Phase == RpsPhase.Choosing && localChoice == Choice.None);
|
||||
SetProgress(state);
|
||||
}
|
||||
|
||||
public void SetElapsed(float elapsed)
|
||||
{
|
||||
SetText(_totalTimeText, elapsed.ToString("0.0") + "s / 60s");
|
||||
}
|
||||
|
||||
public void SetProgress(RpsState state)
|
||||
{
|
||||
if (_totalProgressFill != null)
|
||||
_totalProgressFill.fillAmount = state == null ? 0f : Mathf.Clamp01(state.TotalElapsed / TotalSeconds);
|
||||
|
||||
if (_phaseProgressFill != null)
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
_phaseProgressFill.fillAmount = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
float phaseSeconds = state.Phase == RpsPhase.Revealing ? RevealSeconds : ChooseSeconds;
|
||||
_phaseProgressFill.fillAmount = Mathf.Clamp01(state.PhaseElapsed / phaseSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
}
|
||||
|
||||
private T Find<T>(string name) where T : Component
|
||||
{
|
||||
var transforms = _root.GetComponentsInChildren<Transform>(true);
|
||||
for (int i = 0; i < transforms.Length; i++)
|
||||
{
|
||||
if (transforms[i].name == name)
|
||||
return transforms[i].GetComponent<T>();
|
||||
}
|
||||
|
||||
Debug.LogWarning("[RpsView] Missing UI binding: " + name);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SetButtons(bool interactable)
|
||||
{
|
||||
if (_rockButton != null) _rockButton.interactable = interactable;
|
||||
if (_paperButton != null) _paperButton.interactable = interactable;
|
||||
if (_scissorsButton != null) _scissorsButton.interactable = interactable;
|
||||
}
|
||||
|
||||
private void SetIcon(Image image, Choice choice)
|
||||
{
|
||||
if (image == null) return;
|
||||
|
||||
Sprite sprite = choice == Choice.Rock ? _rockSprite
|
||||
: choice == Choice.Paper ? _paperSprite
|
||||
: choice == Choice.Scissors ? _scissorsSprite
|
||||
: null;
|
||||
image.sprite = sprite;
|
||||
image.enabled = sprite != null;
|
||||
}
|
||||
|
||||
private static void SetText(TextMeshProUGUI text, string value)
|
||||
{
|
||||
if (text != null) text.text = value ?? "";
|
||||
}
|
||||
|
||||
private static int LockedCount(RpsState state)
|
||||
{
|
||||
int count = 0;
|
||||
if (state.Choices[0] != Choice.None) count++;
|
||||
if (state.Choices[1] != Choice.None) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string ChoiceDisplay(Choice choice, RpsPhase phase)
|
||||
{
|
||||
if (choice == Choice.None) return "Hidden";
|
||||
return phase == RpsPhase.Choosing ? "Locked" : ChoiceName(choice);
|
||||
}
|
||||
|
||||
private static string ChoiceName(Choice c)
|
||||
=> c == Choice.Rock ? "Rock"
|
||||
: c == Choice.Paper ? "Paper"
|
||||
: c == Choice.Scissors ? "Scissors" : "-";
|
||||
|
||||
private static string PhaseName(RpsPhase phase)
|
||||
=> phase == RpsPhase.Choosing ? "Choose"
|
||||
: phase == RpsPhase.Revealing ? "Reveal"
|
||||
: "Finished";
|
||||
|
||||
private static string ResultText(RpsState state)
|
||||
{
|
||||
if (state.Phase != RpsPhase.Finished) return "";
|
||||
return state.Winner == 2 ? "Draw" : "Seat " + state.Winner + " wins";
|
||||
}
|
||||
|
||||
private static string FinalRows(RpsState state)
|
||||
{
|
||||
if (state.Phase != RpsPhase.Finished) return "";
|
||||
return "Final score " + state.Scores[0] + " : " + state.Scores[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Generic;
|
||||
using XWorld.Framework;
|
||||
using XWorld.Framework.Protocol;
|
||||
|
||||
namespace RPS.Core
|
||||
{
|
||||
public sealed class RpsLogic : IGameLogic<RpsState, RpsInput, RpsEvent>
|
||||
{
|
||||
public const float ChooseSeconds = 5f;
|
||||
public const float RevealSeconds = 5f;
|
||||
public const float TotalSeconds = 60f;
|
||||
|
||||
private static readonly Choice[] AllChoices =
|
||||
{
|
||||
Choice.Rock,
|
||||
Choice.Paper,
|
||||
Choice.Scissors
|
||||
};
|
||||
|
||||
public RpsState CreateInitial(RoomConfig config, IRandom random)
|
||||
{
|
||||
return new RpsState { Round = 1, Phase = RpsPhase.Choosing };
|
||||
}
|
||||
|
||||
public StepResult<RpsState, RpsEvent> Step(RpsState state, RpsInput input, IRandom random)
|
||||
{
|
||||
var events = new List<RpsEvent>();
|
||||
if (state.Phase == RpsPhase.Finished)
|
||||
return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
|
||||
if (input.Kind == RpsInputKind.Choose)
|
||||
{
|
||||
if (state.Phase == RpsPhase.Choosing
|
||||
&& state.Choices[input.Seat] == Choice.None
|
||||
&& input.Choice != Choice.None)
|
||||
{
|
||||
state.Choices[input.Seat] = input.Choice;
|
||||
}
|
||||
|
||||
return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
}
|
||||
|
||||
state.PhaseElapsed += input.Dt;
|
||||
state.TotalElapsed += input.Dt;
|
||||
|
||||
if (state.Phase == RpsPhase.Choosing && state.PhaseElapsed >= ChooseSeconds)
|
||||
{
|
||||
ResolveRound(state, random, events);
|
||||
state.Phase = RpsPhase.Revealing;
|
||||
state.PhaseElapsed = 0f;
|
||||
}
|
||||
else if (state.Phase == RpsPhase.Revealing && state.PhaseElapsed >= RevealSeconds)
|
||||
{
|
||||
state.Round++;
|
||||
state.Phase = RpsPhase.Choosing;
|
||||
state.PhaseElapsed = 0f;
|
||||
state.Choices[0] = Choice.None;
|
||||
state.Choices[1] = Choice.None;
|
||||
}
|
||||
|
||||
if (state.Phase != RpsPhase.Finished && state.TotalElapsed >= TotalSeconds)
|
||||
Finish(state, events);
|
||||
|
||||
return new StepResult<RpsState, RpsEvent>(state, events);
|
||||
}
|
||||
|
||||
public byte[] Encode(RpsState state)
|
||||
{
|
||||
var writer = new PacketWriter();
|
||||
writer.WriteVarInt(state.Round);
|
||||
writer.WriteByte((byte)state.Phase);
|
||||
writer.WriteSingle(state.PhaseElapsed);
|
||||
writer.WriteSingle(state.TotalElapsed);
|
||||
writer.WriteByte((byte)state.Choices[0]);
|
||||
writer.WriteByte((byte)state.Choices[1]);
|
||||
writer.WriteVarInt(state.Scores[0]);
|
||||
writer.WriteVarInt(state.Scores[1]);
|
||||
writer.WriteBool(state.IsAi[0]);
|
||||
writer.WriteBool(state.IsAi[1]);
|
||||
writer.WriteVarInt(state.Winner);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public RpsState Decode(byte[] data)
|
||||
{
|
||||
var reader = new PacketReader(data);
|
||||
var state = new RpsState
|
||||
{
|
||||
Round = reader.ReadVarInt(),
|
||||
Phase = (RpsPhase)reader.ReadByte(),
|
||||
PhaseElapsed = reader.ReadSingle(),
|
||||
TotalElapsed = reader.ReadSingle()
|
||||
};
|
||||
|
||||
state.Choices[0] = (Choice)reader.ReadByte();
|
||||
state.Choices[1] = (Choice)reader.ReadByte();
|
||||
state.Scores[0] = reader.ReadVarInt();
|
||||
state.Scores[1] = reader.ReadVarInt();
|
||||
state.IsAi[0] = reader.ReadBool();
|
||||
state.IsAi[1] = reader.ReadBool();
|
||||
state.Winner = reader.ReadVarInt();
|
||||
return state;
|
||||
}
|
||||
|
||||
private static void ResolveRound(RpsState state, IRandom random, List<RpsEvent> events)
|
||||
{
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
if (state.Choices[i] == Choice.None)
|
||||
state.Choices[i] = AllChoices[random.Next(3)];
|
||||
}
|
||||
|
||||
int winner = WinnerOf(state.Choices[0], state.Choices[1]);
|
||||
if (winner >= 0)
|
||||
state.Scores[winner]++;
|
||||
|
||||
events.Add(new RpsEvent
|
||||
{
|
||||
Kind = RpsEventKind.RoundResolved,
|
||||
Round = state.Round,
|
||||
RoundWinner = winner
|
||||
});
|
||||
}
|
||||
|
||||
private static int WinnerOf(Choice first, Choice second)
|
||||
{
|
||||
if (first == second)
|
||||
return -1;
|
||||
|
||||
bool firstWins = (first == Choice.Rock && second == Choice.Scissors)
|
||||
|| (first == Choice.Paper && second == Choice.Rock)
|
||||
|| (first == Choice.Scissors && second == Choice.Paper);
|
||||
|
||||
return firstWins ? 0 : 1;
|
||||
}
|
||||
|
||||
private static void Finish(RpsState state, List<RpsEvent> events)
|
||||
{
|
||||
state.Phase = RpsPhase.Finished;
|
||||
state.Winner = state.Scores[0] == state.Scores[1]
|
||||
? 2
|
||||
: state.Scores[0] > state.Scores[1] ? 0 : 1;
|
||||
|
||||
events.Add(new RpsEvent
|
||||
{
|
||||
Kind = RpsEventKind.GameFinished,
|
||||
GameWinner = state.Winner
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace RPS.Core
|
||||
{
|
||||
public enum Choice : byte
|
||||
{
|
||||
None = 0,
|
||||
Rock = 1,
|
||||
Paper = 2,
|
||||
Scissors = 3
|
||||
}
|
||||
|
||||
public enum RpsPhase : byte
|
||||
{
|
||||
Choosing = 0,
|
||||
Revealing = 1,
|
||||
Finished = 2
|
||||
}
|
||||
|
||||
public sealed class RpsState
|
||||
{
|
||||
public int Round;
|
||||
public RpsPhase Phase;
|
||||
public float PhaseElapsed;
|
||||
public float TotalElapsed;
|
||||
public Choice[] Choices = new Choice[2];
|
||||
public int[] Scores = new int[2];
|
||||
public bool[] IsAi = new bool[2];
|
||||
public int Winner = -1;
|
||||
}
|
||||
|
||||
public enum RpsInputKind : byte
|
||||
{
|
||||
Tick = 0,
|
||||
Choose = 1
|
||||
}
|
||||
|
||||
public struct RpsInput
|
||||
{
|
||||
public RpsInputKind Kind;
|
||||
public int Seat;
|
||||
public Choice Choice;
|
||||
public float Dt;
|
||||
|
||||
public static RpsInput Tick(float dt)
|
||||
{
|
||||
return new RpsInput { Kind = RpsInputKind.Tick, Dt = dt };
|
||||
}
|
||||
|
||||
public static RpsInput Choose(int seat, Choice choice)
|
||||
{
|
||||
return new RpsInput { Kind = RpsInputKind.Choose, Seat = seat, Choice = choice };
|
||||
}
|
||||
}
|
||||
|
||||
public enum RpsEventKind : byte
|
||||
{
|
||||
RoundResolved = 0,
|
||||
GameFinished = 1
|
||||
}
|
||||
|
||||
public struct RpsEvent
|
||||
{
|
||||
public RpsEventKind Kind;
|
||||
public int Round;
|
||||
public int RoundWinner;
|
||||
public int GameWinner;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user