This commit is contained in:
2026-08-10 19:23:04 +02:00
commit 404d15a56c
134 changed files with 5374 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(where.exe Unity.exe)",
"Read(//c/Program Files/Unity/Hub/Editor/**)",
"Read(//c/Program Files/Unity/**)",
"Bash(\"/c/Program Files/Unity/Hub/Editor/6000.5.7f1/Editor/Unity.exe\" -batchmode -nographics -quit -projectPath \"C:\\\\Arbeit\\\\Projekte\\\\spiele\\\\SixShopSimulator\" -logFile \"C:\\\\Arbeit\\\\Projekte\\\\spiele\\\\SixShopSimulator\\\\pkg-resolve.log\")",
"Bash(echo \"EXIT CODE: $?\")",
"Bash(curl -s \"https://packages.unity.com/com.unity.netcode.gameobjects\")",
"Bash(sort -u -t'\\(' -k1,1)",
"Bash(\"/c/Program Files/Unity/Hub/Editor/6000.5.7f1/Editor/Unity.exe\" -batchmode -nographics -runTests -projectPath \"C:\\\\Arbeit\\\\Projekte\\\\spiele\\\\SixShopSimulator\" -testPlatform EditMode -testResults \"C:\\\\Arbeit\\\\Projekte\\\\spiele\\\\SixShopSimulator\\\\test-results.xml\" -logFile \"C:\\\\Arbeit\\\\Projekte\\\\spiele\\\\SixShopSimulator\\\\unity-test.log\")"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
*.cubemap filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.asset filter=lfs diff=lfs merge=lfs -text
+23
View File
@@ -0,0 +1,23 @@
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
[Bb]uilds/
[Ll]ogs/
[Uu]ser[Ss]ettings/
[Mm]emoryCaptures/
*.csproj
*.sln
*.userprefs
*.pidb
*.booproj
*.svd
*.apk
*.aab
*.unitypackage
.vs/
.idea/
.vscode/
.DS_Store
sysinfo.txt
crashlytics-build.properties
+38
View File
@@ -0,0 +1,38 @@
# Architektur
## Autoritätsmodell
```
Client (Owner) Server (Host)
────────────── ─────────────
Bewegung ───── prediction ──▶ NetworkTransform (einzige Client-Autorität)
Raycast ───── ServerRpc ───▶ Distanz prüfen ──▶ IInteractable.Interact()
UI-Anzeige ◀─── NetworkVariable ─── Geld / Bestand / Preise
NPC-Optik ◀─── NetworkTransform ── NavMesh-Simulation (nur Server)
```
**Merksatz:** Wenn eine Variable Auswirkung auf Geld oder Bestand hat, darf sie
nur auf dem Server geschrieben werden.
## Warum NPCs nicht jeden Frame ticken
`CustomerAgent` läuft mit `TickInterval = 0.25 s`. Bei 2030 Kunden spart das
den Großteil der Server-CPU, ohne dass man es sieht — Laufwege macht ohnehin
der NavMeshAgent. Wer die KI reaktiver braucht, senkt das Intervall gezielt für
den Kunden an der Kasse, nicht global.
## Netzwerk-Traffic
- Produkte: nur `int ProductId`, nie Referenzen oder Strings.
- `ShelfSlot`: 3 NetworkVariables (Produkt, Menge, Preis). Bei vielen Fächern
später zu einem gebündelten `NetworkList` pro Regal zusammenfassen.
- NPC-Zustand: ein Enum + ein Float für Sprechblasen. Mehr braucht der Client nicht.
## Manuelles Netzwerk-Testen
1. `File > Build Settings > Build` (Development Build an)
2. Build starten → "Host"
3. Im Editor Play drücken → "Join"
Testfälle, die regelmäßig brechen:
- Zwei Spieler greifen gleichzeitig aufs selbe Regalfach
- Client verlässt das Spiel, während er Ware trägt
- Kunde verlässt die Schlange, während ein Spieler gerade scannt
- Client mit hoher Latenz interagiert am Rand der Reichweite
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7ec3fddb6e731cd4e8bb668040911e08
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
fileFormatVersion: 2
guid: 052faaac586de48259a63d0c4782560b
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3}
generateWrapperCode: 0
wrapperCodePath:
wrapperClassName:
wrapperCodeNamespace:
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8105016687592461f977c054a80ce2f2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5a28cea83daa4d34abf9d032e9d95e82
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+432
View File
@@ -0,0 +1,432 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_IndirectSpecularColor: {r: 0.18028378, g: 0.22571412, b: 0.30692285, a: 1}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 12
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 1
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 5
m_PVRFilteringGaussRadiusAO: 2
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &330585543
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 330585546}
- component: {fileID: 330585545}
- component: {fileID: 330585544}
- component: {fileID: 330585547}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!81 &330585544
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
m_Enabled: 1
--- !u!20 &330585545
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
orthographic: 0
orthographic size: 5
m_Depth: -1
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &330585546
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &330585547
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 330585543}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier:
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 1
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
m_TaaSettings:
quality: 3
frameInfluence: 0.1
jitterScale: 1
mipBias: 0
varianceClampScale: 0.9
contrastAdaptiveSharpening: 0
--- !u!1 &410087039
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 410087041}
- component: {fileID: 410087040}
- component: {fileID: 410087042}
m_Layer: 0
m_Name: Directional Light
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &410087040
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 410087039}
m_Enabled: 1
serializedVersion: 11
m_Type: 1
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Intensity: 2
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize: 10
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 5000
m_UseColorTemperature: 1
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShadowRadius: 0
m_ShadowAngle: 0
--- !u!4 &410087041
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 410087039}
serializedVersion: 2
m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261}
m_LocalPosition: {x: 0, y: 3, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0}
--- !u!114 &410087042
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 410087039}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Version: 3
m_UsePipelineSettings: 1
m_AdditionalLightsShadowResolutionTier: 2
m_LightLayerMask: 1
m_RenderingLayers: 1
m_CustomShadowLayers: 0
m_ShadowLayerMask: 1
m_ShadowRenderingLayers: 1
m_LightCookieSize: {x: 1, y: 1}
m_LightCookieOffset: {x: 0, y: 0}
m_SoftShadowQuality: 1
--- !u!1 &832575517
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 832575519}
- component: {fileID: 832575518}
m_Layer: 0
m_Name: Global Volume
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &832575518
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 832575517}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 172515602e62fb746b5d573b38a5fe58, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IsGlobal: 1
priority: 0
blendDistance: 0
weight: 1
sharedProfile: {fileID: 11400000, guid: 10fc4df2da32a41aaa32d77bc913491c, type: 2}
--- !u!4 &832575519
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 832575517}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 330585546}
- {fileID: 410087041}
- {fileID: 832575519}
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 99c9720ab356a0642a771bea13969a05
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a32c49c00ff9b1844adb6f716b0f752a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+12
View File
@@ -0,0 +1,12 @@
{
"name": "AfterHours.Runtime",
"rootNamespace": "AfterHours",
"references": [
"Unity.Netcode.Runtime",
"Unity.Netcode.Components",
"Unity.Collections"
],
"includePlatforms": [],
"allowUnsafeCode": false,
"autoReferenced": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 29f53eee638de7147a47939382c45f71
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e2dd05b235706ab4aa022f4c00c54b33
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+72
View File
@@ -0,0 +1,72 @@
using System;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Core
{
/// <summary>
/// Spielzeit und Tageswechsel. Nur der Server zählt hoch; Clients lesen mit.
/// </summary>
public sealed class GameClock : NetworkBehaviour
{
public static GameClock Instance { get; private set; }
[Header("Konfiguration")]
[SerializeField] private float _realSecondsPerGameHour = 60f;
[SerializeField] private int _openingHour = 10;
[SerializeField] private int _closingHour = 22;
private readonly NetworkVariable<float> _hourOfDay = new(); // 0..24
private readonly NetworkVariable<int> _day = new();
private readonly NetworkVariable<bool> _isOpen = new();
public float HourOfDay => _hourOfDay.Value;
public int Day => _day.Value;
public bool IsOpen => _isOpen.Value;
/// <summary>Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete.</summary>
public event Action<int> DayEnded;
private void Awake() => Instance = this;
public override void OnNetworkSpawn()
{
if (!IsServer) return;
_day.Value = 1;
_hourOfDay.Value = _openingHour;
_isOpen.Value = true;
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
private void Update()
{
if (!IsServer) return;
_hourOfDay.Value += Time.deltaTime / _realSecondsPerGameHour;
_isOpen.Value = _hourOfDay.Value >= _openingHour && _hourOfDay.Value < _closingHour;
if (_hourOfDay.Value >= 24f)
{
_hourOfDay.Value -= 24f;
int finished = _day.Value;
_day.Value = finished + 1;
DayEnded?.Invoke(finished);
}
}
/// <summary>Springt zum nächsten Ladenöffnungszeitpunkt. Wird vom "Feierabend"-Button genutzt.</summary>
public void SkipToOpening()
{
if (!IsServer) return;
// TODO: erst prüfen, ob noch Kunden im Laden sind.
_hourOfDay.Value = _openingHour;
int finished = _day.Value;
_day.Value = finished + 1;
DayEnded?.Invoke(finished);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1c6319a5bac9dc247a1e3f9e025bb5c9
+83
View File
@@ -0,0 +1,83 @@
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Core
{
/// <summary>
/// Einziger Ort, an dem Geld und Ruf verändert werden. Server-authoritativ.
/// Clients lesen ausschließlich die NetworkVariables.
/// </summary>
public sealed class ShopEconomy : NetworkBehaviour
{
public static ShopEconomy Instance { get; private set; }
[SerializeField] private float _startingBalance = 2500f;
private readonly NetworkVariable<float> _balance = new();
private readonly NetworkVariable<float> _reputation = new(); // 0..1
private readonly NetworkVariable<int> _customersServedToday = new();
private readonly NetworkVariable<int> _customersLostToday = new();
public float Balance => _balance.Value;
public float Reputation => _reputation.Value;
public int CustomersServedToday => _customersServedToday.Value;
public int CustomersLostToday => _customersLostToday.Value;
private void Awake() => Instance = this;
public override void OnNetworkSpawn()
{
if (!IsServer) return;
_balance.Value = _startingBalance;
_reputation.Value = 0.5f;
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
/// <summary>Versucht abzubuchen. Gibt false zurück, wenn das Geld nicht reicht.</summary>
public bool TrySpend(float amount)
{
if (!IsServer) { Debug.LogError("[ShopEconomy] TrySpend auf Client aufgerufen."); return false; }
if (amount <= 0f || _balance.Value < amount) return false;
_balance.Value -= amount;
return true;
}
public void Earn(float amount)
{
if (!IsServer) return;
if (amount <= 0f) return;
_balance.Value += amount;
}
public void RegisterSale(float satisfaction)
{
if (!IsServer) return;
_customersServedToday.Value++;
AdjustReputation(Mathf.Lerp(-0.005f, 0.01f, Mathf.Clamp01(satisfaction)));
}
public void RegisterAbandonment()
{
if (!IsServer) return;
_customersLostToday.Value++;
AdjustReputation(-0.015f);
}
public void ResetDailyCounters()
{
if (!IsServer) return;
_customersServedToday.Value = 0;
_customersLostToday.Value = 0;
}
private void AdjustReputation(float delta)
{
_reputation.Value = Mathf.Clamp01(_reputation.Value + delta);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 48276ef6a91772a4f9593792f07b29d2
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 755094105c5bfe746a7545d5b87348bd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+15
View File
@@ -0,0 +1,15 @@
namespace AfterHours.Customers
{
/// <summary>Ein Artikel im Einkaufskorb eines Kunden. Nur serverseitig.</summary>
public readonly struct BasketLine
{
public readonly int ProductId;
public readonly float Price;
public BasketLine(int productId, float price)
{
ProductId = productId;
Price = price;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f271cf0a93651524d826c623bf6c9509
+75
View File
@@ -0,0 +1,75 @@
using System;
namespace AfterHours.Customers
{
/// <summary>
/// Kern-Mechanik des Spiels: Wie wohl fühlt sich ein Kunde gerade?
/// Fällt der Komfort unter <see cref="AbandonThreshold"/>, legt der Kunde
/// die Ware zurück und geht.
///
/// Engine-frei und damit testbar siehe Tests/EditMode/ComfortModelTests.cs.
/// </summary>
public static class ComfortModel
{
public const float AbandonThreshold = 0.25f;
/// <summary>Komfortverlust pro anderem Kunden im Nahbereich.</summary>
private const float CrowdingPenaltyPerPerson = 0.09f;
/// <summary>Malus, wenn Personal ungefragt in Reichweite steht.</summary>
private const float UnwantedStaffPenalty = 0.18f;
/// <summary>Malus, wenn das Regal von der Straße aus einsehbar ist.</summary>
private const float ExposedShelfPenalty = 0.22f;
/// <summary>Erholung pro Sekunde, wenn niemand stört.</summary>
private const float RecoveryPerSecond = 0.12f;
/// <summary>
/// Zielkomfort für die aktuelle Situation (0..1).
/// </summary>
/// <param name="baseTolerance">Charaktereigenschaft des Kunden, 0..1. Höher = unerschrockener.</param>
/// <param name="discretionDemand">Diskretionsbedarf des betrachteten Produkts, 0..1.</param>
/// <param name="nearbyCustomers">Andere Kunden im Nahbereich.</param>
/// <param name="staffNearbyUnrequested">Personal steht daneben, ohne dass Beratung gewünscht war.</param>
/// <param name="shelfExposedToStreet">Regal ist vom Schaufenster aus einsehbar.</param>
public static float EvaluateTarget(
float baseTolerance,
float discretionDemand,
int nearbyCustomers,
bool staffNearbyUnrequested,
bool shelfExposedToStreet)
{
if (nearbyCustomers < 0) throw new ArgumentOutOfRangeException(nameof(nearbyCustomers));
float tolerance = Clamp01(baseTolerance);
float sensitivity = Clamp01(discretionDemand);
// Ein toleranter Kunde bei unkritischer Ware ist praktisch unstörbar.
float exposure = sensitivity * (1f - tolerance * 0.6f);
float penalty = 0f;
penalty += nearbyCustomers * CrowdingPenaltyPerPerson;
if (staffNearbyUnrequested) penalty += UnwantedStaffPenalty;
if (shelfExposedToStreet) penalty += ExposedShelfPenalty;
return Clamp01(1f - penalty * exposure * 2f);
}
/// <summary>Bewegt den aktuellen Komfort auf den Zielwert zu (Frame-Schritt).</summary>
public static float Step(float current, float target, float deltaTime)
{
if (target >= current)
{
// Erholung ist träge …
return Clamp01(current + RecoveryPerSecond * deltaTime);
}
// … Unbehagen schlägt sofort durch.
return Clamp01(target);
}
public static bool ShouldAbandon(float comfort) => comfort < AbandonThreshold;
private static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1aac25a7e1b9fc246a75c4b5e9f7b2ed
+227
View File
@@ -0,0 +1,227 @@
using System.Collections.Generic;
using AfterHours.Core;
using AfterHours.Data;
using AfterHours.Economy;
using AfterHours.Shop;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
namespace AfterHours.Customers
{
/// <summary>
/// Ein Kunde. Die gesamte Entscheidungslogik läuft AUSSCHLIESSLICH auf dem
/// Server; Clients bekommen nur Transform-Sync und den sichtbaren Zustand
/// (für Sprechblasen / Emotes).
///
/// Performance: Der Server tickt die Kunden bewusst nicht jeden Frame,
/// sondern in Intervallen (TickInterval). 30 NPCs à 60 fps wären
/// verschwendete CPU-Zeit.
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
public sealed class CustomerAgent : NetworkBehaviour
{
private const float TickInterval = 0.25f;
private const float ArrivalDistance = 0.6f;
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private float _personalSpaceRadius = 1.6f;
[SerializeField] private LayerMask _customerMask;
private readonly NetworkVariable<CustomerState> _state = new(CustomerState.Entering);
private readonly NetworkVariable<float> _visibleComfort = new(1f);
private NavMeshAgent _agent;
private CashRegister _register;
private ShelfSlot _targetSlot;
private readonly List<BasketLine> _basket = new();
private readonly Collider[] _neighbourBuffer = new Collider[8];
private float _tickTimer;
private float _baseTolerance = 0.5f;
private float _comfort = 1f;
private int _wantedItems = 1;
private ProductCategory _wantedCategory;
public CustomerState State => _state.Value;
public float Comfort => _comfort;
public int BasketCount => _basket.Count;
public BasketLine BasketAt(int index) => _basket[index];
private void Awake() => _agent = GetComponent<NavMeshAgent>();
public override void OnNetworkSpawn()
{
// Clients simulieren nichts NavMesh nur auf dem Server.
_agent.enabled = IsServer;
}
/// <summary>Server: Initialisierung durch den Spawner.</summary>
public void Initialize(float tolerance, int wantedItems, ProductCategory wantedCategory, CashRegister register)
{
if (!IsServer) return;
_baseTolerance = Mathf.Clamp01(tolerance);
_wantedItems = Mathf.Max(1, wantedItems);
_wantedCategory = wantedCategory;
_register = register;
_comfort = 1f;
_state.Value = CustomerState.Browsing;
}
private void Update()
{
if (!IsServer) return;
_tickTimer -= Time.deltaTime;
if (_tickTimer > 0f) return;
float dt = TickInterval - _tickTimer;
_tickTimer = TickInterval;
UpdateComfort(dt);
UpdateBehaviour();
}
private void UpdateComfort(float deltaTime)
{
float discretion = 0.3f;
if (_targetSlot != null && _catalog.TryGet(_targetSlot.ProductId, out var product))
discretion = product.DiscretionDemand;
float target = ComfortModel.EvaluateTarget(
_baseTolerance,
discretion,
CountNearbyCustomers(),
staffNearbyUnrequested: IsStaffCrowding(),
shelfExposedToStreet: _targetSlot != null && _targetSlot.ExposedToStreet);
_comfort = ComfortModel.Step(_comfort, target, deltaTime);
_visibleComfort.Value = _comfort;
if (ComfortModel.ShouldAbandon(_comfort) && _state.Value != CustomerState.Leaving)
Abandon();
}
private void UpdateBehaviour()
{
switch (_state.Value)
{
case CustomerState.Browsing:
if (_basket.Count >= _wantedItems) { GoToCheckout(); break; }
if (_targetSlot == null)
{
_targetSlot = FindShelfToVisit();
if (_targetSlot != null) MoveTo(_targetSlot.transform.position);
}
else if (HasArrivedAtTarget())
{
TryTakeFromTarget();
}
break;
case CustomerState.Queueing:
// Positionierung übernimmt CashRegister.ReflowQueue().
break;
case CustomerState.Leaving:
if (!_agent.pathPending && _agent.remainingDistance < 0.4f) Despawn();
break;
}
}
/// <summary>Nächstes passendes Regal: richtige Kategorie, vorrätig, Preis noch akzeptabel.</summary>
private ShelfSlot FindShelfToVisit()
{
ShelfSlot best = null;
float bestSqrDistance = float.MaxValue;
foreach (var slot in ShelfRegistry.Instance.Slots)
{
if (slot == null || slot.IsEmpty) continue;
if (!_catalog.TryGet(slot.ProductId, out var product)) continue;
if (product.Category != _wantedCategory) continue;
float probability = PricingService.PurchaseProbability(slot.Price, product.RecommendedPrice);
if (probability <= 0f || Random.value > probability) continue;
float sqrDistance = (slot.transform.position - transform.position).sqrMagnitude;
if (sqrDistance < bestSqrDistance)
{
bestSqrDistance = sqrDistance;
best = slot;
}
}
return best;
}
private bool HasArrivedAtTarget()
{
return !_agent.pathPending && _agent.remainingDistance <= ArrivalDistance;
}
private void TryTakeFromTarget()
{
int productId = _targetSlot.ProductId;
float price = _targetSlot.Price;
if (_targetSlot.TakeOne())
_basket.Add(new BasketLine(productId, price));
_targetSlot = null;
}
private int CountNearbyCustomers()
{
int hits = Physics.OverlapSphereNonAlloc(
transform.position, _personalSpaceRadius, _neighbourBuffer, _customerMask);
return Mathf.Max(0, hits - 1); // sich selbst abziehen
}
private bool IsStaffCrowding()
{
// TODO: Spieler in Nähe prüfen, sofern der Kunde keine Beratung wollte.
return false;
}
private void GoToCheckout()
{
_state.Value = CustomerState.Queueing;
MoveTo(_register.Enqueue(this));
}
private void Abandon()
{
_state.Value = CustomerState.Leaving;
_basket.Clear();
_register?.Remove(this);
ShopEconomy.Instance.RegisterAbandonment();
// TODO: zurückgelegte Ware am Regal wieder gutschreiben (oder als "Chaos" liegen lassen).
MoveTo(CustomerSpawner.Instance.ExitPoint.position);
}
/// <summary>Server: Kasse meldet erfolgreichen Abschluss.</summary>
public void OnPaid()
{
if (!IsServer) return;
_state.Value = CustomerState.Leaving;
MoveTo(CustomerSpawner.Instance.ExitPoint.position);
}
public void MoveTo(Vector3 destination)
{
if (!IsServer || !_agent.enabled) return;
_agent.SetDestination(destination);
}
private void Despawn()
{
if (!IsServer) return;
CustomerSpawner.Instance?.NotifyCustomerGone();
NetworkObject.Despawn();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0669ca75bd9edac44a5db06778b39410
@@ -0,0 +1,85 @@
using AfterHours.Core;
using AfterHours.Data;
using AfterHours.Shop;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Customers
{
/// <summary>
/// Erzeugt Kunden abhängig von Uhrzeit und Ruf. Läuft nur auf dem Server.
/// </summary>
public sealed class CustomerSpawner : NetworkBehaviour
{
public static CustomerSpawner Instance { get; private set; }
[SerializeField] private GameObject _customerPrefab;
[SerializeField] private Transform _entryPoint;
[SerializeField] private Transform _exitPoint;
[SerializeField] private CashRegister _register;
[Header("Andrang")]
[SerializeField] private int _maxConcurrent = 20;
[SerializeField] private float _baseSecondsBetweenSpawns = 8f;
[Tooltip("Multiplikator über den Tag: x-Achse 0..24 Uhr, y = Andrang.")]
[SerializeField] private AnimationCurve _rushCurve = AnimationCurve.Linear(0f, 1f, 24f, 1f);
public Transform ExitPoint => _exitPoint;
private float _cooldown;
private int _alive;
private void Awake() => Instance = this;
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
private void Update()
{
if (!IsServer) return;
if (GameClock.Instance == null || !GameClock.Instance.IsOpen) return;
if (_alive >= _maxConcurrent) return;
_cooldown -= Time.deltaTime;
if (_cooldown > 0f) return;
Spawn();
_cooldown = NextInterval();
}
private float NextInterval()
{
float rush = Mathf.Max(0.1f, _rushCurve.Evaluate(GameClock.Instance.HourOfDay));
float reputation = Mathf.Lerp(1.6f, 0.6f, ShopEconomy.Instance.Reputation);
return _baseSecondsBetweenSpawns / rush * reputation * Random.Range(0.7f, 1.3f);
}
private void Spawn()
{
var instance = Instantiate(_customerPrefab, _entryPoint.position, _entryPoint.rotation);
var netObject = instance.GetComponent<NetworkObject>();
netObject.Spawn();
var categories = (ProductCategory[])System.Enum.GetValues(typeof(ProductCategory));
var agent = instance.GetComponent<CustomerAgent>();
agent.Initialize(
tolerance: Random.Range(0.15f, 0.95f),
wantedItems: Random.Range(1, 4),
wantedCategory: categories[Random.Range(0, categories.Length)],
register: _register);
_alive++;
}
/// <summary>Wird von CustomerAgent kurz vor dem Despawn aufgerufen.</summary>
public void NotifyCustomerGone()
{
if (!IsServer) return;
_alive = Mathf.Max(0, _alive - 1);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 976ebea99a2a90e4fa658c69f07afef9
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 259041785f4f96f42b6ce2138e6ad8d1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+31
View File
@@ -0,0 +1,31 @@
namespace AfterHours.Data
{
/// <summary>Grobes Sortiment-Segment. Beeinflusst Kundschaft, Marge und Ruf.</summary>
public enum ProductCategory
{
Wellness, // Massage, Öle, Pflege niedrige Hemmschwelle, kleine Marge
Couples, // Paar-Sortiment Kern des Ladens
Lingerie, // Textil hohe Marge, braucht Umkleide
Novelty, // Scherzartikel Laufkundschaft, kaum Beratung
Specialty // Nische hohe Marge, hohe Hemmschwelle, braucht Beratung
}
public enum ShelfSize
{
Small = 1,
Medium = 2,
Large = 4
}
public enum CustomerState
{
Entering,
Browsing,
SeekingAdvice,
WaitingForStaff,
Queueing,
Paying,
Leaving,
Abandoning // geht ohne Kauf Komfort unter Schwelle
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a86bac230bc87784ca31703d750385db
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
namespace AfterHours.Data
{
/// <summary>Zentrale Auflösung ProductId -> ProductDefinition.</summary>
[CreateAssetMenu(menuName = "AfterHours/Product Catalog", fileName = "ProductCatalog")]
public sealed class ProductCatalog : ScriptableObject
{
[SerializeField] private List<ProductDefinition> _products = new();
private Dictionary<int, ProductDefinition> _byId;
public IReadOnlyList<ProductDefinition> All => _products;
private void EnsureIndexed()
{
if (_byId != null) return;
_byId = new Dictionary<int, ProductDefinition>(_products.Count);
foreach (var product in _products)
{
if (product == null) continue;
if (_byId.ContainsKey(product.ProductId))
{
Debug.LogError($"[ProductCatalog] Doppelte ProductId {product.ProductId} bei '{product.name}'.");
continue;
}
_byId[product.ProductId] = product;
}
}
public bool TryGet(int productId, out ProductDefinition product)
{
EnsureIndexed();
return _byId.TryGetValue(productId, out product);
}
public ProductDefinition Get(int productId) => TryGet(productId, out var p) ? p : null;
#if UNITY_EDITOR
private void OnValidate() => _byId = null;
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff37cbc356c13f849995837f2a7dfc89
+45
View File
@@ -0,0 +1,45 @@
using UnityEngine;
namespace AfterHours.Data
{
/// <summary>
/// Stammdaten eines Artikels. Wird NIE über das Netzwerk übertragen
/// im Netzwerk zirkuliert ausschließlich die ProductId.
/// </summary>
[CreateAssetMenu(menuName = "AfterHours/Product", fileName = "Product_")]
public sealed class ProductDefinition : ScriptableObject
{
[Header("Identität")]
[SerializeField] private int _productId = -1;
[SerializeField] private string _displayName = "Unbenannt";
[SerializeField] private ProductCategory _category = ProductCategory.Wellness;
[Header("Wirtschaft")]
[SerializeField, Min(0.01f)] private float _wholesalePrice = 5f;
[SerializeField, Min(0.01f)] private float _recommendedPrice = 12f;
[Header("Laden")]
[SerializeField] private ShelfSize _shelfSize = ShelfSize.Small;
[Tooltip("0 = greift jeder mit, 1 = Kunde will damit nicht gesehen werden.")]
[SerializeField, Range(0f, 1f)] private float _discretionDemand = 0.3f;
[Tooltip("Wahrscheinlichkeit, dass der Kunde vor dem Kauf Beratung sucht.")]
[SerializeField, Range(0f, 1f)] private float _adviceLikelihood = 0.2f;
[Header("Darstellung (stilisierte Verpackung siehe CLAUDE.md §7)")]
[SerializeField] private GameObject _packagePrefab;
[SerializeField] private Sprite _icon;
public int ProductId => _productId;
public string DisplayName => _displayName;
public ProductCategory Category => _category;
public float WholesalePrice => _wholesalePrice;
public float RecommendedPrice => _recommendedPrice;
public ShelfSize ShelfSize => _shelfSize;
public float DiscretionDemand => _discretionDemand;
public float AdviceLikelihood => _adviceLikelihood;
public GameObject PackagePrefab => _packagePrefab;
public Sprite Icon => _icon;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e52a7daaf085524caf591a33107a59d
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4f403b2316053614daaf052c45a7b85c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+49
View File
@@ -0,0 +1,49 @@
using System;
namespace AfterHours.Economy
{
/// <summary>
/// Reine Preis-/Nachfragelogik. Engine-frei und damit testbar.
/// Keine UnityEngine-Referenzen in dieser Datei.
/// </summary>
public static class PricingService
{
/// <summary>Ab diesem Aufschlag über Empfehlung kauft niemand mehr.</summary>
public const float RejectionThreshold = 1.75f;
/// <summary>
/// Kaufwahrscheinlichkeit relativ zum empfohlenen Preis.
/// 1.0 bei Empfehlungspreis oder darunter, 0.0 ab RejectionThreshold.
/// Dazwischen quadratisch abfallend kleine Aufschläge tun kaum weh,
/// große brechen die Nachfrage schnell ein.
/// </summary>
public static float PurchaseProbability(float askingPrice, float recommendedPrice)
{
if (recommendedPrice <= 0f) throw new ArgumentOutOfRangeException(nameof(recommendedPrice));
if (askingPrice <= 0f) return 1f;
float ratio = askingPrice / recommendedPrice;
if (ratio <= 1f) return 1f;
if (ratio >= RejectionThreshold) return 0f;
float t = (ratio - 1f) / (RejectionThreshold - 1f); // 0..1
return 1f - (t * t);
}
/// <summary>Marge in Prozent des Verkaufspreises.</summary>
public static float MarginRatio(float askingPrice, float wholesalePrice)
{
if (askingPrice <= 0f) return 0f;
return (askingPrice - wholesalePrice) / askingPrice;
}
/// <summary>
/// Erwarteter Deckungsbeitrag pro angebotenem Stück.
/// Nützlich für den späteren "Preis-Optimum"-Hinweis im Preis-UI.
/// </summary>
public static float ExpectedProfitPerUnit(float askingPrice, float recommendedPrice, float wholesalePrice)
{
return PurchaseProbability(askingPrice, recommendedPrice) * (askingPrice - wholesalePrice);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 488bfdde2f57adf4e8d4ce3a8ec065c0
+42
View File
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace AfterHours.Economy
{
/// <summary>Eine Position einer Großhandelsbestellung.</summary>
[Serializable]
public struct OrderLine
{
public int ProductId;
public int Quantity;
public float UnitPrice;
public float LineTotal => Quantity * UnitPrice;
}
/// <summary>Bestellung beim Großhandel. Wird nur serverseitig gehalten.</summary>
public sealed class PurchaseOrder
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
/// <summary>Spielzeit in Sekunden, zu der die Lieferung eintrifft.</summary>
public float DeliveryTime { get; set; }
public bool Delivered { get; set; }
public void Add(int productId, int quantity, float unitPrice)
{
if (quantity <= 0) return;
_lines.Add(new OrderLine { ProductId = productId, Quantity = quantity, UnitPrice = unitPrice });
}
public float Total()
{
float sum = 0f;
foreach (var line in _lines) sum += line.LineTotal;
return sum;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ca74ad78a2bea0e4d985e96612714923
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c1ccc133082caf4eb31bcfed0514b62
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using Unity.Netcode;
namespace AfterHours.Interaction
{
/// <summary>
/// Alles, was der Spieler per Blick + Taste bedienen kann.
/// WICHTIG: Interact() läuft nur auf dem Server. Clients rufen es niemals
/// direkt auf, sondern gehen über PlayerInteractor.RequestInteractRpc.
/// </summary>
public interface IInteractable
{
/// <summary>Text für den Blick-Prompt, z. B. "Regal auffüllen".</summary>
string GetPrompt(ulong clientId);
/// <summary>Serverseitige Ausführung. Muss selbst validieren.</summary>
void Interact(NetworkObject actor);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 994d75674ec8c0e46a8c40f1bee27330
@@ -0,0 +1,69 @@
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Interaction
{
/// <summary>
/// Raycast auf dem Owner-Client für den Prompt, Ausführung auf dem Server.
/// Der Server vertraut dem Client die Zielauswahl NICHT blind er prüft
/// Distanz und Sichtbarkeit erneut (siehe CLAUDE.md §3).
/// </summary>
[RequireComponent(typeof(NetworkObject))]
public sealed class PlayerInteractor : NetworkBehaviour
{
[SerializeField] private Transform _eyes;
[SerializeField] private float _reach = 2.5f;
[SerializeField] private LayerMask _interactableMask = ~0;
/// <summary>Toleranz, weil Client und Server nie exakt denselben Frame sehen.</summary>
private const float ServerReachTolerance = 1.5f;
private IInteractable _current;
public string CurrentPrompt { get; private set; }
private void Update()
{
if (!IsOwner) return;
RefreshTarget();
if (_current != null && Input.GetKeyDown(KeyCode.E))
{
var target = ((MonoBehaviour)_current).GetComponent<NetworkObject>();
if (target != null) RequestInteractRpc(target);
}
}
private void RefreshTarget()
{
_current = null;
CurrentPrompt = null;
if (!Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask))
return;
if (!hit.collider.TryGetComponent<IInteractable>(out var interactable))
return;
_current = interactable;
CurrentPrompt = interactable.GetPrompt(OwnerClientId);
}
[Rpc(SendTo.Server)]
private void RequestInteractRpc(NetworkObjectReference targetRef)
{
if (!targetRef.TryGet(out var target)) return;
// Server-Validierung: ist der Spieler überhaupt in Reichweite?
float distance = Vector3.Distance(transform.position, target.transform.position);
if (distance > _reach + ServerReachTolerance)
{
Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt ({distance:F1} m).");
return;
}
if (target.TryGetComponent<IInteractable>(out var interactable))
interactable.Interact(NetworkObject);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f8feeb0d94c7e44b8d05c33089a77ef
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 45f3bfcc1d96be341b8354325b3bb022
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Networking
{
/// <summary>
/// Einstiegspunkt für Host/Join. Bewusst minimal Steam-Lobbies kommen
/// später über Facepunch.Steamworks dazu (siehe docs/ROADMAP.md).
/// </summary>
public sealed class NetworkBootstrap : MonoBehaviour
{
public const int MaxPlayers = 4;
[SerializeField] private string _gameSceneName = "Shop";
public void HostGame()
{
NetworkManager.Singleton.ConnectionApprovalCallback = ApproveConnection;
if (!NetworkManager.Singleton.StartHost())
{
Debug.LogError("[NetworkBootstrap] Host konnte nicht gestartet werden.");
return;
}
NetworkManager.Singleton.SceneManager.LoadScene(
_gameSceneName, UnityEngine.SceneManagement.LoadSceneMode.Single);
}
public void JoinGame()
{
if (!NetworkManager.Singleton.StartClient())
Debug.LogError("[NetworkBootstrap] Client konnte nicht verbinden.");
}
public void Leave() => NetworkManager.Singleton.Shutdown();
private void ApproveConnection(
NetworkManager.ConnectionApprovalRequest request,
NetworkManager.ConnectionApprovalResponse response)
{
bool hasRoom = NetworkManager.Singleton.ConnectedClientsIds.Count < MaxPlayers;
response.Approved = hasRoom;
response.CreatePlayerObject = true;
response.Reason = hasRoom ? null : "Der Laden ist voll (max. 4 Angestellte).";
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f7b7e91ebc572543a8324413a34c7a9
+71
View File
@@ -0,0 +1,71 @@
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Networking
{
/// <summary>
/// Spielerbewegung mit Client-Prediction: der Owner bewegt sich sofort,
/// NetworkTransform repliziert. Bewegung ist der EINZIGE Bereich, in dem
/// wir dem Client vertrauen siehe CLAUDE.md §3.
/// </summary>
[RequireComponent(typeof(CharacterController))]
public sealed class PlayerAvatar : NetworkBehaviour
{
[SerializeField] private float _walkSpeed = 3.4f;
[SerializeField] private float _mouseSensitivity = 2f;
[SerializeField] private Transform _cameraRoot;
[SerializeField] private float _gravity = -12f;
private CharacterController _controller;
private float _pitch;
private float _verticalVelocity;
private void Awake() => _controller = GetComponent<CharacterController>();
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
if (_cameraRoot != null && _cameraRoot.GetComponentInChildren<Camera>() is { } cam)
cam.gameObject.SetActive(false);
enabled = false;
return;
}
Cursor.lockState = CursorLockMode.Locked;
}
private void Update()
{
if (!IsOwner) return;
Look();
Move();
}
private void Look()
{
float mouseX = Input.GetAxis("Mouse X") * _mouseSensitivity;
float mouseY = Input.GetAxis("Mouse Y") * _mouseSensitivity;
transform.Rotate(Vector3.up, mouseX);
_pitch = Mathf.Clamp(_pitch - mouseY, -85f, 85f);
if (_cameraRoot != null)
_cameraRoot.localRotation = Quaternion.Euler(_pitch, 0f, 0f);
}
private void Move()
{
Vector3 input = new(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
Vector3 direction = transform.TransformDirection(Vector3.ClampMagnitude(input, 1f));
_verticalVelocity = _controller.isGrounded
? -1f
: _verticalVelocity + _gravity * Time.deltaTime;
Vector3 velocity = direction * _walkSpeed + Vector3.up * _verticalVelocity;
_controller.Move(velocity * Time.deltaTime);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f88015bc4effd5b429972c480fabe831
+57
View File
@@ -0,0 +1,57 @@
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Networking
{
/// <summary>
/// Was der Spieler gerade trägt. Server-authoritativ der Client darf
/// weder Produkt noch Menge selbst setzen.
/// </summary>
public sealed class PlayerHands : NetworkBehaviour
{
public const int MaxCarry = 12;
private readonly NetworkVariable<int> _productId = new(-1);
private readonly NetworkVariable<int> _quantity = new(0);
public int ProductId => _productId.Value;
public int Quantity => _quantity.Value;
public bool IsEmpty => _quantity.Value <= 0;
/// <summary>Serverseitig: nimmt so viel wie möglich auf, gibt die tatsächliche Menge zurück.</summary>
public int TryTake(int productId, int quantity)
{
if (!IsServer) return 0;
if (quantity <= 0) return 0;
if (!IsEmpty && _productId.Value != productId)
return 0; // Hände sind mit etwas anderem belegt
int free = MaxCarry - _quantity.Value;
int taken = Mathf.Min(free, quantity);
if (taken <= 0) return 0;
_productId.Value = productId;
_quantity.Value += taken;
return taken;
}
/// <summary>Serverseitig: gibt bis zu <paramref name="quantity"/> ab.</summary>
public int TryGive(int quantity)
{
if (!IsServer || IsEmpty) return 0;
int given = Mathf.Min(_quantity.Value, quantity);
_quantity.Value -= given;
if (_quantity.Value == 0) _productId.Value = -1;
return given;
}
public void Clear()
{
if (!IsServer) return;
_productId.Value = -1;
_quantity.Value = 0;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 402647b9bce9a6a4481b75e1004e3bfb
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 54a0671883c35dd42bfd005bee512b3b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+102
View File
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using AfterHours.Core;
using AfterHours.Data;
using AfterHours.Interaction;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Shop
{
/// <summary>
/// Kasse. Kunden reihen sich ein; ein Spieler scannt Position für Position
/// und schließt ab. Sämtliche Geldbewegung passiert hier auf dem Server.
/// </summary>
public sealed class CashRegister : NetworkBehaviour, IInteractable
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private Transform _queueStart;
[SerializeField] private float _queueSpacing = 0.8f;
/// <summary>Server-only. Clients sehen die Schlange über die NPC-Positionen.</summary>
private readonly List<Customers.CustomerAgent> _queue = new();
private readonly NetworkVariable<float> _pendingTotal = new(0f);
private readonly NetworkVariable<int> _scannedItems = new(0);
public float PendingTotal => _pendingTotal.Value;
public int QueueLength => _queue.Count;
public string GetPrompt(ulong clientId)
{
if (_queue.Count == 0) return "Kasse (frei)";
return _scannedItems.Value > 0
? $"Kassieren — {_pendingTotal.Value:0.00} €"
: "Artikel scannen";
}
/// <summary>Server: ein Klick = ein Artikel scannen, oder abschließen wenn fertig.</summary>
public void Interact(NetworkObject actor)
{
if (!IsServer || _queue.Count == 0) return;
var customer = _queue[0];
if (customer == null) { _queue.RemoveAt(0); return; }
if (_scannedItems.Value < customer.BasketCount)
{
ScanNext(customer);
return;
}
Finalize(customer);
}
private void ScanNext(Customers.CustomerAgent customer)
{
var line = customer.BasketAt(_scannedItems.Value);
_pendingTotal.Value += line.Price;
_scannedItems.Value++;
}
private void Finalize(Customers.CustomerAgent customer)
{
ShopEconomy.Instance.Earn(_pendingTotal.Value);
ShopEconomy.Instance.RegisterSale(customer.Comfort);
customer.OnPaid();
_queue.RemoveAt(0);
_pendingTotal.Value = 0f;
_scannedItems.Value = 0;
ReflowQueue();
}
/// <summary>Server: Kunde stellt sich an. Gibt die Zielposition zurück.</summary>
public Vector3 Enqueue(Customers.CustomerAgent customer)
{
if (!IsServer) return _queueStart.position;
if (!_queue.Contains(customer)) _queue.Add(customer);
return SlotPosition(_queue.IndexOf(customer));
}
/// <summary>Server: Kunde verlässt die Schlange (z. B. weil er aufgibt).</summary>
public void Remove(Customers.CustomerAgent customer)
{
if (!IsServer) return;
if (_queue.Remove(customer)) ReflowQueue();
}
private void ReflowQueue()
{
for (int i = 0; i < _queue.Count; i++)
if (_queue[i] != null) _queue[i].MoveTo(SlotPosition(i));
}
private Vector3 SlotPosition(int index)
{
return _queueStart.position + _queueStart.forward * (-_queueSpacing * index);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 87517e4a128619c469c629b6248f1daa
+22
View File
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace AfterHours.Shop
{
/// <summary>
/// Serverseitige Liste aller aktiven Regalfächer. Damit Kunden ein
/// passendes Fach finden können, ohne FindObjectOfType im Frame-Loop
/// aufzurufen (siehe CLAUDE.md §5).
/// </summary>
public sealed class ShelfRegistry
{
public static readonly ShelfRegistry Instance = new();
private readonly List<ShelfSlot> _slots = new();
public IReadOnlyList<ShelfSlot> Slots => _slots;
public void Register(ShelfSlot slot) => _slots.Add(slot);
public void Unregister(ShelfSlot slot) => _slots.Remove(slot);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36930243258ac6f4f816f2cfc254a52f
+112
View File
@@ -0,0 +1,112 @@
using AfterHours.Data;
using AfterHours.Interaction;
using AfterHours.Networking;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Shop
{
/// <summary>
/// Ein Regalfach: hält genau eine Produktsorte. Auffüllen geht über
/// Interact(), Entnahme durch Kunden serverseitig über TakeOne().
/// </summary>
public sealed class ShelfSlot : NetworkBehaviour, IInteractable
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private int _capacityUnits = 12;
[Tooltip("Ist dieses Fach vom Schaufenster aus einsehbar? Setzt der Level-Designer.")]
[SerializeField] private bool _exposedToStreet;
[SerializeField] private Transform _displayRoot;
private readonly NetworkVariable<int> _productId = new(-1);
private readonly NetworkVariable<int> _quantity = new(0);
private readonly NetworkVariable<float> _price = new(0f);
public int ProductId => _productId.Value;
public int Quantity => _quantity.Value;
public float Price => _price.Value;
public bool ExposedToStreet => _exposedToStreet;
public bool IsEmpty => _quantity.Value <= 0;
public override void OnNetworkSpawn()
{
_quantity.OnValueChanged += (_, _) => RefreshVisuals();
_productId.OnValueChanged += (_, _) => RefreshVisuals();
RefreshVisuals();
if (IsServer) ShelfRegistry.Instance.Register(this);
}
public override void OnNetworkDespawn()
{
if (IsServer) ShelfRegistry.Instance.Unregister(this);
}
public string GetPrompt(ulong clientId)
{
if (IsEmpty) return "Regal befüllen";
var product = _catalog.Get(_productId.Value);
string name = product != null ? product.DisplayName : "?";
return $"{name} — {_quantity.Value}/{Capacity()} — {_price.Value:0.00} €";
}
/// <summary>Server: Spieler füllt aus der Hand auf.</summary>
public void Interact(NetworkObject actor)
{
if (!IsServer) return;
if (!actor.TryGetComponent<PlayerHands>(out var hands) || hands.IsEmpty) return;
// Fach ist mit anderer Ware belegt -> nichts tun.
if (!IsEmpty && _productId.Value != hands.ProductId) return;
int free = Capacity() - _quantity.Value;
if (free <= 0) return;
int moved = hands.TryGive(Mathf.Min(free, hands.Quantity));
if (moved <= 0) return;
if (IsEmpty)
{
_productId.Value = hands.ProductId >= 0 ? hands.ProductId : _productId.Value;
var def = _catalog.Get(_productId.Value);
if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice;
}
_quantity.Value += moved;
}
/// <summary>Server: Kunde entnimmt ein Stück.</summary>
public bool TakeOne()
{
if (!IsServer || IsEmpty) return false;
_quantity.Value--;
if (_quantity.Value == 0) _productId.Value = -1;
return true;
}
/// <summary>Server: Preis setzen (vom Preis-UI aus).</summary>
public void SetPrice(float price)
{
if (!IsServer) return;
_price.Value = Mathf.Max(0.01f, price);
}
/// <summary>Kapazität in Stück, abhängig von der Produktgröße.</summary>
private int Capacity()
{
var product = _catalog.Get(_productId.Value);
int size = product != null ? (int)product.ShelfSize : 1;
return Mathf.Max(1, _capacityUnits / size);
}
private void RefreshVisuals()
{
// TODO: Verpackungs-Prefabs nach Menge ein-/ausblenden (Object Pool).
// Läuft auf allen Peers, rein kosmetisch.
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2bf1c5efe2d53984e90da832661184d8
+49
View File
@@ -0,0 +1,49 @@
using AfterHours.Data;
using AfterHours.Interaction;
using AfterHours.Networking;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Shop
{
/// <summary>
/// Lagerkiste im Backroom: hält Nachschub einer einzigen Produktsorte.
/// Spieler nehmen daraus in die Hände auf und tragen die Ware zum Regal
/// (siehe ShelfSlot.Interact).
/// </summary>
public sealed class StorageCrate : NetworkBehaviour, IInteractable
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private int _productId = -1;
[SerializeField] private int _startingQuantity = 200;
private readonly NetworkVariable<int> _quantity = new(0);
public int ProductId => _productId;
public int Quantity => _quantity.Value;
public bool IsEmpty => _quantity.Value <= 0;
public override void OnNetworkSpawn()
{
if (IsServer) _quantity.Value = _startingQuantity;
}
public string GetPrompt(ulong clientId)
{
var product = _catalog.Get(_productId);
string name = product != null ? product.DisplayName : "?";
return IsEmpty ? $"{name} — Lager leer" : $"{name} nehmen — {_quantity.Value} im Lager";
}
/// <summary>Server: Spieler nimmt Ware in die Hände auf.</summary>
public void Interact(NetworkObject actor)
{
if (!IsServer || IsEmpty) return;
if (!actor.TryGetComponent<PlayerHands>(out var hands)) return;
int want = Mathf.Min(_quantity.Value, PlayerHands.MaxCarry);
int taken = hands.TryTake(_productId, want);
_quantity.Value -= taken;
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2b8651b0bcbd9a442bc9688879ce2605
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 34870c3bd1615004f9e6f2b4a05a7f28
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5855a87ce97ace049915c17c1ba0c9ca
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,13 @@
{
"name": "AfterHours.Tests",
"rootNamespace": "AfterHours.Tests",
"references": [
"AfterHours.Runtime",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": ["Editor"],
"precompiledReferences": ["nunit.framework.dll"],
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"autoReferenced": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 576e5e4dc99f32e4eaebf1eb76385743
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using AfterHours.Customers;
using NUnit.Framework;
namespace AfterHours.Tests
{
public sealed class ComfortModelTests
{
[Test]
public void EmptyStore_LeavesCustomerFullyComfortable()
{
float comfort = ComfortModel.EvaluateTarget(0.5f, 0.8f, 0, false, false);
Assert.AreEqual(1f, comfort, 0.0001f);
}
[Test]
public void CrowdingReducesComfort()
{
float alone = ComfortModel.EvaluateTarget(0.5f, 0.8f, 0, false, false);
float crowded = ComfortModel.EvaluateTarget(0.5f, 0.8f, 4, false, false);
Assert.Less(crowded, alone);
}
[Test]
public void ToleranceCushionsTheSameSituation()
{
float shy = ComfortModel.EvaluateTarget(0.1f, 0.9f, 3, true, false);
float bold = ComfortModel.EvaluateTarget(0.9f, 0.9f, 3, true, false);
Assert.Greater(bold, shy);
}
[Test]
public void HarmlessProductIsUnaffectedByCrowding()
{
float comfort = ComfortModel.EvaluateTarget(0.5f, 0f, 6, true, true);
Assert.AreEqual(1f, comfort, 0.0001f);
}
[Test]
public void WorstCaseTriggersAbandonment()
{
float comfort = ComfortModel.EvaluateTarget(0f, 1f, 5, true, true);
Assert.IsTrue(ComfortModel.ShouldAbandon(comfort), $"Komfort war {comfort}");
}
[Test]
public void RecoveryIsGradual_DiscomfortIsImmediate()
{
Assert.AreEqual(0.3f, ComfortModel.Step(1f, 0.3f, 0.25f), 0.0001f); // sofort
Assert.Less(ComfortModel.Step(0.3f, 1f, 0.25f), 1f); // träge
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee90a53056bfe9c42bf72d0e7ee60e8a
@@ -0,0 +1,55 @@
using AfterHours.Economy;
using NUnit.Framework;
namespace AfterHours.Tests
{
public sealed class PricingServiceTests
{
[Test]
public void AtOrBelowRecommendedPrice_EveryoneBuys()
{
Assert.AreEqual(1f, PricingService.PurchaseProbability(10f, 12f), 0.0001f);
Assert.AreEqual(1f, PricingService.PurchaseProbability(12f, 12f), 0.0001f);
}
[Test]
public void AtRejectionThreshold_NobodyBuys()
{
float price = 12f * PricingService.RejectionThreshold;
Assert.AreEqual(0f, PricingService.PurchaseProbability(price, 12f), 0.0001f);
}
[Test]
public void DemandFallsMonotonically()
{
float previous = 1f;
for (float price = 12f; price < 21f; price += 0.5f)
{
float current = PricingService.PurchaseProbability(price, 12f);
Assert.LessOrEqual(current, previous);
previous = current;
}
}
[Test]
public void SmallMarkupHurtsLessThanLargeOne()
{
float small = 1f - PricingService.PurchaseProbability(13f, 12f);
float large = 1f - PricingService.PurchaseProbability(19f, 12f);
Assert.Less(small, large * 0.5f);
}
[Test]
public void ExpectedProfitPeaksAboveWholesale()
{
float best = 0f, bestPrice = 0f;
for (float price = 5f; price < 21f; price += 0.25f)
{
float profit = PricingService.ExpectedProfitPerUnit(price, 12f, 5f);
if (profit > best) { best = profit; bestPrice = price; }
}
Assert.Greater(bestPrice, 5f);
Assert.Greater(best, 0f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53f7a9ae1e6213b4db8d1f200e402334
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 709f11a7f3c4041caa4ef136ea32d874
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ab09877e2e707104187f6f83e2f62510
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5e6cbd92db86f4b18aec3ed561671858
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 65bc7dbf4170f435aa868c779acfb082
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b83569d67af61e458304325a23e5dfd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f288ae1f4751b564a96ac7587541f7a2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 10fc4df2da32a41aaa32d77bc913491c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 18dc0cd2c080841dea60987a38ce93fa
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ba062aa6c92b140379dbc06b43dd3b9b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+9
View File
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 8a0c9218a650547d98138cd835033977
folderAsset: yes
timeCreated: 1484670163
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
fileFormatVersion: 2
guid: 727a75301c3d24613a3ebcec4a24c2c8
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 11
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
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 0
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
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: 1
spriteTessellationDetail: -1
textureType: 2
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 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
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
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
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
+654
View File
@@ -0,0 +1,654 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &1
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12004, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_PixelRect:
serializedVersion: 2
x: 0
y: 45
width: 1666
height: 958
m_ShowMode: 4
m_Title:
m_RootView: {fileID: 6}
m_MinSize: {x: 950, y: 542}
m_MaxSize: {x: 10000, y: 10000}
--- !u!114 &2
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 466
width: 290
height: 442
m_MinSize: {x: 234, y: 271}
m_MaxSize: {x: 10004, y: 10021}
m_ActualView: {fileID: 14}
m_Panes:
- {fileID: 14}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &3
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 4}
- {fileID: 2}
m_Position:
serializedVersion: 2
x: 973
y: 0
width: 290
height: 908
m_MinSize: {x: 234, y: 492}
m_MaxSize: {x: 10004, y: 14042}
vertical: 1
controlID: 226
--- !u!114 &4
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 290
height: 466
m_MinSize: {x: 204, y: 221}
m_MaxSize: {x: 4004, y: 4021}
m_ActualView: {fileID: 17}
m_Panes:
- {fileID: 17}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &5
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 466
width: 973
height: 442
m_MinSize: {x: 202, y: 221}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 15}
m_Panes:
- {fileID: 15}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &6
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12008, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 7}
- {fileID: 8}
- {fileID: 9}
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 1666
height: 958
m_MinSize: {x: 950, y: 542}
m_MaxSize: {x: 10000, y: 10000}
--- !u!114 &7
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12011, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 1666
height: 30
m_MinSize: {x: 0, y: 0}
m_MaxSize: {x: 0, y: 0}
m_LastLoadedLayoutName: Tutorial
--- !u!114 &8
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 10}
- {fileID: 3}
- {fileID: 11}
m_Position:
serializedVersion: 2
x: 0
y: 30
width: 1666
height: 908
m_MinSize: {x: 713, y: 492}
m_MaxSize: {x: 18008, y: 14042}
vertical: 0
controlID: 74
--- !u!114 &9
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12042, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 938
width: 1666
height: 20
m_MinSize: {x: 0, y: 0}
m_MaxSize: {x: 0, y: 0}
--- !u!114 &10
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12010, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children:
- {fileID: 12}
- {fileID: 5}
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 973
height: 908
m_MinSize: {x: 202, y: 442}
m_MaxSize: {x: 4002, y: 8042}
vertical: 1
controlID: 75
--- !u!114 &11
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 1263
y: 0
width: 403
height: 908
m_MinSize: {x: 277, y: 71}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 13}
m_Panes:
- {fileID: 13}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &12
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12006, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_Children: []
m_Position:
serializedVersion: 2
x: 0
y: 0
width: 973
height: 466
m_MinSize: {x: 202, y: 221}
m_MaxSize: {x: 4002, y: 4021}
m_ActualView: {fileID: 16}
m_Panes:
- {fileID: 16}
m_Selected: 0
m_LastSelected: 0
--- !u!114 &13
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12019, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 275, y: 50}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Inspector
m_Image: {fileID: -6905738622615590433, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 401
height: 887
m_ScrollPosition: {x: 0, y: 0}
m_InspectorMode: 0
m_PreviewResizer:
m_CachedPref: -160
m_ControlHash: -371814159
m_PrefName: Preview_InspectorPreview
m_PreviewWindow: {fileID: 0}
--- !u!114 &14
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12014, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 230, y: 250}
m_MaxSize: {x: 10000, y: 10000}
m_TitleContent:
m_Text: Project
m_Image: {fileID: -7501376956915960154, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 286
height: 421
m_SearchFilter:
m_NameFilter:
m_ClassNames: []
m_AssetLabels: []
m_AssetBundleNames: []
m_VersionControlStates: []
m_ReferencingInstanceIDs:
m_ScenePaths: []
m_ShowAllHits: 0
m_SearchArea: 0
m_Folders:
- Assets
m_ViewMode: 0
m_StartGridSize: 64
m_LastFolders:
- Assets
m_LastFoldersGridSize: -1
m_LastProjectPath: /Users/danielbrauer/Unity Projects/New Unity Project 47
m_IsLocked: 0
m_FolderTreeState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: ee240000
m_LastClickedID: 9454
m_ExpandedIDs: ee24000000ca9a3bffffff7f
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_AssetTreeState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: 68fbffff
m_LastClickedID: 0
m_ExpandedIDs: ee240000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_ListAreaState:
m_SelectedInstanceIDs: 68fbffff
m_LastClickedInstanceID: -1176
m_HadKeyboardFocusLastEvent: 0
m_ExpandedInstanceIDs: c6230000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 1
m_ClientGUIView: {fileID: 0}
m_CreateAssetUtility:
m_EndAction: {fileID: 0}
m_InstanceID: 0
m_Path:
m_Icon: {fileID: 0}
m_ResourceFile:
m_NewAssetIndexInList: -1
m_ScrollPosition: {x: 0, y: 0}
m_GridSize: 64
m_DirectoriesAreaWidth: 110
--- !u!114 &15
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12015, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 1
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Game
m_Image: {fileID: -2087823869225018852, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 32
m_Pos:
serializedVersion: 2
x: 0
y: 19
width: 971
height: 421
m_MaximizeOnPlay: 0
m_Gizmos: 0
m_Stats: 0
m_SelectedSizes: 00000000000000000000000000000000000000000000000000000000000000000000000000000000
m_TargetDisplay: 0
m_ZoomArea:
m_HRangeLocked: 0
m_VRangeLocked: 0
m_HBaseRangeMin: -242.75
m_HBaseRangeMax: 242.75
m_VBaseRangeMin: -101
m_VBaseRangeMax: 101
m_HAllowExceedBaseRangeMin: 1
m_HAllowExceedBaseRangeMax: 1
m_VAllowExceedBaseRangeMin: 1
m_VAllowExceedBaseRangeMax: 1
m_ScaleWithWindow: 0
m_HSlider: 0
m_VSlider: 0
m_IgnoreScrollWheelUntilClicked: 0
m_EnableMouseInput: 1
m_EnableSliderZoom: 0
m_UniformScale: 1
m_UpDirection: 1
m_DrawArea:
serializedVersion: 2
x: 0
y: 17
width: 971
height: 404
m_Scale: {x: 2, y: 2}
m_Translation: {x: 485.5, y: 202}
m_MarginLeft: 0
m_MarginRight: 0
m_MarginTop: 0
m_MarginBottom: 0
m_LastShownAreaInsideMargins:
serializedVersion: 2
x: -242.75
y: -101
width: 485.5
height: 202
m_MinimalGUI: 1
m_defaultScale: 2
m_TargetTexture: {fileID: 0}
m_CurrentColorSpace: 0
m_LastWindowPixelSize: {x: 1942, y: 842}
m_ClearInEditMode: 1
m_NoCameraWarning: 1
m_LowResolutionForAspectRatios: 01000000000100000100
--- !u!114 &16
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12013, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 1
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Scene
m_Image: {fileID: 2318424515335265636, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 32
m_Pos:
serializedVersion: 2
x: 0
y: 19
width: 971
height: 445
m_SceneLighting: 1
lastFramingTime: 0
m_2DMode: 0
m_isRotationLocked: 0
m_AudioPlay: 0
m_Position:
m_Target: {x: 0, y: 0, z: 0}
speed: 2
m_Value: {x: 0, y: 0, z: 0}
m_RenderMode: 0
m_ValidateTrueMetals: 0
m_SceneViewState:
showFog: 1
showMaterialUpdate: 0
showSkybox: 1
showFlares: 1
showImageEffects: 1
grid:
xGrid:
m_Target: 0
speed: 2
m_Value: 0
yGrid:
m_Target: 1
speed: 2
m_Value: 1
zGrid:
m_Target: 0
speed: 2
m_Value: 0
m_Rotation:
m_Target: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226}
speed: 2
m_Value: {x: -0.08717229, y: 0.89959055, z: -0.21045254, w: -0.3726226}
m_Size:
m_Target: 10
speed: 2
m_Value: 10
m_Ortho:
m_Target: 0
speed: 2
m_Value: 0
m_LastSceneViewRotation: {x: 0, y: 0, z: 0, w: 0}
m_LastSceneViewOrtho: 0
m_ReplacementShader: {fileID: 0}
m_ReplacementString:
m_LastLockedObject: {fileID: 0}
m_ViewIsLockedToObject: 0
--- !u!114 &17
MonoBehaviour:
m_ObjectHideFlags: 52
m_PrefabParentObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 1
m_Script: {fileID: 12061, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_AutoRepaintOnSceneChange: 0
m_MinSize: {x: 200, y: 200}
m_MaxSize: {x: 4000, y: 4000}
m_TitleContent:
m_Text: Hierarchy
m_Image: {fileID: -590624980919486359, guid: 0000000000000000d000000000000000,
type: 0}
m_Tooltip:
m_DepthBufferBits: 0
m_Pos:
serializedVersion: 2
x: 2
y: 19
width: 286
height: 445
m_TreeViewState:
scrollPos: {x: 0, y: 0}
m_SelectedIDs: 68fbffff
m_LastClickedID: -1176
m_ExpandedIDs: 7efbffff00000000
m_RenameOverlay:
m_UserAcceptedRename: 0
m_Name:
m_OriginalName:
m_EditFieldRect:
serializedVersion: 2
x: 0
y: 0
width: 0
height: 0
m_UserData: 0
m_IsWaitingForDelay: 0
m_IsRenaming: 0
m_OriginalEventType: 11
m_IsRenamingFilename: 0
m_ClientGUIView: {fileID: 0}
m_SearchString:
m_ExpandedScenes:
-
m_CurrenRootInstanceID: 0
m_Locked: 0
m_CurrentSortingName: TransformSorting
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: eabc9546105bf4accac1fd62a63e88e6
timeCreated: 1487337779
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
+9
View File
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 5a9bcd70e6a4b4b05badaa72e827d8e0
folderAsset: yes
timeCreated: 1475835190
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
+9
View File
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 3ad9b87dffba344c89909c6d1b1c17e1
folderAsset: yes
timeCreated: 1475593892
licenseType: Store
DefaultImporter:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,242 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Reflection;
[CustomEditor(typeof(Readme))]
[InitializeOnLoad]
public class ReadmeEditor : Editor
{
static string s_ShowedReadmeSessionStateName = "ReadmeEditor.showedReadme";
static string s_ReadmeSourceDirectory = "Assets/TutorialInfo";
const float k_Space = 16f;
static ReadmeEditor()
{
EditorApplication.delayCall += SelectReadmeAutomatically;
}
static void RemoveTutorial()
{
if (EditorUtility.DisplayDialog("Remove Readme Assets",
$"All contents under {s_ReadmeSourceDirectory} will be removed, are you sure you want to proceed?",
"Proceed",
"Cancel"))
{
if (Directory.Exists(s_ReadmeSourceDirectory))
{
FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory);
FileUtil.DeleteFileOrDirectory(s_ReadmeSourceDirectory + ".meta");
}
else
{
Debug.Log($"Could not find the Readme folder at {s_ReadmeSourceDirectory}");
}
var readmeAsset = SelectReadme();
if (readmeAsset != null)
{
var path = AssetDatabase.GetAssetPath(readmeAsset);
FileUtil.DeleteFileOrDirectory(path + ".meta");
FileUtil.DeleteFileOrDirectory(path);
}
AssetDatabase.Refresh();
}
}
static void SelectReadmeAutomatically()
{
if (!SessionState.GetBool(s_ShowedReadmeSessionStateName, false))
{
var readme = SelectReadme();
SessionState.SetBool(s_ShowedReadmeSessionStateName, true);
if (readme && !readme.loadedLayout)
{
LoadLayout();
readme.loadedLayout = true;
}
}
}
static void LoadLayout()
{
var assembly = typeof(EditorApplication).Assembly;
var windowLayoutType = assembly.GetType("UnityEditor.WindowLayout", true);
var method = windowLayoutType.GetMethod("LoadWindowLayout", BindingFlags.Public | BindingFlags.Static);
method.Invoke(null, new object[] { Path.Combine(Application.dataPath, "TutorialInfo/Layout.wlt"), false });
}
static Readme SelectReadme()
{
var ids = AssetDatabase.FindAssets("Readme t:Readme");
if (ids.Length == 1)
{
var readmeObject = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(ids[0]));
Selection.objects = new UnityEngine.Object[] { readmeObject };
return (Readme)readmeObject;
}
else
{
Debug.Log("Couldn't find a readme");
return null;
}
}
protected override void OnHeaderGUI()
{
var readme = (Readme)target;
Init();
var iconWidth = Mathf.Min(EditorGUIUtility.currentViewWidth / 3f - 20f, 128f);
GUILayout.BeginHorizontal("In BigTitle");
{
if (readme.icon != null)
{
GUILayout.Space(k_Space);
GUILayout.Label(readme.icon, GUILayout.Width(iconWidth), GUILayout.Height(iconWidth));
}
GUILayout.Space(k_Space);
GUILayout.BeginVertical();
{
GUILayout.FlexibleSpace();
GUILayout.Label(readme.title, TitleStyle);
GUILayout.FlexibleSpace();
}
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
}
GUILayout.EndHorizontal();
}
public override void OnInspectorGUI()
{
var readme = (Readme)target;
Init();
foreach (var section in readme.sections)
{
if (!string.IsNullOrEmpty(section.heading))
{
GUILayout.Label(section.heading, HeadingStyle);
}
if (!string.IsNullOrEmpty(section.text))
{
GUILayout.Label(section.text, BodyStyle);
}
if (!string.IsNullOrEmpty(section.linkText))
{
if (LinkLabel(new GUIContent(section.linkText)))
{
Application.OpenURL(section.url);
}
}
GUILayout.Space(k_Space);
}
if (GUILayout.Button("Remove Readme Assets", ButtonStyle))
{
RemoveTutorial();
}
}
bool m_Initialized;
GUIStyle LinkStyle
{
get { return m_LinkStyle; }
}
[SerializeField]
GUIStyle m_LinkStyle;
GUIStyle TitleStyle
{
get { return m_TitleStyle; }
}
[SerializeField]
GUIStyle m_TitleStyle;
GUIStyle HeadingStyle
{
get { return m_HeadingStyle; }
}
[SerializeField]
GUIStyle m_HeadingStyle;
GUIStyle BodyStyle
{
get { return m_BodyStyle; }
}
[SerializeField]
GUIStyle m_BodyStyle;
GUIStyle ButtonStyle
{
get { return m_ButtonStyle; }
}
[SerializeField]
GUIStyle m_ButtonStyle;
void Init()
{
if (m_Initialized)
return;
m_BodyStyle = new GUIStyle(EditorStyles.label);
m_BodyStyle.wordWrap = true;
m_BodyStyle.fontSize = 14;
m_BodyStyle.richText = true;
m_TitleStyle = new GUIStyle(m_BodyStyle);
m_TitleStyle.fontSize = 26;
m_HeadingStyle = new GUIStyle(m_BodyStyle);
m_HeadingStyle.fontStyle = FontStyle.Bold;
m_HeadingStyle.fontSize = 18;
m_LinkStyle = new GUIStyle(m_BodyStyle);
m_LinkStyle.wordWrap = false;
// Match selection color which works nicely for both light and dark skins
m_LinkStyle.normal.textColor = new Color(0x00 / 255f, 0x78 / 255f, 0xDA / 255f, 1f);
m_LinkStyle.stretchWidth = false;
m_ButtonStyle = new GUIStyle(EditorStyles.miniButton);
m_ButtonStyle.fontStyle = FontStyle.Bold;
m_Initialized = true;
}
bool LinkLabel(GUIContent label, params GUILayoutOption[] options)
{
var position = GUILayoutUtility.GetRect(label, LinkStyle, options);
Handles.BeginGUI();
Handles.color = LinkStyle.normal.textColor;
Handles.DrawLine(new Vector3(position.xMin, position.yMax), new Vector3(position.xMax, position.yMax));
Handles.color = Color.white;
Handles.EndGUI();
EditorGUIUtility.AddCursorRect(position, MouseCursor.Link);
return GUI.Button(position, label, LinkStyle);
}
}
@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 476cc7d7cd9874016adc216baab94a0a
timeCreated: 1484146680
licenseType: Store
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+16
View File
@@ -0,0 +1,16 @@
using System;
using UnityEngine;
public class Readme : ScriptableObject
{
public Texture2D icon;
public string title;
public Section[] sections;
public bool loadedLayout;
[Serializable]
public class Section
{
public string heading, text, linkText, url;
}
}
@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: fcf7219bab7fe46a1ad266029b2fee19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- icon: {instanceID: 0}
executionOrder: 0
icon: {fileID: 2800000, guid: a186f8a87ca4f4d3aa864638ad5dfb65, type: 3}
userData:
assetBundleName:
assetBundleVariant:
+143
View File
@@ -0,0 +1,143 @@
# CLAUDE.md — AfterHours (Boutique Simulator)
Diese Datei ist die Arbeitsanweisung für Claude Code in diesem Repo.
Lies sie vollständig, bevor du Code änderst.
---
## 1. Projekt in einem Satz
Koop-Ladensimulator (24 Spieler) für PC: Die Spieler betreiben gemeinsam eine
diskrete Erwachsenen-Boutique — Ware bestellen, einräumen, bepreisen, Kunden
beraten, kassieren, Laden ausbauen.
**Codename:** AfterHours
**Zielplattform:** Windows (Steam), später Linux/Proton
**Zielrating:** Mature, NICHT Adult-Only. Siehe §7 — das ist eine harte Constraint.
---
## 2. Tech-Stack
| Bereich | Entscheidung |
|--------------|-----------------------------------------------|
| Engine | Unity 6 LTS (6000.0.x) |
| Sprache | C# (.NET Standard 2.1) |
| Netcode | Unity Netcode for GameObjects (NGO) 2.x |
| Transport | Unity Transport + Steam Relay (Facepunch) |
| KI-Pathing | Unity NavMesh (NavMeshAgent, nur serverseitig)|
| Tests | Unity Test Framework (NUnit), EditMode |
| Daten | ScriptableObjects (`Assets/Data/...`) |
**Warum NGO und nicht FishNet:** erstklassige Doku, First-Party-Support, reicht
für 4 Spieler + ~30 NPCs vollkommen aus. Nicht ohne Rücksprache wechseln.
---
## 3. Die wichtigste Regel: Server-Authority
> **Der Host simuliert. Clients fragen und zeigen an.**
Alles, was Geld, Inventar, Warenbestand oder Kundenlogik betrifft, läuft
**ausschließlich** auf dem Host. Clients senden Absichten via `ServerRpc`,
der Server validiert und schreibt in `NetworkVariable`s.
**Konkret:**
-`[Rpc(SendTo.Server)]` → validieren → `NetworkVariable` setzen
- ❌ Niemals Geld/Bestand clientseitig verändern und "hoffen"
- ❌ Niemals einem Client-Wert vertrauen, ohne ihn zu prüfen
(Beispiel: Client sagt "ich scanne Produkt X" → Server prüft, ob der Client
dieses Produkt tatsächlich in der Hand hält)
- Kunden-NPCs: Bewegung + Entscheidungen nur auf dem Server.
Clients bekommen nur Transform-Sync und Zustandsflags.
Jede Methode, die Zustand ändert, wird mit `if (!IsServer) return;`
abgesichert — auch wenn sie "eigentlich" nur serverseitig aufgerufen wird.
---
## 4. Ordnerstruktur
```
Assets/Scripts/
├── Core/ GameClock, ShopEconomy, GameBootstrap — Kern-Loop & Zustand
├── Data/ ScriptableObjects: ProductDefinition, ProductCatalog, Enums
├── Economy/ PricingService, SupplierService, PurchaseOrder
├── Shop/ Shelf, ShelfSlot, CashRegister, StorageCrate
├── Customers/ CustomerAgent, CustomerBrain (FSM), ComfortModel, Spawner
├── Interaction/ IInteractable, PlayerInteractor, Carryable
├── Networking/ NetworkBootstrap, PlayerAvatar, PlayerHands
└── Tests/EditMode/ NUnit-Tests für reine Logik
```
**Namespaces spiegeln Ordner:** `AfterHours.Economy`, `AfterHours.Customers`, …
---
## 5. Code-Konventionen
- Klassen/Methoden `PascalCase`, Felder `_camelCase` mit Unterstrich,
Konstanten `PascalCase`.
- `[SerializeField] private` statt `public` für Inspector-Felder.
- **Reine Logik gehört in statische, engine-freie Klassen** (`PricingService`,
`ComfortModel`). Nur die sind testbar — MonoBehaviours sind es nicht.
Wenn du eine Formel schreibst und sie in einem `MonoBehaviour` landet:
rausziehen.
- Produkte werden im Netzwerk **nie als Referenz**, sondern als
`ProductId` (int) übertragen. Auflösung über `ProductCatalog`.
- Keine `FindObjectOfType` im Frame-Loop. Referenzen im `Awake` cachen.
- Kein `async void` außer in Event-Handlern.
---
## 6. Tests
```bash
# Unity CLI (Pfad ggf. anpassen)
Unity -runTests -batchmode -projectPath . -testPlatform EditMode
```
Neue Logik in `Economy/` oder `Customers/` **braucht** einen EditMode-Test.
Netzwerk- und Szenenlogik wird nicht getestet — dort zählt manuelles
Host+Client-Testing (siehe `docs/ARCHITECTURE.md`).
---
## 7. Content-Constraint (nicht verhandelbar)
Das Spiel darf **nicht** in Steams "Adult Only"-Kategorie landen, weil das
Sichtbarkeit, Regionen und Zahlungsdienstleister massiv einschränkt.
**Deshalb gilt:**
- Produkte werden als **stilisierte Verpackungen / Icons** dargestellt,
nie explizit modelliert.
- Kein sexuell explizites Bildmaterial, keine Nacktheit, keine sexuellen Akte.
- Humor ist trocken und situativ (Verlegenheit, Beratungspannen,
Stoßzeiten-Chaos) — nicht anzüglich.
- Ton der Kundendialoge: höflich, vage, alltäglich. Die Komik entsteht aus
dem, was *nicht* gesagt wird.
Wenn ein Feature-Vorschlag dagegen verstößt: melde es, statt es umzusetzen.
---
## 8. Aktueller Stand
Gerüst steht. Implementiert sind Datenmodell, Preis-/Komfort-Formeln,
Regal-/Kassen-Grundgerüst und der Netzwerk-Bootstrap. Es existieren **noch
keine Unity-Szenen, Prefabs oder Assets** — die musst du bzw. der Nutzer im
Editor anlegen.
Offene Punkte in Prioritätsreihenfolge: siehe `docs/ROADMAP.md`.
`// TODO:`-Marker im Code zeigen die konkreten Lücken.
---
## 9. Arbeitsweise mit mir
- Bevor du eine neue Datei anlegst: prüfe, ob die Logik in eine bestehende
gehört. Das Projekt soll klein und lesbar bleiben.
- Ändere nie mehrere Systeme in einem Rutsch. Ein System, ein Commit.
- Wenn eine Anforderung mit §3 (Server-Authority) oder §7 (Content) kollidiert:
nachfragen, nicht raten.
- Commit-Messages: `feat(economy): ...`, `fix(customers): ...`, `docs: ...`
+36
View File
@@ -0,0 +1,36 @@
# Design-Kern
## Der Loop
Bestellen → Einräumen → Bepreisen → Beraten → Kassieren → Ausbauen
## Was das Spiel von anderen Shop-Sims unterscheidet
**1. Komfort als Ressource.**
Kunden haben einen Komfortwert. Enge Gänge, Personal, das ungefragt danebensteht,
und Regale mit Sichtachse zum Schaufenster senken ihn. Fällt er unter 25 %, legt
der Kunde die Ware zurück und geht. Das macht *Ladenlayout* zur zentralen
Designentscheidung — nicht Regalfläche, sondern Blickachsen.
**2. Beratung statt bloßem Kassieren.**
Kunden umschreiben, was sie suchen. Der Spieler leitet daraus die Kategorie ab.
Treffer = größerer Warenkorb + Stammkunde. Fehlgriff = peinliche Stille und ein
Kunde, der schneller geht.
**3. Sortimentspolitik.**
Wellness zieht Laufkundschaft mit kleiner Marge. Specialty bringt Marge, verlangt
aber Diskretion und Beratung. Wer beides mischt, braucht räumliche Trennung.
## Koop-Rollen (Anspielung an Overcooked in Stoßzeiten)
| Rolle | Aufgabe |
|----------|--------------------------------------|
| Kasse | Scannen, Schlange abarbeiten |
| Lager | Kisten holen, Regale auffüllen |
| Beratung | Kunden mit `SeekingAdvice` bedienen |
| Einkauf | Bestellungen, Preise, Layout |
Die Rollen sind nicht hart zugewiesen — die Rush-Kurve erzwingt Arbeitsteilung
von selbst.
## Ton
Trocken, situativ, nie anzüglich. Die Komik entsteht aus Verlegenheit und
Zeitdruck, nicht aus dem Produkt. Siehe CLAUDE.md §7.

Some files were not shown because too many files have changed in this diff Show More