Last active
November 4, 2025 23:30
-
-
Save Koshimizu-Takehito/0ddf118c3a9f99eebefaf06bff94e68d to your computer and use it in GitHub Desktop.
Conway's Game of Life(コンウェイのライフゲーム)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| // MARK: - GameOfLifeScreen | |
| /// Conway のライフゲーム画面(メタル描画 + コントロール)。 | |
| struct GameOfLifeScreen: View { | |
| @State private var viewModel = GameOfLifeViewModel() | |
| var body: some View { | |
| VStack(spacing: 24) { | |
| Text("Number of Cycles: \(viewModel.numberOfCycles)") | |
| .font(.headline) | |
| .foregroundStyle(.secondary) | |
| GeometryReader { geometry in | |
| // Metal 描画 | |
| MetalGameOfLifeView(viewModel: viewModel) | |
| .animation(.default, value: viewModel.scale) | |
| .scaledToFit() | |
| .overlay(alignment: .topLeading) { | |
| // デバッグ表示 | |
| HStack(spacing: 12) { | |
| Text("Size: \(viewModel.size)×\(viewModel.size)") | |
| Text("Interval: \(Int(viewModel.cycleIntervalMS)) ms") | |
| Text(viewModel.isRunning ? "Running" : "Stopped") | |
| } | |
| .monospaced() | |
| .font(.caption) | |
| .fontWeight(.semibold) | |
| .padding(8) | |
| .background(.thinMaterial, in: .rect(cornerRadius: 8)) | |
| .padding(8) | |
| } | |
| .simultaneousGesture(magnifyGesture) | |
| .simultaneousGesture(panGesture(size: geometry.size)) | |
| } | |
| .scaledToFit() | |
| // 操作パネル | |
| controls | |
| .padding() | |
| } | |
| .monospacedDigit() | |
| .navigationTitle("Conway's Game of Life") | |
| .toolbar(content: toolbar) | |
| .toolbarTitleDisplayMode(.inlineLarge) | |
| .tint(.blue) | |
| } | |
| // MARK: Gestures (継続点を考慮) | |
| /// ピンチズーム用ジェスチャ(継続点: `lastScale`)。 | |
| private var magnifyGesture: some Gesture { | |
| MagnifyGesture() | |
| .onChanged { value in | |
| // 継続点: lastScale * magnification | |
| viewModel.scale = max(1.0, viewModel.lastScale * value.magnification) | |
| viewModel.clampLocation() | |
| } | |
| .onEnded { _ in | |
| viewModel.lastScale = viewModel.scale | |
| } | |
| } | |
| /// ドラッグ(パン)用ジェスチャ(継続点: `lastLocation`)。 | |
| /// - Parameter size: ジオメトリ(ピクセル幅・高さ)。 | |
| private func panGesture(size: CGSize) -> some Gesture { | |
| DragGesture(minimumDistance: 0) | |
| .onChanged { value in | |
| let s = max(1.0, viewModel.scale) | |
| let dx = (value.translation.width / size.width) / s | |
| let dy = (value.translation.height / size.height) / s | |
| viewModel.location = CGPoint( | |
| x: viewModel.lastLocation.x + dx, | |
| y: viewModel.lastLocation.y - dy | |
| ) | |
| viewModel.clampLocation() | |
| } | |
| .onEnded { _ in | |
| viewModel.lastLocation = viewModel.location | |
| } | |
| } | |
| // MARK: Controls | |
| /// 操作パネルビュー。 | |
| @ViewBuilder | |
| private var controls: some View { | |
| VStack(spacing: 12) { | |
| Picker("Grid Size", selection: $viewModel.size) { | |
| ForEach([64, 128, 256, 512, 1024], id: \.self) { n in | |
| Text("\(n)").tag(n) | |
| } | |
| } | |
| .disabled(viewModel.isRunning) | |
| Slider(value: $viewModel.cycleIntervalMS, in: 3 ... 100, step: 1) | |
| HStack(spacing: 16) { | |
| Button { | |
| viewModel.isRunning = true | |
| } label: { | |
| Label("Start", systemImage: "play.fill") | |
| } | |
| .disabled(viewModel.isRunning) | |
| Button { | |
| viewModel.isRunning = false | |
| } label: { | |
| Label("Stop", systemImage: "pause.fill") | |
| } | |
| .disabled(!viewModel.isRunning) | |
| Button { | |
| viewModel.isRunning = false | |
| viewModel.resetStats() | |
| viewModel.resetView() | |
| let s = viewModel.size // 再初期化 | |
| viewModel.size = s | |
| } label: { | |
| Label("Reset", systemImage: "arrow.counterclockwise") | |
| } | |
| } | |
| } | |
| .pickerStyle(.segmented) | |
| .buttonStyle(.borderedProminent) | |
| .font(.body.weight(.semibold)) | |
| } | |
| @ToolbarContentBuilder | |
| private func toolbar() -> some ToolbarContent { | |
| // ズーム用コントロール | |
| ToolbarItemGroup(placement: .bottomBar) { | |
| Button { | |
| viewModel.decrementScale() | |
| } label: { | |
| Image(systemName: "minus.magnifyingglass") | |
| } | |
| .disabled(viewModel.scale == 1) | |
| Button { | |
| viewModel.resetView() | |
| } label: { | |
| Image(systemName: "1.magnifyingglass") | |
| } | |
| .disabled(viewModel.scale == 1) | |
| Button { | |
| viewModel.incrementScale() | |
| } label: { | |
| Image(systemName: "plus.magnifyingglass") | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| import Observation | |
| // MARK: - GameOfLifeViewModel | |
| /// Conway のライフゲーム表示・操作に関する状態を管理する ViewModel。 | |
| @MainActor | |
| @Observable | |
| final class GameOfLifeViewModel { | |
| // MARK: Current View State | |
| /// 拡大率(1.0 を既定とする)。 | |
| var scale: CGFloat = 1.0 | |
| /// 正規化空間上の表示中心位置。(-0.5 ... 0.5) を想定。 | |
| var location: CGPoint = .zero | |
| /// 追加オフセット(必要に応じて利用)。 | |
| var offset: CGPoint = .zero | |
| /// 自動ステップ実行フラグ。 | |
| var isRunning: Bool = false | |
| /// 自動ステップの間隔(ミリ秒)。 | |
| var cycleIntervalMS: Double = 30.0 | |
| /// 盤面の一辺セル数。 | |
| /// 値変更時は `onSizeChanged` が呼ばれる。 | |
| var size: Int = 256 { | |
| didSet { | |
| onSizeChanged?(size) | |
| } | |
| } | |
| // MARK: Gesture Anchors | |
| /// ズーム操作の継続点(直前の拡大率)。 | |
| @ObservationIgnored var lastScale: CGFloat = 1.0 | |
| /// パン操作の継続点(直前の座標)。 | |
| @ObservationIgnored var lastLocation: CGPoint = .zero | |
| // MARK: Metrics | |
| /// 実行済みステップ数(描画用メトリクス)。 | |
| private(set) var numberOfCycles: Int = 0 | |
| // MARK: Renderer Hooks | |
| /// サイズ変更時に呼ばれるフック。 | |
| @ObservationIgnored var onSizeChanged: ((Int) -> Void)? | |
| /// ステップコミット後に呼ばれるフック。 | |
| @ObservationIgnored var onStepCommitted: (() -> Void)? | |
| // MARK: Lifecycle | |
| /// 1 ステップ進行が確定したことを通知する。 | |
| func didCommitStep() { | |
| numberOfCycles += 1 | |
| onStepCommitted?() | |
| } | |
| /// 表示系メトリクスをリセットする。 | |
| func resetStats() { | |
| numberOfCycles = 0 | |
| } | |
| // MARK: Zoom Controls | |
| /// 既定表示(1x・中央)へリセットする。 | |
| func resetView() { | |
| scale = 1.0 | |
| lastScale = 1.0 | |
| location = .zero | |
| lastLocation = .zero | |
| offset = .zero | |
| } | |
| /// 段階的に拡大する(継続点も更新)。 | |
| func incrementScale() { | |
| let new = max(1.0, scale * 1.5) | |
| scale = new | |
| lastScale = new | |
| clampLocation() | |
| } | |
| /// 段階的に縮小する(継続点も更新、1x なら中央へ)。 | |
| func decrementScale() { | |
| let new = max(1.0, scale / 1.5) | |
| scale = new | |
| lastScale = new | |
| if new == 1.0 { | |
| location = .zero | |
| lastLocation = .zero | |
| offset = .zero | |
| } else { | |
| clampLocation() | |
| } | |
| } | |
| /// 現在の `scale` に基づいて `location` をクランプする。 | |
| func clampLocation() { | |
| let s = max(1.0, scale) | |
| let v = 0.5 - (1.0 / (2.0 * s)) // 表示領域の半径(正規化) | |
| location.x = CGFloat(max(-v, min(v, Double(location.x)))) | |
| location.y = CGFloat(max(-v, min(v, Double(location.y)))) | |
| } | |
| } | |
| #Preview { | |
| NavigationStack { | |
| GameOfLifeScreen() | |
| } | |
| .colorScheme(.dark) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Metal | |
| import MetalKit | |
| import Observation | |
| import SwiftUI | |
| import simd | |
| // MARK: - MTKView Representable | |
| /// Metal による Game of Life の描画ビュー(`UIViewRepresentable`)。 | |
| struct MetalGameOfLifeView: UIViewRepresentable { | |
| // MARK: Coordinator | |
| /// `MTKViewDelegate` 実装と Metal パイプラインの管理。 | |
| final class Coordinator: NSObject, MTKViewDelegate { | |
| // MARK: Types | |
| /// フラグメントシェーダへ送る表示用ユニフォーム。 | |
| struct ViewUniforms { | |
| var width: UInt32 | |
| var height: UInt32 | |
| var scale: Float | |
| var location: SIMD2<Float> // (-0.5...0.5) | |
| var offset: SIMD2<Float> | |
| var foreground: SIMD4<Float> // RGBA | |
| var background: SIMD4<Float> | |
| var pad: Float = 0.05 | |
| } | |
| // MARK: Stored Properties | |
| private let viewModel: GameOfLifeViewModel | |
| let device: MTLDevice | |
| private let queue: MTLCommandQueue | |
| private var computePSO: MTLComputePipelineState! | |
| private var renderPSO: MTLRenderPipelineState! | |
| private var srcTex: MTLTexture! | |
| private var dstTex: MTLTexture! | |
| private var lastStepTime: CFTimeInterval = CACurrentMediaTime() | |
| // MARK: Init | |
| init(viewModel: GameOfLifeViewModel, device: MTLDevice) { | |
| self.viewModel = viewModel | |
| self.device = device | |
| self.queue = device.makeCommandQueue()! | |
| super.init() | |
| buildPipelines() | |
| makeTextures(size: viewModel.size) | |
| // VM フック | |
| viewModel.onSizeChanged = { [weak self] new in | |
| self?.makeTextures(size: new) | |
| } | |
| } | |
| // MARK: Setup | |
| /// コンピュート/レンダーの各パイプラインを構築する。 | |
| private func buildPipelines() { | |
| let library = try! device.makeDefaultLibrary(bundle: .main) | |
| // Compute | |
| let cs = library.makeFunction(name: "lifeStep")! | |
| computePSO = try! device.makeComputePipelineState(function: cs) | |
| // Render | |
| let vs = library.makeFunction(name: "fullscreenQuadVS")! | |
| let fs = library.makeFunction(name: "lifeBlitFS")! | |
| let desc = MTLRenderPipelineDescriptor() | |
| desc.vertexFunction = vs | |
| desc.fragmentFunction = fs | |
| desc.colorAttachments[0].pixelFormat = .bgra8Unorm | |
| renderPSO = try! device.makeRenderPipelineState(descriptor: desc) | |
| } | |
| /// 指定サイズでピンポン用テクスチャを生成し、乱数初期化する。 | |
| /// - Parameter size: 一辺セル数。 | |
| private func makeTextures(size: Int) { | |
| let d = MTLTextureDescriptor.texture2DDescriptor( | |
| pixelFormat: .r8Uint, | |
| width: size, | |
| height: size, | |
| mipmapped: false | |
| ) | |
| d.usage = [.shaderRead, .shaderWrite] | |
| srcTex = device.makeTexture(descriptor: d) | |
| dstTex = device.makeTexture(descriptor: d) | |
| randomize(texture: srcTex) | |
| randomize(texture: dstTex) // 初期は同じでも OK | |
| viewModel.resetStats() | |
| } | |
| /// R8Uint テクスチャを 0/1 でランダム初期化する。 | |
| private func randomize(texture: MTLTexture) { | |
| let count = texture.width * texture.height | |
| var bytes = [UInt8](repeating: 0, count: count) | |
| for i in 0 ..< count { | |
| bytes[i] = UInt8.random(in: 0 ... 1) | |
| } | |
| bytes.withUnsafeBytes { ptr in | |
| texture.replace( | |
| region: MTLRegionMake2D(0, 0, texture.width, texture.height), | |
| mipmapLevel: 0, | |
| withBytes: ptr.baseAddress!, | |
| bytesPerRow: texture.width | |
| ) | |
| } | |
| } | |
| // MARK: MTKViewDelegate | |
| func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { | |
| // NOP | |
| } | |
| func draw(in view: MTKView) { | |
| guard | |
| let drawable = view.currentDrawable, | |
| let commandBuffer = queue.makeCommandBuffer() | |
| else { return } | |
| // 1) 必要なら 1 ステップ進める(間隔は cycleIntervalMS) | |
| if viewModel.isRunning { | |
| let now = CACurrentMediaTime() | |
| let elapsedMS = (now - lastStepTime) * 1000.0 | |
| if elapsedMS >= viewModel.cycleIntervalMS { | |
| encodeCompute(into: commandBuffer) | |
| swap(&srcTex, &dstTex) | |
| lastStepTime = now | |
| viewModel.didCommitStep() | |
| } | |
| } | |
| // 2) 描画 | |
| if let rp = view.currentRenderPassDescriptor { | |
| encodeRender(into: commandBuffer, with: rp) | |
| commandBuffer.present(drawable) | |
| } | |
| commandBuffer.commit() | |
| } | |
| // MARK: Encode | |
| /// ライフゲームの 1 ステップを計算するコンピュートパスをエンコードする。 | |
| private func encodeCompute(into cb: MTLCommandBuffer) { | |
| guard let enc = cb.makeComputeCommandEncoder() else { return } | |
| enc.setComputePipelineState(computePSO) | |
| enc.setTexture(srcTex, index: 0) | |
| enc.setTexture(dstTex, index: 1) | |
| var wh1 = SIMD3<UInt32>(UInt32(srcTex.width), UInt32(srcTex.height), 1) | |
| var wrap: UInt32 = 1 // 1 にするとトーラス(ラップ)境界 | |
| enc.setBytes(&wh1, length: MemoryLayout<SIMD3<UInt32>>.stride, index: 0) | |
| enc.setBytes(&wrap, length: MemoryLayout<UInt32>.stride, index: 1) | |
| let w = computePSO.threadExecutionWidth | |
| let h = max(1, computePSO.maxTotalThreadsPerThreadgroup / w) | |
| let tg = MTLSize(width: w, height: h, depth: 1) | |
| let ng = MTLSize( | |
| width: (srcTex.width + w - 1) / w, | |
| height: (srcTex.height + h - 1) / h, | |
| depth: 1 | |
| ) | |
| enc.dispatchThreadgroups(ng, threadsPerThreadgroup: tg) | |
| enc.endEncoding() | |
| } | |
| /// 表示(フルスクリーン・クアッド)のレンダーパスをエンコードする。 | |
| private func encodeRender(into cb: MTLCommandBuffer, with rpd: MTLRenderPassDescriptor) { | |
| guard let enc = cb.makeRenderCommandEncoder(descriptor: rpd) else { return } | |
| enc.setRenderPipelineState(renderPSO) | |
| // uniforms | |
| var uni = ViewUniforms( | |
| width: UInt32(srcTex.width), | |
| height: UInt32(srcTex.height), | |
| scale: Float(max(1.0, viewModel.scale)), | |
| location: SIMD2<Float>(Float(viewModel.location.x), Float(viewModel.location.y)), | |
| offset: SIMD2<Float>(Float(viewModel.offset.x), Float(viewModel.offset.y)), | |
| foreground: SIMD4<Float>(0.2, 0.9, 0.7, 1.0), // mint-ish | |
| background: SIMD4<Float>(0.05, 0.05, 0.10, 1.0) // dark bluish | |
| ) | |
| enc.setFragmentBytes(&uni, length: MemoryLayout<ViewUniforms>.stride, index: 0) | |
| enc.setFragmentTexture(srcTex, index: 0) | |
| // フルスクリーンクアッド(3頂点トリック) | |
| enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) | |
| enc.endEncoding() | |
| } | |
| } | |
| // MARK: UIViewRepresentable | |
| /// ViewModel 参照。 | |
| let viewModel: GameOfLifeViewModel | |
| func makeCoordinator() -> Coordinator { | |
| let device = MTLCreateSystemDefaultDevice()! | |
| return Coordinator(viewModel: viewModel, device: device) | |
| } | |
| func makeUIView(context: Context) -> MTKView { | |
| let device = context.coordinator.device | |
| let view = MTKView(frame: .zero, device: device) | |
| view.enableSetNeedsDisplay = false | |
| view.isPaused = false | |
| view.framebufferOnly = true | |
| view.colorPixelFormat = .bgra8Unorm | |
| view.delegate = context.coordinator | |
| return view | |
| } | |
| func updateUIView(_ uiView: MTKView, context: Context) { | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment