330 lines
14 KiB
JavaScript
330 lines
14 KiB
JavaScript
// Engine-Grundlagen des Hustler-Hochhauses: Palette, Raster, Figuren mit Posen, Text, Sprechblasen.
|
||
// Portiert aus entwuerfe/sprites.js (ES5-IIFE mit fester Skala S = 3). Neu: Raster je Kontext statt fester Skala,
|
||
// Posen (stand, type, coffee, sleep), Graustufe für nicht angeschlossene Worker, Warnzeichen.
|
||
// Kein React, kein Appwrite, kein window/document: alles Zeichnen geht über den übergebenen Canvas-Kontext.
|
||
|
||
export const PAL = Object.freeze({
|
||
o: '#3a2f3d', // Umriss (dunkles Pflaume-Grau statt Schwarz)
|
||
b: '#d98a66', // Körper (Ton)
|
||
s: '#b8694a', // Körper Schatten
|
||
h: '#f2b897', // Körper Licht
|
||
e: '#2b2230', // Augen
|
||
w: '#ffffff',
|
||
g: '#9aa3b2', // Papier-Umriss
|
||
p: '#c3cad6', // Papier-Zeilen
|
||
ink: '#1a1f36',
|
||
mute: '#697386',
|
||
line: '#ebeef1',
|
||
floor: '#f6f7f9',
|
||
floor2: '#eef0f4',
|
||
wood: '#c9a27e', wood2: '#a8825f',
|
||
metal: '#c3c8d2', metal2: '#8c93a3',
|
||
screen: '#1f2a44',
|
||
red: '#d6455d', green: '#3aa66b', gold: '#e8b730', gold2: '#b98a12',
|
||
shadow: 'rgba(26,31,54,0.12)',
|
||
})
|
||
|
||
// Hut-Farben zeigen das Modell: Opus = Krone, Sonnet = Kappe, Haiku = Mütze, Overseer = Kommandomütze
|
||
export const HAT = Object.freeze({
|
||
opus: Object.freeze(['#e8b730', '#b98a12']),
|
||
sonnet: Object.freeze(['#4a7fd6', '#2f5ea8']),
|
||
haiku: Object.freeze(['#5cbf8a', '#3a9466']),
|
||
overseer: Object.freeze(['#7a5cd6', '#54399f']),
|
||
})
|
||
|
||
// Ein Farbton pro Station/Team
|
||
export const TEAM = Object.freeze({
|
||
radar: '#4a90d9', // Anfragen-Radar
|
||
aerzte: '#3fb6b2', // Ärzte & Praxen
|
||
handwerk: '#e8873a', // Handwerk
|
||
selbst: '#c66bd1', // Selbstständige
|
||
laden: '#8fb83a', // Läden & Gastro
|
||
bewertung: '#e0b13a',
|
||
werkstatt: '#d98a66',
|
||
pruefung: '#d6455d',
|
||
freigabe: '#7a5cd6',
|
||
archiv: '#8c93a3',
|
||
tresor: '#3aa66b',
|
||
})
|
||
|
||
export const FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace'
|
||
|
||
// ---------- Raster ----------
|
||
|
||
// Raster je Kontext: k = Geräte-Pixel je logischer Einheit, (ox, oy) = Versatz in Geräte-Pixeln.
|
||
// Ohne setGrid gilt k = 3 (die Skala der Vorlage). Ein WeakMap hält den Zustand außerhalb des Kontexts.
|
||
const DEFAULT_GRID = Object.freeze({ k: 3, ox: 0, oy: 0 })
|
||
const grids = new WeakMap()
|
||
const gridOf = (ctx) => grids.get(ctx) || DEFAULT_GRID
|
||
|
||
export function setGrid(ctx, { k, ox = 0, oy = 0 }) {
|
||
grids.set(ctx, { k, ox, oy })
|
||
}
|
||
|
||
// Rechteck in logischen Einheiten. Beide Kanten werden einzeln gerundet (die rechte Kante eines Rechtecks ist die
|
||
// linke des nächsten: keine Nähte), die Größe beträgt mindestens ein Geräte-Pixel.
|
||
export function px(ctx, x, y, w, h, color) {
|
||
const { k, ox, oy } = gridOf(ctx)
|
||
const left = Math.round(x * k + ox)
|
||
const top = Math.round(y * k + oy)
|
||
ctx.fillStyle = color
|
||
ctx.fillRect(
|
||
left,
|
||
top,
|
||
Math.max(1, Math.round((x + w) * k + ox) - left),
|
||
Math.max(1, Math.round((y + h) * k + oy) - top),
|
||
)
|
||
}
|
||
|
||
// Fläche mit Deckkraft (save/restore gleichen sich aus)
|
||
export function tint(ctx, x, y, w, h, color, alpha) {
|
||
ctx.save()
|
||
ctx.globalAlpha = alpha
|
||
px(ctx, x, y, w, h, color)
|
||
ctx.restore()
|
||
}
|
||
|
||
// ---------- Sprite-Karten ----------
|
||
|
||
const BODY = [
|
||
'....oooo....',
|
||
'..oohhhboo..',
|
||
'.ohhbbbbbbo.',
|
||
'.ohbbbbbbbo.',
|
||
'obbebbbbebso',
|
||
'obbebbbbebso',
|
||
'obbbbbbbbbso',
|
||
'obbbbbbbbbso',
|
||
'.obbbbbbbso.',
|
||
'.ossbbbbsso.',
|
||
'..oossssoo..',
|
||
]
|
||
// Schlafen: die obere Augenzeile wird zu Körperfarbe, nur die Zeile darunter bleibt (zwei Augenpunkte)
|
||
const BODY_SLEEP = BODY.map((row, i) => (i === 4 ? row.replace(/e/g, 'b') : row))
|
||
const LEGS = [
|
||
['...oo..oo...', '...oo..oo...'], // stehen
|
||
['..oo....oo..', '.oo......oo.'], // Schritt A
|
||
['....oooo....', '....oooo....'], // Schritt B
|
||
]
|
||
const HATS = {
|
||
opus: ['...H.HH.H...', '...HHHHHH...', '...SSSSSS...'],
|
||
sonnet: ['....HHHH....', '...HHHHHH...', '...SSSSSSSS.'],
|
||
haiku: ['.....HH.....', '...HHHHHH...', '...HSHSHSH..'],
|
||
overseer: ['...HHHHHH...', '..HHHHwHHH..', '..SSSSSSSS..'],
|
||
}
|
||
const VISOR = ['obVwVVVVVVso', 'obVVVVVVVVso'] // ersetzt die Augenzeilen 4–5 beim Overseer
|
||
const PAPER = ['gggggg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gggggg']
|
||
const ARROW = ['ooooo', '.ooo.', '..o..']
|
||
const ALERT = ['rrr', 'rrr', 'rrr', '.r.', '.r.', '...', '.r.'] // 3 × 7: „!“
|
||
|
||
// Umriss um eine Karte: jede leere Zelle (auch diagonal) neben einer belegten wird 'o'; die Karte wächst um 1 je Seite
|
||
function outlined(map) {
|
||
const h = map.length
|
||
const w = map[0].length
|
||
const at = (r, c) => (r >= 0 && r < h && c >= 0 && c < w ? map[r][c] : '.')
|
||
const nextToInk = (r, c) => [-1, 0, 1].some((dr) => [-1, 0, 1].some((dc) => at(r + dr, c + dc) !== '.'))
|
||
const rows = []
|
||
for (let r = -1; r <= h; r++) {
|
||
let row = ''
|
||
for (let c = -1; c <= w; c++) row += at(r, c) === '.' && nextToInk(r, c) ? 'o' : at(r, c)
|
||
rows.push(row)
|
||
}
|
||
return rows
|
||
}
|
||
const ALERT_OUTLINED = outlined(ALERT)
|
||
|
||
// Grautöne für nicht angeschlossene Worker (Umriss, Augen und Papier bleiben)
|
||
const GREY = { b: '#aeb3bd', s: '#8a909c', h: '#c9cdd5', H: '#9ca2ae', S: '#7f8693' }
|
||
|
||
function workerColors(hat, grey) {
|
||
const hc = HAT[hat] || HAT.haiku
|
||
if (grey) return { o: PAL.o, b: GREY.b, s: GREY.s, h: GREY.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: GREY.H, S: GREY.S, V: GREY.S }
|
||
return { o: PAL.o, b: PAL.b, s: PAL.s, h: PAL.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: hc[0], S: hc[1], V: HAT.overseer[1] }
|
||
}
|
||
|
||
// Zeichnet eine Karte zellenweise: (x, y) = linke obere Ecke in logischen Einheiten, sc = Zellgröße, flip spiegelt
|
||
function drawMap(ctx, map, x, y, colors, flip, sc) {
|
||
for (let r = 0; r < map.length; r++) {
|
||
const row = map[r]
|
||
for (let c = 0; c < row.length; c++) {
|
||
const ch = row[c]
|
||
if (ch === '.') continue
|
||
const col = colors[ch]
|
||
if (!col) continue
|
||
const cx = flip ? row.length - 1 - c : c
|
||
px(ctx, x + cx * sc, y + r * sc, sc, sc, col)
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawSelect(ctx, x, y, sc, t) {
|
||
const hop = Math.round(Math.sin(t * 4)) * sc
|
||
drawMap(ctx, ARROW, x, y + hop, { o: PAL.ink }, false, sc)
|
||
}
|
||
|
||
// Kaffeetasse an der Hand (3 × 3) mit einem Dampfpixel, der mit t flackert
|
||
function drawCup(ctx, x, y, sc, flip, t) {
|
||
const cx = x + (flip ? -1 : 10) * sc
|
||
const cy = y + 6 * sc
|
||
px(ctx, cx, cy, 3 * sc, 3 * sc, PAL.metal2)
|
||
px(ctx, cx, cy, 3 * sc, sc, PAL.wood2)
|
||
if (Math.floor(t * 3) % 2 === 0) px(ctx, cx + sc, cy - 2 * sc, sc, sc, PAL.g)
|
||
}
|
||
|
||
// Kleines „z“ aus drei Rechtecken über dem Hut, steigt in ganzen Pixeln mit t (alle 2,5 s von vorn)
|
||
function drawSleepZ(ctx, x, y, sc, flip, t) {
|
||
const rise = Math.floor((Math.max(0, t) * 2) % 5)
|
||
const zx = x + (flip ? 0 : 9) * sc
|
||
const zy = y - (6 + rise) * sc
|
||
px(ctx, zx, zy, 3 * sc, sc, PAL.mute)
|
||
px(ctx, zx + sc, zy + sc, sc, sc, PAL.mute)
|
||
px(ctx, zx, zy + 2 * sc, 3 * sc, sc, PAL.mute)
|
||
}
|
||
|
||
/* Zeichnet einen kleinen Worker. (x, y) = linke obere Ecke des Körpers (ohne Hut), logische Einheiten.
|
||
hat: 'opus' | 'sonnet' | 'haiku' | 'overseer'; frame: 0 stehen, 1/2 laufen; facing: 1 rechts, -1 links;
|
||
carrying: Dokument in der Hand; scale: 1 normal, 2 groß; selected: Pfeil über dem Hut;
|
||
pose: 'stand' und 'type' sehen gleich aus (das Wippen macht die Regie), 'coffee' mit Tasse, 'sleep' mit z;
|
||
grey: halb durchsichtig in Grautönen (nicht angeschlossen), t: Zeit in Sekunden für z, Dampf und Pfeil.
|
||
Rückgabe: Trefferfläche { x, y, w, h } in logischen Einheiten (bei Graustufe unverändert). */
|
||
export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 } = {}) {
|
||
const sc = scale
|
||
const flip = facing === -1
|
||
const bob = frame === 1 ? -1 * sc : 0
|
||
const colors = workerColors(hat, grey)
|
||
|
||
if (grey) {
|
||
ctx.save()
|
||
ctx.globalAlpha = 0.55
|
||
}
|
||
px(ctx, x + 2 * sc, y + 13 * sc, 8 * sc, 1 * sc, PAL.shadow)
|
||
drawMap(ctx, LEGS[frame] || LEGS[0], x, y + 11 * sc, colors, flip, sc)
|
||
drawMap(ctx, pose === 'sleep' ? BODY_SLEEP : BODY, x, y + bob, colors, flip, sc)
|
||
if (hat === 'overseer') drawMap(ctx, VISOR, x, y + bob + 4 * sc, colors, flip, sc)
|
||
drawMap(ctx, HATS[hat] || HATS.haiku, x, y + bob - 2 * sc, colors, flip, sc)
|
||
if (carrying) drawMap(ctx, PAPER, x + (flip ? -2 : 8) * sc, y + bob + 6 * sc, colors, false, sc)
|
||
if (pose === 'coffee') drawCup(ctx, x, y + bob, sc, flip, t)
|
||
else if (pose === 'sleep') drawSleepZ(ctx, x, y + bob, sc, flip, t)
|
||
if (grey) ctx.restore()
|
||
|
||
if (selected) drawSelect(ctx, x + 3.5 * sc, y + bob - 7 * sc, sc, t)
|
||
return { x: x - (carrying && flip ? 2 * sc : 0), y: y - 2 * sc, w: (carrying ? 14 : 12) * sc, h: 16 * sc }
|
||
}
|
||
|
||
export function drawPaper(ctx, x, y) {
|
||
drawMap(ctx, PAPER, x, y, { g: PAL.g, w: PAL.w, p: PAL.p }, false, 1)
|
||
}
|
||
|
||
// Rotes „!“ mit Umriss (bleibt stehen, solange ein Worker den Status fehler hat).
|
||
// (x, y) = Mitte der Unterkante, wie die Spitze der Sprechblase: das Zeichen steht über diesem Punkt.
|
||
export function drawAlertMark(ctx, x, y) {
|
||
const cols = ALERT_OUTLINED[0].length
|
||
drawMap(ctx, ALERT_OUTLINED, x - cols / 2, y - ALERT_OUTLINED.length, { r: PAL.red, o: PAL.o }, false, 1)
|
||
}
|
||
|
||
// ---------- Text (Pixel-Schrift, Größe wächst mit dem Raster) ----------
|
||
|
||
const fontOf = (weight, devPx) => `${weight} ${Math.round(devPx * 100) / 100}px ${FONT_FAMILY}`
|
||
|
||
/* Text an (x, y) in logischen Einheiten (y = Oberkante der Zeile). Schrift = size * k / 3 Geräte-Pixel,
|
||
bei k = 3 also size px. bg: Farbe eines Kastens hinter dem Text; halo: Farbe eines dünnen Rands um die Buchstaben
|
||
(lesbar auch über dunklen Flächen wie einem Monitor); align: 'left' | 'center' | 'right'. */
|
||
export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg, halo } = {}) {
|
||
const { k, ox, oy } = gridOf(ctx)
|
||
const f = k / 3
|
||
const str = String(text ?? '')
|
||
const dx = Math.round(x * k + ox)
|
||
const dy = Math.round(y * k + oy)
|
||
ctx.font = fontOf(weight, size * f)
|
||
ctx.textBaseline = 'top'
|
||
ctx.textAlign = align
|
||
if (bg) {
|
||
const tw = ctx.measureText(str).width
|
||
const left = align === 'center' ? dx - tw / 2 : align === 'right' ? dx - tw : dx
|
||
ctx.fillStyle = bg
|
||
ctx.fillRect(Math.round(left - 4 * f), Math.round(dy - 2 * f), Math.round(tw + 8 * f), Math.round(size * f + 5 * f))
|
||
}
|
||
if (halo) {
|
||
ctx.lineJoin = 'round'
|
||
ctx.lineWidth = Math.max(2, Math.round(3 * f))
|
||
ctx.strokeStyle = halo
|
||
ctx.strokeText(str, dx, dy)
|
||
}
|
||
ctx.fillStyle = color
|
||
ctx.fillText(str, dx, dy)
|
||
ctx.textAlign = 'left'
|
||
}
|
||
|
||
function deviceRect(ctx, x, y, w, h, color) {
|
||
ctx.fillStyle = color
|
||
ctx.fillRect(x, y, w, h)
|
||
}
|
||
|
||
// Breite der Zeichenfläche in Geräte-Pixeln (ohne Canvas 960, wie die Welt bei Raster 3)
|
||
const canvasWidth = (ctx) => (ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960)
|
||
|
||
/* Sprechblase über einem Worker: (x, y) = Spitze der Blase (logisch). Maße wachsen mit dem Raster; die Blase
|
||
wird in Geräte-Pixeln in die Breite von ctx.canvas geklemmt (Rand 2 bei k = 3, ohne Canvas 960). */
|
||
export function drawBubble(ctx, x, y, text) {
|
||
const str = String(text ?? '')
|
||
if (str === '') return
|
||
const { k, ox, oy } = gridOf(ctx)
|
||
const f = k / 3
|
||
const u = (n) => Math.max(1, Math.round(n * f))
|
||
ctx.font = fontOf(500, 12 * f)
|
||
ctx.textBaseline = 'top'
|
||
ctx.textAlign = 'left'
|
||
const pad = u(7)
|
||
const edge = u(2)
|
||
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
|
||
const bh = u(22)
|
||
const tipX = Math.round(x * k + ox)
|
||
const by = Math.round(y * k + oy) - bh - u(6)
|
||
const maxW = canvasWidth(ctx)
|
||
const bx = Math.max(edge, Math.min(maxW - bw - edge, Math.round(tipX - bw / 2)))
|
||
// Die Spitze bleibt am Kasten, auch wenn der Kasten wegen des Rands verschoben wurde
|
||
const tailW = u(6)
|
||
const tx = Math.max(bx + Math.round(tailW / 2), Math.min(bx + bw - Math.round(tailW / 2), tipX))
|
||
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
|
||
deviceRect(ctx, bx, by, bw, bh, PAL.w)
|
||
deviceRect(ctx, tx - Math.round(tailW / 2), by + bh + edge, tailW, u(3), PAL.o)
|
||
deviceRect(ctx, tx - u(1), by + bh + edge + u(3), u(2), u(2), PAL.o)
|
||
ctx.fillStyle = PAL.ink
|
||
ctx.fillText(str, bx + pad, by + u(5))
|
||
}
|
||
|
||
/* Sprechblase neben einem Worker, für Otto an der Oberkante der Welt, wo über dem Kopf kein Platz ist (Vorlage: sideBubble).
|
||
leftX/rightX = linke und rechte Körperkante (logisch), y = Oberkante der Blase. Die Blase steht links vom Körper und ihre
|
||
Spitze zeigt nach rechts auf leftX; fehlt links der Platz (Rand 4), steht sie rechts davon mit der Spitze auf rightX,
|
||
in die Breite von ctx.canvas geklemmt. Maße und Schrift wachsen mit dem Raster. */
|
||
export function drawSideBubble(ctx, text, { leftX, rightX, y }) {
|
||
const str = String(text ?? '')
|
||
if (str === '') return
|
||
const { k, ox, oy } = gridOf(ctx)
|
||
const f = k / 3
|
||
const u = (n) => Math.max(1, Math.round(n * f))
|
||
ctx.font = fontOf(500, 12 * f)
|
||
ctx.textBaseline = 'top'
|
||
ctx.textAlign = 'left'
|
||
const pad = u(7)
|
||
const edge = u(2)
|
||
const gap = u(9) // Blasenrand bis Körperkante: Rahmen, Spitze (8) und ein Pixel Luft
|
||
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
|
||
const bh = u(22)
|
||
const by = Math.round(y * k + oy)
|
||
let bx = Math.round(leftX * k + ox) - gap - bw
|
||
const onLeft = bx >= u(4)
|
||
if (!onLeft) bx = Math.max(edge, Math.min(canvasWidth(ctx) - bw - edge, Math.round(rightX * k + ox) + gap))
|
||
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
|
||
deviceRect(ctx, bx, by, bw, bh, PAL.w)
|
||
if (onLeft) {
|
||
deviceRect(ctx, bx + bw + edge, by + u(7), u(4), u(4), PAL.o)
|
||
deviceRect(ctx, bx + bw + u(6), by + u(8), u(2), u(2), PAL.o)
|
||
} else {
|
||
deviceRect(ctx, bx - u(6), by + u(7), u(4), u(4), PAL.o)
|
||
deviceRect(ctx, bx - u(8), by + u(8), u(2), u(2), PAL.o)
|
||
}
|
||
ctx.fillStyle = PAL.ink
|
||
ctx.fillText(str, bx + pad, by + u(5))
|
||
}
|