Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions PiggyEscape/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
*.xcodeproj
*.xcworkspace/
.build/
Derived/
DerivedData/
Empty file.
Binary file not shown.
Binary file added PiggyEscape/PiggyEscape/Resources/Piggy.usdc
Binary file not shown.
Binary file added PiggyEscape/PiggyEscape/Resources/Wood_Color.usdc
Binary file not shown.
48 changes: 48 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/AssetLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import SceneKit
import UIKit

/// 3D 모델 파일(.usdz/.usdc/.usda/.obj)을 불러오는 도우미 모음.
/// 모델을 못 찾으면 단순한 정육면체(복셀) 박스로 대신 채운다("폴백").
enum AssetLoader {
@MainActor
static func object(named name: String, fallback: @MainActor () -> SCNNode) -> SCNNode {
object(named: name) ?? fallback()
}

@MainActor
static func object(named name: String) -> SCNNode? {
for ext in ["usdz", "usdc", "usda"] {
if let url = Bundle.main.url(forResource: name, withExtension: ext),
let scene = try? SCNScene(url: url, options: nil) {
return wrap(scene)
}
}
if let url = Bundle.main.url(forResource: name, withExtension: "obj"),
let scene = try? SCNScene(url: url, options: [
.convertToYUp: true,
.createNormalsIfAbsent: true
]) {
return wrap(scene)
}
return nil
}

private static func wrap(_ scene: SCNScene) -> SCNNode {
let node = SCNNode()
scene.rootNode.childNodes.forEach { node.addChildNode($0.clone()) }
return node
}

static func voxelBox(width: CGFloat, height: CGFloat, length: CGFloat,
color: UIColor) -> SCNNode {
let box = SCNBox(width: width * 0.96,
height: height * 0.96,
length: length * 0.96,
chamferRadius: 0.02)
let mat = SCNMaterial()
mat.diffuse.contents = color
mat.lightingModel = .blinn
box.materials = [mat]
return SCNNode(geometry: box)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import SceneKit

/// SwiftUI ↔ SceneKit(SCNView)을 잇는 다리. 방·돼지·가짜 소파를 씬에 담고,
/// 탭하면 돼지가 가짜 소파로 "숨는" 하나의 인터랙션만 처리한다.
struct ClosedWorldSceneView: UIViewRepresentable {
func makeUIView(context: Context) -> SCNView {
let view = SCNView()
let scene = SCNScene()

scene.rootNode.addChildNode(RoomBuilder.build())
let pig = PigPlacement.makePigNode()
scene.rootNode.addChildNode(pig)
scene.rootNode.addChildNode(FakeSofa.makeSofaNode())

// 카메라는 방 안쪽(roomDepth/2 = 2보다 작은 z)에 둔다 — 방 밖에 두면
// Wall_1(z=+2)에 가려 내부가 전혀 보이지 않는다.
let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0, 2, 1.7)
cameraNode.look(at: SCNVector3(0, 0.3, -0.5))
scene.rootNode.addChildNode(cameraNode)

let light = SCNNode()
light.light = SCNLight()
light.light?.type = .omni
light.position = SCNVector3(0, 3, 2)
scene.rootNode.addChildNode(light)

view.scene = scene
// pointOfView가 비어 있으면 SceneKit이 전체 씬을 자동으로 프레이밍하는
// 기본 카메라를 대신 사용한다 — 위에서 공들여 배치한 cameraNode는
// 렌더링에 전혀 쓰이지 않고 무시된다. 반드시 명시적으로 지정해야 한다.
view.pointOfView = cameraNode
view.allowsCameraControl = true
view.autoenablesDefaultLighting = true

context.coordinator.pigNode = pig
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap))
view.addGestureRecognizer(tap)

return view
}

func updateUIView(_ uiView: SCNView, context: Context) {}

func makeCoordinator() -> Coordinator { Coordinator() }

