feat(web): derive and register the watchpost artwork
BIN
apps/web/public/images/backgrounds/Aschengrube.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/Wachturm.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/runtime/Aschengrube-960.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/web/public/images/backgrounds/runtime/Wachturm-960.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/web/public/images/combat/icons/burned-hound-128.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/images/combat/icons/raider-captain-128.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/combat/icons/raider-scout-128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/combat/icons/raider-veteran-128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/combat/sprites/burned-hound-760.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
apps/web/public/images/combat/sprites/raider-captain-620.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
apps/web/public/images/combat/sprites/raider-scout-620.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
apps/web/public/images/combat/sprites/raider-veteran-620.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/web/public/images/monsters/burned-hound.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/monsters/raider-captain.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/monsters/raider-scout.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/monsters/raider-veteran.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/monsters/runtime/burned-hound-560.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-captain-560.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-scout-560.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-veteran-560.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -14,6 +14,8 @@ import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.c
|
|||||||
const RUNTIME_ARTWORK: Readonly<Record<string, string>> = {
|
const RUNTIME_ARTWORK: Readonly<Record<string, string>> = {
|
||||||
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
|
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
|
||||||
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
|
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
|
||||||
|
'/images/backgrounds/Wachturm.png': '/images/backgrounds/runtime/Wachturm-960.jpg',
|
||||||
|
'/images/backgrounds/Aschengrube.png': '/images/backgrounds/runtime/Aschengrube-960.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { monsterCutoutPath, monsterIconPath, runtimeMonsterArtworkPath } from './monster-artwork';
|
import {
|
||||||
|
combatMonsterSpriteScale,
|
||||||
|
monsterCutoutPath,
|
||||||
|
monsterIconPath,
|
||||||
|
runtimeMonsterArtworkPath,
|
||||||
|
} from './monster-artwork';
|
||||||
|
|
||||||
describe('runtimeMonsterArtworkPath', () => {
|
describe('runtimeMonsterArtworkPath', () => {
|
||||||
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
|
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
|
||||||
@@ -42,3 +47,32 @@ describe('monsterIconPath', () => {
|
|||||||
expect(monsterIconPath('dawnwolf')).toBeUndefined();
|
expect(monsterIconPath('dawnwolf')).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('watchpost monsters', () => {
|
||||||
|
const keys = ['raider-scout', 'raider-veteran', 'burned-hound', 'raider-captain'];
|
||||||
|
|
||||||
|
it('has a cutout for every watchpost monster', () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(monsterCutoutPath(key)).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has an icon for every watchpost monster', () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(monsterIconPath(key)).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a runtime derivative for every watchpost artwork', () => {
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(runtimeMonsterArtworkPath(`/images/monsters/${key}.png`)).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the captain larger than the hound', () => {
|
||||||
|
// A hulking elite and a low-slung dog must not share a silhouette height.
|
||||||
|
expect(combatMonsterSpriteScale('raider-captain')).toBeGreaterThan(
|
||||||
|
combatMonsterSpriteScale('burned-hound'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
|
|||||||
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
||||||
'/images/monsters/wild-road-dog.png': '/images/monsters/runtime/wild-road-dog-560.png',
|
'/images/monsters/wild-road-dog.png': '/images/monsters/runtime/wild-road-dog-560.png',
|
||||||
'/images/monsters/charred-looter.png': '/images/monsters/runtime/charred-looter-560.png',
|
'/images/monsters/charred-looter.png': '/images/monsters/runtime/charred-looter-560.png',
|
||||||
|
'/images/monsters/raider-scout.png': '/images/monsters/runtime/raider-scout-560.jpg',
|
||||||
|
'/images/monsters/raider-veteran.png': '/images/monsters/runtime/raider-veteran-560.jpg',
|
||||||
|
'/images/monsters/burned-hound.png': '/images/monsters/runtime/burned-hound-560.jpg',
|
||||||
|
'/images/monsters/raider-captain.png': '/images/monsters/runtime/raider-captain-560.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
|
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
|
||||||
@@ -16,6 +20,10 @@ const MONSTER_CUTOUT: Readonly<Record<string, string>> = {
|
|||||||
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
|
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
|
||||||
'wild-road-dog': '/images/combat/sprites/wild-road-dog-760.png',
|
'wild-road-dog': '/images/combat/sprites/wild-road-dog-760.png',
|
||||||
'charred-looter': '/images/combat/sprites/charred-looter-620.png',
|
'charred-looter': '/images/combat/sprites/charred-looter-620.png',
|
||||||
|
'raider-scout': '/images/combat/sprites/raider-scout-620.png',
|
||||||
|
'raider-veteran': '/images/combat/sprites/raider-veteran-620.png',
|
||||||
|
'burned-hound': '/images/combat/sprites/burned-hound-760.png',
|
||||||
|
'raider-captain': '/images/combat/sprites/raider-captain-620.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONSTER_ICON: Readonly<Record<string, string>> = {
|
const MONSTER_ICON: Readonly<Record<string, string>> = {
|
||||||
@@ -23,6 +31,10 @@ const MONSTER_ICON: Readonly<Record<string, string>> = {
|
|||||||
'road-bandit': '/images/combat/icons/road-bandit-128.png',
|
'road-bandit': '/images/combat/icons/road-bandit-128.png',
|
||||||
'wild-road-dog': '/images/combat/icons/wild-road-dog-128.png',
|
'wild-road-dog': '/images/combat/icons/wild-road-dog-128.png',
|
||||||
'charred-looter': '/images/combat/icons/charred-looter-128.png',
|
'charred-looter': '/images/combat/icons/charred-looter-128.png',
|
||||||
|
'raider-scout': '/images/combat/icons/raider-scout-128.png',
|
||||||
|
'raider-veteran': '/images/combat/icons/raider-veteran-128.png',
|
||||||
|
'burned-hound': '/images/combat/icons/burned-hound-128.png',
|
||||||
|
'raider-captain': '/images/combat/icons/raider-captain-128.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Share of the battlefield height each monster sprite occupies, so a hulking
|
// Share of the battlefield height each monster sprite occupies, so a hulking
|
||||||
@@ -32,6 +44,10 @@ const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
|
|||||||
'road-bandit': 0.82,
|
'road-bandit': 0.82,
|
||||||
'wild-road-dog': 0.58,
|
'wild-road-dog': 0.58,
|
||||||
'charred-looter': 0.86,
|
'charred-looter': 0.86,
|
||||||
|
'raider-scout': 0.78,
|
||||||
|
'raider-veteran': 0.84,
|
||||||
|
'burned-hound': 0.6,
|
||||||
|
'raider-captain': 0.9,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MONSTER_SCALE = 0.6;
|
const DEFAULT_MONSTER_SCALE = 0.6;
|
||||||
|
|||||||
146
tools/derive-monster-assets.ps1
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# tools/derive-monster-assets.ps1
|
||||||
|
# Derives the web assets each monster needs from the hand-made art in
|
||||||
|
# art/enemies and art/backgrounds.
|
||||||
|
#
|
||||||
|
# Per monster it writes four files:
|
||||||
|
# apps/web/public/images/monsters/<key>.png full painted artwork
|
||||||
|
# apps/web/public/images/monsters/runtime/<key>-560.jpg downscaled for the web
|
||||||
|
# apps/web/public/images/combat/sprites/<key>-<h>.png background-free cutout
|
||||||
|
# apps/web/public/images/combat/icons/<key>-128.png medallion icon
|
||||||
|
#
|
||||||
|
# The generated files are committed, so this only needs re-running when the
|
||||||
|
# source art changes. Re-runnable: it overwrites its own output and touches
|
||||||
|
# nothing else.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$artEnemies = Join-Path $root 'art\enemies'
|
||||||
|
$artCutouts = Join-Path $artEnemies 'transparent-background'
|
||||||
|
$artBackgrounds = Join-Path $root 'art\backgrounds'
|
||||||
|
$webImages = Join-Path $root 'apps\web\public\images'
|
||||||
|
|
||||||
|
foreach ($dir in @(
|
||||||
|
(Join-Path $webImages 'monsters\runtime'),
|
||||||
|
(Join-Path $webImages 'combat\sprites'),
|
||||||
|
(Join-Path $webImages 'combat\icons'),
|
||||||
|
(Join-Path $webImages 'backgrounds\runtime'))) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Scaled {
|
||||||
|
param(
|
||||||
|
[string] $SourcePath,
|
||||||
|
[string] $TargetPath,
|
||||||
|
[int] $TargetWidth,
|
||||||
|
[string] $Format,
|
||||||
|
[switch] $Opaque
|
||||||
|
)
|
||||||
|
$img = [System.Drawing.Image]::FromFile($SourcePath)
|
||||||
|
try {
|
||||||
|
$height = [int][Math]::Round($img.Height * $TargetWidth / $img.Width)
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap $TargetWidth, $height
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
if ($Opaque) {
|
||||||
|
$graphics.Clear([System.Drawing.Color]::Black)
|
||||||
|
}
|
||||||
|
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.DrawImage($img, 0, 0, $TargetWidth, $height)
|
||||||
|
$graphics.Dispose()
|
||||||
|
if ($Format -eq 'jpg') {
|
||||||
|
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
|
||||||
|
Where-Object { $_.MimeType -eq 'image/jpeg' }
|
||||||
|
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
|
||||||
|
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter(
|
||||||
|
[System.Drawing.Imaging.Encoder]::Quality, 82)
|
||||||
|
$bitmap.Save($TargetPath, $codec, $params)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
}
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$img.Dispose()
|
||||||
|
}
|
||||||
|
Write-Host "wrote $(Split-Path -Leaf $TargetPath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Icon {
|
||||||
|
param(
|
||||||
|
[string] $SourcePath,
|
||||||
|
[string] $TargetPath,
|
||||||
|
# Crop window as fractions of the source, chosen per monster so the icon
|
||||||
|
# lands on the head rather than on whatever the centre happens to be.
|
||||||
|
[double] $CropX, [double] $CropY, [double] $CropSize
|
||||||
|
)
|
||||||
|
$img = [System.Drawing.Image]::FromFile($SourcePath)
|
||||||
|
try {
|
||||||
|
$side = [int]([Math]::Min($img.Width, $img.Height) * $CropSize)
|
||||||
|
$x = [int]($img.Width * $CropX)
|
||||||
|
$y = [int]($img.Height * $CropY)
|
||||||
|
$bitmap = New-Object System.Drawing.Bitmap 128, 128
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.DrawImage(
|
||||||
|
$img,
|
||||||
|
(New-Object System.Drawing.Rectangle 0, 0, 128, 128),
|
||||||
|
(New-Object System.Drawing.Rectangle $x, $y, $side, $side),
|
||||||
|
[System.Drawing.GraphicsUnit]::Pixel)
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$img.Dispose()
|
||||||
|
}
|
||||||
|
Write-Host "wrote $(Split-Path -Leaf $TargetPath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `source` is the painted file, `cutout` the background-free one.
|
||||||
|
#
|
||||||
|
# NOTE the deliberate crossing on the first two rows: the file named
|
||||||
|
# raider-scout depicts the heavier, plated, spear-carrying figure and is the
|
||||||
|
# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10
|
||||||
|
# design decision D7.
|
||||||
|
$monsters = @(
|
||||||
|
@{ key = 'raider-scout'; source = 'raider-veteran'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 },
|
||||||
|
@{ key = 'raider-veteran'; source = 'raider-scout'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 },
|
||||||
|
@{ key = 'burned-hound'; source = 'burned-hound'; spriteHeight = 760; cropX = 0.06; cropY = 0.12; cropSize = 0.34 },
|
||||||
|
@{ key = 'raider-captain'; source = 'Pluendererhauptmann'; spriteHeight = 620; cropX = 0.36; cropY = 0.02; cropSize = 0.28 }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($monster in $monsters) {
|
||||||
|
$key = $monster.key
|
||||||
|
$painted = Join-Path $artEnemies "$($monster.source).png"
|
||||||
|
$cutout = Join-Path $artCutouts "$($monster.source).png"
|
||||||
|
|
||||||
|
Copy-Item -Force $painted (Join-Path $webImages "monsters\$key.png")
|
||||||
|
Write-Host "wrote $key.png"
|
||||||
|
|
||||||
|
Save-Scaled -SourcePath $painted `
|
||||||
|
-TargetPath (Join-Path $webImages "monsters\runtime\$key-560.jpg") `
|
||||||
|
-TargetWidth 560 -Format 'jpg' -Opaque
|
||||||
|
|
||||||
|
Save-Scaled -SourcePath $cutout `
|
||||||
|
-TargetPath (Join-Path $webImages "combat\sprites\$key-$($monster.spriteHeight).png") `
|
||||||
|
-TargetWidth $monster.spriteHeight -Format 'png'
|
||||||
|
|
||||||
|
Save-Icon -SourcePath $cutout `
|
||||||
|
-TargetPath (Join-Path $webImages "combat\icons\$key-128.png") `
|
||||||
|
-CropX $monster.cropX -CropY $monster.cropY -CropSize $monster.cropSize
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Raider Captain has a hand-made icon. Authored art beats a crop.
|
||||||
|
Copy-Item -Force (Join-Path $artEnemies 'PluendererhauptmannIcon.png') `
|
||||||
|
(Join-Path $webImages 'combat\icons\raider-captain-128.png')
|
||||||
|
Write-Host 'wrote raider-captain-128.png (hand-made)'
|
||||||
|
|
||||||
|
foreach ($background in 'Wachturm', 'Aschengrube') {
|
||||||
|
$source = Join-Path $artBackgrounds "$background.png"
|
||||||
|
Copy-Item -Force $source (Join-Path $webImages "backgrounds\$background.png")
|
||||||
|
Write-Host "wrote $background.png"
|
||||||
|
Save-Scaled -SourcePath $source `
|
||||||
|
-TargetPath (Join-Path $webImages "backgrounds\runtime\$background-960.jpg") `
|
||||||
|
-TargetWidth 960 -Format 'jpg' -Opaque
|
||||||
|
}
|
||||||