Skip to content

Commit

Permalink
init
Browse files Browse the repository at this point in the history
  • Loading branch information
ottijp committed Nov 28, 2023
0 parents commit 1b75d93
Show file tree
Hide file tree
Showing 8 changed files with 298 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
22 changes: 22 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// swift-tools-version: 5.9

import PackageDescription

let package = Package(
name: "Confetti",
platforms: [
.iOS(.v15)
],
products: [
.library(
name: "Confetti",
targets: ["Confetti"]),
],
targets: [
.target(
name: "Confetti"),
.testTarget(
name: "ConfettiTests",
dependencies: ["Confetti"]),
]
)
158 changes: 158 additions & 0 deletions Sources/Confetti/ConfettiScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Foundation
import SpriteKit

class ConfettiScene: SKScene {
/// Confetti emission duration in seconds.
/// Duration for all confetto to fall isn't controllable. It's depends on confetto falling speed that are random.
var emissionDuration: Double!

// emission rate per seconds
private let emissionRate = 120.0
// max angle from straight down (270 degree) in radians
private let maxDirectionAngle = Double.pi / 4
// color: new year confetti https://www.schemecolor.com/new-year-confetti.php
private let colors = [SKColor(hex: "dc4353")!, SKColor(hex: "e8d4b4")!, SKColor(hex: "de45ba")!, SKColor(hex: "3a378b")!, SKColor(hex: "66d3e1")!]
// debug mode
private let debug = false
// label for debug
private var nodeCountLabel: SKLabelNode!

// timer for emission
private var emissionTimer: Timer?

convenience init(size: CGSize, emissionDuration: Double) {
self.init(size: size)
self.emissionDuration = emissionDuration
}

// generate random confetti size
private func randomSize() -> CGSize {
let longSide = [18.0, 22.0, 25.0].randomElement()!
let aspectRatio = [0.5, 0.4, 0.3].randomElement()!
return CGSize(width: longSide, height: longSide * aspectRatio)
}

// generate random confetti direction
private func randomDirection() -> Double {
Double.random(in: (Double.pi * 1.5 - maxDirectionAngle) ... (Double.pi * 1.5 + maxDirectionAngle))
}

// generate random confetti rotation speed
private func randomRotationSpeed() -> Double {
Double.random(in: 0.3...4.0) * [-1, 1].randomElement()!
}

// generate random confetti scale speed
private func randomScaleSpeed() -> Double {
Double.random(in: 0.8...1.3)
}

// generate random confetti color
private func randomColor() -> SKColor {
colors.randomElement()!
}

// generage random confetti initial position
private func randomInitialPosition(viewSize: CGSize) -> CGPoint {
let maxXMovement = viewSize.height * sin(maxDirectionAngle)
let x = Double.random(in: -maxXMovement ... (viewSize.width + maxXMovement))
// FIXME: 固定値(10)ではなくノードの回転を考慮した取りうる最大高さを計算して足す
let y = Double.random(in: (viewSize.height + 10) ... viewSize.height * 1.2)
return CGPoint(x: x, y: y)
}

override func update(_ currentTime: TimeInterval) {
// remove fallen confetto
for node in children {
if node.position.y < -50 {
node.removeAllActions()
node.removeFromParent()
}
}

// update label for debug
if debug {
nodeCountLabel.text = "confetti count: \(children.count - 1)"
}
}

override func didMove(to view: SKView) {
// make background transparent
backgroundColor = .clear
view.allowsTransparency = true
view.backgroundColor = .clear

// set confetti emission timer
emissionTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / emissionRate, repeats: true) { timer in
// create random confetti and add it to scene
let confettiNode = self.createConfettiNode(
color: self.randomColor(),
size: self.randomSize(),
direction: self.randomDirection(),
rotationSpeedX: self.randomRotationSpeed(),
rotationSpeedY: self.randomRotationSpeed(),
rotationSpeedZ: self.randomRotationSpeed(),
scaleSpeed: self.randomScaleSpeed())
confettiNode.position = self.randomInitialPosition(viewSize: view.frame.size)
self.addChild(confettiNode)
}

// finish emisison after `emissionDuration` sec
Timer.scheduledTimer(withTimeInterval: emissionDuration, repeats: false) { timer in
self.emissionTimer?.invalidate()
}

// put label for debug
if debug {
nodeCountLabel = SKLabelNode(text: "")
nodeCountLabel.position = CGPoint(x: 50, y: 50)
nodeCountLabel.fontColor = .blue
addChild(nodeCountLabel)
}
}