final class Coordinator: NSObject {
var pigNode: SCNNode?

@objc func handleTap() {
pigNode?.runAction(HideAction.makeMoveAction())
}
}
}
21 changes: 21 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/FakeSofa.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SceneKit
import UIKit

/// "가짜 소파" — 개발자가 코드로 선언한 숨는 지점. 실제 방의 진짜 소파와는
/// 아무 관계가 없다. 스텝 5의 "숨어봐" 인터랙션이 이동시키는 목적지가 바로 이 좌표다.
enum FakeSofa {
static let hardcodedPosition = SCNVector3(1.2, 0, -1.2)
/// 방(벽 높이 2.5m)과 돼지(0.6m) 사이, "작은 가구 한 점" 정도의 눈대중 높이.
private static let standardHeight: Float = 0.45

@MainActor
static func makeSofaNode() -> SCNNode {
let model = AssetLoader.object(named: "Wood_Color") {
AssetLoader.voxelBox(width: 0.8, height: 0.4, length: 0.5, color: .brown)
}
SceneKitGeometry.normalize(model, toHeight: standardHeight)
model.name = "FakeSofa"
model.position = hardcodedPosition
return model
}
}
10 changes: 10 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/HideAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SceneKit

/// "숨어봐" 인터랙션의 핵심: 하드코딩된 가짜 소파 좌표로 이동하는 액션 하나.
/// 이 액션은 실제 방에 있는 진짜 소파가 어디 있든 상관하지 않는다 —
/// 목적지는 오직 FakeSofa.hardcodedPosition, 즉 개발자가 선언한 좌표뿐이다.
enum HideAction {
static func makeMoveAction() -> SCNAction {
.move(to: FakeSofa.hardcodedPosition, duration: 0.5)
}
}
23 changes: 23 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/NodeInspector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import SceneKit

/// 노드 하나를 들여다보고, 생김새(geometry)·물리(physicsBody)·행동(action)이
/// 전부 같은 SCNNode 객체 위에 얹혀 있다는 걸 텍스트로 보여준다.
/// RealityKit의 ECS는 이 세 가지를 각각 다른 Component로 분리하지만,
/// SceneKit은 분리하지 않는다 — 이걸 실행 결과로 확인하기 위한 디버그 도구.
enum NodeInspector {
static func describe(_ node: SCNNode) -> [String] {
var lines: [String] = []

if let geometry = node.geometry {
lines.append("geometry: \(type(of: geometry))")
}
if let physicsBody = node.physicsBody {
lines.append("physicsBody: type=\(physicsBody.type.rawValue)")
}
for key in node.actionKeys {
lines.append("action[\(key)]: \(node.action(forKey: key).map(String.init(describing:)) ?? "nil")")
}

return lines
}
}
40 changes: 40 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/PigPlacement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import SceneKit

/// 돼지를 하드코딩된 좌표에 배치한다. 이 좌표는 개발자가 정한 것일 뿐,
/// 방 안의 어떤 실제 기준(가구 위치 등)과도 연결되어 있지 않다.
enum PigPlacement {
static let hardcodedPosition = SCNVector3(0, 0, 0)
private static let standardHeight: Float = 0.6

@MainActor
static func makePigNode() -> SCNNode {
let pig = SCNNode()
let model = SCNNode()
let art: SCNNode

if let bundledModel = AssetLoader.object(named: "Piggy") {
// Piggy.usdc는 Blender의 Z-up 좌표계로 만든 모델이다. SceneKit의 y-up 세계에
// 세워 주고, 화면을 향하도록 roll을 보정한다.
bundledModel.eulerAngles = SCNVector3(Float.pi / 2, 0, Float.pi)
art = bundledModel
} else {
art = AssetLoader.voxelBox(width: 0.4, height: 0.4, length: 0.6, color: .systemPink)
}
model.addChildNode(art)

// 바깥 `pig`은 위치와 행동을 맡고, 안쪽 `model`은 에셋의 회전·크기·바닥 정렬만
// 맡는다. 그래서 나중에 이동 액션을 실행해도 모델 보정값이 섞이지 않는다.
SceneKitGeometry.normalize(model, toHeight: standardHeight)
pig.addChildNode(model)

let (lo, hi) = SceneKitGeometry.boundingBox(of: pig)
model.position = SCNVector3(
-(lo.x + hi.x) / 2,
-lo.y,
-(lo.z + hi.z) / 2
)
pig.name = "Piggy"
pig.position = hardcodedPosition
return pig
}
}
74 changes: 74 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/RoomBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import SceneKit
import UIKit

