ADD : 돈주머니 프랍 추가 및 카메라 전환 버그 해결 및 fov 제어기능 추가

This commit is contained in:
user 2026-01-19 02:28:27 +09:00
parent ba817cb4e3
commit 6a7deb6e96
52 changed files with 3251 additions and 124 deletions

8
Assets/Preset.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ecda75af11211dd4ead9d2948b3d3b7e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6d80c97dc0290914c9ccf17efc450e2f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/Preset/후원/WefLab.prefab (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9470035ddd1c1aa4bb67805985097d7d
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7066e932cb18c694594dc51b487741c2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/Preset/후원/던질 프랍/돈주머니.prefab (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6b5a0e552e06dfd4a82dfbaa0d8fdac3
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b7d1ad4cc4839d408d3d6e39ec6047f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 06d22216356ccad42b5434d79bc5ac8c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2195070c29d0ed04ab72ed53f6780ce0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 83c9cb0f42010264f86ac30b1da1432d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 60e1891c4dc0c374da17786077d08f80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 3ded3cb3c3ae6f243b3a449aef14524f
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: 2
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: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
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
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,22 @@
fileFormatVersion: 2
guid: 61efca492f301314c9b7189c17f6e417
ScriptedImporter:
internalIDToNameTable: []
externalObjects:
- first:
type: UnityEngine:Material
assembly: UnityEngine.CoreModule
name: material_0
second: {fileID: 2100000, guid: 83c9cb0f42010264f86ac30b1da1432d, type: 2}
- first:
type: UnityEngine:Texture
assembly: UnityEngine.CoreModule
name: Image_0
second: {fileID: 2800000, guid: 3ded3cb3c3ae6f243b3a449aef14524f, type: 3}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: cc45016b844e7624dae3aec10fb443ea, type: 3}
reverseAxis: 2
renderPipeline: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 259f604cdbf223b4c85a423dd5d3d63c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 80d0fe3b10048864f93050efd36883e9
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,158 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Coin
m_Shader: {fileID: 211, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords:
- _ALPHATEST_ON
m_InvalidKeywords: []
m_LightmapFlags: 0
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: 2450
stringTagMap:
RenderType: TransparentCutout
disabledShaderPasses:
- MOTIONVECTORS
- GRABPASS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 2800000, guid: 08c6cf6250054064fb85c4f953406a6f, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 2800000, guid: 08c6cf6250054064fb85c4f953406a6f, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BlendOp: 0
- _BumpScale: 1
- _CameraFadingEnabled: 0
- _CameraFarFadeDistance: 2
- _CameraNearFadeDistance: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _ColorMode: 0
- _Cull: 0
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DistortionBlend: 0.5
- _DistortionEnabled: 0
- _DistortionStrength: 1
- _DistortionStrengthScaled: 0
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EmissionEnabled: 0
- _EnvironmentReflections: 1
- _FlipbookMode: 0
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _LightingEnabled: 0
- _Metallic: 0
- _Mode: 1
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SoftParticlesEnabled: 0
- _SoftParticlesFarFadeDistance: 1
- _SoftParticlesNearFadeDistance: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _CameraFadeParams: {r: 0, g: Infinity, b: 0, a: 0}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _ColorAddSubDiff: {r: 0, g: 0, b: 0, a: 0}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SoftParticleFadeParams: {r: 0, g: 0, b: 0, a: 0}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &6738923856525603992
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 77bcf914247406744892f91d7497c54e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

BIN
Assets/ResourcesData/Prop/돈주머니/Particle/Coin.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 08c6cf6250054064fb85c4f953406a6f
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: 4
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: 4
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
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ac3169f25f136984e9eda821c9b67868
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4a438ec402234494db1c4597ce20dc41
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ec9ad2e9fbffb604184a0ee84836d48b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,23 @@
fileFormatVersion: 2
guid: 483113300e6fec0488ef74bbb771c3da
AudioImporter:
externalObjects: {}
serializedVersion: 8
defaultSettings:
serializedVersion: 2
loadType: 0
sampleRateSetting: 0
sampleRateOverride: 44100
compressionFormat: 1
quality: 1
conversionMode: 0
preloadAudioData: 0
platformSettingOverrides: {}
forceToMono: 0
normalize: 1
loadInBackground: 0
ambisonic: 0
3D: 1
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0597c595d70f57242b02e1f8ecd7f88e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 52eff15a79fe4224993692534a200c90
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: 4
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: 4
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
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fd54180499ff72f4bbcb70fe47ba5f8a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,315 @@
using UnityEngine;
namespace Streamingle
{
/// <summary>
/// Component attached to throwable objects.
/// Uses parabolic interpolation for smooth arc trajectory.
/// On reaching target, enables collision detection - bounces off colliders or falls.
/// </summary>
public class ThrowableObject : MonoBehaviour
{
[HideInInspector]
public ThrowableObjectLauncher launcher;
[Header("Settings")]
[Tooltip("Play sound on hit")]
public AudioClip[] hitSounds;
[Tooltip("Spawn particle effect on hit")]
public GameObject hitEffectPrefab;
[Tooltip("Destroy hit effect after this time")]
public float hitEffectLifetime = 2f;
[Header("Trajectory Settings")]
[Tooltip("Time to reach target in seconds")]
public float flightDuration = 1.5f;
[Tooltip("Arc height (higher = more curved trajectory)")]
public float arcHeight = 2f;
[Tooltip("Rotation speed while flying")]
public float rotationSpeed = 180f;
[Header("Collision Settings")]
[Tooltip("Bounce force multiplier when hitting collider")]
public float bounceForce = 5f;
[Tooltip("Additional upward force on bounce")]
public float bounceUpForce = 2f;
[Tooltip("Time to stay after reaching target before deactivating")]
public float postArrivalLifetime = 3f;
// Trajectory state
private Vector3 startPosition;
private Vector3 targetPosition;
private Transform targetTransform;
private float flightTime;
private float currentTime;
private bool isFlying = false;
private bool hasArrived = false;
private bool hasCollided = false;
private Vector3 rotationAxis;
private Vector3 arrivalVelocity;
// Components
private Rigidbody rb;
private AudioSource audioSource;
private Collider[] colliders;
// Lifetime
private float lifetime;
private float spawnTime;
void Awake()
{
rb = GetComponent<Rigidbody>();
audioSource = GetComponent<AudioSource>();
colliders = GetComponents<Collider>();
}
/// <summary>
/// Initialize the throwable object with target position
/// </summary>
public void Initialize(Transform target, float lifetime)
{
Initialize(target, lifetime, Vector3.zero);
}
/// <summary>
/// Initialize with offset for target position
/// </summary>
public void Initialize(Transform target, float lifetime, Vector3 targetOffset)
{
this.lifetime = lifetime;
this.spawnTime = Time.time;
this.hasArrived = false;
this.hasCollided = false;
this.isFlying = true;
this.currentTime = 0f;
this.flightTime = flightDuration;
this.startPosition = transform.position;
this.targetTransform = target;
// Calculate target position with offset
if (target != null)
{
this.targetPosition = target.position + targetOffset;
}
else
{
this.targetPosition = transform.position + Vector3.forward * 5f;
}
// Random rotation axis for spinning effect
rotationAxis = Random.insideUnitSphere.normalized;
// Disable physics during flight
if (rb != null)
{
rb.isKinematic = true;
rb.useGravity = false;
}
// Disable colliders during flight
SetCollidersEnabled(false);
}
void Update()
{
// Check lifetime
if (lifetime > 0 && Time.time - spawnTime > lifetime)
{
Deactivate();
return;
}
if (!isFlying) return;
// Store previous position for velocity calculation
Vector3 prevPos = transform.position;
currentTime += Time.deltaTime;
float t = Mathf.Clamp01(currentTime / flightTime);
// Calculate parabolic position
Vector3 currentPos = CalculateParabolicPosition(t);
transform.position = currentPos;
// Calculate velocity for when we switch to physics
if (Time.deltaTime > 0)
{
arrivalVelocity = (currentPos - prevPos) / Time.deltaTime;
}
// Rotate while flying
transform.Rotate(rotationAxis, rotationSpeed * Time.deltaTime, Space.World);
// Check if reached target
if (t >= 1f)
{
OnReachedTarget();
}
}
/// <summary>
/// Calculate position along parabolic arc
/// </summary>
private Vector3 CalculateParabolicPosition(float t)
{
// Linear interpolation for base position
Vector3 linearPos = Vector3.Lerp(startPosition, targetPosition, t);
// Parabolic arc (peaks at t=0.5)
float parabola = 4f * arcHeight * t * (1f - t);
// Add arc height to Y position
linearPos.y += parabola;
return linearPos;
}
/// <summary>
/// Called when object reaches the target position
/// </summary>
private void OnReachedTarget()
{
isFlying = false;
hasArrived = true;
// Re-enable colliders for collision detection
SetCollidersEnabled(true);
// Enable physics - object will either hit a collider and bounce or fall
if (rb != null)
{
rb.isKinematic = false;
rb.useGravity = true;
// Continue with the arrival velocity (maintains momentum)
rb.linearVelocity = arrivalVelocity;
rb.angularVelocity = rotationAxis * rotationSpeed * Mathf.Deg2Rad;
}
// Schedule deactivation (object falls if no collision)
Invoke(nameof(Deactivate), postArrivalLifetime);
}
void OnCollisionEnter(Collision collision)
{
if (!hasArrived || hasCollided) return;
// Check if collision is with the target
bool isTargetHit = false;
if (targetTransform != null)
{
// Check if collided object is the target or a child of target
Transform hitTransform = collision.collider.transform;
isTargetHit = hitTransform == targetTransform || hitTransform.IsChildOf(targetTransform);
}
hasCollided = true;
// Only notify launcher and play effects if hit the target
if (isTargetHit)
{
if (launcher != null)
{
launcher.OnObjectHitTarget(gameObject, collision.collider, collision);
}
// Play hit sound
PlayHitSound();
// Spawn hit effect
SpawnHitEffect(collision);
}
// Apply bounce force regardless of what was hit
if (rb != null && collision.contacts.Length > 0)
{
Vector3 normal = collision.contacts[0].normal;
Vector3 reflectedVel = Vector3.Reflect(rb.linearVelocity, normal);
// Apply bounce with some force
rb.linearVelocity = reflectedVel.normalized * bounceForce + Vector3.up * bounceUpForce;
rb.angularVelocity = Random.insideUnitSphere * 10f;
}
}
private void SetCollidersEnabled(bool enabled)
{
if (colliders == null) return;
foreach (var col in colliders)
{
if (col != null) col.enabled = enabled;
}
}
private void PlayHitSound()
{
if (hitSounds == null || hitSounds.Length == 0) return;
AudioClip clip = hitSounds[Random.Range(0, hitSounds.Length)];
if (clip == null) return;
if (audioSource != null)
{
audioSource.PlayOneShot(clip);
}
else
{
AudioSource.PlayClipAtPoint(clip, transform.position);
}
}
private void SpawnHitEffect(Collision collision = null)
{
if (hitEffectPrefab == null) return;
Vector3 pos = transform.position;
Quaternion rot = Quaternion.identity;
if (collision != null && collision.contacts.Length > 0)
{
pos = collision.contacts[0].point;
rot = Quaternion.LookRotation(collision.contacts[0].normal);
}
GameObject effect = Instantiate(hitEffectPrefab, pos, rot);
if (hitEffectLifetime > 0)
{
Destroy(effect, hitEffectLifetime);
}
}
private void Deactivate()
{
CancelInvoke();
isFlying = false;
hasArrived = false;
hasCollided = false;
if (launcher != null && launcher.usePooling)
{
// Return to pool
gameObject.SetActive(false);
}
else
{
// Destroy
Destroy(gameObject);
}
}
void OnDisable()
{
CancelInvoke();
isFlying = false;
hasArrived = false;
hasCollided = false;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 53f705dd7db28b4489129cbca2dfe7b7

View File

@ -0,0 +1,340 @@
using UnityEngine;
using UnityEngine.Events;
namespace Streamingle
{
/// <summary>
/// Launches throwable objects at a target (avatar's head).
/// Connect to WefLab donation events to throw objects on donations.
/// Uses parabolic interpolation for smooth arc trajectories.
/// </summary>
public class ThrowableObjectLauncher : MonoBehaviour
{
[Header("Target")]
[Tooltip("The target transform to throw objects at (usually avatar's head)")]
public Transform target;
[Tooltip("Fixed offset from target position")]
public Vector3 targetOffset = Vector3.zero;
[Tooltip("Random offset range for hit position")]
public Vector3 targetRandomOffset = new Vector3(0.1f, 0.1f, 0.1f);
[Header("Target Collider")]
[Tooltip("Automatically create a collider on target for bounce detection")]
public bool autoCreateTargetCollider = true;
[Tooltip("Radius of the auto-created sphere collider")]
public float targetColliderRadius = 0.15f;
[Header("Spawn Settings")]
[Tooltip("Prefabs to throw (randomly selected)")]
public GameObject[] throwablePrefabs;
[Tooltip("Spawn position offset from this transform")]
public Vector3 spawnOffset = new Vector3(0, 1f, 0);
[Tooltip("Randomize spawn position within this range")]
public Vector3 spawnRandomRange = new Vector3(5f, 1f, 5f);
[Header("Throw Settings")]
[Tooltip("Delay between throws when throwing multiple")]
public float throwInterval = 0.15f;
[Header("Object Lifetime")]
[Tooltip("Destroy thrown objects after this time (0 = never)")]
public float objectLifetime = 5f;
[Tooltip("Use object pooling instead of instantiate/destroy")]
public bool usePooling = true;
[Tooltip("Pool size per prefab")]
public int poolSizePerPrefab = 10;
[Header("Events")]
public UnityEvent<GameObject> onObjectThrown;
public UnityEvent<GameObject, Collision> onObjectHit;
// Object pool
private GameObject[][] objectPool;
private int[] poolIndices;
// Auto-created collider
private SphereCollider createdTargetCollider;
void Start()
{
if (usePooling)
{
InitializePool();
}
if (autoCreateTargetCollider && target != null)
{
SetupTargetCollider();
}
}
private void SetupTargetCollider()
{
// Check if target already has a collider
var existingCollider = target.GetComponent<Collider>();
if (existingCollider != null)
{
UnityEngine.Debug.Log($"[ThrowableObjectLauncher] Target already has collider: {existingCollider.GetType().Name}");
return;
}
// Create sphere collider on target with offset applied
createdTargetCollider = target.gameObject.AddComponent<SphereCollider>();
createdTargetCollider.radius = targetColliderRadius;
// Apply targetOffset to collider center (convert world offset to local space)
createdTargetCollider.center = target.InverseTransformDirection(targetOffset);
// Ensure target has rigidbody (kinematic) for collision detection
var rb = target.GetComponent<Rigidbody>();
if (rb == null)
{
rb = target.gameObject.AddComponent<Rigidbody>();
rb.isKinematic = true;
rb.useGravity = false;
}
UnityEngine.Debug.Log($"[ThrowableObjectLauncher] Created SphereCollider on target: {target.name}, radius: {targetColliderRadius}, center: {createdTargetCollider.center}");
}
void OnDestroy()
{
// Clean up created collider
if (createdTargetCollider != null)
{
Destroy(createdTargetCollider);
}
}
private void InitializePool()
{
if (throwablePrefabs == null || throwablePrefabs.Length == 0) return;
objectPool = new GameObject[throwablePrefabs.Length][];
poolIndices = new int[throwablePrefabs.Length];
for (int i = 0; i < throwablePrefabs.Length; i++)
{
if (throwablePrefabs[i] == null) continue;
objectPool[i] = new GameObject[poolSizePerPrefab];
for (int j = 0; j < poolSizePerPrefab; j++)
{
var obj = Instantiate(throwablePrefabs[i], transform);
obj.SetActive(false);
// Add throwable component if not present
var throwable = obj.GetComponent<ThrowableObject>();
if (throwable == null)
{
throwable = obj.AddComponent<ThrowableObject>();
}
throwable.launcher = this;
objectPool[i][j] = obj;
}
}
}
/// <summary>
/// Throw a random object at the target
/// </summary>
public void ThrowObject()
{
ThrowObject(Random.Range(0, throwablePrefabs.Length));
}
/// <summary>
/// Throw a specific object at the target
/// </summary>
public void ThrowObject(int prefabIndex)
{
if (throwablePrefabs == null || throwablePrefabs.Length == 0)
{
UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No throwable prefabs assigned");
return;
}
if (target == null)
{
UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No target assigned");
return;
}
prefabIndex = Mathf.Clamp(prefabIndex, 0, throwablePrefabs.Length - 1);
if (throwablePrefabs[prefabIndex] == null) return;
// Get or create object
GameObject obj = GetObject(prefabIndex);
if (obj == null) return;
// Calculate spawn position
Vector3 spawnPos = transform.position + transform.TransformDirection(spawnOffset);
spawnPos += new Vector3(
Random.Range(-spawnRandomRange.x, spawnRandomRange.x),
Random.Range(-spawnRandomRange.y, spawnRandomRange.y),
Random.Range(-spawnRandomRange.z, spawnRandomRange.z)
);
// Calculate random offset for target position
Vector3 randomOffset = new Vector3(
Random.Range(-targetRandomOffset.x, targetRandomOffset.x),
Random.Range(-targetRandomOffset.y, targetRandomOffset.y),
Random.Range(-targetRandomOffset.z, targetRandomOffset.z)
);
// Combine fixed offset + random offset
Vector3 combinedOffset = targetOffset + randomOffset;
// Setup object position
obj.transform.position = spawnPos;
obj.transform.rotation = Random.rotation;
obj.SetActive(true);
// Setup throwable component with combined offset
var throwable = obj.GetComponent<ThrowableObject>();
if (throwable != null)
{
throwable.Initialize(target, objectLifetime, combinedOffset);
}
onObjectThrown?.Invoke(obj);
}
/// <summary>
/// Throw multiple objects
/// </summary>
public void ThrowMultiple(int count)
{
for (int i = 0; i < count; i++)
{
// Delay each throw slightly
float delay = i * throwInterval;
StartCoroutine(ThrowDelayed(delay));
}
}
private System.Collections.IEnumerator ThrowDelayed(float delay)
{
yield return new WaitForSeconds(delay);
ThrowObject();
}
private GameObject GetObject(int prefabIndex)
{
GameObject obj;
if (usePooling && objectPool != null && objectPool[prefabIndex] != null)
{
// Find inactive object in pool
for (int i = 0; i < poolSizePerPrefab; i++)
{
int idx = (poolIndices[prefabIndex] + i) % poolSizePerPrefab;
if (!objectPool[prefabIndex][idx].activeInHierarchy)
{
poolIndices[prefabIndex] = (idx + 1) % poolSizePerPrefab;
obj = objectPool[prefabIndex][idx];
// Ensure launcher reference is set
var throwable = obj.GetComponent<ThrowableObject>();
if (throwable != null) throwable.launcher = this;
return obj;
}
}
// All in use, reuse oldest
poolIndices[prefabIndex] = (poolIndices[prefabIndex] + 1) % poolSizePerPrefab;
obj = objectPool[prefabIndex][poolIndices[prefabIndex]];
obj.SetActive(false);
// Ensure launcher reference is set
var throwableOldest = obj.GetComponent<ThrowableObject>();
if (throwableOldest != null) throwableOldest.launcher = this;
return obj;
}
else
{
// Instantiate new
obj = Instantiate(throwablePrefabs[prefabIndex]);
var throwable = obj.GetComponent<ThrowableObject>();
if (throwable == null)
{
throwable = obj.AddComponent<ThrowableObject>();
}
throwable.launcher = this;
return obj;
}
}
/// <summary>
/// Called by ThrowableObject when it reaches the target
/// </summary>
public void OnObjectHitTarget(GameObject obj, Collider hitCollider, Collision collision)
{
onObjectHit?.Invoke(obj, collision);
}
#region Context Menu Test Methods
[ContextMenu("Test: Throw 1")]
private void TestThrowOne()
{
ThrowObject();
}
[ContextMenu("Test: Throw 5")]
private void TestThrowFive()
{
ThrowMultiple(5);
}
#endregion
void OnDrawGizmosSelected()
{
// Draw spawn area
Gizmos.color = Color.green;
Vector3 spawnPos = transform.position + transform.TransformDirection(spawnOffset);
Gizmos.DrawWireCube(spawnPos, spawnRandomRange * 2);
// Draw target
if (target != null)
{
Gizmos.color = Color.red;
Vector3 targetPos = target.position + targetOffset;
Gizmos.DrawWireSphere(targetPos, 0.2f);
Gizmos.DrawWireCube(targetPos, targetRandomOffset * 2);
// Draw target collider radius (at offset position)
if (autoCreateTargetCollider)
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(target.position + targetOffset, targetColliderRadius);
}
// Draw trajectory preview (parabolic arc)
Gizmos.color = Color.yellow;
DrawParabolicArc(spawnPos, targetPos, 2f, 20);
}
}
private void DrawParabolicArc(Vector3 start, Vector3 end, float arcHeight, int segments)
{
Vector3 prevPos = start;
for (int i = 1; i <= segments; i++)
{
float t = i / (float)segments;
Vector3 linearPos = Vector3.Lerp(start, end, t);
float parabola = 4f * arcHeight * t * (1f - t);
linearPos.y += parabola;
Gizmos.DrawLine(prevPos, linearPos);
prevPos = linearPos;
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85064059a4495fd48824a7a421bee446

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Streamingle.Background
@ -37,48 +36,4 @@ namespace Streamingle.Background
}
}
/// <summary>
/// 배경 씬 데이터를 저장하는 ScriptableObject
/// </summary>
[CreateAssetMenu(fileName = "BackgroundSceneDatabase", menuName = "Streamingle/Background Scene Database")]
public class BackgroundSceneDatabase : ScriptableObject
{
public List<BackgroundSceneInfo> scenes = new List<BackgroundSceneInfo>();
/// <summary>
/// 카테고리별로 씬 목록 반환
/// </summary>
public Dictionary<string, List<BackgroundSceneInfo>> GetScenesByCategory()
{
var result = new Dictionary<string, List<BackgroundSceneInfo>>();
foreach (var scene in scenes)
{
string category = scene.Category;
if (!result.ContainsKey(category))
{
result[category] = new List<BackgroundSceneInfo>();
}
result[category].Add(scene);
}
return result;
}
/// <summary>
/// 씬 이름으로 검색
/// </summary>
public BackgroundSceneInfo FindByName(string sceneName)
{
return scenes.Find(s => s.sceneName == sceneName);
}
/// <summary>
/// 씬 경로로 검색
/// </summary>
public BackgroundSceneInfo FindByPath(string scenePath)
{
return scenes.Find(s => s.scenePath == scenePath);
}
}
}

View File

@ -0,0 +1,50 @@
using System.Collections.Generic;
using UnityEngine;
namespace Streamingle.Background
{
/// <summary>
/// 배경 씬 데이터를 저장하는 ScriptableObject
/// </summary>
[CreateAssetMenu(fileName = "BackgroundSceneDatabase", menuName = "Streamingle/Background Scene Database")]
public class BackgroundSceneDatabase : ScriptableObject
{
public List<BackgroundSceneInfo> scenes = new List<BackgroundSceneInfo>();
/// <summary>
/// 카테고리별로 씬 목록 반환
/// </summary>
public Dictionary<string, List<BackgroundSceneInfo>> GetScenesByCategory()
{
var result = new Dictionary<string, List<BackgroundSceneInfo>>();
foreach (var scene in scenes)
{
string category = scene.Category;
if (!result.ContainsKey(category))
{
result[category] = new List<BackgroundSceneInfo>();
}
result[category].Add(scene);
}
return result;
}
/// <summary>
/// 씬 이름으로 검색
/// </summary>
public BackgroundSceneInfo FindByName(string sceneName)
{
return scenes.Find(s => s.sceneName == sceneName);
}
/// <summary>
/// 씬 경로로 검색
/// </summary>
public BackgroundSceneInfo FindByPath(string scenePath)
{
return scenes.Find(s => s.scenePath == scenePath);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee084dfc7f17cb7498423a57ca4bd971

View File

@ -1,4 +1,5 @@
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using System.Collections;
using System.Collections.Generic;
@ -164,6 +165,11 @@ public class CameraManager : MonoBehaviour, IController
[System.NonSerialized] public Vector3 savedFocusPoint;
[System.NonSerialized] public bool hasOrbitState = false;
// 프리셋별 FOV 저장 (Alt+Q 복원용)
[System.NonSerialized] public float initialFOV;
[System.NonSerialized] public float savedFOV;
[System.NonSerialized] public bool hasInitialFOV = false;
public CameraPreset(CinemachineCamera camera)
{
virtualCamera = camera;
@ -181,6 +187,14 @@ public class CameraManager : MonoBehaviour, IController
initialPosition = virtualCamera.transform.position;
initialRotation = virtualCamera.transform.rotation;
hasInitialState = true;
// FOV 초기값 저장
if (virtualCamera.Lens.FieldOfView > 0)
{
initialFOV = virtualCamera.Lens.FieldOfView;
savedFOV = initialFOV;
hasInitialFOV = true;
}
}
// 초기 상태 복원 (Alt+Q)
@ -189,25 +203,36 @@ public class CameraManager : MonoBehaviour, IController
if (!hasInitialState || virtualCamera == null) return;
virtualCamera.transform.position = initialPosition;
virtualCamera.transform.rotation = initialRotation;
// FOV 초기값 복원
if (hasInitialFOV)
{
var lens = virtualCamera.Lens;
lens.FieldOfView = initialFOV;
virtualCamera.Lens = lens;
savedFOV = initialFOV;
}
}
// orbit 상태 저장 (카메라 전환 시)
public void SaveOrbitState(float hAngle, float vAngle, float dist, Vector3 focus)
public void SaveOrbitState(float hAngle, float vAngle, float dist, Vector3 focus, float fov)
{
savedHorizontalAngle = hAngle;
savedVerticalAngle = vAngle;
savedDistance = dist;
savedFocusPoint = focus;
savedFOV = fov;
hasOrbitState = true;
}
// orbit 상태 복원 (카메라 전환 시)
public bool TryRestoreOrbitState(out float hAngle, out float vAngle, out float dist, out Vector3 focus)
public bool TryRestoreOrbitState(out float hAngle, out float vAngle, out float dist, out Vector3 focus, out float fov)
{
hAngle = savedHorizontalAngle;
vAngle = savedVerticalAngle;
dist = savedDistance;
focus = savedFocusPoint;
fov = savedFOV;
return hasOrbitState;
}
}
@ -235,6 +260,11 @@ public class CameraManager : MonoBehaviour, IController
[SerializeField] private float minZoomDistance = 0.5f;
[SerializeField] private float maxZoomDistance = 50f;
[Header("FOV Settings")]
[SerializeField, Range(0.1f, 5f)] private float fovSensitivity = 1f;
[SerializeField] private float minFOV = 1f;
[SerializeField] private float maxFOV = 90f;
[Header("Rotation Target")]
[Tooltip("체크하면 아바타 머리를 자동으로 찾아 회전 중심점으로 사용합니다.")]
[SerializeField] private bool useAvatarHeadAsTarget = true;
@ -246,9 +276,12 @@ public class CameraManager : MonoBehaviour, IController
[SerializeField] private bool useBlendTransition = false;
[Tooltip("블렌드 전환 시간 (초)")]
[SerializeField, Range(0.1f, 2f)] private float blendTime = 0.5f;
[Tooltip("실시간 블렌딩 (두 카메라 동시 렌더링). 비활성화 시 스냅샷 블렌딩 사용")]
[SerializeField] private bool useRealtimeBlend = true;
// 블렌드용 렌더 텍스처와 카메라
private RenderTexture blendRenderTexture;
private RenderTexture prevCameraRenderTexture; // 실시간 블렌딩용 이전 카메라 렌더 텍스처
private Camera blendCamera;
private CinemachineCamera currentCamera;
@ -433,7 +466,6 @@ public class CameraManager : MonoBehaviour, IController
avatarHeadTransform = animator.GetBoneTransform(HumanBodyBones.Head);
if (avatarHeadTransform != null)
{
Debug.Log($"[CameraManager] 아바타 머리를 회전 중심점으로 설정: {avatarHeadTransform.name}");
return;
}
}
@ -543,7 +575,7 @@ public class CameraManager : MonoBehaviour, IController
private void HandleInput()
{
// 입력 우선순위 처리: Orbit > AltRightZoom > Zoom > Rotation > Panning
// 입력 우선순위 처리: Orbit > CtrlRightZoom > Zoom > FOV > Rotation > Panning
if (inputHandler.IsOrbitActive())
{
HandleOrbiting();
@ -556,6 +588,10 @@ public class CameraManager : MonoBehaviour, IController
{
HandleDragZoom();
}
else if (inputHandler.IsFOVActive())
{
HandleFOV();
}
else if (inputHandler.IsRightMouseHeld())
{
HandleRotation();
@ -651,6 +687,24 @@ public class CameraManager : MonoBehaviour, IController
targetDistance = Mathf.Clamp(targetDistance, minZoomDistance, maxZoomDistance);
}
/// <summary>
/// Shift + 좌클릭/우클릭 드래그: FOV 조절 (위로 밀면 FOV 감소=줌인, 아래로 밀면 FOV 증가=줌아웃)
/// </summary>
private void HandleFOV()
{
if (currentCamera == null) return;
Vector2 delta = inputHandler.GetLookDelta();
if (delta.sqrMagnitude < float.Epsilon) return;
// 위로 밀면 FOV 감소 (줌인 효과), 아래로 밀면 FOV 증가 (줌아웃 효과)
float fovDelta = -delta.y * fovSensitivity;
var lens = currentCamera.Lens;
lens.FieldOfView = Mathf.Clamp(lens.FieldOfView + fovDelta, minFOV, maxFOV);
currentCamera.Lens = lens;
}
/// <summary>
/// 스무딩을 적용하여 카메라 위치 업데이트
/// </summary>
@ -742,8 +796,6 @@ public class CameraManager : MonoBehaviour, IController
/// </summary>
public void SetImmediate(int index)
{
Debug.Log($"[CameraManager] 카메라 {index}번으로 전환 시작 (총 {cameraPresets?.Count ?? 0}개)");
if (!ValidateCameraIndex(index)) return;
var newPreset = cameraPresets[index];
@ -760,7 +812,8 @@ public class CameraManager : MonoBehaviour, IController
// 이전 프리셋의 orbit 상태 저장
if (oldPreset != null && oldPreset.allowMouseControl)
{
oldPreset.SaveOrbitState(targetHorizontalAngle, targetVerticalAngle, targetDistance, targetFocusPoint);
float currentFOV = oldPreset.virtualCamera != null ? oldPreset.virtualCamera.Lens.FieldOfView : 60f;
oldPreset.SaveOrbitState(targetHorizontalAngle, targetVerticalAngle, targetDistance, targetFocusPoint, currentFOV);
}
currentPreset = newPreset;
@ -770,7 +823,7 @@ public class CameraManager : MonoBehaviour, IController
if (newPreset.hasOrbitState && newPreset.allowMouseControl)
{
// 저장된 orbit 상태 복원
if (newPreset.TryRestoreOrbitState(out float hAngle, out float vAngle, out float dist, out Vector3 focus))
if (newPreset.TryRestoreOrbitState(out float hAngle, out float vAngle, out float dist, out Vector3 focus, out float fov))
{
targetHorizontalAngle = hAngle;
targetVerticalAngle = vAngle;
@ -781,6 +834,14 @@ public class CameraManager : MonoBehaviour, IController
verticalAngle = vAngle;
currentDistance = dist;
focusPoint = focus;
// FOV 복원
if (newPreset.virtualCamera != null && fov > 0)
{
var lens = newPreset.virtualCamera.Lens;
lens.FieldOfView = fov;
newPreset.virtualCamera.Lens = lens;
}
}
}
else
@ -797,7 +858,6 @@ public class CameraManager : MonoBehaviour, IController
streamDeckManager.NotifyCameraChanged();
}
Debug.Log($"[CameraManager] 카메라 전환 완료: {newCameraName}");
}
private bool ValidateCameraIndex(int index)
@ -832,9 +892,10 @@ public class CameraManager : MonoBehaviour, IController
return;
}
// 블렌드용 카메라 생성
GameObject blendCamObj = new GameObject("BlendCamera");
blendCamObj.transform.SetParent(mainCamera.transform.parent);
// 블렌드용 카메라 생성 - 독립적인 오브젝트로 (부모 없음)
// Cinemachine Brain의 영향을 피하기 위해 부모를 설정하지 않음
GameObject blendCamObj = new GameObject("BlendCamera_Independent");
blendCamObj.transform.SetParent(null); // 부모 없이 루트에 배치
blendCamera = blendCamObj.AddComponent<Camera>();
// 메인 카메라 설정 복사
@ -860,7 +921,6 @@ public class CameraManager : MonoBehaviour, IController
blendCameraData.volumeTrigger = mainCameraData.volumeTrigger;
}
Debug.Log("[CameraManager] 블렌드 카메라 생성 완료 (URP 설정 포함)");
}
/// <summary>
@ -886,61 +946,73 @@ public class CameraManager : MonoBehaviour, IController
Destroy(blendRenderTexture);
}
// 새 렌더 텍스처 생성
blendRenderTexture = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32);
// 새 렌더 텍스처 생성 - 색상 전용 (depth 없음), 리니어 색공간에서 올바른 블렌딩
var descriptor = new RenderTextureDescriptor(width, height, RenderTextureFormat.ARGB32, 0); // depth = 0
descriptor.sRGB = true; // sRGB 텍스처로 설정하여 리니어 파이프라인과 일치
blendRenderTexture = new RenderTexture(descriptor);
blendRenderTexture.name = "CameraBlendRT";
blendRenderTexture.Create();
Debug.Log($"[CameraManager] 블렌드 렌더 텍스처 생성: {width}x{height}");
}
/// <summary>
/// 크로스 디졸브 블렌드 전환 코루틴
/// </summary>
private IEnumerator BlendTransitionCoroutine(int targetIndex, float duration)
{
if (useRealtimeBlend)
{
yield return RealtimeBlendTransitionCoroutine(targetIndex, duration);
}
else
{
yield return SnapshotBlendTransitionCoroutine(targetIndex, duration);
}
}
/// <summary>
/// 스냅샷 블렌딩 (이전 화면 정지)
/// </summary>
private IEnumerator SnapshotBlendTransitionCoroutine(int targetIndex, float duration)
{
isBlending = true;
// 블렌드 카메라/텍스처 준비
EnsureBlendCamera();
// 블렌드 텍스처 준비
EnsureBlendRenderTexture();
if (blendCamera == null || blendRenderTexture == null)
if (blendRenderTexture == null)
{
Debug.LogError("[CameraManager] 블렌드 카메라 또는 텍스처 생성 실패");
SetImmediate(targetIndex);
isBlending = false;
blendCoroutine = null;
yield break;
}
// 메인 카메라의 현재 위치/회전 복사 (Cinemachine이 제어하는 실제 카메라)
Camera mainCamera = Camera.main;
if (mainCamera != null)
// 렌더 패스에서 현재 프레임(포스트 프로세싱 적용 후)을 캡처하도록 요청
CameraBlendController.RequestCapture(blendRenderTexture);
// 캡처가 완료될 때까지 대기 (다음 프레임 렌더링 후)
yield return new WaitForEndOfFrame();
yield return null; // 렌더 패스가 실행될 때까지 한 프레임 더 대기
// 캡처 완료 확인
if (!CameraBlendController.CaptureReady)
{
blendCamera.transform.position = mainCamera.transform.position;
blendCamera.transform.rotation = mainCamera.transform.rotation;
blendCamera.fieldOfView = mainCamera.fieldOfView;
CameraBlendController.EndBlend();
SetImmediate(targetIndex);
isBlending = false;
blendCoroutine = null;
yield break;
}
// 블렌드 카메라로 현재 화면을 렌더 텍스처에 렌더링
blendCamera.targetTexture = blendRenderTexture;
blendCamera.enabled = true;
blendCamera.Render();
blendCamera.enabled = false;
// 블렌딩 시작 - BlendAmount = 1 (이전 카메라만 보임)
CameraBlendController.StartBlendAfterCapture();
CameraBlendController.BlendAmount = 1f;
Debug.Log($"[CameraManager] 블렌드 시작 - Duration: {duration}s");
// 블렌딩 시작 - BlendAmount = 0 (이전 카메라 A만 보임)
CameraBlendController.StartBlend(blendRenderTexture);
// 한 프레임 대기 - 블렌딩이 적용된 상태로 렌더링되도록
yield return null;
// 카메라 전환 (이제 BlendAmount=0이므로 A만 보임)
// 카메라 전환 (이제 BlendAmount=1이므로 캡처된 이전 화면만 보임)
SetImmediate(targetIndex);
// 블렌드 진행: 0 → 1 (A에서 B로)
// 블렌드 진행: 1 → 0 (이전 카메라가 서서히 사라지고 새 카메라가 드러남)
float elapsed = 0f;
while (elapsed < duration)
{
@ -948,21 +1020,145 @@ public class CameraManager : MonoBehaviour, IController
float t = Mathf.Clamp01(elapsed / duration);
// 부드러운 이징 적용 (SmoothStep)
t = t * t * (3f - 2f * t);
CameraBlendController.BlendAmount = t;
// 1에서 0으로: 이전 카메라가 서서히 사라짐
CameraBlendController.BlendAmount = 1f - t;
yield return null;
}
CameraBlendController.BlendAmount = 1f;
CameraBlendController.BlendAmount = 0f;
// 블렌딩 종료
CameraBlendController.EndBlend();
Debug.Log("[CameraManager] 블렌드 완료");
isBlending = false;
blendCoroutine = null;
}
/// <summary>
/// 실시간 블렌딩 (두 카메라 동시 렌더링)
/// 이전 카메라 위치를 저장해두고 그 위치에서 렌더링, 서서히 사라지면서 새 카메라가 드러남
/// </summary>
private IEnumerator RealtimeBlendTransitionCoroutine(int targetIndex, float duration)
{
isBlending = true;
// 이전 카메라 프리셋 저장
var prevPreset = currentPreset;
if (prevPreset == null || prevPreset.virtualCamera == null)
{
SetImmediate(targetIndex);
isBlending = false;
blendCoroutine = null;
yield break;
}
// SetImmediate 호출 전에 이전 카메라의 위치/회전/FOV를 저장
Vector3 prevCameraPosition = prevPreset.virtualCamera.transform.position;
Quaternion prevCameraRotation = prevPreset.virtualCamera.transform.rotation;
float prevCameraFOV = prevPreset.virtualCamera.Lens.FieldOfView;
// 블렌드 텍스처 준비
EnsureBlendRenderTexture();
EnsurePrevCameraRenderTexture();
if (blendRenderTexture == null || prevCameraRenderTexture == null)
{
SetImmediate(targetIndex);
isBlending = false;
blendCoroutine = null;
yield break;
}
// 블렌드 카메라 준비
EnsureBlendCamera();
if (blendCamera == null)
{
SetImmediate(targetIndex);
isBlending = false;
blendCoroutine = null;
yield break;
}
// 블렌드 카메라를 이전 카메라 위치로 설정하고 첫 프레임 렌더링
blendCamera.transform.SetPositionAndRotation(prevCameraPosition, prevCameraRotation);
blendCamera.fieldOfView = prevCameraFOV;
blendCamera.targetTexture = prevCameraRenderTexture;
// Camera.Render()로 렌더링 (URP에서도 포스트 프로세싱 적용됨)
blendCamera.Render();
Graphics.Blit(prevCameraRenderTexture, blendRenderTexture);
// 실시간 블렌딩 시작 (BlendAmount = 1에서 시작, 이전 카메라가 100% 보임)
CameraBlendController.StartRealtimeBlend(blendRenderTexture);
CameraBlendController.BlendAmount = 1f;
// 새 카메라로 전환 (메인 카메라는 이제 새 위치에서 렌더링됨)
SetImmediate(targetIndex);
// 블렌드 진행: 1 → 0 (이전 카메라 화면이 서서히 사라지고 새 카메라가 드러남)
float elapsed = 0f;
while (elapsed < duration)
{
// 이전 카메라 시점에서 먼저 렌더링 (메인 카메라 렌더링 전에)
blendCamera.transform.SetPositionAndRotation(prevCameraPosition, prevCameraRotation);
blendCamera.fieldOfView = prevCameraFOV;
blendCamera.targetTexture = prevCameraRenderTexture;
// Camera.Render()로 렌더링 (URP에서도 포스트 프로세싱 적용됨)
blendCamera.Render();
Graphics.Blit(prevCameraRenderTexture, blendRenderTexture);
// 다음 프레임까지 대기
yield return null;
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
// 부드러운 이징 적용 (SmoothStep)
t = t * t * (3f - 2f * t);
// 1에서 0으로: 이전 카메라가 서서히 사라짐
CameraBlendController.BlendAmount = 1f - t;
}
CameraBlendController.BlendAmount = 0f;
// 블렌딩 종료
CameraBlendController.EndBlend();
isBlending = false;
blendCoroutine = null;
}
/// <summary>
/// 실시간 블렌딩용 이전 카메라 렌더 텍스처 생성/갱신
/// </summary>
private void EnsurePrevCameraRenderTexture()
{
int width = Screen.width;
int height = Screen.height;
// 이미 적절한 크기의 텍스처가 있으면 재사용
if (prevCameraRenderTexture != null &&
prevCameraRenderTexture.width == width &&
prevCameraRenderTexture.height == height)
{
return;
}
// 기존 텍스처 해제
if (prevCameraRenderTexture != null)
{
prevCameraRenderTexture.Release();
Destroy(prevCameraRenderTexture);
}
// 새 렌더 텍스처 생성 - HDR 포맷 + depth 포함 (포스트 프로세싱 지원)
var descriptor = new RenderTextureDescriptor(width, height, RenderTextureFormat.DefaultHDR, 24);
descriptor.sRGB = false; // HDR은 리니어 포맷
prevCameraRenderTexture = new RenderTexture(descriptor);
prevCameraRenderTexture.name = "PrevCameraRT";
prevCameraRenderTexture.Create();
}
private void UpdateCameraPriorities(CinemachineCamera newCamera)
{
if (newCamera == null)

View File

@ -17,6 +17,7 @@ public class CameraManagerEditor : Editor
private bool showControlSettings = true;
private bool showSmoothingSettings = true;
private bool showZoomSettings = true;
private bool showFOVSettings = true;
private bool showRotationTarget = true;
private bool showBlendSettings = true;
@ -29,10 +30,14 @@ public class CameraManagerEditor : Editor
private SerializedProperty rotationSmoothingProp;
private SerializedProperty minZoomDistanceProp;
private SerializedProperty maxZoomDistanceProp;
private SerializedProperty fovSensitivityProp;
private SerializedProperty minFOVProp;
private SerializedProperty maxFOVProp;
private SerializedProperty useAvatarHeadAsTargetProp;
private SerializedProperty manualRotationTargetProp;
private SerializedProperty useBlendTransitionProp;
private SerializedProperty blendTimeProp;
private SerializedProperty useRealtimeBlendProp;
// 스타일
private GUIStyle headerStyle;
@ -52,10 +57,14 @@ public class CameraManagerEditor : Editor
rotationSmoothingProp = serializedObject.FindProperty("rotationSmoothing");
minZoomDistanceProp = serializedObject.FindProperty("minZoomDistance");
maxZoomDistanceProp = serializedObject.FindProperty("maxZoomDistance");
fovSensitivityProp = serializedObject.FindProperty("fovSensitivity");
minFOVProp = serializedObject.FindProperty("minFOV");
maxFOVProp = serializedObject.FindProperty("maxFOV");
useAvatarHeadAsTargetProp = serializedObject.FindProperty("useAvatarHeadAsTarget");
manualRotationTargetProp = serializedObject.FindProperty("manualRotationTarget");
useBlendTransitionProp = serializedObject.FindProperty("useBlendTransition");
blendTimeProp = serializedObject.FindProperty("blendTime");
useRealtimeBlendProp = serializedObject.FindProperty("useRealtimeBlend");
}
private void OnDisable()
@ -126,6 +135,9 @@ public class CameraManagerEditor : Editor
// 줌 제한 섹션
DrawZoomLimitsSection();
// FOV 설정 섹션
DrawFOVSettingsSection();
// 회전 타겟 섹션
DrawRotationTargetSection();
@ -211,6 +223,35 @@ public class CameraManagerEditor : Editor
EditorGUILayout.EndVertical();
}
private void DrawFOVSettingsSection()
{
EditorGUILayout.BeginVertical(sectionBoxStyle);
showFOVSettings = EditorGUILayout.Foldout(showFOVSettings, "FOV Settings", true, EditorStyles.foldoutHeader);
if (showFOVSettings)
{
EditorGUILayout.Space(5);
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(fovSensitivityProp, new GUIContent("FOV 감도", "Shift + 드래그 시 FOV 변화 감도 (0.01 ~ 5)"));
EditorGUILayout.PropertyField(minFOVProp, new GUIContent("최소 FOV", "카메라 최소 FOV (줌인 한계)"));
EditorGUILayout.PropertyField(maxFOVProp, new GUIContent("최대 FOV", "카메라 최대 FOV (줌아웃 한계)"));
// 경고 표시
if (minFOVProp.floatValue >= maxFOVProp.floatValue)
{
EditorGUILayout.HelpBox("최소 FOV는 최대 FOV보다 작아야 합니다.", MessageType.Warning);
}
EditorGUILayout.HelpBox("Shift + 좌클릭/우클릭 드래그로 FOV를 조절합니다.\n위로 밀면 줌인(FOV 감소), 아래로 밀면 줌아웃(FOV 증가)", MessageType.Info);
EditorGUI.indentLevel--;
}
EditorGUILayout.EndVertical();
}
private void DrawRotationTargetSection()
{
EditorGUILayout.BeginVertical(sectionBoxStyle);
@ -254,11 +295,20 @@ public class CameraManagerEditor : Editor
EditorGUI.BeginDisabledGroup(!useBlendTransitionProp.boolValue);
EditorGUILayout.PropertyField(blendTimeProp, new GUIContent("블렌드 시간", "크로스 디졸브 소요 시간 (초)"));
EditorGUILayout.PropertyField(useRealtimeBlendProp, new GUIContent("실시간 블렌딩", "두 카메라를 동시에 렌더링하며 블렌딩 (성능 비용 증가)"));
EditorGUI.EndDisabledGroup();
if (useBlendTransitionProp.boolValue)
{
EditorGUILayout.HelpBox("URP Renderer에 CameraBlendRendererFeature가 추가되어 있어야 합니다.", MessageType.Info);
if (useRealtimeBlendProp.boolValue)
{
EditorGUILayout.HelpBox("실시간 모드: 블렌딩 중 두 카메라 모두 실시간으로 렌더링됩니다.\n성능 비용이 증가하지만 이전 카메라도 움직입니다.", MessageType.Info);
}
else
{
EditorGUILayout.HelpBox("스냅샷 모드: 이전 화면을 캡처한 후 블렌딩합니다.\n성능 효율적이지만 이전 화면이 정지합니다.", MessageType.Info);
}
EditorGUILayout.HelpBox("URP Renderer에 CameraBlendRendererFeature가 추가되어 있어야 합니다.", MessageType.Warning);
}
EditorGUI.indentLevel--;

View File

@ -9,6 +9,7 @@ public class InputHandler : MonoBehaviour
private bool isOrbitActive;
private bool isZoomActive;
private bool isCtrlRightZoomActive;
private bool isFOVActive;
// 현재 활성화된 입력 모드 (충돌 방지)
private InputMode currentMode = InputMode.None;
@ -23,6 +24,7 @@ public class InputHandler : MonoBehaviour
Orbit, // Alt + 우클릭 또는 Alt + 좌클릭
Zoom, // Ctrl + 좌클릭
CtrlRightZoom, // Ctrl + 우클릭
FOV, // Shift + 좌클릭 또는 Shift + 우클릭
Rotation, // 우클릭
Pan // 휠클릭
}
@ -57,6 +59,7 @@ public class InputHandler : MonoBehaviour
isOrbitActive = false;
isZoomActive = false;
isCtrlRightZoomActive = false;
isFOVActive = false;
isRightMouseHeld = false;
isMiddleMouseHeld = false;
lastScrollValue = 0f;
@ -85,6 +88,7 @@ public class InputHandler : MonoBehaviour
bool middleMouse = Input.GetMouseButton(2);
bool altKey = Input.GetKey(KeyCode.LeftAlt) || Input.GetKey(KeyCode.RightAlt);
bool ctrlKey = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
bool shiftKey = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
// 모든 마우스 버튼이 해제되면 모드 리셋
if (!leftMouse && !rightMouse && !middleMouse)
@ -107,6 +111,10 @@ public class InputHandler : MonoBehaviour
{
currentMode = InputMode.Zoom;
}
else if (shiftKey && (leftMouse || rightMouse))
{
currentMode = InputMode.FOV;
}
else if (rightMouse)
{
currentMode = InputMode.Rotation;
@ -121,6 +129,7 @@ public class InputHandler : MonoBehaviour
isOrbitActive = (currentMode == InputMode.Orbit) && (rightMouse || leftMouse) && altKey;
isZoomActive = (currentMode == InputMode.Zoom) && leftMouse && ctrlKey;
isCtrlRightZoomActive = (currentMode == InputMode.CtrlRightZoom) && rightMouse && ctrlKey;
isFOVActive = (currentMode == InputMode.FOV) && (leftMouse || rightMouse) && shiftKey;
isRightMouseHeld = (currentMode == InputMode.Rotation) && rightMouse;
isMiddleMouseHeld = (currentMode == InputMode.Pan) && middleMouse;
@ -137,6 +146,10 @@ public class InputHandler : MonoBehaviour
{
currentMode = InputMode.None;
}
else if (currentMode == InputMode.FOV && ((!leftMouse && !rightMouse) || !shiftKey))
{
currentMode = InputMode.None;
}
else if (currentMode == InputMode.Rotation && !rightMouse)
{
currentMode = InputMode.None;
@ -156,6 +169,7 @@ public class InputHandler : MonoBehaviour
public bool IsOrbitActive() => isOrbitActive;
public bool IsZoomActive() => isZoomActive;
public bool IsCtrlRightZoomActive() => isCtrlRightZoomActive;
public bool IsFOVActive() => isFOVActive;
/// <summary>
/// 현재 어떤 입력 모드도 활성화되지 않았는지 확인합니다.

View File

@ -727,7 +727,12 @@ namespace Streamingle.Prop.Editor
if (ext == ".prefab")
{
propInfo.prefabPaths.Add(assetPath);
// Prefab 폴더 안에 있는 프리팹만 수집 (Particle 등 다른 폴더 제외)
string parentFolder = Path.GetFileName(Path.GetDirectoryName(file));
if (parentFolder.Equals("Prefab", StringComparison.OrdinalIgnoreCase))
{
propInfo.prefabPaths.Add(assetPath);
}
}
else if (ext == ".glb" || ext == ".fbx" || ext == ".obj")
{

View File

@ -33,7 +33,7 @@ Material:
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _BlendAmount: 1
- _BlendAmount: 0.000075280666
m_Colors: []
m_BuildTextureStacks: []
m_AllowLocking: 1

View File

@ -37,14 +37,15 @@ Shader "Streamingle/CameraBlend"
{
float2 uv = input.texcoord;
// 현재 카메라 (Blitter에서 전달됨)
// 현재 카메라 (Blitter에서 전달됨) - 동일한 샘플러 사용
float4 currentColor = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv);
// 이전 카메라 (캡처된 텍스처)
float4 prevColor = SAMPLE_TEXTURE2D(_PrevTex, sampler_PrevTex, uv);
// 이전 카메라 (캡처된 텍스처) - 동일한 Linear 샘플러 사용
float4 prevColor = SAMPLE_TEXTURE2D(_PrevTex, sampler_LinearClamp, uv);
// 두 카메라를 블렌딩 (BlendAmount: 0 = 이전, 1 = 현재)
float4 finalColor = lerp(prevColor, currentColor, _BlendAmount);
// 리니어 공간에서 블렌딩 (BlendAmount: 1 = 이전, 0 = 현재)
// 이전 카메라(B)가 위에 덮이고 서서히 사라짐
float4 finalColor = lerp(currentColor, prevColor, _BlendAmount);
return finalColor;
}

View File

@ -12,8 +12,8 @@ public class CameraBlendRendererFeature : ScriptableRendererFeature
[System.Serializable]
public class Settings
{
// BeforeRenderingPostProcessing으로 변경 - 포스트 프로세싱 전에 블렌딩
public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
// AfterRenderingPostProcessing - 모든 포스트 프로세싱 적용 후 블렌딩
public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;
public Material blendMaterial;
}
@ -35,8 +35,9 @@ public class CameraBlendRendererFeature : ScriptableRendererFeature
if (renderingData.cameraData.cameraType != CameraType.Game)
return;
// 블렌딩이 활성화된 경우에만 패스 추가
if (CameraBlendController.IsBlending && CameraBlendController.BlendTexture != null)
// 캡처 요청이 있거나 블렌딩 중일 때 패스 추가
if (CameraBlendController.CaptureRequested ||
(CameraBlendController.IsBlending && CameraBlendController.BlendTexture != null))
{
renderer.EnqueuePass(blendPass);
}
@ -63,6 +64,12 @@ public class CameraBlendRenderPass : ScriptableRenderPass
public TextureHandle destinationTexture;
}
private class CapturePassData
{
public TextureHandle sourceTexture;
public RenderTexture targetTexture;
}
public CameraBlendRenderPass(CameraBlendRendererFeature.Settings settings)
{
this.settings = settings;
@ -75,16 +82,12 @@ public class CameraBlendRenderPass : ScriptableRenderPass
if (settings.blendMaterial == null)
return;
if (!CameraBlendController.IsBlending || CameraBlendController.BlendTexture == null)
return;
var resourceData = frameData.Get<UniversalResourceData>();
var cameraData = frameData.Get<UniversalCameraData>();
// 백버퍼인 경우 스킵 (BeforeRenderingPostProcessing에서는 일반적으로 false)
// 백버퍼인 경우 스킵
if (resourceData.isActiveTargetBackBuffer)
{
Debug.LogWarning("[CameraBlend] isActiveTargetBackBuffer - skipping");
return;
}
@ -93,10 +96,36 @@ public class CameraBlendRenderPass : ScriptableRenderPass
// source가 유효한지 확인
if (!source.IsValid())
{
Debug.LogWarning("[CameraBlend] source texture is not valid");
return;
}
// 캡처 요청 처리 - 현재 프레임(포스트 프로세싱 적용 후)을 캡처
if (CameraBlendController.CaptureRequested && CameraBlendController.BlendTexture != null)
{
// UnsafePass를 사용하여 RenderTexture에 직접 복사
using (var builder = renderGraph.AddUnsafePass<CapturePassData>("Camera Blend Capture", out var captureData, profilingSampler))
{
captureData.sourceTexture = source;
captureData.targetTexture = CameraBlendController.BlendTexture;
builder.UseTexture(source, AccessFlags.Read);
builder.AllowPassCulling(false);
builder.SetRenderFunc((CapturePassData data, UnsafeGraphContext context) =>
{
// NativeCommandBuffer를 통해 Blit 수행
var nativeCmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd);
nativeCmd.Blit(data.sourceTexture, data.targetTexture);
CameraBlendController.CaptureReady = true;
});
}
return; // 캡처만 하고 블렌딩은 다음 프레임부터
}
// 블렌딩이 활성화되지 않았으면 스킵
if (!CameraBlendController.IsBlending || CameraBlendController.BlendTexture == null)
return;
// cameraColorDesc 사용 - 카메라 색상 텍스처 설명자
var cameraTargetDesc = renderGraph.GetTextureDesc(resourceData.cameraColor);
cameraTargetDesc.name = "_CameraBlendDestination";
@ -158,6 +187,9 @@ public static class CameraBlendController
private static bool isBlending = false;
private static float blendAmount = 1f;
private static RenderTexture blendTexture;
private static bool captureRequested = false;
private static bool captureReady = false;
private static bool isRealtimeMode = false;
public static bool IsBlending
{
@ -177,6 +209,63 @@ public static class CameraBlendController
set => blendTexture = value;
}
public static bool CaptureRequested
{
get => captureRequested;
set => captureRequested = value;
}
public static bool CaptureReady
{
get => captureReady;
set => captureReady = value;
}
public static bool IsRealtimeMode
{
get => isRealtimeMode;
set => isRealtimeMode = value;
}
/// <summary>
/// 다음 프레임에서 현재 화면을 캡처하도록 요청합니다. (스냅샷 모드)
/// </summary>
public static void RequestCapture(RenderTexture targetTexture)
{
blendTexture = targetTexture;
captureRequested = true;
captureReady = false;
isRealtimeMode = false;
}
/// <summary>
/// 캡처가 완료된 후 블렌딩을 시작합니다. (스냅샷 모드)
/// BlendAmount = 1에서 시작 (이전 카메라 100% 보임)
/// </summary>
public static void StartBlendAfterCapture()
{
if (captureReady && blendTexture != null)
{
blendAmount = 1f;
isBlending = true;
captureRequested = false;
}
}
/// <summary>
/// 실시간 블렌딩을 시작합니다. (매 프레임 이전 카메라 렌더링)
/// BlendAmount = 1에서 시작 (이전 카메라 100% 보임)
/// </summary>
public static void StartRealtimeBlend(RenderTexture texture)
{
blendTexture = texture;
blendAmount = 1f;
isBlending = true;
isRealtimeMode = true;
captureRequested = false;
captureReady = false;
}
public static void StartBlend(RenderTexture texture)
{
blendTexture = texture;
@ -189,6 +278,9 @@ public static class CameraBlendController
isBlending = false;
blendAmount = 1f;
blendTexture = null;
captureRequested = false;
captureReady = false;
isRealtimeMode = false;
}
public static void Reset()

View File

@ -47,14 +47,29 @@ namespace WefLab
[Tooltip("Maximum donation amount (inclusive, -1 for unlimited)")]
public int maxAmount = -1;
[Header("Event Settings")]
[Tooltip("Number of times to repeat the event (minimum 1)")]
[Header("Repeat Settings")]
[Tooltip("Use amount-based repeat count (divide amount by amountPerRepeat)")]
public bool useAmountBasedRepeat = false;
[Tooltip("Amount of KRW per repeat (e.g., 1000 = 1 repeat per 1000 KRW)")]
[Min(1)]
public int repeatCount = 1;
public int amountPerRepeat = 1000;
[Tooltip("Minimum repeat count")]
[Min(1)]
public int minRepeatCount = 1;
[Tooltip("Maximum repeat count (0 = unlimited)")]
[Min(0)]
public int maxRepeatCount = 20;
[Tooltip("Fixed repeat count (used when useAmountBasedRepeat is false)")]
[Min(1)]
public int fixedRepeatCount = 1;
[Tooltip("Delay between each repeat in seconds (0 for immediate)")]
[Min(0)]
public float repeatDelay = 0f;
public float repeatDelay = 0.15f;
[Header("Event")]
[Tooltip("Unity event to trigger when donation amount is in range")]
@ -73,6 +88,29 @@ namespace WefLab
return true;
}
/// <summary>
/// Calculate repeat count based on donation amount
/// </summary>
public int GetRepeatCount(int amount)
{
if (!useAmountBasedRepeat)
{
return fixedRepeatCount;
}
// Calculate repeat count based on amount
int count = amount / amountPerRepeat;
// Apply min/max limits
count = Mathf.Max(count, minRepeatCount);
if (maxRepeatCount > 0)
{
count = Mathf.Min(count, maxRepeatCount);
}
return count;
}
}
/// <summary>
@ -89,6 +127,27 @@ namespace WefLab
[Tooltip("Donation event triggers based on amount ranges")]
public DonationEventTrigger[] donationTriggers = Array.Empty<DonationEventTrigger>();
[Header("Queue Settings")]
[Tooltip("Enable sequential processing of donations")]
public bool enableQueue = true;
[Tooltip("Delay between each donation alert (seconds)")]
[Min(0.1f)]
public float alertDelay = 3f;
[Tooltip("Maximum queue size (0 = unlimited)")]
[Min(0)]
public int maxQueueSize = 50;
// Queue state (visible in Inspector for debugging)
[Header("Queue Status (Read Only)")]
[SerializeField] private int queueCount = 0;
[SerializeField] private bool isProcessingQueue = false;
// Donation queue
private Queue<DonationData> donationQueue = new Queue<DonationData>();
private Coroutine queueProcessorCoroutine = null;
// Hidden connection info
[HideInInspector] public bool isConnected = false;
[HideInInspector] public string currentSid = "";
@ -532,7 +591,7 @@ namespace WefLab
timestamp = timestamp
};
TriggerDonationEvents(donation);
EnqueueDonation(donation);
}
/// <summary>
@ -580,8 +639,64 @@ namespace WefLab
Debug.Log($"[WefLab] Donation: {donorName} - {rawAmount} → {amount} KRW ({platform})");
// Trigger events
TriggerDonationEvents(donation);
// Enqueue donation for sequential processing
EnqueueDonation(donation);
}
/// <summary>
/// Enqueue donation for sequential processing
/// </summary>
private void EnqueueDonation(DonationData donation)
{
if (enableQueue)
{
// Check queue size limit
if (maxQueueSize > 0 && donationQueue.Count >= maxQueueSize)
{
Debug.LogWarning($"[WefLab] Queue full ({maxQueueSize}), dropping oldest donation");
donationQueue.Dequeue();
}
donationQueue.Enqueue(donation);
queueCount = donationQueue.Count;
Debug.Log($"[WefLab] Donation queued. Queue size: {queueCount}");
// Start queue processor if not running
if (!isProcessingQueue)
{
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
}
}
else
{
// Direct trigger without queue (original behavior)
TriggerDonationEvents(donation);
}
}
/// <summary>
/// Process donation queue sequentially with fixed delay
/// </summary>
private IEnumerator ProcessDonationQueue()
{
isProcessingQueue = true;
while (donationQueue.Count > 0)
{
var donation = donationQueue.Dequeue();
queueCount = donationQueue.Count;
// Trigger events
TriggerDonationEvents(donation);
Debug.Log($"[WefLab] Alert triggered. Waiting {alertDelay:F1}s. Remaining in queue: {donationQueue.Count}");
// Wait for fixed delay
yield return new WaitForSeconds(alertDelay);
}
isProcessingQueue = false;
queueProcessorCoroutine = null;
}
/// <summary>
@ -606,10 +721,11 @@ namespace WefLab
// Check if donation amount is in range
if (trigger.IsInRange(donation.amount))
{
Debug.Log($"[WefLab] Triggering: {trigger.triggerName} (Amount: {donation.amount}, Range: {trigger.minAmount}-{(trigger.maxAmount >= 0 ? trigger.maxAmount.ToString() : "unlimited")}, Repeat: {trigger.repeatCount}x)");
int repeatCount = trigger.GetRepeatCount(donation.amount);
Debug.Log($"[WefLab] Triggering: {trigger.triggerName} (Amount: {donation.amount}, Range: {trigger.minAmount}-{(trigger.maxAmount >= 0 ? trigger.maxAmount.ToString() : "unlimited")}, Repeat: {repeatCount}x)");
// Start coroutine to handle repeated invocation
StartCoroutine(InvokeRepeatedEvent(trigger, donation));
StartCoroutine(InvokeRepeatedEvent(trigger, donation, repeatCount));
triggeredAny = true;
}
}
@ -623,15 +739,15 @@ namespace WefLab
/// <summary>
/// Coroutine to invoke event multiple times with delay
/// </summary>
private IEnumerator InvokeRepeatedEvent(DonationEventTrigger trigger, DonationData donation)
private IEnumerator InvokeRepeatedEvent(DonationEventTrigger trigger, DonationData donation, int repeatCount)
{
for (int i = 0; i < trigger.repeatCount; i++)
for (int i = 0; i < repeatCount; i++)
{
// Invoke the event
trigger.onDonation?.Invoke(donation);
// Wait for delay before next repetition (skip on last iteration)
if (i < trigger.repeatCount - 1 && trigger.repeatDelay > 0)
if (i < repeatCount - 1 && trigger.repeatDelay > 0)
{
yield return new WaitForSeconds(trigger.repeatDelay);
}
@ -724,6 +840,81 @@ namespace WefLab
return isConnected && ws != null && ws.IsAlive;
}
/// <summary>
/// Get current queue count
/// </summary>
public int GetQueueCount()
{
return donationQueue.Count;
}
/// <summary>
/// Check if queue is being processed
/// </summary>
public bool IsQueueProcessing()
{
return isProcessingQueue;
}
/// <summary>
/// Clear all pending donations in queue
/// </summary>
public void ClearQueue()
{
donationQueue.Clear();
queueCount = 0;
Debug.Log("[WefLab] Queue cleared");
}
/// <summary>
/// Skip current alert and move to next in queue
/// </summary>
public void SkipCurrentAlert()
{
if (queueProcessorCoroutine != null)
{
StopCoroutine(queueProcessorCoroutine);
queueProcessorCoroutine = null;
}
if (donationQueue.Count > 0)
{
Debug.Log("[WefLab] Skipping to next alert");
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
}
else
{
isProcessingQueue = false;
Debug.Log("[WefLab] No more alerts in queue");
}
}
/// <summary>
/// Pause queue processing
/// </summary>
public void PauseQueue()
{
if (queueProcessorCoroutine != null)
{
StopCoroutine(queueProcessorCoroutine);
queueProcessorCoroutine = null;
isProcessingQueue = false;
Debug.Log("[WefLab] Queue paused");
}
}
/// <summary>
/// Resume queue processing
/// </summary>
public void ResumeQueue()
{
if (!isProcessingQueue && donationQueue.Count > 0)
{
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
Debug.Log("[WefLab] Queue resumed");
}
}
#endregion
}
}