feat: UNO Kotlin Multiplatform app (Android, Desktop, Web)

- Full UNO game logic with AI opponents
- Compose Multiplatform UI with animations
- Shared module (commonMain) with game engine
- Desktop app (Compose Desktop)
- Android app (Compose Multiplatform)
- Web app (Kotlin/Wasm)
- Nix shell for reproducible dev environment
This commit is contained in:
2026-08-02 18:21:57 +02:00
commit 786b813f7b
25 changed files with 2093 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Gradle
.gradle/
build/
local.properties
*.hprof
# IDE
.idea/
*.iml
*.ipr
*.iws
.vscode/
# Nix
result
.jdk
# Kotlin
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar
# OS
.DS_Store
Thumbs.db
.kotlin/
+48
View File
@@ -0,0 +1,48 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
}
sourceSets {
androidMain.dependencies {
implementation(project(":shared"))
implementation(libs.androidx.activity.compose)
implementation(compose.material3)
}
}
}
android {
namespace = "com.uno.android"
compileSdk = 35
defaultConfig {
applicationId = "com.uno.android"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="UNO"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,95 @@
package com.uno.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.uno.game.GameEngine
import com.uno.game.model.*
import com.uno.shared.UnoGameScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
UnoApp()
}
}
}
}
}
@Composable
fun UnoApp() {
val engine = remember { GameEngine() }
var gameState by remember { mutableStateOf(engine.startGame()) }
var gameStarted by remember { mutableStateOf(false) }
if (!gameStarted) {
StartScreen(onStartGame = { playerCount ->
gameState = engine.startGame(playerCount)
gameStarted = true
})
} else {
UnoGameScreen(
state = gameState,
onPlayCard = { card, color ->
gameState = engine.playCard(gameState, 0, card, color)
gameState = runAiTurns(engine, gameState)
},
onDrawCard = {
gameState = engine.drawCard(gameState, 0)
gameState = runAiTurns(engine, gameState)
},
onCallUno = { gameState = engine.callUno(gameState, 0) }
)
if (gameState.isFinished) {
GameOverScreen(winner = gameState.winner, onPlayAgain = { gameState = engine.startGame() })
}
}
}
private fun runAiTurns(engine: GameEngine, state: GameState): GameState {
var currentState = state
while (currentState.currentPlayerIndex != 0 && !currentState.isFinished) {
currentState = engine.aiPlay(currentState, currentState.currentPlayerIndex)
}
return currentState
}
@Composable
fun StartScreen(onStartGame: (Int) -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("UNO", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
Text("Kotlin Multiplatform", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { onStartGame(4) }) { Text("Spiel starten (4 Spieler)") }
}
}
@Composable
fun GameOverScreen(winner: Player?, onPlayAgain: () -> Unit) {
AlertDialog(
onDismissRequest = onPlayAgain,
title = { Text("Spiel vorbei!") },
text = { Text(if (winner != null) "${winner.name} hat gewonnen!" else "Unentschieden!") },
confirmButton = {
Button(onClick = onPlayAgain) { Text("Nochmal spielen") }
}
)
}
+8
View File
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.kotlinSerialization) apply false
}
+24
View File
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(project(":shared"))
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.foundation)
}
}
}
compose.desktop {
application {
mainClass = "com.uno.desktop.MainKt"
}
}
@@ -0,0 +1,94 @@
package com.uno.desktop
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import com.uno.game.GameEngine
import com.uno.game.model.*
import com.uno.shared.UnoGameScreen
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "UNO - Kotlin Multiplatform",
state = WindowState(size = DpSize(900.dp, 700.dp))
) {
UnoDesktopApp()
}
}
@Composable
fun UnoDesktopApp() {
val engine = remember { GameEngine() }
var gameState by remember { mutableStateOf(engine.startGame()) }
var gameStarted by remember { mutableStateOf(false) }
if (!gameStarted) {
DesktopStartScreen(onStartGame = { playerCount ->
gameState = engine.startGame(playerCount)
gameStarted = true
})
} else {
UnoGameScreen(
state = gameState,
onPlayCard = { card, color ->
gameState = engine.playCard(gameState, 0, card, color)
gameState = runAiTurns(engine, gameState)
},
onDrawCard = {
gameState = engine.drawCard(gameState, 0)
gameState = runAiTurns(engine, gameState)
},
onCallUno = { gameState = engine.callUno(gameState, 0) }
)
if (gameState.isFinished) {
DesktopGameOverScreen(
winner = gameState.winner,
onPlayAgain = { gameState = engine.startGame() }
)
}
}
}
private fun runAiTurns(engine: GameEngine, state: GameState): GameState {
var currentState = state
while (currentState.currentPlayerIndex != 0 && !currentState.isFinished) {
currentState = engine.aiPlay(currentState, currentState.currentPlayerIndex)
}
return currentState
}
@Composable
fun DesktopStartScreen(onStartGame: (Int) -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("UNO", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
Text("Desktop Edition", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { onStartGame(4) }) { Text("Spiel starten (4 Spieler)") }
}
}
@Composable
fun DesktopGameOverScreen(winner: Player?, onPlayAgain: () -> Unit) {
AlertDialog(
onDismissRequest = onPlayAgain,
title = { Text("Spiel vorbei!") },
text = { Text(if (winner != null) "${winner.name} hat gewonnen!" else "Unentschieden!") },
confirmButton = {
Button(onClick = onPlayAgain) { Text("Nochmal spielen") }
}
)
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options="-Xmx2048M"
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.configuration-cache=true
+20
View File
@@ -0,0 +1,20 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
compose-multiplatform = "1.7.3"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.3" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+27
View File
@@ -0,0 +1,27 @@
pluginManagement {
repositories {
google {
mavenContent {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Uno"
include(":shared")
include(":androidApp")
include(":desktopApp")
include(":webApp")
+44
View File
@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
jvm()
wasmJs {
browser()
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
}
}
}
android {
namespace = "com.uno.shared"
compileSdk = 35
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -0,0 +1,236 @@
package com.uno.game
import com.uno.game.model.*
import kotlin.random.Random
class GameEngine {
fun createDeck(): Deck {
val cards = mutableListOf<Card>()
for (color in CardColor.entries) {
cards.add(Card(color, CardValue.ZERO))
for (value in listOf(
CardValue.ONE, CardValue.TWO, CardValue.THREE, CardValue.FOUR,
CardValue.FIVE, CardValue.SIX, CardValue.SEVEN, CardValue.EIGHT, CardValue.NINE
)) {
cards.add(Card(color, value))
cards.add(Card(color, value))
}
cards.add(Card(color, CardValue.SKIP))
cards.add(Card(color, CardValue.SKIP))
cards.add(Card(color, CardValue.REVERSE))
cards.add(Card(color, CardValue.REVERSE))
cards.add(Card(color, CardValue.DRAW_TWO))
cards.add(Card(color, CardValue.DRAW_TWO))
}
repeat(4) {
cards.add(Card(null, CardValue.WILD))
cards.add(Card(null, CardValue.WILD_DRAW_FOUR))
}
return Deck(drawPile = cards.shuffled(Random))
}
fun dealCards(deck: Deck, playerCount: Int, cardsPerPlayer: Int = 7): Triple<Deck, List<Player>, Player> {
var currentDeck = deck
val players = mutableListOf<Player>()
for (i in 0 until playerCount) {
val (drawnDeck, cards) = currentDeck.draw(cardsPerPlayer)
currentDeck = drawnDeck
players.add(
Player(
id = "player_$i",
name = if (i == 0) "Du" else "KI $i",
hand = cards,
isHuman = i == 0
)
)
}
var (drawnDeck, firstCard) = currentDeck.draw(1)
while (firstCard.first().isWild) {
val reshuffled = createDeck()
val reshuffledState = reshuffled.copy(
drawPile = reshuffled.drawPile + currentDeck.discardPile + firstCard
)
val result = reshuffledState.draw(1)
drawnDeck = result.first.copy(discardPile = emptyList())
firstCard = result.second
}
val initialCard = firstCard.first()
drawnDeck = drawnDeck.discard(initialCard)
return Triple(drawnDeck, players, players[0])
}
fun startGame(playerCount: Int = 4): GameState {
val deck = createDeck()
val (newDeck, players, _) = dealCards(deck, playerCount)
return GameState(
phase = GamePhase.PLAYING,
players = players,
currentPlayerIndex = 0,
deck = newDeck,
direction = GameDirection.CLOCKWISE,
currentColor = newDeck.topCard?.color
)
}
fun playCard(state: GameState, playerIndex: Int, card: Card, chosenColor: CardColor? = null): GameState {
val player = state.players[playerIndex]
val topCard = state.deck.topCard
require(player.hand.contains(card)) { "Spieler hat diese Karte nicht" }
if (topCard != null) {
require(card.canPlayOn(topCard, state.currentColor)) { "Karte kann nicht gespielt werden" }
}
val newHand = player.hand - card
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hand = newHand)
var newState = state.copy(
players = newPlayers,
deck = state.deck.discard(card),
currentColor = if (card.isWild) chosenColor else card.color
)
if (newHand.isEmpty()) {
return newState.copy(
phase = GamePhase.FINISHED,
winner = newPlayers[playerIndex]
)
}
newState = when (card.value) {
CardValue.REVERSE -> {
val newDirection = state.direction.reverse()
val newCurrent = if (state.players.size == 2) state.nextPlayerIndex() else state.nextPlayerIndex()
newState.copy(direction = newDirection, currentPlayerIndex = newCurrent)
}
CardValue.SKIP -> {
val skipped = state.nextPlayerIndex()
newState.copy(currentPlayerIndex = state.nextPlayerIndex().let { idx ->
val size = state.players.size
when (state.direction) {
GameDirection.CLOCKWISE -> (skipped + 1) % size
GameDirection.COUNTER_CLOCKWISE -> (skipped - 1 + size) % size
}
})
}
CardValue.DRAW_TWO -> {
val targetIndex = state.nextPlayerIndex()
val targetPlayer = state.players[targetIndex]
val (drawnDeck, drawnCards) = state.deck.draw(2)
val updatedPlayers = state.players.toMutableList()
updatedPlayers[targetIndex] = targetPlayer.copy(hand = targetPlayer.hand + drawnCards)
val nextAfterSkip = when (state.direction) {
GameDirection.CLOCKWISE -> (targetIndex + 1) % state.players.size
GameDirection.COUNTER_CLOCKWISE -> (targetIndex - 1 + state.players.size) % state.players.size
}
newState.copy(
deck = drawnDeck,
players = updatedPlayers,
currentPlayerIndex = nextAfterSkip
)
}
CardValue.WILD_DRAW_FOUR -> {
val targetIndex = state.nextPlayerIndex()
val targetPlayer = state.players[targetIndex]
val (drawnDeck, drawnCards) = newState.deck.draw(4)
val updatedPlayers = newState.players.toMutableList()
updatedPlayers[targetIndex] = targetPlayer.copy(hand = targetPlayer.hand + drawnCards)
val nextAfterSkip = when (newState.direction) {
GameDirection.CLOCKWISE -> (targetIndex + 1) % newState.players.size
GameDirection.COUNTER_CLOCKWISE -> (targetIndex - 1 + newState.players.size) % newState.players.size
}
newState.copy(
deck = drawnDeck,
players = updatedPlayers,
currentPlayerIndex = nextAfterSkip
)
}
else -> {
newState.copy(currentPlayerIndex = newState.nextPlayerIndex())
}
}
return newState
}
fun drawCard(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
var deck = state.deck
if (deck.drawPileEmpty) {
val topCard = deck.topCard ?: return state
val reshuffled = deck.discardPile.dropLast(1).shuffled(Random)
deck = Deck(drawPile = reshuffled, discardPile = listOf(topCard))
}
val (newDeck, drawnCards) = deck.draw(1)
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hand = player.hand + drawnCards)
return state.copy(
players = newPlayers,
deck = newDeck,
currentPlayerIndex = state.nextPlayerIndex()
)
}
fun callUno(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
val newPlayers = state.players.toMutableList()
newPlayers[playerIndex] = player.copy(hasCalledUno = true)
return state.copy(
players = newPlayers,
lastAction = "${player.name} ruft UNO!"
)
}
fun aiPlay(state: GameState, playerIndex: Int): GameState {
val player = state.players[playerIndex]
if (player.isHuman) return state
val topCard = state.deck.topCard
val playableCards = if (topCard != null) {
player.hand.filter { it.canPlayOn(topCard, state.currentColor) }
} else {
emptyList()
}
if (playableCards.isEmpty()) {
return drawCard(state, playerIndex)
}
val nonWildCards = playableCards.filter { !it.isWild }
val cardToPlay = if (nonWildCards.isNotEmpty()) {
nonWildCards.maxByOrNull { it.value.points } ?: nonWildCards.first()
} else {
playableCards.first()
}
val chosenColor = if (cardToPlay.isWild) {
val colorCounts = player.hand.filter { !it.isWild }
.groupBy { it.color }
.mapValues { it.value.size }
colorCounts.maxByOrNull { it.value }?.key ?: CardColor.RED
} else {
null
}
var newState = playCard(state, playerIndex, cardToPlay, chosenColor)
if (player.hand.size == 2 && !player.hasCalledUno) {
newState = callUno(newState, playerIndex)
}
return newState
}
}
@@ -0,0 +1,59 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class CardColor {
RED, GREEN, BLUE, YELLOW;
val displayName: String
get() = when (this) {
RED -> "Rot"
GREEN -> "Grün"
BLUE -> "Blau"
YELLOW -> "Gelb"
}
}
@Serializable
enum class CardValue(val points: Int) {
ZERO(0),
ONE(1), TWO(2), THREE(3), FOUR(4),
FIVE(5), SIX(6), SEVEN(7), EIGHT(8), NINE(9),
SKIP(20),
REVERSE(20),
DRAW_TWO(20),
WILD(50),
WILD_DRAW_FOUR(50);
val isActionCard: Boolean
get() = this in listOf(SKIP, REVERSE, DRAW_TWO, WILD, WILD_DRAW_FOUR)
val isWild: Boolean
get() = this in listOf(WILD, WILD_DRAW_FOUR)
}
@Serializable
data class Card(
val color: CardColor?,
val value: CardValue
) {
val isWild: Boolean get() = value.isWild
val isActionCard: Boolean get() = value.isActionCard
val displayValue: String
get() = when (value) {
CardValue.SKIP -> "🚫"
CardValue.REVERSE -> "↩️"
CardValue.DRAW_TWO -> "+2"
CardValue.WILD -> "🌈"
CardValue.WILD_DRAW_FOUR -> "+4"
else -> value.ordinal.toString()
}
fun canPlayOn(topCard: Card, currentColor: CardColor?): Boolean {
if (isWild) return true
if (color == currentColor) return true
if (value == topCard.value) return true
return false
}
}
@@ -0,0 +1,33 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class GameDirection {
CLOCKWISE, COUNTER_CLOCKWISE;
fun reverse(): GameDirection = when (this) {
CLOCKWISE -> COUNTER_CLOCKWISE
COUNTER_CLOCKWISE -> CLOCKWISE
}
}
@Serializable
data class Deck(
val drawPile: List<Card> = emptyList(),
val discardPile: List<Card> = emptyList()
) {
val topCard: Card? get() = discardPile.lastOrNull()
val drawPileEmpty: Boolean get() = drawPile.isEmpty()
val discardPileSize: Int get() = discardPile.size
fun draw(count: Int = 1): Pair<Deck, List<Card>> {
val drawn = drawPile.take(count)
val remaining = drawPile.drop(count)
return Pair(copy(drawPile = remaining), drawn)
}
fun discard(card: Card): Deck {
return copy(discardPile = discardPile + card)
}
}
@@ -0,0 +1,45 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
enum class GamePhase {
WAITING_FOR_PLAYERS,
PLAYING,
FINISHED
}
@Serializable
enum class TurnAction {
PLAY_CARD,
DRAW_CARD,
CALL_UNO,
PASS
}
@Serializable
data class GameState(
val phase: GamePhase = GamePhase.WAITING_FOR_PLAYERS,
val players: List<Player> = emptyList(),
val currentPlayerIndex: Int = 0,
val deck: Deck = Deck(),
val direction: GameDirection = GameDirection.CLOCKWISE,
val currentColor: CardColor? = null,
val winner: Player? = null,
val pendingDraw: Int = 0,
val lastAction: String = ""
) {
val currentPlayer: Player? get() = players.getOrNull(currentPlayerIndex)
val isFinished: Boolean get() = phase == GamePhase.FINISHED
val topCard: Card? get() = deck.topCard
fun nextPlayerIndex(): Int {
val size = players.size
return when (direction) {
GameDirection.CLOCKWISE -> (currentPlayerIndex + 1) % size
GameDirection.COUNTER_CLOCKWISE -> (currentPlayerIndex - 1 + size) % size
}
}
fun findPlayer(id: String): Player? = players.find { it.id == id }
}
@@ -0,0 +1,15 @@
package com.uno.game.model
import kotlinx.serialization.Serializable
@Serializable
data class Player(
val id: String,
val name: String,
val hand: List<Card> = emptyList(),
val isHuman: Boolean = true,
val hasCalledUno: Boolean = false
) {
val cardCount: Int get() = hand.size
val isEmpty: Boolean get() = hand.isEmpty()
}
@@ -0,0 +1,733 @@
package com.uno.shared
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.uno.game.GameEngine
import com.uno.game.model.*
import kotlin.math.roundToInt
// region --- Animations ---
@Composable
fun rememberPulseAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition()
val pulse by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
return pulse
}
@Composable
fun rememberGlowAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition()
val glow by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
return glow
}
@Composable
fun AnimatedCardEntry(
card: Card,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isPlayable: Boolean = true,
animationDelay: Int = 0
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(card) {
visible = false
kotlinx.coroutines.delay(animationDelay.toLong())
visible = true
}
AnimatedVisibility(
visible = visible,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300, delayMillis = animationDelay))
) {
UnoCardView(card = card, onClick = onClick, modifier = modifier, isPlayable = isPlayable)
}
}
@Composable
fun AnimatedTopCard(card: Card) {
var prevState by remember { mutableStateOf(card) }
var playAnim by remember { mutableStateOf(false) }
LaunchedEffect(card) {
if (card != prevState) {
playAnim = true
kotlinx.coroutines.delay(400)
playAnim = false
prevState = card
}
}
val scale by animateFloatAsState(
targetValue = if (playAnim) 1.3f else 1f,
animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessLow)
)
val rotation by animateFloatAsState(
targetValue = if (playAnim) 15f else 0f,
animationSpec = tween(400, easing = FastOutSlowInEasing)
)
Box(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
rotationZ = rotation
}
) {
UnoCardView(card = card, onClick = {})
}
}
// endregion
@Composable
fun UnoGameScreen(
state: GameState,
onPlayCard: (Card, CardColor?) -> Unit,
onDrawCard: () -> Unit,
onCallUno: () -> Unit
) {
var showColorPicker by remember { mutableStateOf(false) }
var pendingWildCard by remember { mutableStateOf<Card?>(null) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var showUnoCall by remember { mutableStateOf(false) }
var previousLastAction by remember { mutableStateOf(state.lastAction) }
LaunchedEffect(state.lastAction) {
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
showUnoCall = true
kotlinx.coroutines.delay(1500)
showUnoCall = false
}
previousLastAction = state.lastAction
}
LaunchedEffect(state.deck.topCard) {
previousTopCard = state.deck.topCard
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
GameInfoBar(state)
Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state)
Spacer(modifier = Modifier.height(12.dp))
DrawPileAndTopCard(state, onDrawCard)
Spacer(modifier = Modifier.height(12.dp))
PlayerHand(
player = state.players.firstOrNull(),
topCard = state.deck.topCard,
currentColor = state.currentColor,
isCurrentTurn = state.currentPlayerIndex == 0,
onCardClick = { card ->
if (card.isWild) {
pendingWildCard = card
showColorPicker = true
} else {
onPlayCard(card, null)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
ActionButtons(state, onDrawCard, onCallUno)
AnimatedContent(
targetState = state.lastAction,
transitionSpec = {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
},
label = "action"
) { action ->
if (action.isNotEmpty()) {
Text(
text = action,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
// UNO call overlay
AnimatedVisibility(
visible = showUnoCall,
enter = scaleIn(
initialScale = 0f,
animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = scaleOut(targetScale = 2f) + fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "UNO!",
fontSize = 72.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFFFF6D00),
modifier = Modifier
.shadow(16.dp, RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(16.dp))
.padding(horizontal = 32.dp, vertical = 16.dp)
)
}
// Winner celebration overlay
if (state.isFinished) {
WinnerCelebration(state.winner) {
// dismiss handled by caller
}
}
}
if (showColorPicker) {
AnimatedColorPickerDialog(
onColorSelected = { color ->
pendingWildCard?.let { onPlayCard(it, color) }
showColorPicker = false
pendingWildCard = null
},
onDismiss = {
showColorPicker = false
pendingWildCard = null
}
)
}
}
// region --- GameInfoBar ---
@Composable
private fun GameInfoBar(state: GameState) {
val directionRotation by animateFloatAsState(
targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f,
animationSpec = tween(500, easing = FastOutSlowInEasing),
label = "direction"
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Richtung:",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "↗️",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.graphicsLayer {
rotationZ = directionRotation
}
)
}
Text(
text = "Stapel: ${state.deck.drawPile.size}",
style = MaterialTheme.typography.bodyMedium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Farbe: ",
style = MaterialTheme.typography.bodyMedium
)
state.currentColor?.let { color ->
val bgColor = when (color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
}
Box(
modifier = Modifier
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(bgColor)
)
}
}
}
}
}
// endregion
// region --- OpponentHands ---
@Composable
private fun OpponentHands(state: GameState) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
state.players.drop(1).forEach { player ->
OpponentHandView(player, isActive = state.currentPlayerIndex == state.players.indexOf(player))
}
}
}
@Composable
private fun OpponentHandView(player: Player, isActive: Boolean) {
val pulse = if (isActive) rememberPulseAnimation() else 1f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(pulse)
) {
if (isActive) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = player.name,
style = MaterialTheme.typography.labelMedium,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold,
color = if (isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Row {
repeat(minOf(player.hand.size, 5)) {
AnimatedVisibility(
visible = true,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(200, delayMillis = it * 50)
)
) {
CardBack()
}
}
if (player.hand.size > 5) {
Text("+${player.hand.size - 5}", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
}
}
Text(
text = "${player.hand.size} Karten",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun CardBack() {
Box(
modifier = Modifier
.size(width = 30.dp, height = 45.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A237E))
.border(1.dp, Color.White, RoundedCornerShape(4.dp))
.padding(2.dp),
contentAlignment = Alignment.Center
) {
Text("UNO", color = Color.White, fontSize = 7.sp, fontWeight = FontWeight.Bold)
}
}
// endregion
// region --- DrawPileAndTopCard ---
@Composable
private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) {
var drawHover by remember { mutableStateOf(false) }
val drawScale by animateFloatAsState(
targetValue = if (drawHover) 1.1f else 1f,
animationSpec = spring(dampingRatio = 0.5f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.scale(drawScale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
drawHover = true
tryAwaitRelease()
drawHover = false
},
onTap = { onDrawCard() }
)
}
) {
CardBack()
Spacer(modifier = Modifier.height(4.dp))
Text("Ziehen", style = MaterialTheme.typography.labelSmall)
}
Spacer(modifier = Modifier.width(32.dp))
state.deck.topCard?.let { card ->
AnimatedTopCard(card = card)
}
}
}
// endregion
// region --- UnoCardView ---
@Composable
fun UnoCardView(
card: Card,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isPlayable: Boolean = true
) {
val glowAlpha = if (isPlayable) rememberGlowAnimation() else 0f
val backgroundColor = when (card.color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
null -> Color(0xFF424242)
}
val textColor = if (card.color == CardColor.YELLOW) Color.Black else Color.White
Box(
modifier = modifier
.size(width = 50.dp, height = 75.dp)
.shadow(
elevation = if (isPlayable) (4 * glowAlpha).dp else 2.dp,
shape = RoundedCornerShape(6.dp),
ambientColor = backgroundColor,
spotColor = backgroundColor
)
.clip(RoundedCornerShape(6.dp))
.background(backgroundColor)
.then(
if (isPlayable) {
Modifier.border(
width = (1.5f + glowAlpha * 1.5f).dp,
color = Color.White.copy(alpha = glowAlpha * 0.8f),
shape = RoundedCornerShape(6.dp)
)
} else {
Modifier.border(2.dp, Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
}
)
.clickable(enabled = isPlayable) { onClick() }
.padding(4.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = card.displayValue,
color = textColor,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
if (card.color != null) {
Text(text = card.color.displayName, color = textColor, fontSize = 7.sp)
}
}
}
}
// endregion
// region --- PlayerHand ---
@Composable
private fun PlayerHand(
player: Player?,
topCard: Card?,
currentColor: CardColor?,
isCurrentTurn: Boolean,
onCardClick: (Card) -> Unit
) {
if (player == null) return
val turnScale by animateFloatAsState(
targetValue = if (isCurrentTurn) 1.02f else 1f,
animationSpec = spring(dampingRatio = 0.6f)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(turnScale)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isCurrentTurn) {
val dotPulse = rememberPulseAnimation()
Box(
modifier = Modifier
.size((8 * dotPulse).dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = "${player.name} (${player.hand.size} Karten)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy((-8).dp),
contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(player.hand, key = { "${it.color}_${it.value}_${player.hand.indexOf(it)}" }) { card ->
val canPlay = topCard != null && card.canPlayOn(topCard, currentColor)
AnimatedCardEntry(
card = card,
onClick = { onCardClick(card) },
modifier = Modifier.padding(horizontal = 4.dp),
isPlayable = canPlay && isCurrentTurn,
animationDelay = player.hand.indexOf(card) * 30
)
}
}
}
}
// endregion
// region --- ActionButtons ---
@Composable
private fun ActionButtons(
state: GameState,
onDrawCard: () -> Unit,
onCallUno: () -> Unit
) {
val isMyTurn = state.currentPlayerIndex == 0
val player = state.players.firstOrNull()
val hasTwoCards = player?.hand?.size == 2
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onDrawCard, enabled = isMyTurn) {
Text("Karte ziehen")
}
val unoScale by animateFloatAsState(
targetValue = if (hasTwoCards && isMyTurn && !(player?.hasCalledUno ?: true)) 1.1f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Button(
onClick = onCallUno,
enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true),
modifier = Modifier.scale(unoScale),
colors = ButtonDefaults.buttonColors(
containerColor = if (hasTwoCards) Color(0xFFFF6D00)
else MaterialTheme.colorScheme.primary
)
) {
Text("UNO!")
}
}
}
// endregion
// region --- WinnerCelebration ---
@Composable
fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition()
val celebrationScale by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
val celebrationRotation by infiniteTransition.animateFloat(
initialValue = -3f,
targetValue = 3f,
animationSpec = infiniteRepeatable(
animation = tween(400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "🎉 Gewonnen! 🎉",
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = celebrationScale
scaleY = celebrationScale
rotationZ = celebrationRotation
},
textAlign = TextAlign.Center,
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.primary
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = winner?.name ?: "Unbekannt",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge)
}
},
confirmButton = {
Button(
onClick = onDismiss,
modifier = Modifier.scale(celebrationScale)
) {
Text("Nochmal spielen")
}
}
)
}
// endregion
// region --- AnimatedColorPickerDialog ---
@Composable
fun AnimatedColorPickerDialog(
onColorSelected: (CardColor) -> Unit,
onDismiss: () -> Unit
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
AlertDialog(
onDismissRequest = onDismiss,
title = {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()
) {
Text("Farbe wählen")
}
},
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CardColor.entries.forEachIndexed { index, color ->
val bgColor = when (color) {
CardColor.RED -> Color(0xFFE53935)
CardColor.GREEN -> Color(0xFF43A047)
CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835)
}
AnimatedVisibility(
visible = visible,
enter = scaleIn(
initialScale = 0f,
animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)
) + fadeIn(tween(300, delayMillis = index * 80))
) {
Box(
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(8.dp))
.background(bgColor)
.clickable { onColorSelected(color) },
contentAlignment = Alignment.Center
) {
Text(
text = color.displayName,
color = if (color == CardColor.YELLOW) Color.Black else Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
},
confirmButton = {}
)
}
// endregion
+61
View File
@@ -0,0 +1,61 @@
{
pkgs ? import <nixpkgs> { config.allowUnfree = true; },
}:
let
androidSdk = pkgs.androidenv.composeAndroidPackages {
buildToolsVersions = [ "35.0.0" ];
platformVersions = [ "35" ];
abiVersions = [ "x86_64" ];
includeEmulator = false;
includeSystemImages = false;
includeNDK = false;
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
jdk21
kotlin
gradle
androidSdk.androidsdk
android-tools
libGL
libX11
libXcursor
libXext
libXi
libXrender
fontconfig
libxkbcommon
];
shellHook = ''
ln -sfn ${pkgs.jdk21}/lib/openjdk .jdk
export ANDROID_HOME="${androidSdk.androidsdk}/libexec/android-sdk"
export ANDROID_SDK_ROOT="${androidSdk.androidsdk}/libexec/android-sdk"
export GRADLE_OPTS="-Dorg.gradle.java.installations.paths=${pkgs.jdk21}/lib/openjdk"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$JAVA_HOME/bin:$PATH"
export LD_LIBRARY_PATH=${
pkgs.lib.makeLibraryPath [
pkgs.libGL
pkgs.libX11
pkgs.libXcursor
pkgs.libXext
pkgs.libXi
pkgs.libXrender
pkgs.fontconfig
pkgs.libxkbcommon
]
}
echo "sdk.dir=$ANDROID_HOME" > local.properties
echo "=== UNO KMP Dev Shell ==="
echo "JDK: $(java -version 2>&1 | head -1)"
echo "Kotlin: $(kotlin -version 2>&1 | head -1)"
echo "Gradle: $(gradle --version 2>&1 | grep 'Gradle')"
echo "Android: $ANDROID_HOME"
echo "========================"
'';
}
+21
View File
@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
kotlin {
wasmJs {
browser()
}
sourceSets {
wasmJsMain.dependencies {
implementation(project(":shared"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
@@ -0,0 +1,112 @@
package com.uno.web
import androidx.compose.runtime.*
import com.uno.game.GameEngine
import com.uno.game.model.*
import com.uno.shared.UnoGameScreen
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
fun main() {
val root = document.getElementById("root") as HTMLElement
root.innerHTML = ""
val container = document.createElement("div")
container.id = "app"
root.appendChild(container)
composeWebRoot(container)
}
@Composable
fun composeWebRoot(container: HTMLElement) {
val engine = remember { GameEngine() }
var gameState by remember { mutableStateOf(engine.startGame()) }
var gameStarted by remember { mutableStateOf(false) }
if (!gameStarted) {
WebStartScreen(
onStartGame = { playerCount ->
gameState = engine.startGame(playerCount)
gameStarted = true
}
)
} else {
UnoGameScreen(
state = gameState,
onPlayCard = { card, color ->
gameState = engine.playCard(gameState, 0, card, color)
gameState = runAiTurns(engine, gameState)
},
onDrawCard = {
gameState = engine.drawCard(gameState, 0)
gameState = runAiTurns(engine, gameState)
},
onCallUno = {
gameState = engine.callUno(gameState, 0)
}
)
if (gameState.isFinished) {
WebGameOverScreen(
winner = gameState.winner,
onPlayAgain = {
gameState = engine.startGame()
}
)
}
}
}
private fun runAiTurns(engine: GameEngine, state: GameState): GameState {
var currentState = state
while (currentState.currentPlayerIndex != 0 && !currentState.isFinished) {
currentState = engine.aiPlay(currentState, currentState.currentPlayerIndex)
}
return currentState
}
@Composable
fun WebStartScreen(onStartGame: (Int) -> Unit) {
androidx.compose.foundation.layout.Column(
modifier = androidx.compose.foundation.layout.fillMaxSize().padding(32.dp),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
androidx.compose.material3.Text(
text = "UNO",
style = androidx.compose.material3.MaterialTheme.typography.displayLarge,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier)
androidx.compose.material3.Text(
text = "Web Edition",
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier)
androidx.compose.material3.Button(onClick = { onStartGame(4) }) {
androidx.compose.material3.Text("Spiel starten (4 Spieler)")
}
}
}
@Composable
fun WebGameOverScreen(winner: Player?, onPlayAgain: () -> Unit) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onPlayAgain,
title = { androidx.compose.material3.Text("Spiel vorbei!") },
text = {
androidx.compose.material3.Text(
text = if (winner != null) "${winner.name} hat gewonnen!"
else "Unentschieden!"
)
},
confirmButton = {
androidx.compose.material3.Button(onClick = onPlayAgain) {
androidx.compose.material3.Text("Nochmal spielen")
}
}
)
}
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UNO - Kotlin Multiplatform</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#root { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="root"></div>
<script src="webApp.js"></script>
</body>
</html>