/// 방을 짓는 빌더. 이 세계에 있는 모든 것 — 벽이든 바닥이든 —은
/// 여기 코드로 써넣은 것만 존재한다. 좌표 원점(0,0,0)도 개발자가 임의로 선언한 것.
enum RoomBuilder {
static let roomWidth: Float = 4
static let roomDepth: Float = 4
static let wallHeight: Float = 2.5
private static let wallThickness: Float = 0.1
private static let floorHeight: Float = 0.1

@MainActor
static func build() -> SCNNode {
let room = SCNNode()
room.name = "Room"
room.position = SCNVector3(0, 0, 0) // 좌표 원점을 여기서 임의로 선언한다

room.addChildNode(makeFloor())

let wallSpecs: [(name: String, position: SCNVector3, eulerY: Float, width: Float)] = [
("Wall_0", SCNVector3(0, wallHeight / 2, -roomDepth / 2), 0, roomWidth),
("Wall_1", SCNVector3(0, wallHeight / 2, roomDepth / 2), 0, roomWidth),
("Wall_2", SCNVector3(-roomWidth / 2, wallHeight / 2, 0), .pi / 2, roomDepth),
("Wall_3", SCNVector3(roomWidth / 2, wallHeight / 2, 0), .pi / 2, roomDepth)
]
for spec in wallSpecs {
let wall = AssetLoader.voxelBox(width: CGFloat(spec.width), height: CGFloat(wallHeight),
length: CGFloat(wallThickness), color: UIColor(white: 0.95, alpha: 1))
wall.name = spec.name
wall.position = spec.position
wall.eulerAngles = SCNVector3(0, spec.eulerY, 0)
room.addChildNode(wall)
}

return room
}

/// `Ground_Color`은 Blender의 Z-up 좌표계로 만들어진 세로 타일이다.
/// 모델을 먼저 눕히고 실제 크기를 잰 다음, 방의 x/z 면적과 얇은 y 두께에 맞춘다.
/// 이 순서가 바뀌면 바닥이 세로로 남아 카메라 시야를 가릴 수 있다.
@MainActor
private static func makeFloor() -> SCNNode {
let floor = SCNNode()
floor.name = "Floor"

guard let ground = AssetLoader.object(named: "Ground_Color") else {
let fallback = AssetLoader.voxelBox(width: CGFloat(roomWidth), height: CGFloat(floorHeight),
length: CGFloat(roomDepth), color: UIColor(white: 0.8, alpha: 1))
fallback.position = SCNVector3(0, floorHeight / 2, 0)
floor.addChildNode(fallback)
return floor
}

ground.eulerAngles = SCNVector3(-Float.pi / 2, 0, 0)
floor.addChildNode(ground)

let (lo, hi) = SceneKitGeometry.boundingBox(of: floor)
let width = hi.x - lo.x
let height = hi.y - lo.y
let depth = hi.z - lo.z
guard width > 0.0001, height > 0.0001, depth > 0.0001 else { return floor }

// `floor`는 회전된 모델을 감싸는 컨테이너다. 여기서 스케일해야 월드의 x/y/z축을
// 각각 방의 폭/두께/깊이에 맞출 수 있다.
floor.scale = SCNVector3(roomWidth / width, floorHeight / height, roomDepth / depth)
floor.position = SCNVector3(
-(lo.x + hi.x) / 2 * floor.scale.x,
-lo.y * floor.scale.y,
-(lo.z + hi.z) / 2 * floor.scale.z
)
return floor
}
}
49 changes: 49 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ClosedWorld/SceneKitGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import SceneKit

