Files
tickte-system/src/components/workers/engine/sprites.js
T

330 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}