Files
StackChan/app/ios/Runner/View/StackChanRobot.swift

491 lines
17 KiB
Swift

/*
SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD
SPDX-License-Identifier: MIT
*/
//
// StackChanRobot.swift
// Runner
//
// Created by on 2026/1/30.
//
import SceneKit
class StackChanRobot: NSObject, FlutterPlatformView, FlutterStreamHandler {
private let sceneView: SCNView
private let methodChannel: FlutterMethodChannel
private var currentDanceData: DanceData?
private let expressionLayer = ExpressionLayer(data: ExpressionData(leftEye: ExpressionItem(weight: 100), rightEye: ExpressionItem(weight: 100), mouth: ExpressionItem()))
private let planeNodeName = "expressionPlane"
private let rotateKey = "autoRotate"
private var topLook: Bool = false
private let methodChannelName = "com.stackchan.robot.method"
private var defaultCameraNode: SCNNode?
private var topCameraNode: SCNNode?
init(
frame: CGRect,
viewId: Int64,
messenger: FlutterBinaryMessenger,
args: Any?
) {
self.sceneView = SCNView(frame: frame)
self.methodChannel = FlutterMethodChannel(
name: methodChannelName + "_\(viewId)",
binaryMessenger: messenger
)
super.init()
methodChannel.setMethodCallHandler(handleMethodCall)
setupSceneView()
setupInitialScene()
}
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
}
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "updateDanceData":
if let json = call.arguments as? String {
updateDanceData(from: json)
result(nil)
} else {
result(FlutterError(
code: "INVALID_ARGS",
message: "Expected JSON string",
details: nil
))
}
case "setTopLook":
if let topLook = call.arguments as? Bool {
self.topLook = topLook
setupCamera()
result(nil)
} else {
result(FlutterError(
code: "INVALID_ARGS",
message: "Expected boolean value",
details: nil
))
}
case "setAllowsCameraControl":
if let allowsControl = call.arguments as? Bool {
sceneView.allowsCameraControl = allowsControl
result(nil)
} else {
result(FlutterError(
code: "INVALID_ARGS",
message: "Expected boolean value",
details: nil
))
}
case "dispose":
cleanup()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
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 {
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 - 35
if let rootNode = scene.rootNode.childNodes.first {
setupRobotHierarchy(rootNode: rootNode, scene: scene)
if let defaultCamera = scene.rootNode.childNode(withName: "camera", recursively: true) {
defaultCameraNode = defaultCamera
} else {
defaultCameraNode = createDefaultCameraNode(rootNode: rootNode)
rootNode.addChildNode(defaultCameraNode!)
}
sceneView.pointOfView = defaultCameraNode
}
sceneView.scene = scene
}
private func createDefaultCameraNode(rootNode: SCNNode) -> SCNNode {
let cameraNode = SCNNode()
cameraNode.name = "defaultCamera"
let camera = SCNCamera()
camera.zFar = 200
cameraNode.camera = camera
cameraNode.position = SCNVector3(x: 0, y: -100, z: 0)
let lookAtConstraint = SCNLookAtConstraint(target: rootNode)
lookAtConstraint.isGimbalLockEnabled = true
cameraNode.constraints = [lookAtConstraint]
return cameraNode
}
private func createTopCameraNode(rootNode: SCNNode) -> SCNNode {
let cameraNode = SCNNode()
cameraNode.name = "leftTopCamera"
let camera = SCNCamera()
camera.zFar = 300
cameraNode.camera = camera
cameraNode.position = SCNVector3(x: 0, y: -100, z: 70)
let lookAtConstraint = SCNLookAtConstraint(target: rootNode)
lookAtConstraint.isGimbalLockEnabled = true
cameraNode.constraints = [lookAtConstraint]
return cameraNode
}
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)
}
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 material = SCNMaterial()
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 setupCamera() {
guard let scene = sceneView.scene,
let rootNode = scene.rootNode.childNodes.first else {
return
}
rootNode.childNodes.filter { $0.name == "leftTopCamera" }.forEach { $0.removeFromParentNode() }
if topLook {
if topCameraNode == nil {
topCameraNode = createTopCameraNode(rootNode: rootNode)
rootNode.addChildNode(topCameraNode!)
}
sceneView.pointOfView = topCameraNode
} else {
if defaultCameraNode == nil {
defaultCameraNode = createDefaultCameraNode(rootNode: rootNode)
rootNode.addChildNode(defaultCameraNode!)
}
sceneView.pointOfView = defaultCameraNode
}
}
private func updateDanceData(from json: String) {
guard let danceData = DanceData.from(jsonString: json) else {
return
}
currentDanceData = danceData
DispatchQueue.main.async {
self.applyDanceData(danceData)
}
}
private func applyDanceData(_ data: DanceData) {
guard let scene = sceneView.scene,
let rootNode = scene.rootNode.childNodes.first else {
return
}
// Update servo positions
updateServos(rootNode: rootNode, data: data)
// Update RGB color
updateRGBColor(rootNode: rootNode, data: data)
// Update expression
updateExpression(data: data)
}
private func updateServos(rootNode: SCNNode, data: DanceData) {
if let yawAxis = rootNode.childNode(withName: "yawAxis", recursively: true),
let pitchAxis = rootNode.childNode(withName: "pitchAxis", recursively: true) {
yawAxis.removeAction(forKey: rotateKey)
// Update yaw (rotation around Y axis)
if data.yawServo.rotate == 0 {
let clampedYaw = max(-128, min(128, data.yawServo.angle / 10))
let yawRadians = Float(clampedYaw) * Float.pi / 180.0
yawAxis.rotation = SCNVector4(0, 1, 0, yawRadians)
} else {
let rotateSpeed = max(-100, min(100, data.yawServo.rotate / 10))
let radiansPerSecond = Float(rotateSpeed) / 100.0 * Float.pi * 2
let rotateAction = SCNAction.customAction(duration: .infinity) { node, _ in
let deltaTime: Float = 1.0 / 60.0
node.eulerAngles.y += radiansPerSecond * deltaTime
}
yawAxis.runAction(rotateAction, forKey: rotateKey)
}
// Update pitch (head tilt)
let clampedPitch = max(0, min(90, data.pitchServo.angle / 10))
let pitchRadians = Float(clampedPitch) * Float.pi / 180.0
pitchAxis.eulerAngles.x = -pitchRadians
}
}
private func updateRGBColor(rootNode: SCNNode, data: DanceData) {
rootNode.enumerateChildNodes { node, _ in
if let materials = node.geometry?.materials {
for material in materials {
if material.name == "MTL12" {
if let color = UIColor(hex: data.leftRgbColor) {
material.emission.contents = color
}
break
}
}
}
}
}
private func updateExpression(data: DanceData) {
guard let planeNode = sceneView.scene?.rootNode.childNode(withName: planeNodeName, recursively: true),
let plane = planeNode.geometry as? SCNPlane else {
return
}
let expressionData = ExpressionData(
leftEye: data.leftEye,
rightEye: data.rightEye,
mouth: data.mouth
)
expressionLayer.data = expressionData
expressionLayer.setNeedsDisplay()
let newImage = expressionRenderer().image { ctx in
self.expressionLayer.render(in: ctx.cgContext)
}
plane.firstMaterial?.diffuse.contents = newImage
}
private func expressionRenderer() -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = UIScreen.main.scale
format.opaque = false
return UIGraphicsImageRenderer(
size: expressionLayer.bounds.size,
format: format
)
}
private func cleanup() {
// Stop all animations
sceneView.scene?.rootNode.childNodes.forEach { node in
node.removeAllActions()
node.removeFromParentNode()
}
// Clean up scene
sceneView.scene = nil
sceneView.isPlaying = false
// Remove method call handler
methodChannel.setMethodCallHandler(nil)
}
deinit {
cleanup()
}
}
class ExpressionLayer: CALayer {
var data: ExpressionData
let reverse: Bool
init(data: ExpressionData, reverse: Bool = false) {
self.data = data
self.reverse = reverse
super.init()
self.contentsScale = UIScreen.main.scale
self.setNeedsDisplay()
}
override init(layer: Any) {
if let layer = layer as? ExpressionLayer {
self.data = layer.data
self.reverse = layer.reverse
} else {
self.data = ExpressionData(leftEye: ExpressionItem(), rightEye: ExpressionItem(), mouth: ExpressionItem())
self.reverse = false
}
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(in ctx: CGContext) {
let rect = self.frame
// Background
ctx.setFillColor(UIColor.black.withAlphaComponent(0.7).cgColor)
ctx.fill(rect)
let eyeSize = rect.width / 10
func drawEye(_ item: ExpressionItem, at point: CGPoint) {
// Calculate scale based on size (-100 to 100)
// 0 -> 1.0 (keep current size)
// -100 -> 0.5 (half normal radius)
// 100 -> 2.0 (double normal radius)
let clampedSize = max(-100, min(100, item.size))
let sizeScale: CGFloat
if clampedSize >= 0 {
sizeScale = 1.0 + CGFloat(clampedSize) / 100.0
} else {
sizeScale = 1.0 + CGFloat(clampedSize) / 200.0
}
let scaledEyeSize = eyeSize * sizeScale
let visibleHeight = scaledEyeSize * (CGFloat(item.weight) / 100)
let centerX = point.x + CGFloat(item.x / 10) + eyeSize / 2
let centerY = point.y + CGFloat(item.y / 10) + eyeSize / 2
let eyeRect = CGRect(
x: centerX - scaledEyeSize / 2,
y: centerY - scaledEyeSize / 2,
width: scaledEyeSize,
height: scaledEyeSize
)
ctx.saveGState()
// Rotation
let rotationDegrees = CGFloat(item.rotation) / 10.0
let center = CGPoint(x: eyeRect.midX, y: eyeRect.midY)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: rotationDegrees * .pi / 180)
ctx.translateBy(x: -center.x, y: -center.y)
// Clip height
let maskRect = CGRect(
x: eyeRect.minX,
y: eyeRect.maxY - visibleHeight,
width: scaledEyeSize,
height: visibleHeight
)
ctx.addRect(maskRect)
ctx.clip()
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillEllipse(in: eyeRect)
ctx.restoreGState()
}
let eyeY = (rect.height * 0.4) - (eyeSize / 2)
let leftEyePoint = CGPoint(x: (rect.width / 4) - (eyeSize / 2), y: eyeY)
let rightEyePoint = CGPoint(x: (rect.width / 4 * 3) - (eyeSize / 2), y: eyeY)
if reverse {
// Temporarily swap rotation angles
let leftEyeRotation = data.leftEye.rotation
let rightEyeRotation = data.rightEye.rotation
var leftEye = data.leftEye
var rightEye = data.rightEye
leftEye.rotation = rightEyeRotation
rightEye.rotation = leftEyeRotation
drawEye(leftEye, at: rightEyePoint)
drawEye(rightEye, at: leftEyePoint)
} else {
drawEye(data.leftEye, at: leftEyePoint)
drawEye(data.rightEye, at: rightEyePoint)
}
// Draw mouth
ctx.saveGState()
let width = rect.width * 0.3 - CGFloat(data.mouth.weight / 10)
let height = 3 + CGFloat(data.mouth.weight) * 0.2
let x = ((rect.width - width) / 2) + CGFloat(data.mouth.x / 10)
let y = (rect.height * 0.65) + CGFloat(data.mouth.y / 10)
let rotationDegrees = CGFloat(data.mouth.rotation) / 10.0
let center = CGPoint(x: x + width / 2, y: y + height / 2)
ctx.translateBy(x: center.x, y: center.y)
ctx.rotate(by: rotationDegrees * .pi / 180)
ctx.translateBy(x: -center.x, y: -center.y)
let mouthRect = CGRect(x: x, y: y, width: width, height: height)
let mouthPath = UIBezierPath(roundedRect: mouthRect, cornerRadius: height / 2)
ctx.addPath(mouthPath.cgPath)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillPath()
ctx.restoreGState()
}
}