/// 로드된 3D 모델은 원본 제작 스케일이 제각각이라, 방 좌표계(미터 단위)에
/// 맞춰 다시 정규화해야 한다. `PigPlacement`가 먼저 쓰던 "바운딩 박스를 재고
/// 목표 크기에 맞춰 균일하게 스케일한다" 패턴을 여러 자리(돼지, 가짜 소파,
/// 바닥)에서 공유하기 위한 도우미.
enum SceneKitGeometry {
/// 노드(및 하위 계층 전체)의 로컬 좌표계 기준 바운딩 박스.
static func boundingBox(of node: SCNNode) -> (SCNVector3, SCNVector3) {
var lo = SCNVector3(Float.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude)
var hi = SCNVector3(-Float.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude)
node.enumerateHierarchy { child, _ in
guard let geometry = child.geometry else { return }
let (minB, maxB) = geometry.boundingBox
for x in [minB.x, maxB.x] {
for y in [minB.y, maxB.y] {
for z in [minB.z, maxB.z] {
let p = child.convertPosition(SCNVector3(x, y, z), to: node)
lo = SCNVector3(min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))
hi = SCNVector3(max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))
}
}
}
}
return (lo, hi)
}

/// 세로(y) 치수를 재서 `targetHeight`에 맞도록 균일하게(x/y/z 동일 비율) 스케일한다.
/// 돼지·가짜 소파처럼 "세워서 놓는" 소품에 쓴다.
@MainActor
static func normalize(_ node: SCNNode, toHeight targetHeight: Float) {
let (lo, hi) = boundingBox(of: node)
let height = hi.y - lo.y
guard height > 0.0001 else { return }
let scale = targetHeight / height
node.scale = SCNVector3(scale, scale, scale)
}

/// 바닥면(x/z) 중 더 큰 치수를 재서 `targetWidth`에 맞도록 균일하게 스케일한다.
/// 바닥 타일처럼 "가로로 깔아 놓는" 소품에 쓴다.
@MainActor
static func normalize(_ node: SCNNode, toFootprintWidth targetWidth: Float) {
let (lo, hi) = boundingBox(of: node)
let footprint = max(hi.x - lo.x, hi.z - lo.z)
guard footprint > 0.0001 else { return }
let scale = targetWidth / footprint
node.scale = SCNVector3(scale, scale, scale)
}
}
8 changes: 8 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import SwiftUI

struct ContentView: View {
var body: some View {
ClosedWorldSceneView()
.ignoresSafeArea()
}
}
10 changes: 10 additions & 0 deletions PiggyEscape/PiggyEscape/Sources/PiggyEscapeApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

@main
struct PiggyEscapeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@Tutorials(name: "씬킷에서 리얼리티킷으로") {
@Intro(title: "갇힌 캐릭터의 탈출") {
개발자가 만든 가짜 세계에 갇혀 살던 돼지가 균열을 뚫고 진짜 세계로 도망친다.
이 튜토리얼은 그 탈출과 술래잡기를 직접 만들어보며,
SceneKit이 가짜로 짓는 세계와 RealityKit이 진짜로 읽는 세계의 차이를 체험한다.
}

@Chapter(name: "Chapter 1: 갇힌 세계") {
SceneKit만으로 방을 짓고 돼지를 그 안에 가둔다.
이 세계에 있는 모든 것은 코드로 선언한 것의 총합일 뿐이라는 걸,
"숨어봐"가 실패하는 순간으로 직접 확인한다.

@TutorialReference(tutorial: "doc:01-ClosedWorld")
}
}
Loading