Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,39 @@ jobs:
# PowerShell runs on Linux, so the Windows installer is checkable here
# rather than trusted on inspection.
- name: Install PowerShell
env:
# Authenticated so the release lookup is not subject to the shared
# runners' unauthenticated GitHub API rate limit. Hitting that limit
# returned an empty URL, `curl -fsSL ""` failed, and the whole job
# went red for a reason that had nothing to do with the code.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Known-good fallback if the API is unavailable entirely.
PWSH_FALLBACK: "7.4.6"
run: |
URL=$(curl -fsSL https://api.github.com/repos/PowerShell/PowerShell/releases/latest \
| grep -o '"browser_download_url": *"[^"]*linux-x64.tar.gz"' | cut -d'"' -f4 | head -1)
mkdir -p "$HOME/.pwsh" && curl -fsSL "$URL" | tar -xz -C "$HOME/.pwsh"
set -euo pipefail

resolve_url() {
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/PowerShell/PowerShell/releases/latest \
| grep -o '"browser_download_url": *"[^"]*linux-x64.tar.gz"' \
| cut -d'"' -f4 | head -1
}

URL=""
for attempt in 1 2 3; do
URL=$(resolve_url || true)
[ -n "$URL" ] && break
echo "release lookup attempt $attempt failed, retrying" >&2
sleep $((attempt * 5))
done

if [ -z "$URL" ]; then
echo "falling back to pinned PowerShell $PWSH_FALLBACK" >&2
URL="https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_FALLBACK}/powershell-${PWSH_FALLBACK}-linux-x64.tar.gz"
fi

mkdir -p "$HOME/.pwsh"
curl -fsSL --retry 3 --retry-delay 5 "$URL" | tar -xz -C "$HOME/.pwsh"
chmod +x "$HOME/.pwsh/pwsh"
echo "$HOME/.pwsh" >> "$GITHUB_PATH"

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ assets-source/**/raw/*
!assets-source/**/raw/.gitkeep

.DS_Store

# Python bytecode from the Blender asset generators.
__pycache__/
*.pyc
8 changes: 4 additions & 4 deletions apps/game/public/assets/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"generator": "tools/art/build-assets.mjs",
"blender": "Blender 4.5.12 LTS",
"commit": "fae10c13fb39b93d0d4d66f67a506d9d9078487d",
"commit": "f24a07a4ca2a223aed5561a9ab08ce87dea1f262",
"license": "Original work, © NIGHTCELL 7. No third-party assets.",
"source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.",
"textureSize": 1024,
Expand Down Expand Up @@ -67,9 +67,9 @@
"ui_hover.mp3"
],
"bytes": {
"models": 1135736,
"models": 1235920,
"textures": 1971598,
"audio": 323166,
"total": 3430500
"audio": 326680,
"total": 3534198
}
}
Binary file modified apps/game/public/assets/models/character.glb
Binary file not shown.
Binary file removed tools/art/blender/__pycache__/_lib.cpython-311.pyc
Binary file not shown.
99 changes: 99 additions & 0 deletions tools/art/blender/_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,102 @@ def output_path(filename: str) -> str:
else:
base = os.path.join(os.path.dirname(__file__), "..", "..", "..", "build", "models")
return os.path.abspath(os.path.join(base, filename))


# ------------------------------------------------------------------ lofting

def ring(
centre: tuple[float, float, float],
rx: float,
ry: float,
segments: int = 12,
squash: float = 0.0,
rotation: float = 0.0,
) -> list[Vector]:
"""
One cross-section: an ellipse, optionally squared off toward a rectangle.

`squash` blends from a true ellipse (0) toward a rounded rectangle (1).
Bodies are not circular in section — a chest is broad and flat, a forearm
is nearly round — and that difference is most of what separates a lofted
figure from a stack of cylinders.
"""
points: list[Vector] = []
for i in range(segments):
a = rotation + (i / segments) * math.tau
c, s = math.cos(a), math.sin(a)
# Superellipse-ish: push the point outward toward the bounding box.
if squash > 0.0:
m = max(abs(c), abs(s))
c = c * (1 - squash) + (c / m) * squash
s = s * (1 - squash) + (s / m) * squash
points.append(Vector((centre[0] + c * rx, centre[1] + s * ry, centre[2])))
return points


def loft(bm: bmesh.types.BMesh, rings: list[list[Vector]], cap: bool = True) -> None:
"""
Bridge a sequence of equal-length rings into a tube.

This is the core of the character work. Stacking primitives gives a figure
made of visible blocks no amount of texturing hides; lofting a profile that
changes along its length gives limbs that taper and a torso that actually
has a waist.
"""
if len(rings) < 2:
return

verts = [[bm.verts.new(p) for p in r] for r in rings]

for a, b in zip(verts, verts[1:]):
n = len(a)
for i in range(n):
j = (i + 1) % n
try:
bm.faces.new((a[i], a[j], b[j], b[i]))
except ValueError:
# Duplicate face where two rings coincide; harmless.
pass

if cap:
for end in (verts[0], verts[-1]):
try:
bm.faces.new(end)
except ValueError:
pass

bm.verts.index_update()
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])


def limb(
bm: bmesh.types.BMesh,
joints: list[tuple[tuple[float, float, float], float, float]],
segments: int = 10,
squash: float = 0.0,
) -> None:
"""
Loft a limb through `(centre, rx, ry)` joints.

Rings are oriented in the XY plane and stacked in Z, which suits a figure
modelled standing in a neutral pose — every limb here runs broadly
vertically, and the rig bends them later.
"""
loft(bm, [ring(c, rx, ry, segments, squash) for c, rx, ry in joints])


def subdivide_smooth(bm: bmesh.types.BMesh, cuts: int = 1) -> None:
"""
Catmull-Clark-ish smoothing pass.

Applied to the body only. Gear stays faceted: hard surfaces should read as
hard, and smoothing a plate carrier makes it look inflated.
"""
for _ in range(cuts):
bmesh.ops.subdivide_edges(
bm,
edges=bm.edges[:],
cuts=1,
use_grid_fill=True,
smooth=1.0,
)
Loading
Loading