Introduce a textured cube with raylib #2

Merged
kornerr merged 24 commits from mahjong/mahjong:ray into master 2026-10-17 06:03:34 +02:00
32 changed files with 767 additions and 0 deletions
Showing only changes of commit 184454cf59 - Show all commits

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
build
.build
.DS_Store
.gradle
gradle-daemon-jvm.properties
.idea
.kotlin
local.properties
Package.resolved
*.xcodeproj
+151
View File
@@ -0,0 +1,151 @@
# OGS Mahjong (`mj`)
A macOS SwiftUI app for **OGS Mahjong**, built on the **Dialect v4** declarative
reactive engine (see the sibling `../dialect` repo — this project is a
downstream consumer of its code generator).
Business logic lives in `dialect.yml` (YAML). A generator turns it into
`game/ver-mac/src/dialect.swift` (the runtime engine + `struct F` field
constants + component/should/effect registration). The Swift app shell
(`GameApp`/`AppDelegate`) and the SwiftUI views live outside the generated file
and talk to the engine through generated functions like `rootSet(...)`.
Remote: `https://git.opengamestudio.org/mahjong/mahjong` · Version: `2.0.0`
> Current `dialect.yml` is still the "Root / counter" template inherited from the
> dialect example — the Mahjong game logic has not been written yet. The app
> shell, packaging and symlink wiring are in place.
## Structure
```
dialect.yml Single source of truth for the reactive logic.
Generates game/ver-mac/src/dialect.swift
components/ SHARED source-of-truth for all components.
other/other.swift Shared helpers (otherPrintStderr, otherWriteFile,
otherSetupConsoleLogging, ...)
root/root.swift RootComponent (owns DialectController, registers
effects + shoulds, sets didSetup on setup())
root/rootVM.swift RootVM (ObservableObject / @Published countText)
root/mac/view/RootView.swift Per-platform SwiftUI view
game/ver-mac/ macOS app (SPM executable target "ogs-mahjong")
Package.swift SPM manifest (macOS 13+, swift-tools 5.9)
Info.plist Bundle id org.opengamestudio.Mahjong, exec ogs-mahjong
Resources/AppIcon.icns App icon
src/
GameApp.swift @main SwiftUI App entry
AppDelegate.swift Creates RootComponent + RootVM, calls root.setup()
dialect.swift GENERATED — do not hand-edit (see chicken-egg below)
other/other.swift -> symlink components/other/other.swift
root/root.swift -> symlink components/root/root.swift
root/rootVM.swift -> symlink components/root/rootVM.swift
root/view/RootView.swift-> symlink components/root/mac/view/RootView.swift
ref/swiftraylib Symlink to ../../swiftraylib — the raylib/SwiftUI
renderer sample the game will use for 3D rendering.
util/ Build/run scripts (see Build & Run)
paths APP_MAC = .build/OGS Mahjong.app
build-mac generateDialect -> buildMac -> packageMac
run-mac runMac
rm-mac-geom Resets saved window geometry
step/ Sourced steps: generateDialect, buildMac,
packageMac, runMac
history/gitjs/ GitJS "Glavred" history + the original JS source of the
web Mahjong, kept for reference — not part of the build.
```
## Symlink parity rule
`components/` is the source of truth; `game/ver-mac/src/` contains real
directories whose files are **relative symlinks** back into `components/`:
```
src/other/other.swift -> ../../../../components/other/other.swift (4 up)
src/root/root.swift -> ../../../../components/root/root.swift (4 up)
src/root/rootVM.swift -> ../../../../components/root/rootVM.swift (4 up)
src/root/view/RootView.swift-> ../../../../../components/root/mac/view/RootView.swift (5 up)
```
SPM silently **skips** a dangling symlink (no error), so after moving
`components/` or a subdir, audit for broken links:
```bash
find . -type l ! -exec test -e {} \; -not -path './.build/*'
```
## Prerequisite: the generator
`util/step/generateDialect` calls the **sibling** dialect repo:
```bash
$SDIR/../../dialect/util/run-generator --file=$SDIR/../dialect.yml
```
So `/Volumes/MOE/p/dialect` must exist and its generator must be built before
`util/build-mac` can regenerate:
```bash
(cd ../dialect && util/build-generator) # once, or after dialect core changes
```
The generator resolves the `output:` path **relative to `dialect.yml`'s
directory**, which is why `dialect.yml` says `game/ver-mac/src/dialect.swift`.
## Build & Run
```bash
util/build-mac # generateDialect + swift build + package OGS Mahjong.app
util/run-mac # runs .build/OGS Mahjong.app/Contents/MacOS/ogs-mahjong
util/rm-mac-geom # reset the app's saved window geometry
```
`build-mac` sources `util/paths` then the `util/step/*` scripts in order:
1. `generateDialect` — runs the sibling generator, writes `src/dialect.swift`
2. `buildMac` — `cd game/ver-mac && swift build`
3. `packageMac` — assembles `.build/OGS Mahjong.app` (binary `ogs-mahjong`,
`Info.plist`, `Resources/AppIcon.icns`)
## Editing the dialect
- Add/modify contexts and components in `dialect.yml`, then run
`util/build-mac` to regenerate `dialect.swift`.
- **Chicken-egg**: `struct F` (field-name constants) lives only in the
generated `dialect.swift`. Adding a new field to a context in `dialect.yml`
requires hand-patching its `static let <field> = "<field>"` into `struct F`
in `dialect.swift` (ASCII-alphabetical order) if you cannot regenerate first,
otherwise the build fails on `F.<field>` being unresolved.
- `dialect.swift` is generated — edits there are overwritten on the next
`util/build-mac`. Change the source (`dialect.yml` / `components/`) instead.
- **Validate `dialect.yml` after edits** (e.g. `ruby -ryaml -e 'YAML.load_file("dialect.yml")'`).
Every should-branch label (`1. Upon each ...`) MUST end with a colon — the
parser (`components/yml/ymlFun.swift`) blindly `dropLast()`s it, so a missing
colon silently eats the label's last character in the generated `about`
comment. Note the file can never pass a *strict* YAML parser: condition and
reaction lines (under `if:` / `then:`) contain unquoted `: ` (e.g.
`c.countText = "Count: '\(c.count)'"`), which standard YAML rejects. So treat
a parser complaint pointing *inside* an `if:`/`then:` block as expected; any
complaint elsewhere (like a missing branch-label colon) is a real bug.
## Swift 6
The project currently builds in Swift 5 language mode
(`util/step/buildMac` has the Swift 6 flag commented out). Enabling
`-Xswiftc -swift-version -Xswiftc 6` fails today on the singleton/`@MainActor`
concurrency errors (`RootComponent.singleton`, `RootVM.shared` in
`components/root/`, and the `RootVM.shared!.countText = ...` oneliner in the
generated `dialect.swift`). Do not flip it on without addressing those.
## Conventions
- `game/ver-mac/.build/`, `build/`, `.DS_Store`, Xcode/Gradle artifacts are
git-ignored.
- Prefer editing files in `components/` (the real files); the `src/` entries are
symlinks.
- The generated UI callback plumbing is nonisolated; UI singletons are weak
(`RootComponent.singleton`, `RootVM.shared`) and are accessed from the
generated oneliners with force-unwrap (`RootVM.shared!.countText = ...`).
+15
View File
@@ -0,0 +1,15 @@
Copyright (c) 2026 OpenGameStudio
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
===
Third parties
===
raylib - Copyright (c) 2013-2026 Ramon Santamaria (@raysan5)
+1
View File
@@ -0,0 +1 @@
2.0.0
+70
View File
@@ -0,0 +1,70 @@
import Foundation
/// Decode a base64 string back into a regular string
func otherBase64ToString(_ encoded: String) -> String {
guard
let data = Data(base64Encoded: encoded),
let str = String(data: data, encoding: .utf8)
else {
return ""
}
return str
}
/// Capitalize the first letter of a string
func otherCapitalize(_ s: String) -> String {
guard let first = s.first else {
return s
}
return String(first).uppercased() + String(s.dropFirst())
}
/// Launch to wait for asynchrnous actions
func otherLaunch() {
RunLoop.current.run()
}
/// Count leading spaces in a line
func otherLineIndent(_ line: String) -> Int {
var count = 0
for ch in line {
if ch == " " {
count += 1
} else {
break
}
}
return count
}
/// Print to stderr
func otherPrintStderr(_ txt: String) {
if let dat = "\(txt)\n".data(using: .utf8) {
FileHandle.standardError.write(dat)
}
}
/// Print each key/value processed by a controller into stderr
func otherSetupConsoleLogging(
_ ctrl: DialectController,
_ prefix: String
) {
ctrl.registerCallback { c -> Void in
let value = c.fieldAny(c.recentField)
let line = "ИГР \(prefix) k/v: '\(c.recentField)'/'\(value)'"
otherPrintStderr(line)
}
}
/// Write output text to a file
func otherWriteFile(
_ path: String,
_ content: String
) {
try? content.write(toFile: path, atomically: true, encoding: .utf8)
}
/// Terminate the main RunLoop
func otherShutdown() {
exit(0)
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
struct RootView: View {
@ObservedObject var vm: RootVM
init(_ vm: RootVM) {
self.vm = vm
}
var body: some View {
VStack(spacing: 16) {
Text("Hello, World!")
.font(.largeTitle)
Text(vm.countText)
.font(.title3.monospacedDigit())
Button("Increment") {
rootSet(F.didClickIncrement, true)
}
}
.padding(40)
}
}
+14
View File
@@ -0,0 +1,14 @@
class RootComponent {
let ctrl = DialectController(RootContext())
static private(set) weak var singleton: RootComponent?
init() {
Self.singleton = self
rootRegisterEffects(ctrl)
rootRegisterShoulds(ctrl)
}
func setup() {
rootSet(F.didSetup, true)
}
}
+11
View File
@@ -0,0 +1,11 @@
import Combine
final class RootVM: ObservableObject {
@Published var countText = ""
static private(set) weak var shared: RootVM?
init() {
Self.shared = self
}
}
+51
View File
@@ -0,0 +1,51 @@
version: 4
output:
game/ver-mac/src/dialect.swift:
type: swift
RootComponent:
type: component
oneliners:
- countText: print(c.countText)
- countText: RootVM.shared!.countText = c.countText
shoulds:
count:
1. Upon hitting 10 the first time:
if:
c.recentField == F.didClickIncrement &&
c.count == 7
then:
c.count += 10
2. Upon each button click:
if:
c.recentField == F.didClickIncrement
then:
c.count += 1
countText:
1. Upon each count change:
if:
c.recentField == F.count
then:
c.countText = "Count: '\(c.count)'"
2. Upon launch:
if:
c.recentField == F.didLaunch
then:
c.countText = "Press the button to count"
didLaunch:
1. Only once during the first setup:
if:
c.recentField == F.didSetup &&
c.didLaunch == false
then:
c.didLaunch = true
RootContext:
type: context
fields:
count: Int
countText: String
didClickIncrement: Bool
didLaunch: Bool
didSetup: Bool
+26
View File
@@ -0,0 +1,26 @@
<?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>CFBundleName</key>
<string>OGS Mahjong</string>
<key>CFBundleDisplayName</key>
<string>OGS Mahjong</string>
<key>CFBundleIdentifier</key>
<string>org.opengamestudio.Mahjong</string>
<key>CFBundleExecutable</key>
<string>ogs-mahjong</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "OGSMahjong",
platforms: [
.macOS(.v13)
],
targets: [
.executableTarget(
name: "ogs-mahjong",
path: "src"
)
]
)
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
import AppKit
final class AppDelegate:
NSObject,
NSApplicationDelegate
{
let root = RootComponent()
let rootVM = RootVM()
func applicationDidFinishLaunching(_ notification: Notification) {
// Bring the app to the foreground
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
otherSetupConsoleLogging(root.ctrl, "Root")
root.setup()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
}
+14
View File
@@ -0,0 +1,14 @@
import AppKit
import SwiftUI
@main
struct GameApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
RootView(appDelegate.rootVM)
}
.defaultSize(width: 640, height: 480)
}
}
+276
View File
@@ -0,0 +1,276 @@
// Reusable "none" constant
public let DIALECT_CONTEXT_RECENT_FIELD_NONE = "none"
// Base protocol for each component's state
public protocol DialectContext {
var recentField: String { get set }
func field<T>(_ name: String) -> T
func fieldAny(_ name: String) -> Any
mutating func setField(_ name: String, _ value: Any)
}
// Default implementation of `fieldAny` method for the protocol
public extension DialectContext {
func fieldAny(_ name: String) -> Any {
return field(name)
}
}
// The engine of the dialect components
public class DialectController {
var callbacks = [(DialectContext) -> Void]()
var context: DialectContext
var functions = [(DialectContext) -> DialectContext]()
var isProcessingQueue = false
var queue = [DialectContext]()
public init(_ context: DialectContext) {
self.context = context
}
func executeFunctions() {
let c = queue.removeFirst()
// Keep SSOT: Only allow single field change per should-function
context.recentField = c.recentField
context.setField(c.recentField, c.fieldAny(c.recentField))
for f in functions {
let ctx = f(context)
if ctx.recentField != DIALECT_CONTEXT_RECENT_FIELD_NONE {
queue.append(ctx)
}
}
reportContext()
}
func processQueue() {
// Prevent recursion.
if isProcessingQueue {
return
}
isProcessingQueue = true
while (queue.count > 0) {
executeFunctions()
}
isProcessingQueue = false
}
public func registerCallback(_ cb: @escaping (DialectContext) -> Void) {
callbacks.append(cb)
}
public func registerFieldCallback(
_ fieldName: String,
_ cb: @escaping (DialectContext) -> Void
) {
callbacks.append({ c in
if c.recentField == fieldName {
cb(c)
}
})
}
public func registerFunction(_ f: @escaping (DialectContext) -> DialectContext) {
functions.append(f)
}
func reportContext() {
for cb in callbacks {
cb(context)
}
}
public func set(
_ fieldName: String,
_ value: Any
) {
var c = context
c.setField(fieldName, value)
c.recentField = fieldName
queue.append(c)
processQueue()
}
}
// Register several oneliner callbacks to a controller
func registerOneliners<T>(
_ ctrl: DialectController,
_ items: [Any]
) -> T? {
let halfCount = items.count / 2
for i in 0..<halfCount {
let field = items[i * 2] as! String
let callback = items[i * 2 + 1] as! (T) -> Void
ctrl.registerFieldCallback(field) { cc in
let c = cc as! T
callback(c)
}
}
// A hack for generics to operate
return nil
}
// Context field names for static type check
public struct F {
public static let count = "count"
public static let countText = "countText"
public static let didClickIncrement = "didClickIncrement"
public static let didLaunch = "didLaunch"
public static let didSetup = "didSetup"
}
public struct RootContext: DialectContext {
public var count = Int()
public var countText = String()
public var didClickIncrement = Bool()
public var didLaunch = Bool()
public var didSetup = Bool()
public var recentField = ""
public func field<T>(_ name: String) -> T {
if (name == "count") {
return count as! T
}
else if (name == "countText") {
return countText as! T
}
else if (name == "didClickIncrement") {
return didClickIncrement as! T
}
else if (name == "didLaunch") {
return didLaunch as! T
}
else if (name == "didSetup") {
return didSetup as! T
}
return "unknown-field-name" as! T
}
public mutating func setField(
_ name: String,
_ value: Any
) {
if (name == "count") {
count = value as! Int
}
else if (name == "countText") {
countText = value as! String
}
else if (name == "didClickIncrement") {
didClickIncrement = value as! Bool
}
else if (name == "didLaunch") {
didLaunch = value as! Bool
}
else if (name == "didSetup") {
didSetup = value as! Bool
}
}
}
public func rootSet(
_ key: String,
_ value: Any
) {
RootComponent.singleton!.ctrl.set(key, value)
}
func rootShouldResetCount(_ c: RootContext) -> RootContext {
var c = c
/* 1. Upon hitting 10 the first time */
if
c.recentField == F.didClickIncrement &&
c.count == 7
{
c.count += 10
c.recentField = F.count
return c
}
/* 2. Upon each button click */
if
c.recentField == F.didClickIncrement
{
c.count += 1
c.recentField = F.count
return c
}
c.recentField = DIALECT_CONTEXT_RECENT_FIELD_NONE
return c
}
func rootShouldResetCountText(_ c: RootContext) -> RootContext {
var c = c
/* 1. Upon each count change */
if
c.recentField == F.count
{
c.countText = "Count: '\(c.count)'"
c.recentField = F.countText
return c
}
/* 2. Upon launch */
if
c.recentField == F.didLaunch
{
c.countText = "Press the button to count"
c.recentField = F.countText
return c
}
c.recentField = DIALECT_CONTEXT_RECENT_FIELD_NONE
return c
}
func rootShouldResetDidLaunch(_ c: RootContext) -> RootContext {
var c = c
/* 1. Only once during the first setup */
if
c.recentField == F.didSetup &&
c.didLaunch == false
{
c.didLaunch = true
c.recentField = F.didLaunch
return c
}
c.recentField = DIALECT_CONTEXT_RECENT_FIELD_NONE
return c
}
func rootRegisterShoulds(_ ctrl: DialectController) {
[
rootShouldResetCount,
rootShouldResetCountText,
rootShouldResetDidLaunch,
].forEach { f in
ctrl.registerFunction { c in f(c as! RootContext) }
}
}
func rootRegisterEffects(_ ctrl: DialectController) {
let _: RootContext? = registerOneliners(ctrl, [
F.countText, { (c: RootContext) in print(c.countText) },
F.countText, { (c: RootContext) in RootVM.shared!.countText = c.countText },
])
}
+1
View File
@@ -0,0 +1 @@
../../../../components/other/other.swift
+1
View File
@@ -0,0 +1 @@
../../../../components/root/root.swift
+1
View File
@@ -0,0 +1 @@
../../../../components/root/rootVM.swift
+1
View File
@@ -0,0 +1 @@
../../../../../components/root/mac/view/RootView.swift
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
../../swiftraylib
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash -e
SDIR=$(cd "$(dirname "$0")" ; pwd -P)
T=$SDIR/step
source $SDIR/paths
STEP=0
source $T/generateDialect
source $T/buildMac
source $T/packageMac
+1
View File
@@ -0,0 +1 @@
APP_MAC="$SDIR/../game/ver-mac/.build/OGS Mahjong.app"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash -e
killall cfprefsd 2>/dev/null || true
sleep 1
rm -f $HOME/Library/Preferences/OGS\ Mahjong.plist \
$HOME/Library/Preferences/org.opengamestudio.Mahjong.plist
echo "removed app preferences (window geometry)"
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash -e
SDIR=$(cd "$(dirname "$0")" ; pwd -P)
T=$SDIR/step
source $SDIR/paths
STEP=0
source $T/runMac
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash -e
STEP=$((STEP+1))
echo -e "\n> > > > Step №$STEP. Build for macOS"
cd $SDIR/../game/ver-mac
swift build # -Xswiftc -swift-version -Xswiftc 6
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash -e
STEP=$((STEP+1))
echo -e "\n> > > > Step №$STEP. Generate Dialect"
$SDIR/../../dialect/util/run-generator --file=$SDIR/../dialect.yml
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash -e
STEP=$((STEP+1))
echo -e "\n> > > > Step №$STEP. Make an app package for macOS"
rm -rf "$APP_MAC"
APP_DAT="$APP_MAC/Contents"
mkdir -p "$APP_DAT/MacOS"
mkdir -p "$APP_DAT/Resources"
cp "$SDIR/../game/ver-mac/.build/debug/ogs-mahjong" "$APP_DAT/MacOS/ogs-mahjong"
cp "$SDIR/../game/ver-mac/Info.plist" "$APP_DAT/Info.plist"
cp "$SDIR/../game/ver-mac/Resources/AppIcon.icns" "$APP_DAT/Resources/AppIcon.icns"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash -e
STEP=$((STEP+1))
echo -e "\n> > > > Step №$STEP. Run macOS app"
"$APP_MAC/Contents/MacOS/ogs-mahjong"