Files
StackChan/app/ios/Runner/View/StackChanRotaryRobot.swift
T
2026-04-27 12:16:53 +08:00

137 lines
4.8 KiB
Swift

//
// StackChanRotaryRobot.swift
// Runner
//
// Created by 鸿 on 2026/1/30.
//
import SceneKit
class StackChanRotaryRobot: NSObject, FlutterPlatformView, FlutterStreamHandler {
private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
private let planeNodeName = "expressionPlane"
private let sceneView: SCNView
func view() -> UIView {
return sceneView
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
return nil
}
init(
frame: CGRect,
viewId: Int64,
messenger: FlutterBinaryMessenger,
args: Any?
) {
self.sceneView = SCNView(frame: frame)
super.init()
setupSceneView()
setupInitialScene()
}
private func setupSceneView() {
sceneView.antialiasingMode = .multisampling4X
sceneView.autoenablesDefaultLighting = true
sceneView.allowsCameraControl = false
sceneView.backgroundColor = .clear
sceneView.isPlaying = true
}
private func setupInitialScene() {
guard let scene = SCNScene(named: "StackChanModel.scn") else {
print("Failed to load StackChanModel.scn")
return
}
scene.rootNode.eulerAngles = SCNVector3Zero
scene.rootNode.eulerAngles.x = -Float.pi / 2
scene.rootNode.position.y = scene.rootNode.position.y + 25
scene.rootNode.position.z = scene.rootNode.position.z - 45
let clampedPitch = max(0, min(900, 200))
let pitchRatio = Float(clampedPitch) / 900.0
let pitchAngle = -Float.pi / 2 * (1 + pitchRatio)
scene.rootNode.eulerAngles.x = pitchAngle
if let rootNode = scene.rootNode.childNodes.first {
setupRobotHierarchy(rootNode: rootNode, scene: scene)
}
sceneView.scene = scene
}
private func setupRobotHierarchy(rootNode: SCNNode, scene: SCNScene) {
guard let foundation = rootNode.childNode(withName: "_00_stackchan450_3", recursively: false),
let centralComponent = rootNode.childNode(withName: "_00_stackchan450_2", recursively: false),
let head = rootNode.childNode(withName: "_00_stackchan450_1", recursively: false) else {
return
}
let yawAxis = SCNNode()
yawAxis.name = "yawAxis"
let centralWorldPos = centralComponent.worldPosition
yawAxis.worldPosition.z = centralWorldPos.z + 15
foundation.addChildNode(yawAxis)
let centralWorldTransform = centralComponent.worldTransform
yawAxis.addChildNode(centralComponent)
centralComponent.setWorldTransform(centralWorldTransform)
// Setup pitch axis for head movement
let headWorldTransform = head.worldTransform
let pitchAxis = SCNNode()
pitchAxis.name = "pitchAxis"
pitchAxis.worldPosition.z = pitchAxis.worldPosition.z - 20
centralComponent.addChildNode(pitchAxis)
pitchAxis.addChildNode(head)
head.setWorldTransform(headWorldTransform)
// Add expression plane to head
addExpressionPlane(to: head)
//
let rotateAction = SCNAction.rotateBy(x: 0, y: CGFloat(2 * Double.pi), z: 0, duration: 5)
let repeatAction = SCNAction.repeatForever(rotateAction)
scene.rootNode.runAction(repeatAction)
}
private func addExpressionPlane(to head: SCNNode) {
let plane = SCNPlane(width: 42, height: 32)
let magnification: CGFloat = 5
let size = CGSize(width: magnification * plane.width, height: magnification * plane.height)
expressionLayer.frame = CGRect(origin: .zero, size: size)
expressionLayer.setNeedsDisplay()
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
let material = SCNMaterial()
material.diffuse.contents = newImage
plane.materials = [material]
let planeNode = SCNNode(geometry: plane)
planeNode.name = planeNodeName
planeNode.position = head.position
planeNode.position.z = planeNode.position.z - 4.5
head.addChildNode(planeNode)
}
private func expressionRenderer() -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = UIScreen.main.scale
format.opaque = false
return UIGraphicsImageRenderer(
size: expressionLayer.bounds.size,
format: format
)
}
}