713 lines
25 KiB
Python
713 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate P1, an osu!stable skin styled after pax.moe.
|
|
|
|
Every element is drawn at SS x the @2x size, then downsampled to @2x and @1x.
|
|
Sizes below are in @1x ("logical") pixels. Everything visual is drawn here.
|
|
Sounds can optionally come from skins you already have:
|
|
gen.py OUT [--assets SKIN] [--hitsounds SKIN]
|
|
"""
|
|
|
|
import math
|
|
import re
|
|
import argparse
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
SS = 4 # supersample factor on top of @2x
|
|
K = 2 * SS # canvas pixels per logical pixel
|
|
|
|
GRN = (0, 255, 0)
|
|
GRN2 = (0, 204, 0) # hyprland gradient end
|
|
DARK = (0, 34, 0) # hyprland inactive border
|
|
RED = (255, 85, 85) # quickshell bar warning
|
|
CYAN = (40, 234, 242) # cursor; matches the Beafowl trail so the two blend
|
|
WHITE = (255, 255, 255)
|
|
|
|
|
|
|
|
def find_font(pattern="Terminess Nerd Font Mono:style=Bold"):
|
|
"""Resolve the font file through fontconfig, so any distro's install works."""
|
|
out = subprocess.run(["fc-match", "-f", "%{file}", pattern], capture_output=True, text=True)
|
|
path = out.stdout.strip()
|
|
if "Terminess" not in Path(path).name:
|
|
sys.exit(f"Terminess Nerd Font not found (fc-match gave {path or 'nothing'}); install it first")
|
|
return path
|
|
|
|
|
|
FONT = find_font()
|
|
|
|
OUT: Path
|
|
|
|
|
|
def rgba(c, a=1.0):
|
|
return (*c, round(255 * a))
|
|
|
|
|
|
class C:
|
|
"""A canvas in logical pixels."""
|
|
|
|
def __init__(self, w, h, bg=(0, 0, 0, 0)):
|
|
self.w, self.h = w, h
|
|
self.im = Image.new("RGBA", (round(w * K), round(h * K)), bg)
|
|
self.d = ImageDraw.Draw(self.im)
|
|
|
|
def p(self, v):
|
|
return v * K
|
|
|
|
def ring(self, cx, cy, r, width, col):
|
|
r, w = self.p(r), self.p(width)
|
|
cx, cy = self.p(cx), self.p(cy)
|
|
self.d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=col, width=max(1, round(w)))
|
|
|
|
def disc(self, cx, cy, r, col):
|
|
r, cx, cy = self.p(r), self.p(cx), self.p(cy)
|
|
self.d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=col)
|
|
|
|
def rect(self, x0, y0, x1, y1, fill=None, outline=None, width=1):
|
|
self.d.rectangle((self.p(x0), self.p(y0), self.p(x1), self.p(y1)),
|
|
fill=fill, outline=outline, width=max(1, round(self.p(width))))
|
|
|
|
def line(self, pts, col, width=1):
|
|
self.d.line([(self.p(x), self.p(y)) for x, y in pts], fill=col,
|
|
width=max(1, round(self.p(width))), joint="curve")
|
|
|
|
def brackets(self, x0, y0, x1, y1, arm, col, width=1, all4=False):
|
|
"""pax.moe corner brackets: top left and bottom right L shapes."""
|
|
self.line([(x0, y0 + arm), (x0, y0), (x0 + arm, y0)], col, width)
|
|
self.line([(x1, y1 - arm), (x1, y1), (x1 - arm, y1)], col, width)
|
|
if all4:
|
|
self.line([(x1 - arm, y0), (x1, y0), (x1, y0 + arm)], col, width)
|
|
self.line([(x0 + arm, y1), (x0, y1), (x0, y1 - arm)], col, width)
|
|
|
|
def text(self, s, cx, cy, size, col, font=FONT, anchor="mm"):
|
|
f = ImageFont.truetype(font, round(self.p(size)))
|
|
self.d.text((self.p(cx), self.p(cy)), s, font=f, fill=col, anchor=anchor)
|
|
|
|
def glow(self, radius, strength=1.0):
|
|
"""Blur a copy of the current layer underneath it, like text-shadow."""
|
|
# Blur premultiplied (RGBa): transparent pixels are stored as black, and a
|
|
# straight RGBA blur mixes that black into the edge as a dark fringe.
|
|
blur = self.im.convert("RGBa").filter(ImageFilter.GaussianBlur(self.p(radius))).convert("RGBA")
|
|
if strength != 1.0:
|
|
a = blur.getchannel("A").point(lambda v: min(255, round(v * strength)))
|
|
blur.putalpha(a)
|
|
blur.alpha_composite(self.im)
|
|
self.im = blur
|
|
self.d = ImageDraw.Draw(self.im)
|
|
|
|
def save(self, name, hd_only=False, jpg=False):
|
|
for scale, suffix in ((2, "@2x"), (1, "")):
|
|
if hd_only and scale == 1:
|
|
continue
|
|
size = (max(1, round(self.w * scale)), max(1, round(self.h * scale)))
|
|
out = self.im.resize(size, Image.LANCZOS)
|
|
if jpg:
|
|
out.convert("RGB").save(OUT / f"{name}{suffix}.jpg", quality=92)
|
|
else:
|
|
out.save(OUT / f"{name}{suffix}.png")
|
|
|
|
|
|
def blank(name):
|
|
"""A 1x1 transparent image hides an element the game would otherwise draw."""
|
|
Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(OUT / f"{name}.png")
|
|
|
|
|
|
def text_img(name, s, size, col, pad=8, glow=3, font=FONT):
|
|
"""Text sized to its own bounding box."""
|
|
f = ImageFont.truetype(font, round(size * K))
|
|
l, t, r, b = f.getbbox(s)
|
|
w, h = (r - l) / K + 2 * pad, (b - t) / K + 2 * pad
|
|
c = C(w, h)
|
|
c.d.text((pad * K - l, pad * K - t), s, font=f, fill=col)
|
|
if glow:
|
|
c.glow(glow, 1.4)
|
|
c.save(name)
|
|
|
|
|
|
# ---------------------------------------------------------------- gameplay
|
|
|
|
def hitcircles():
|
|
# Tinted by the combo colour: drawn white so the tint shows through.
|
|
c = C(128, 128)
|
|
# Ring spans 58 to 63 so at slider heads it covers the body's border.
|
|
c.disc(64, 64, 60, rgba(WHITE, 0.16))
|
|
c.ring(64, 64, 63, 5, rgba(WHITE))
|
|
c.ring(64, 64, 50, 1, rgba(WHITE, 0.35))
|
|
c.save("hitcircle")
|
|
|
|
blank("hitcircleoverlay")
|
|
|
|
a = C(126, 126)
|
|
a.ring(63, 63, 59, 6, rgba(WHITE)) # thick rim reads at small sizes
|
|
a.save("approachcircle")
|
|
|
|
for n in range(10):
|
|
f = ImageFont.truetype(FONT, 54 * K)
|
|
l, t, r, b = f.getbbox(str(n))
|
|
d = C((r - l) / K + 8, (b - t) / K + 8)
|
|
d.d.text((4 * K - l, 4 * K - t), str(n), font=f, fill=rgba((210, 255, 210)))
|
|
d.glow(2, 1.2)
|
|
d.save(f"default-{n}")
|
|
|
|
|
|
def sliders():
|
|
# Not tinted by the game (Normal blend), so one colour serves every combo:
|
|
# white stays neutral around every combo colour.
|
|
s = C(256, 256)
|
|
s.ring(128, 128, 118, 2, rgba(WHITE, 0.85))
|
|
s.glow(3, 1.5)
|
|
s.save("sliderfollowcircle")
|
|
|
|
b = C(118, 118)
|
|
b.ring(59, 59, 38, 4, rgba(WHITE))
|
|
b.disc(59, 59, 9, rgba(WHITE))
|
|
b.save("sliderb")
|
|
blank("sliderb-nd")
|
|
blank("sliderendcircle") # no circle at the slider tail; without this
|
|
blank("sliderendcircleoverlay") # stable falls back to the hit circle
|
|
blank("sliderb-spec")
|
|
|
|
r = C(128, 128)
|
|
r.text(">", 64, 62, 72, rgba(WHITE)) # Terminess, like the numbers; points right, the game rotates it
|
|
r.save("reversearrow")
|
|
|
|
p = C(16, 16)
|
|
p.rect(5, 5, 11, 11, fill=rgba(WHITE))
|
|
p.glow(1.5, 1.2)
|
|
p.save("sliderscorepoint")
|
|
|
|
followpoints()
|
|
|
|
for name in ("lighting", "particle50", "particle100", "particle300"):
|
|
blank(name)
|
|
|
|
|
|
|
|
FP_LEN, FP_FEATHER, FP_PEAK = 128, 24, 0.85 # @1x px, @1x px, opacity
|
|
FP_FRAMES, FP_IN, FP_OUT = 72, 8, 14 # 1.2 s at AnimationFramerate 60
|
|
|
|
|
|
def smooth(t):
|
|
t = min(1.0, max(0.0, t))
|
|
return t * t * (3 - 2 * t)
|
|
|
|
|
|
def followpoints():
|
|
"""A continuous line from overlapping pieces, built like the line
|
|
followpoints in Beafowl and piwack: osu! places a piece every 32 osu px at
|
|
the circle's scale and fades each one on its own, so the ends are feathered
|
|
(neighbours blend, a fading piece never leaves a hard edge) and every piece
|
|
also eases in and out over its own animation frames."""
|
|
base = C(FP_LEN, 12)
|
|
n_w, n_h = base.im.size
|
|
k = n_w / FP_LEN
|
|
feather = FP_FEATHER * k
|
|
across = [smooth(1 - abs(y + 0.5 - n_h / 2) / (2.2 * k)) for y in range(n_h)]
|
|
along = [smooth(min(x + 0.5, n_w - x - 0.5) / feather) for x in range(n_w)]
|
|
shape = [along[x] * across[y] for y in range(n_h) for x in range(n_w)]
|
|
for i in range(FP_FRAMES):
|
|
level = FP_PEAK * min(smooth(i / FP_IN), smooth((FP_FRAMES - 1 - i) / FP_OUT))
|
|
alpha = Image.new("L", (n_w, n_h))
|
|
alpha.putdata([round(255 * level * v) for v in shape])
|
|
c = C(FP_LEN, 12)
|
|
c.im = Image.new("RGBA", (n_w, n_h), rgba(WHITE))
|
|
c.im.putalpha(alpha)
|
|
c.save(f"followpoint-{i}")
|
|
|
|
|
|
def judgements():
|
|
for name in ("hit300", "hit300g", "hit300k"):
|
|
blank(name)
|
|
text_img("hit100", "100", 34, rgba(GRN, 0.8))
|
|
text_img("hit100k", "100", 34, rgba(GRN, 0.8))
|
|
text_img("hit50", "50", 34, rgba(GRN, 0.45))
|
|
text_img("hit0", "x", 54, rgba(RED), glow=4)
|
|
blank("comboburst")
|
|
|
|
|
|
# Trail dot: alpha by radius in @1x px, a solid core with a soft falloff,
|
|
# linearly interpolated between these points.
|
|
TRAIL_PROFILE = [(0, 255), (15, 255), (16, 192), (17, 118), (18, 84), (19, 58),
|
|
(20, 37), (21, 21), (22, 10), (23, 5), (24, 2), (26, 0)]
|
|
|
|
|
|
def trail_alpha(r):
|
|
for (r0, a0), (r1, a1) in zip(TRAIL_PROFILE, TRAIL_PROFILE[1:]):
|
|
if r <= r1:
|
|
return a0 + (a1 - a0) * (r - r0) / (r1 - r0)
|
|
return 0
|
|
|
|
|
|
def cursor_trail():
|
|
"""55 px cyan dot that the cursor glow blends into; drawn per pixel from
|
|
TRAIL_PROFILE so the falloff is exact rather than a blur guess."""
|
|
c = C(55, 55)
|
|
n, mid = c.im.width, c.im.width / 2
|
|
alpha = Image.new("L", (n, n))
|
|
alpha.putdata([round(trail_alpha(math.hypot(x + 0.5 - mid, y + 0.5 - mid) / K))
|
|
for y in range(n) for x in range(n)])
|
|
c.im = Image.new("RGBA", (n, n), rgba(CYAN))
|
|
c.im.putalpha(alpha)
|
|
c.save("cursortrail")
|
|
|
|
|
|
def cursor():
|
|
"""A solid dot: white core, cyan band, wide soft glow that fades into the
|
|
trail. Cyan so the cursor stands apart from the green circles."""
|
|
cursor_trail()
|
|
# no cursormiddle: its presence switches stable to the continuous trail; Beafowl uses the disjoint one
|
|
|
|
sm = C(24, 24) # smoke (hold C): soft cyan dots that build a line
|
|
sm.disc(12, 12, 5, rgba(CYAN, 0.55))
|
|
sm.glow(3, 1.0)
|
|
sm.save("cursor-smoke")
|
|
|
|
c = C(72, 72)
|
|
c.disc(36, 36, 16, rgba(CYAN))
|
|
c.glow(6, 1.3)
|
|
c.disc(36, 36, 10, rgba(WHITE))
|
|
c.save("cursor")
|
|
|
|
|
|
def spinner():
|
|
size = 480
|
|
m = size / 2
|
|
b = C(size, size)
|
|
for i in range(72):
|
|
a = 2 * math.pi * i / 72
|
|
r0, r1 = (206, 222) if i % 6 else (196, 226)
|
|
b.line([(m + r0 * math.cos(a), m + r0 * math.sin(a)),
|
|
(m + r1 * math.cos(a), m + r1 * math.sin(a))],
|
|
rgba(GRN, 0.66 if i % 6 else 1.0), 2)
|
|
b.glow(2, 1.2)
|
|
b.save("spinner-bottom")
|
|
|
|
t = C(size, size)
|
|
t.ring(m, m, 180, 1, rgba(GRN, 0.34))
|
|
t.save("spinner-top")
|
|
|
|
mid = C(160, 160)
|
|
mid.ring(80, 80, 60, 3, rgba(WHITE))
|
|
mid.save("spinner-middle")
|
|
|
|
mid2 = C(40, 40)
|
|
mid2.rect(14, 14, 26, 26, fill=rgba(GRN))
|
|
mid2.glow(2, 1.5)
|
|
mid2.save("spinner-middle2")
|
|
|
|
# The game tints the glow cyan (additive): drawn pure green, cyan x green = green.
|
|
g = C(size, size)
|
|
g.ring(m, m, 214, 10, rgba(GRN, 0.6))
|
|
g.glow(10, 1.0)
|
|
g.save("spinner-glow")
|
|
|
|
a = C(384, 384)
|
|
a.ring(192, 192, 188, 3, rgba(GRN))
|
|
a.save("spinner-approachcircle")
|
|
|
|
# osu!taiko still uses spinner-circle with the new style; reuse the dial look.
|
|
c = C(size, size)
|
|
for i in range(72):
|
|
ang = 2 * math.pi * i / 72
|
|
r0, r1 = (206, 222) if i % 6 else (196, 226)
|
|
c.line([(m + r0 * math.cos(ang), m + r0 * math.sin(ang)),
|
|
(m + r1 * math.cos(ang), m + r1 * math.sin(ang))],
|
|
rgba(GRN, 0.66 if i % 6 else 1.0), 2)
|
|
c.disc(m, m, 6, rgba(GRN))
|
|
c.glow(2, 1.2)
|
|
c.save("spinner-circle")
|
|
|
|
r = C(280, 56)
|
|
r.rect(1, 1, 279, 55, fill=rgba((0, 0, 0), 0.85), outline=rgba(GRN, 0.34))
|
|
r.brackets(1, 1, 279, 55, 10, rgba(GRN), 2)
|
|
r.text("rpm", 24, 28, 22, rgba(GRN, 0.66), anchor="lm")
|
|
r.save("spinner-rpm")
|
|
|
|
text_img("spinner-clear", "clear", 64, rgba(GRN))
|
|
text_img("spinner-spin", "spin", 48, rgba(GRN, 0.8))
|
|
|
|
|
|
# ---------------------------------------------------------------- hud
|
|
|
|
def scorebar():
|
|
bg = C(640, 36)
|
|
bg.rect(8, 9, 616, 23, outline=rgba(GRN, 0.34))
|
|
bg.brackets(8, 9, 616, 23, 6, rgba(GRN), 1)
|
|
bg.save("scorebar-bg")
|
|
|
|
# With a marker the colour sits at (12,12) and is multiply tinted.
|
|
col = C(600, 8)
|
|
col.rect(0, 0, 600, 8, fill=rgba(WHITE))
|
|
col.save("scorebar-colour")
|
|
|
|
mk = C(24, 24)
|
|
mk.rect(8, 6, 16, 18, fill=rgba(GRN))
|
|
mk.glow(3, 1.6)
|
|
mk.save("scorebar-marker")
|
|
|
|
|
|
def score_fonts():
|
|
glyphs = {str(n): str(n) for n in range(10)}
|
|
glyphs.update({"comma": ",", "dot": ".", "percent": "%", "x": "x"})
|
|
for key, ch in glyphs.items():
|
|
f = ImageFont.truetype(FONT, 44 * K)
|
|
l, t, r, b = f.getbbox("0") # fixed cell keeps digits aligned
|
|
w = (r - l) / K + 6
|
|
if key in ("comma", "dot"):
|
|
w = 14
|
|
c = C(w, (b - t) / K + 10)
|
|
c.d.text((c.w * K / 2, c.h * K / 2), ch, font=f, fill=rgba(WHITE), anchor="mm")
|
|
c.glow(2, 0.8)
|
|
c.save(f"score-{key}")
|
|
|
|
f2 = ImageFont.truetype(FONT, 14 * K)
|
|
e = C(11 if key not in ("comma", "dot") else 5, 14)
|
|
e.d.text((e.w * K / 2, e.h * K / 2), ch, font=f2, fill=rgba((170, 255, 170)), anchor="mm")
|
|
e.save(f"scoreentry-{key}")
|
|
|
|
|
|
def input_overlay():
|
|
bg = C(193, 55)
|
|
bg.rect(1, 1, 192, 54, fill=rgba((0, 0, 0), 0.85), outline=rgba(GRN, 0.34))
|
|
bg.save("inputoverlay-background")
|
|
|
|
k = C(43, 46)
|
|
k.rect(4, 5, 39, 41, fill=rgba((0, 0, 0), 0.8), outline=rgba(WHITE), width=2)
|
|
k.save("inputoverlay-key")
|
|
|
|
|
|
def playfield():
|
|
s = C(200, 60)
|
|
s.rect(1, 1, 199, 59, fill=rgba((0, 0, 0), 0.85), outline=rgba(GRN, 0.34))
|
|
s.brackets(1, 1, 199, 59, 10, rgba(GRN), 2)
|
|
s.text("skip >>", 100, 30, 26, rgba(GRN))
|
|
s.save("play-skip")
|
|
|
|
for name, col in (("arrow-warning", RED), ("arrow-pause", GRN), ("arrow-generic", GRN)):
|
|
a = C(64, 64)
|
|
a.line([(20, 12), (44, 32), (20, 52)], rgba(col), 6)
|
|
a.glow(3, 1.5)
|
|
a.save(name)
|
|
shutil.copy(OUT / "arrow-warning.png", OUT / "play-warningarrow.png")
|
|
shutil.copy(OUT / "arrow-warning@2x.png", OUT / "play-warningarrow@2x.png")
|
|
|
|
text_img("play-unranked", "unranked", 22, rgba(GRN, 0.5), glow=0)
|
|
text_img("section-pass", "+", 160, rgba(GRN), glow=8)
|
|
text_img("section-fail", "x", 160, rgba(RED), glow=8)
|
|
text_img("ready", "ready?", 70, rgba(GRN))
|
|
text_img("go", "go", 110, rgba(GRN), glow=6)
|
|
for n in (1, 2, 3):
|
|
text_img(f"count{n}", str(n), 140, rgba(GRN), glow=6)
|
|
|
|
t = C(4, 24)
|
|
t.rect(1, 0, 3, 24, fill=rgba(WHITE))
|
|
t.save("options-offset-tick")
|
|
|
|
# Pillars beside 4:3 storyboards on widescreen. Origin right; the game
|
|
# stretches it and flips it for the right side, so the hairline is inner.
|
|
mb = C(24, 768, rgba((0, 0, 0)))
|
|
mb.rect(22, 0, 24, 768, fill=rgba(GRN, 0.34))
|
|
mb.save("masking-border")
|
|
|
|
|
|
def pause_screens():
|
|
"""Plain: a dark overlay and text buttons, nothing else."""
|
|
for name in ("pause-overlay", "fail-background"):
|
|
C(1366, 768, rgba((0, 0, 0), 0.78)).save(name)
|
|
|
|
for name, label in (("pause-continue", "continue"), ("pause-retry", "retry"),
|
|
("pause-back", "back to menu"), ("pause-replay", "replay")):
|
|
text_img(name, label, 40, rgba(GRN), pad=16, glow=2)
|
|
|
|
|
|
# ---------------------------------------------------------------- ranking
|
|
|
|
GRADES = {
|
|
"XH": ("SS", (235, 255, 235)), "X": ("SS", GRN),
|
|
"SH": ("S", (235, 255, 235)), "S": ("S", GRN),
|
|
"A": ("A", GRN2), "B": ("B", (0, 170, 0)), "C": ("C", (0, 130, 0)), "D": ("D", RED),
|
|
}
|
|
|
|
|
|
def ranking():
|
|
for key, (letters, col) in GRADES.items():
|
|
big = C(320, 320)
|
|
big.brackets(20, 20, 300, 300, 40, rgba(col, 0.66), 3)
|
|
big.text(letters, 160, 168, 230 if len(letters) == 1 else 170, rgba(col))
|
|
big.glow(6, 1.4)
|
|
big.save(f"ranking-{key}")
|
|
|
|
sm = C(34, 40)
|
|
sm.text(letters, 17, 21, 30 if len(letters) == 1 else 22, rgba(col))
|
|
sm.glow(1.5, 1.2)
|
|
sm.save(f"ranking-{key}-small")
|
|
|
|
p = C(620, 520)
|
|
p.rect(1, 1, 619, 519, fill=rgba((0, 0, 0), 0.82), outline=rgba(GRN, 0.12))
|
|
p.brackets(1, 1, 619, 519, 24, rgba(GRN, 0.66), 2)
|
|
p.save("ranking-panel")
|
|
|
|
g = C(320, 160)
|
|
g.rect(1, 1, 319, 159, fill=rgba((0, 0, 0), 0.85), outline=rgba(GRN, 0.34))
|
|
g.brackets(1, 1, 319, 159, 12, rgba(GRN), 2)
|
|
for x in range(40, 320, 40):
|
|
g.line([(x, 4), (x, 156)], rgba(GRN, 0.08), 1)
|
|
g.line([(4, 80), (316, 80)], rgba(GRN, 0.12), 1)
|
|
g.save("ranking-graph")
|
|
|
|
text_img("ranking-title", "results_", 64, rgba(GRN))
|
|
text_img("ranking-accuracy", "accuracy", 26, rgba(GRN, 0.66), glow=0)
|
|
text_img("ranking-maxcombo", "max combo", 26, rgba(GRN, 0.66), glow=0)
|
|
text_img("ranking-perfect", "perfect", 40, rgba(GRN))
|
|
for name, label in (("ranking-retry", "retry"), ("ranking-replay", "replay")):
|
|
b = C(300, 60)
|
|
b.rect(1, 1, 299, 59, fill=rgba((0, 0, 0), 0.9), outline=rgba(GRN, 0.34))
|
|
b.brackets(1, 1, 299, 59, 10, rgba(GRN), 2)
|
|
b.text(f"[ {label} ]", 150, 30, 28, rgba(GRN))
|
|
b.save(name)
|
|
|
|
|
|
# ---------------------------------------------------------------- menus
|
|
|
|
def menu_background():
|
|
W, H = 1366, 768
|
|
c = C(W, H, rgba((0, 0, 0)))
|
|
# oscilloscope graticule: 10 x 8 divisions with minor ticks on the axes
|
|
x0, y0, x1, y1 = 183, 64, 1183, 704
|
|
dx, dy = (x1 - x0) / 10, (y1 - y0) / 8
|
|
for i in range(11):
|
|
c.line([(x0 + i * dx, y0), (x0 + i * dx, y1)], rgba(GRN, 0.07), 1)
|
|
for j in range(9):
|
|
c.line([(x0, y0 + j * dy), (x1, y0 + j * dy)], rgba(GRN, 0.07), 1)
|
|
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
for i in range(51):
|
|
x = x0 + i * dx / 5
|
|
c.line([(x, cy - 5), (x, cy + 5)], rgba(GRN, 0.14), 1)
|
|
for j in range(41):
|
|
y = y0 + j * dy / 5
|
|
c.line([(cx - 5, y), (cx + 5, y)], rgba(GRN, 0.14), 1)
|
|
# phosphor trace: a 3:2 Lissajous figure
|
|
# pi/2 phase; pi/4 degenerates into an open curve traced twice
|
|
pts = [(cx + 380 * math.sin(3 * t + math.pi / 2), cy + 250 * math.sin(2 * t))
|
|
for t in (2 * math.pi * k / 4000 for k in range(4001))]
|
|
c.line(pts, rgba(GRN, 0.9), 2.5)
|
|
c.glow(6, 1.8)
|
|
c.brackets(24, 24, W - 24, H - 24, 36, rgba(GRN, 0.34), 1)
|
|
c.text("pax.moe", W - 48, H - 44, 20, rgba(GRN, 0.34), anchor="rm")
|
|
c.save("menu-background", jpg=True)
|
|
|
|
text_img("welcome_text", "welcome_", 80, rgba(GRN))
|
|
blank("menu-snow")
|
|
|
|
|
|
def song_select():
|
|
b = C(200, 64)
|
|
b.rect(1, 1, 199, 63, fill=rgba((0, 0, 0), 0.85), outline=rgba(GRN, 0.34))
|
|
b.brackets(1, 1, 199, 63, 10, rgba(GRN), 2)
|
|
b.text("< back", 100, 32, 28, rgba(GRN))
|
|
b.save("menu-back")
|
|
|
|
# Tinted by the game per state, so grey scale with a hairline frame.
|
|
m = C(690, 85)
|
|
m.rect(1, 1, 689, 84, fill=rgba(WHITE, 0.10), outline=rgba(WHITE, 0.45))
|
|
m.save("menu-button-background")
|
|
|
|
t = C(142, 24)
|
|
t.rect(1, 1, 141, 24, fill=rgba(WHITE, 0.12), outline=rgba(WHITE, 0.5))
|
|
t.save("selection-tab")
|
|
|
|
for name, label, w in (("selection-mode", "mode", 92), ("selection-mods", "mods", 77),
|
|
("selection-random", "rand", 77), ("selection-options", "opts", 77)):
|
|
for over in (False, True):
|
|
c = C(w, 87)
|
|
if over:
|
|
c.rect(4, 8, w - 4, 82, fill=rgba(GRN, 0.12))
|
|
c.brackets(4, 8, w - 4, 82, 10, rgba(GRN), 2)
|
|
c.text(label, w / 2, 50, 24, rgba(GRN, 1.0 if over else 0.66))
|
|
if over:
|
|
c.glow(2, 1.2)
|
|
c.save(name + ("-over" if over else ""))
|
|
|
|
top = C(1366, 96)
|
|
top.rect(0, 0, 1366, 95, fill=rgba((0, 0, 0), 0.82))
|
|
top.line([(0, 95), (1366, 95)], rgba(GRN, 0.34), 1)
|
|
top.save("songselect-top")
|
|
bot = C(1366, 90)
|
|
bot.rect(0, 0, 1366, 90, fill=rgba((0, 0, 0), 0.82))
|
|
bot.line([(0, 0), (1366, 0)], rgba(GRN, 0.34), 1)
|
|
bot.save("songselect-bottom")
|
|
|
|
s = C(50, 50)
|
|
s.rect(17, 17, 33, 33, fill=rgba(WHITE))
|
|
s.save("star")
|
|
blank("star2") # kiai side fountains, combo burst, cursor and song select particles
|
|
|
|
for side in ("left", "middle", "right"):
|
|
bt = C(16 if side != "middle" else 4, 52)
|
|
bt.rect(0, 0, bt.w, 52, fill=rgba(WHITE, 0.14))
|
|
bt.line([(0, 1), (bt.w, 1)], rgba(WHITE, 0.6), 1)
|
|
bt.line([(0, 51), (bt.w, 51)], rgba(WHITE, 0.6), 1)
|
|
if side == "left":
|
|
bt.line([(1, 1), (1, 51)], rgba(WHITE, 0.6), 1)
|
|
if side == "right":
|
|
bt.line([(15, 1), (15, 51)], rgba(WHITE, 0.6), 1)
|
|
bt.save(f"button-{side}")
|
|
|
|
|
|
def mode_icon(c, mode, s, col, wid):
|
|
m = s / 2
|
|
if mode == "osu":
|
|
c.ring(m, m, s * 0.36, wid, col)
|
|
c.disc(m, m, s * 0.09, col)
|
|
elif mode == "taiko":
|
|
c.ring(m, m, s * 0.36, wid, col)
|
|
c.ring(m, m, s * 0.18, wid, col)
|
|
elif mode == "fruits":
|
|
c.ring(m, m, s * 0.36, wid, col)
|
|
c.line([(m - s * 0.2, m + s * 0.2), (m + s * 0.2, m - s * 0.2)], col, wid)
|
|
else:
|
|
for i in range(4):
|
|
x = m - s * 0.27 + i * s * 0.18
|
|
c.rect(x - s * 0.04, m - s * 0.3, x + s * 0.04, m + s * 0.3, fill=col)
|
|
|
|
|
|
def modes():
|
|
for mode in ("osu", "taiko", "fruits", "mania"):
|
|
for suffix, s, wid in (("", 256, 8), ("-med", 128, 5), ("-small", 32, 2)):
|
|
c = C(s, s)
|
|
mode_icon(c, mode, s, rgba(GRN), wid)
|
|
c.glow(s / 40, 1.3)
|
|
c.save(f"mode-{mode}{suffix}")
|
|
|
|
|
|
MODS = {
|
|
# reduction: dim; increase: bright; other: white green
|
|
"easy": ("EZ", 0.55), "nofail": ("NF", 0.55), "halftime": ("HT", 0.55), "spunout": ("SO", 0.55),
|
|
"hardrock": ("HR", 1.0), "suddendeath": ("SD", 1.0), "perfect": ("PF", 1.0), "doubletime": ("DT", 1.0),
|
|
"nightcore": ("NC", 1.0), "hidden": ("HD", 1.0), "flashlight": ("FL", 1.0), "fadein": ("FI", 1.0),
|
|
"mirror": ("MR", 1.0), "random": ("RD", 1.0),
|
|
"relax": ("RX", "w"), "relax2": ("AP", "w"), "autoplay": ("AT", "w"), "cinema": ("CN", "w"),
|
|
"scorev2": ("V2", "w"), "target": ("TP", "w"), "touchdevice": ("TD", "w"),
|
|
"freemodallowed": ("FM", "w"), "keycoop": ("CO", "w"),
|
|
}
|
|
MODS.update({f"key{n}": (f"{n}K", 1.0) for n in range(1, 10)})
|
|
|
|
|
|
def mod_icons():
|
|
for name, (code, tier) in MODS.items():
|
|
col = rgba((220, 255, 220)) if tier == "w" else rgba(GRN, tier)
|
|
c = C(64, 64)
|
|
c.rect(6, 6, 58, 58, fill=rgba((0, 0, 0), 0.9), outline=rgba(GRN, 0.34))
|
|
c.brackets(6, 6, 58, 58, 10, col, 2)
|
|
c.text(code, 32, 33, 26, col)
|
|
c.glow(1.5, 1.1)
|
|
c.save(f"selection-mod-{name}")
|
|
|
|
|
|
def extras():
|
|
"""Remaining elements from the wiki list that would fall back to default."""
|
|
sel = C(128, 128)
|
|
sel.ring(64, 64, 60, 3, rgba(WHITE))
|
|
sel.save("hitcircleselect")
|
|
|
|
blank("cursor-ripple")
|
|
|
|
text_img("multi-skipped", "skip", 18, rgba(WHITE, 0.8), pad=4, glow=0)
|
|
text_img("score-pp", "pp", 40, rgba(WHITE), pad=4, glow=2)
|
|
text_img("ranking-winner", "winner", 48, rgba(GRN))
|
|
|
|
rf = C(25, 25)
|
|
rf.rect(4, 4, 21, 21, outline=rgba(GRN), width=2)
|
|
rf.save("rank-forum")
|
|
|
|
|
|
# ---------------------------------------------------------------- skin.ini
|
|
|
|
SKIN_INI = """[General]
|
|
Name: P1
|
|
Author: pax
|
|
Version: 2.7
|
|
AnimationFramerate: 60
|
|
AllowSliderBallTint: 0
|
|
ComboBurstRandom: 0
|
|
CursorCentre: 1
|
|
CursorExpand: 0
|
|
CursorRotate: 0
|
|
CursorTrailRotate: 0
|
|
HitCircleOverlayAboveNumber: 0
|
|
LayeredHitSounds: 1
|
|
SliderBallFlip: 0
|
|
SpinnerFadePlayfield: 1
|
|
SpinnerFrequencyModulate: 0
|
|
SpinnerNoBlink: 1
|
|
|
|
[Colours]
|
|
Combo1: 0,255,0
|
|
Combo2: 0,180,0
|
|
Combo3: 0,100,0
|
|
InputOverlayText: 0,255,0
|
|
MenuGlow: 0,255,0
|
|
SliderBorder: 26,26,26
|
|
SliderTrackOverride: 14,14,14
|
|
SongSelectActiveText: 0,255,0
|
|
SongSelectInactiveText: 0,170,0
|
|
SpinnerBackground: 0,0,0
|
|
StarBreakAdditive: 0,255,0
|
|
|
|
[Fonts]
|
|
HitCirclePrefix: default
|
|
HitCircleOverlap: 4
|
|
ScorePrefix: score
|
|
ScoreOverlap: 4
|
|
ComboPrefix: score
|
|
ComboOverlap: 4
|
|
"""
|
|
|
|
|
|
def main():
|
|
global OUT
|
|
ap = argparse.ArgumentParser(description="Render the P1 osu!stable skin.")
|
|
ap.add_argument("out", type=Path, help="output skin folder")
|
|
ap.add_argument("--assets", type=Path, help="existing skin to take all sounds from")
|
|
ap.add_argument("--hitsounds", type=Path, help="existing skin whose gameplay sounds replace --assets' ones")
|
|
ap.add_argument("--osk", type=Path, help="also pack the finished skin into this .osk file")
|
|
args = ap.parse_args()
|
|
OUT = args.out
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
cursor()
|
|
for f in (hitcircles, sliders, judgements, spinner, scorebar, score_fonts,
|
|
input_overlay, playfield, pause_screens, ranking, menu_background,
|
|
song_select, modes, mod_icons, extras):
|
|
f()
|
|
(OUT / "skin.ini").write_text(SKIN_INI)
|
|
if args.assets:
|
|
for snd in args.assets.iterdir():
|
|
if snd.suffix.lower() in (".wav", ".ogg", ".mp3"):
|
|
shutil.copy2(snd, OUT / snd.name)
|
|
if args.hitsounds:
|
|
# Gameplay sounds from --hitsounds replace the --assets ones entirely: same
|
|
# names in another extension, or numbered variants, would mix sets.
|
|
hit = re.compile(r"^((normal|soft|drum)-(hit|slider)|nightcore-|combobreak)", re.I)
|
|
for f in OUT.iterdir():
|
|
if f.suffix.lower() in (".wav", ".ogg", ".mp3") and hit.match(f.name):
|
|
f.unlink()
|
|
for snd in args.hitsounds.rglob("*"):
|
|
if snd.suffix.lower() in (".wav", ".ogg", ".mp3") and hit.match(snd.name):
|
|
shutil.copy2(snd, OUT / snd.name)
|
|
print(f"{sum(1 for _ in OUT.iterdir())} files in {OUT}")
|
|
if args.osk:
|
|
# An .osk is a zip of the skin folder's files; osu! imports it on open.
|
|
with zipfile.ZipFile(args.osk, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for f in sorted(OUT.iterdir()):
|
|
z.write(f, f.name)
|
|
print(f"packed {args.osk}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|