// create confetti node
private func createConfettiNode(color: SKColor, size: CGSize, direction: Double,
rotationSpeedX: Double, rotationSpeedY: Double, rotationSpeedZ: Double, scaleSpeed: Double) -> SKNode {

let node = SKShapeNode(path: .init(rect: CGRect(origin: .zero, size: size), transform: nil), centered: true)
node.fillColor = color
node.strokeColor = .clear

// wrapping node for x-rotation and y-rotation
let transformNode = SKTransformNode()
transformNode.addChild(node)

// x-rotation action
let rotationActionX = SKAction.customAction(withDuration: abs(rotationSpeedX)) { (node: SKNode, time: CGFloat) -> Void in
(node as! SKTransformNode).xRotation = (time / rotationSpeedX) * 2 * CGFloat(Double.pi)
}

// y-rotation action
let rotationActionY = SKAction.customAction(withDuration: abs(rotationSpeedY)) { (node: SKNode, time: CGFloat) -> Void in
(node as! SKTransformNode).yRotation = (time / rotationSpeedY) * 2 * CGFloat(Double.pi)
}

// z-rotation action
let rotationActionZ = SKAction.customAction(withDuration: abs(rotationSpeedZ)) { (node: SKNode, time: CGFloat) -> Void in
(node as! SKTransformNode).zRotation = (time / rotationSpeedZ) * 2 * CGFloat(Double.pi)
}

// move action
// biger(near) faster, smaller(far) slower
let moveSpeed = pow(size.width, 1.2) * 8
let moveAction = SKAction.move(by: CGVector(dx: cos(direction) * moveSpeed, dy: sin(direction) * moveSpeed), duration: 1.0)

// scale action
let scaleAction = SKAction.scale(by: scaleSpeed, duration: 1.0)

// add actions to node
transformNode.run(SKAction.repeatForever(rotationActionX))
transformNode.run(SKAction.repeatForever(rotationActionY))
transformNode.run(SKAction.repeatForever(rotationActionZ))
transformNode.run(SKAction.repeatForever(moveAction))
transformNode.run(SKAction.repeatForever(scaleAction))

return transformNode
}
}
29 changes: 29 additions & 0 deletions Sources/Confetti/ConfettiView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import SwiftUI
import SpriteKit

public struct ConfettiView: View {
// Confetti emission duration in seconds.
private var emissionDuration: Double

/// Creates a new confetti view.
/// - Parameters:
/// - emissionDuration: Confetti emission duration in seconds. Duration for all confetto to fall isn't controllable. It's depends on confetto falling speed that are random.
public init(emissionDuration: Double = 2.0) {
self.emissionDuration = emissionDuration
}

public var body: some View {
GeometryReader {
SpriteView(scene: ConfettiScene(size: $0.size, emissionDuration: emissionDuration), options: [.allowsTransparency])
.background(.clear)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ConfettiView(emissionDuration: 120.0)
}
}
22 changes: 22 additions & 0 deletions Sources/Confetti/UIColor+HexCode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import UIKit

extension UIColor {
/// create new object with hex string
convenience init?(hex: String, alpha: CGFloat = 1.0) {
// delete "#" prefix
let hexNorm = hex.hasPrefix("#") ? String(hex.dropFirst(1)) : hex

// scan each byte of RGB respectively
let scanner = Scanner(string: hexNorm)
var color: UInt64 = 0
if scanner.scanHexInt64(&color) {
let red = CGFloat((color & 0xFF0000) >> 16) / 255.0
let green = CGFloat((color & 0x00FF00) >> 8) / 255.0
let blue = CGFloat(color & 0x0000FF) / 255.0
self.init(red: red, green: green, blue: blue, alpha: alpha)
} else {
// invalid format
return nil
}
}
}
7 changes: 7 additions & 0 deletions Tests/ConfettiTests/ConfettiTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// import XCTest
// @testable import Confetti
//
// final class ConfettiTests: XCTestCase {
// func testExample() throws {
// }
// }
44 changes: 44 additions & 0 deletions Tests/ConfettiTests/UIColor+HexCodeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import XCTest
@testable import Confetti

final class UIColorHexCodeTests: XCTestCase {
var red, green, blue, alpha: CGFloat!

override func setUp() {
red = 0
green = 0
blue = 0
alpha = 0
}

func testWhite() throws {
let color = UIColor(hex: "FFFFFF")!
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
XCTAssertEqual(red, 1.0)
XCTAssertEqual(green, 1.0)
XCTAssertEqual(blue, 1.0)
XCTAssertEqual(alpha, 1.0)
}

func testBlack() throws {
let color = UIColor(hex: "000000")!
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
XCTAssertEqual(red, 0.0)
XCTAssertEqual(green, 0.0)
XCTAssertEqual(blue, 0.0)
XCTAssertEqual(alpha, 1.0)
}

func testSkyBlue() throws {
let color = UIColor(hex: "#3366cc", alpha: 0.9)!
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
XCTAssertEqual(red, 0.2)
XCTAssertEqual(green, 0.4)
XCTAssertEqual(blue, 0.8)
XCTAssertEqual(alpha, 0.9)
}

func testInvalidFormat() throws {
XCTAssertNil(UIColor(hex: "white"))
}
}

0 comments on commit 1b75d93

Please sign in to comment.