Brand the app with the Wallace tartan and a Celtic splash

- The launcher icon is a single square of the tartan, drawn as a vector
  so it stays sharp at any density and ships no PNGs. The adaptive
  icon's foreground is deliberately empty: the cloth is the icon.
- Tartan.kt draws the sett as warp, then as weft at half opacity. That
  overlap is what gives the blended squares and reads as woven cloth
  rather than a printed grid, and it is reused by both the icon and the
  splash.
- The app opens on the tartan under "Pro Libertate Games" in Uncial
  Antiqua, holds two seconds, then fades. It is an overlay rather than a
  destination, so the menu is already composed behind it and the fade
  reveals a live screen. The window background is set to a dark red so
  launch does not flash white before Compose draws.

Two things to be aware of:

- WallaceSett is an approximation. It reads as Wallace — bold red and
  black check with a narrow gold overstripe — but the registered
  threadcount could not be verified here, so the counts were chosen to
  look right. They are plain data and can be swapped for the registered
  ones without touching anything else.
- Uncial Antiqua is bundled under the SIL Open Font License 1.1, with
  the full licence at docs/licenses/UncialAntiqua-OFL.txt. The OFL
  requires that to be distributed with the font, so it must not be
  separated from it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 02:52:55 +00:00
parent ce83f7c2c4
commit dbe76c34c4
12 changed files with 465 additions and 37 deletions
@@ -82,11 +82,9 @@ fun AppRoot(env: AppEnv) {
env.updater.installedChannel() == UpdateChannel.DEV
}
fun push(route: Route) {
stack = stack + route
}
val push: (Route) -> Unit = { route -> stack = stack + route }
fun pop() {
val pop: () -> Unit = {
if (stack.size > 1) {
// Leaving a lobby or a table tears the networking down with it.
when (stack.last()) {
@@ -99,6 +97,35 @@ fun AppRoot(env: AppEnv) {
BackHandler(enabled = stack.size > 1) { pop() }
var splashDone by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
AppContent(
env = env,
settings = settings,
stack = stack,
showComingSoon = showComingSoon,
push = push,
pop = pop,
)
// Over the top, so the menu behind it is already composed when it fades.
if (!splashDone) {
SplashScreen(onFinished = { splashDone = true })
}
}
}
/** The navigation stack proper, kept separate so the splash can sit over it. */
@Composable
private fun AppContent(
env: AppEnv,
settings: Settings,
stack: List<Route>,
showComingSoon: Boolean,
push: (Route) -> Unit,
pop: () -> Unit,
) {
when (val route = stack.last()) {
is Route.Menu -> MainMenuScreen(
// Dev builds show the games that are not finished yet; production
@@ -0,0 +1,125 @@
package org.prolibertate.games.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.TartanBackground
/** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L
private const val SPLASH_FADE_MILLIS = 600
/**
* Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font
* License. See docs/licenses/UncialAntiqua-OFL.txt.
*/
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
/**
* The launch screen: Wallace tartan under the name of the house.
*
* Drawn as an overlay rather than a separate destination, so the menu is
* already composed and ready behind it and the fade reveals a live screen
* instead of a blank one.
*/
@Composable
fun SplashScreen(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
var fading by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS),
label = "splashFade",
)
LaunchedEffect(Unit) {
delay(SPLASH_HOLD_MILLIS)
fading = true
delay(SPLASH_FADE_MILLIS.toLong())
onFinished()
}
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
// Weave scale follows the screen so the cloth reads the same on a phone
// and a tablet rather than turning into a fine grid on a large display.
val threadWidth = (minOf(maxWidth, maxHeight).value * 0.012f).dp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Pro Libertate",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
color = Color(0xFFF7EFD8),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
Text(
text = "Games",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
color = Color(0xFFF2C200),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
}
}
}
@@ -0,0 +1,97 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** One band of a tartan sett, measured in threads. */
data class TartanStripe(val color: Color, val threads: Int)
val WallaceRed = Color(0xFFB3121B)
val WallaceBlack = Color(0xFF111111)
val WallaceGold = Color(0xFFF2C200)
/**
* The Wallace sett.
*
* NOTE: this is an approximation, not a certified threadcount. It reproduces
* what the Wallace tartan reads as — a bold red and black check divided by a
* narrow gold overstripe — but the exact thread counts held by the Scottish
* Register of Tartans could not be verified from here, so the numbers below
* are chosen to look right rather than to be authoritative. They are plain
* data: replacing them with the registered counts changes the whole app's
* branding and needs no other edit.
*/
val WallaceSett: List<TartanStripe> = listOf(
TartanStripe(WallaceRed, 40),
TartanStripe(WallaceBlack, 40),
TartanStripe(WallaceGold, 4),
)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = WallaceSett,
threadSize: Float,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Warp: full-height bands.
var x = 0f
while (x < size.width) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, 0f),
size = Size(width, size.height),
)
offset += width
}
x += repeat
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = 0f
while (y < size.height) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(0f, offset),
size = Size(size.width, height),
)
offset += height
}
y += repeat
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
*/
@Composable
fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = WallaceSett,
threadWidth: Dp = 2.dp,
) {
Canvas(modifier = modifier) {
drawTartan(sett = sett, threadSize = threadWidth.toPx())
}
}
@@ -1,11 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A single square of the Wallace tartan.
The sett is laid down as warp (opaque, vertical) over a black ground, then as
weft (half opacity, horizontal), which is what produces the blended squares
rather than a flat grid. One full repeat is 72 units wide, so the centre
72x72 safe zone of the adaptive icon shows exactly one repeat.
Thread proportions match WallaceSett in Tartan.kt — see the note there about
these being a visual approximation rather than the registered threadcount.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Solid grass-green field. -->
<!-- Ground -->
<path
android:fillColor="#2E7D32"
android:fillColor="#111111"
android:pathData="M0,0h108v108h-108z" />
<!-- Warp: red -->
<path
android:fillColor="#B3121B"
android:pathData="M0,0h34.29v108h-34.29z" />
<path
android:fillColor="#B3121B"
android:pathData="M72,0h34.29v108h-34.29z" />
<!-- Warp: gold overstripe -->
<path
android:fillColor="#F2C200"
android:pathData="M68.57,0h3.43v108h-3.43z" />
<!-- Weft: red -->
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,0h108v34.29h-108z" />
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,72h108v34.29h-108z" />
<!-- Weft: black -->
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,34.29h108v34.28h-108z" />
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,106.29h108v1.71h-108z" />
<!-- Weft: gold overstripe -->
<path
android:fillColor="#F2C200"
android:fillAlpha="0.5"
android:pathData="M0,68.57h108v3.43h-108z" />
</vector>
@@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
foreground layer carries nothing — anything drawn here would sit on top of the
tartan and break the "single square of tartan" idea.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- A black hole in the center of the icon's safe zone. -->
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:fillColor="#00000000"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector>
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback round launcher icon for API < 26. -->
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector>
+4 -1
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B5E20</color>
<color name="ic_launcher_background">#B3121B</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Shown for the instant before Compose draws, so launch does not flash
white before the tartan splash appears. -->
<color name="splash_background">#4A0B10</color>
</resources>
+1
View File
@@ -5,5 +5,6 @@
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>